@jescrich/nestjs-workflow
Version:
Workflow and State Machines for NestJS
688 lines (583 loc) • 20 kB
Markdown
<img src="https://joseescrich.com/logos/nestjs-workflow.png" alt="logo" width="200" style="margin-bottom:20px"/>
# NestJS Workflow & State Machine
A flexible workflow engine built on top of NestJS framework, enabling developers to create, manage, and execute complex workflows in their Node.js applications.
## Table of Contents
- [Features](#features)
- [Stateless Architecture](#stateless-architecture)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Module Registration](#module-registration)
- [Define a Workflow](#define-a-workflow)
- [Message Format](#message-format)
- [Configuring Actions and Conditions](#configuring-actions-and-conditions)
- [Complete Example with Kafka Integration](#complete-example-with-kafka-integration)
- [Entity Service](#entity-service)
- [Kafka Integration](#using-entityservice-with-workflow)
## Features
- Workflow Definitions: Define workflows using a simple, declarative syntax
- State Management: Track and persist workflow states
- Event-Driven Architecture: Built on NestJS's event system for flexible workflow triggers
- Transition Rules: Configure complex transition conditions between workflow states
- Extensible: Easily extend with custom actions, conditions, and triggers
- TypeScript Support: Full TypeScript support with strong typing
- Integration Friendly: Seamlessly integrates with existing NestJS applications
- Kafka Integration: Easily integrate with Kafka for event-driven workflows
- Stateless Design: Lightweight implementation with no additional storage requirements
Documentation: https://jescrich.github.io/libraries/docs/workflow/intro
# Stateless Architecture
## NestJS Workflow is designed with a stateless architecture, which offers several key benefits:
Benefits of Stateless Design
- Simplicity: No additional database or storage configuration required
- Domain-Driven: State is maintained within your domain entities where it belongs
- Lightweight: Minimal overhead and dependencies
- Scalability: Easily scales horizontally with your application
- Flexibility: Works with any persistence layer or storage mechanism
- Integration: Seamlessly integrates with your existing data model and repositories
- The workflow engine doesn't maintain any state itself - instead, it operates on your domain entities, reading their current state and applying transitions according to your defined rules. This approach aligns with domain-driven design principles by keeping the state with the entity it belongs to.
This stateless design means you can:
Use your existing repositories and data access patterns
Persist workflow state alongside your entity data
Avoid complex synchronization between separate state stores
Maintain transactional integrity with your domain operations
```
// Example of how state is part of your domain entity
export class Order {
id: string;
items: OrderItem[];
totalAmount: number;
status: OrderStatus; // The workflow state is a property of your entity
// Your domain logic here
}
```
The workflow engine simply reads and updates this state property according to your defined transitions, without needing to maintain any separate state storage.
## Installation
```bash
npm install @jescrich/nestjs-workflow
```
Or using yarn:
```bash
yarn add @jescrich/nestjs-workflow
```
## Quick Start
### Module Registration
```typescript
import { Module } from '@nestjs/common';
import { WorkflowModule } from '@jescrich/nestjs-workflow';
// Register a workflow
@Module({
imports: [
WorkflowModule.register({
name: 'simpleworkflow',
definition: orderWorkflowDefinition,
}),
],
})
export class AppModule {}
```
### Define a Workflow
```typescript
import { WorkflowDefinition } from '@jescrich/nestjs-workflow';
// Define your entity and state/event enums
export enum OrderEvent {
Create = 'order.create',
Submit = 'order.submit',
Update = 'order.update',
Complete = 'order.complete',
Fail = 'order.fail',
Cancel = 'order.cancel',
}
export enum OrderStatus {
Pending = 'pending',
Processing = 'processing',
Completed = 'completed',
Failed = 'failed',
}
export class Order {
id: string;
name: string;
price: number;
items: string[];
status: OrderStatus;
}
// Create workflow definition
const orderWorkflowDefinition: WorkflowDefinition<Order, any, OrderEvent, OrderStatus> = {
states: {
finals: [OrderStatus.Completed, OrderStatus.Failed],
idles: [OrderStatus.Pending, OrderStatus.Processing, OrderStatus.Completed, OrderStatus.Failed],
failed: OrderStatus.Failed,
},
transitions: [
{
from: OrderStatus.Pending,
to: OrderStatus.Processing,
event: OrderEvent.Submit,
conditions: [(entity: Order, payload: any) => entity.price > 10],
},
{
from: OrderStatus.Pending,
to: OrderStatus.Pending,
event: OrderEvent.Update,
actions: [
async (entity: Order, payload: any) => {
entity.price = payload.price;
entity.items = payload.items;
return entity;
},
],
},
{
from: OrderStatus.Processing,
to: OrderStatus.Completed,
event: OrderEvent.Complete,
},
{
from: OrderStatus.Processing,
to: OrderStatus.Failed,
event: OrderEvent.Fail,
},
],
entity: {
new: () => new Order(),
update: async (entity: Order, status: OrderStatus) => {
entity.status = status;
return entity;
},
load: async (urn: string) => {
// In a real application, load from database
return new Order();
},
status: (entity: Order) => entity.status,
urn: (entity: Order) => entity.id,
},
};
```
### Use the Workflow in a Service
```typescript
import { Injectable } from '@nestjs/common';
import { WorkflowService } from '@jescrich/nestjs-workflow';
import { Order, OrderEvent, OrderStatus } from './order.model';
@Injectable()
export class OrderService {
constructor(
private readonly workflowService: WorkflowService<Order, any, OrderEvent, OrderStatus>,
) {}
async createOrder() {
const order = new Order();
order.id = 'order-123';
order.name = 'Order 123';
order.price = 100;
order.items = ['Item 1', 'Item 2', 'Item 3'];
order.status = OrderStatus.Pending;
return order;
}
async submitOrder(id: string) {
// Emit an event to trigger workflow transition
const result = await this.workflowService.emit({
urn: id,
event: OrderEvent.Submit
});
return result;
}
async updateOrder(id: string, price: number, items: string[]) {
// Emit an event with payload to update the order
const result = await this.workflowService.emit({
urn: id,
event: OrderEvent.Update,
payload: {
price: price,
items: items,
},
});
return result;
}
}
```
## Configuring Actions and Conditions
NestJS Workflow provides two different approaches for configuring actions and conditions in your workflows:
### 1. Inline Functions in Transitions
You can define actions and conditions directly in the transition definition as shown in the example above:
```typescript
{
from: OrderStatus.Pending,
to: OrderStatus.Processing,
event: OrderEvent.Submit,
conditions: [(entity: Order, payload: any) => entity.price > 10],
actions: [
async (entity: Order, payload: any) => {
// Perform action
return entity;
},
],
}
```
### 2. Using Decorators (Class-based approach)
For more complex workflows, you can use a class-based approach with decorators:
```typescript
import { Injectable } from '@nestjs/common';
import { WorkflowAction, OnEvent, OnStatusChanged } from '@jescrich/nestjs-workflow';
@Injectable()
@WorkflowAction()
export class OrderActions {
// Handler triggered on specific event
@OnEvent({ event: OrderEvent.Submit })
execute(params: { entity: Order; payload: any }): Promise<Order> {
const { entity, payload } = params;
entity.price = entity.price * 100;
return Promise.resolve(entity);
}
// Handler triggered when status changes
@OnStatusChanged({ from: OrderStatus.Pending, to: OrderStatus.Processing })
onStatusChanged(params: { entity: Order; payload: any }): Promise<Order> {
const { entity, payload } = params;
entity.name = 'Status changed to processing';
return Promise.resolve(entity);
}
}
```
Then include these action classes in your workflow definition:
```typescript
const definition: WorkflowDefinition<Order, any, OrderEvent, OrderStatus> = {
actions: [OrderActions],
// ...other properties
states: {
finals: [OrderStatus.Completed, OrderStatus.Failed],
idles: [OrderStatus.Pending, OrderStatus.Processing, OrderStatus.Completed, OrderStatus.Failed],
failed: OrderStatus.Failed,
},
transitions: [
{
from: OrderStatus.Pending,
to: OrderStatus.Processing,
event: OrderEvent.Submit,
},
// Other transitions
],
// ...
};
```
### Execution Order with @OnEvent
You can control the execution order of multiple handlers for the same event:
```typescript
@Injectable()
@WorkflowAction()
export class OrderActions {
@OnEvent({ event: OrderEvent.Submit, order: 1 })
firstHandler(params: { entity: Order; payload: any }): Promise<Order> {
// Executes first
return Promise.resolve(params.entity);
}
@OnEvent({ event: OrderEvent.Submit, order: 2 })
secondHandler(params: { entity: Order; payload: any }): Promise<Order> {
// Executes second
return Promise.resolve(params.entity);
}
}
```
### Error Handling with @OnStatusChanged
By default, if a status change handler fails, the workflow will transition to the failed state:
```typescript
@OnStatusChanged({ from: OrderStatus.Pending, to: OrderStatus.Processing })
onStatusChanged(params: { entity: Order; payload: any }): Promise<Order> {
// If this throws an error, the workflow will move to the failed state
throw new Error("This will cause transition to failed state");
}
```
You can disable this behavior by setting failOnError: false:
```typescript
@OnStatusChanged({
from: OrderStatus.Pending,
to: OrderStatus.Processing,
failOnError: false
})
onStatusChanged(params: { entity: Order; payload: any }): Promise<Order> {
// If this throws an error, the workflow will continue to the next state
throw new Error("This error will be logged but won't affect the workflow");
}
```
Remember to register your action classes as providers in your module:
```typescript
@Module({
imports: [
WorkflowModule.register({
name: 'orderWorkflow',
definition,
}),
],
providers: [OrderActions],
})
export class OrderModule {}
```
## Kafka Integration
NestJS Workflow now supports integration with Apache Kafka, allowing your workflows to react to Kafka events and trigger state transitions based on messages from your event streaming platform.
### Setting Up Kafka Integration
To configure your workflow to listen to Kafka events, you need to add a `kafka` property to your workflow definition:
```typescript
const orderWorkflowDefinition: WorkflowDefinition<Order, any, OrderEvent, OrderStatus> = {
// ... other workflow properties
states: {
finals: [OrderStatus.Completed, OrderStatus.Failed],
idles: [OrderStatus.Pending, OrderStatus.Processing, OrderStatus.Completed, OrderStatus.Failed],
failed: OrderStatus.Failed,
},
transitions: [
// Your transitions here
],
// Kafka configuration
kafka: {
brokers: 'localhost:9092',
events: [
{ topic: 'orders.submitted', event: OrderEvent.Submit },
{ topic: 'orders.completed', event: OrderEvent.Complete },
{ topic: 'orders.failed', event: OrderEvent.Fail }
]
},
entity: {
// Entity configuration
new: () => new Order(),
update: async (entity: Order, status: OrderStatus) => {
entity.status = status;
return entity;
},
load: async (urn: string) => {
// Load entity from storage
return new Order();
},
status: (entity: Order) => entity.status,
urn: (entity: Order) => entity.id
}
};
```
### How It Works
When you configure Kafka integration:
1. The workflow engine will connect to the specified Kafka brokers
2. It will subscribe to the topics you've defined in the `events` array
3. When a message arrives on a subscribed topic, the workflow engine will:
- Map the topic to the corresponding workflow event
- Extract the entity URN from the message
- Load the entity using your defined `entity.load` function
- Emit the mapped workflow event with the Kafka message as payload
### Complete Example with Kafka Integration
````typescript
import { Injectable, Module } from '@nestjs/common';
import { WorkflowModule, WorkflowDefinition, WorkflowService } from '@jescrich/nestjs-workflow';
// Define your entity and state/event enums
export enum OrderEvent {
Create = 'order.create',
Submit = 'order.submit',
Complete = 'order.complete',
Fail = 'order.fail',
}
export enum OrderStatus {
Pending = 'pending',
Processing = 'processing',
Completed = 'completed',
Failed = 'failed',
}
export class Order {
id: string;
name: string;
price: number;
items: string[];
status: OrderStatus;
}
// Create workflow definition with Kafka integration
const orderWorkflowDefinition: WorkflowDefinition<Order, any, OrderEvent, OrderStatus> = {
states: {
finals: [OrderStatus.Completed, OrderStatus.Failed],
idles: [OrderStatus.Pending, OrderStatus.Processing, OrderStatus.Completed, OrderStatus.Failed],
failed: OrderStatus.Failed,
},
transitions: [
{
from: OrderStatus.Pending,
to: OrderStatus.Processing,
event: OrderEvent.Submit,
conditions: [(entity: Order, payload: any) => entity.price > 10],
},
{
from: OrderStatus.Processing,
to: OrderStatus.Completed,
event: OrderEvent.Complete,
},
{
from: OrderStatus.Processing,
to: OrderStatus.Failed,
event: OrderEvent.Fail,
},
],
// Kafka configuration
kafka: {
brokers: 'localhost:9092',
events: [
{ topic: 'orders.submitted', event: OrderEvent.Submit },
{ topic: 'orders.completed', event: OrderEvent.Complete },
{ topic: 'orders.failed', event: OrderEvent.Fail }
]
},
entity: {
new: () => new Order(),
update: async (entity: Order, status: OrderStatus) => {
entity.status = status;
return entity;
},
load: async (urn: string) => {
// In a real application, load from database
const order = new Order();
order.id = urn;
order.status = OrderStatus.Pending;
return order;
},
status: (entity: Order) => entity.status,
urn: (entity: Order) => entity.id
}
};
@Module({
imports: [
WorkflowModule.register({
name: 'orderWorkflow',
definition: orderWorkflowDefinition,
}),
],
})
export class AppModule {}
````
### Message Format
The Kafka messages should include the entity URN so that the workflow engine can load the correct entity. For example:
```json
{
"urn": "order-123",
"price": 150,
"items": ["Item 1", "Item 2"]
}
```
With this setup, your workflow will automatically react to Kafka messages and trigger the appropriate state transitions based on your workflow definition.
## Entity Service Implementation
NestJS Workflow allows you to implement an `EntityService` to manage your entity's lifecycle and state. This provides a cleaner separation of concerns between your workflow logic and entity management.
### Creating an EntityService
Instead of defining entity operations inline in your workflow definition, you can create a dedicated service:
```typescript
import { Injectable } from '@nestjs/common';
import { EntityService } from '@jescrich/nestjs-workflow';
import { Order, OrderStatus } from './order.model';
import { OrderRepository } from './order.repository';
@Injectable()
export class OrderEntityService extends EntityService<Order, OrderStatus> {
constructor(private readonly orderRepository: OrderRepository) {
super();
}
// Create a new entity instance
new(): Promise<Order> {
return Promise.resolve(new Order());
}
// Update entity status
async update(entity: Order, status: OrderStatus): Promise<Order> {
entity.status = status;
return this.orderRepository.save(entity);
}
// Load entity by URN
async load(urn: string): Promise<Order> {
const order = await this.orderRepository.findByUrn(urn);
if (!order) {
throw new Error(`Order with URN ${urn} not found`);
}
return order;
}
// Get current status
status(entity: Order): OrderStatus {
return entity.status;
}
// Get entity URN
urn(entity: Order): string {
return entity.id;
}
}
```
### Registering the EntityService
Register your EntityService as a provider in your module:
```typescript
@Module({
imports: [
TypeOrmModule.forFeature([OrderEntity]),
],
providers: [
OrderEntityService,
OrderRepository,
],
exports: [OrderEntityService],
})
export class OrderModule {}
```
### Using EntityService with Workflow
There are two ways to use your EntityService with a workflow:
#### 1. Reference in Workflow Definition
```typescript
import { Module } from '@nestjs/common';
import { WorkflowModule } from '@jescrich/nestjs-workflow';
import { OrderEntityService } from './order-entity.service';
const orderWorkflowDefinition: WorkflowDefinition<Order, any, OrderEvent, OrderStatus> = {
states: {
finals: [OrderStatus.Completed, OrderStatus.Failed],
idles: [OrderStatus.Pending, OrderStatus.Processing, OrderStatus.Completed, OrderStatus.Failed],
failed: OrderStatus.Failed,
},
transitions: [
// Your transitions here
],
// Reference your EntityService class instead of inline functions
entity: OrderEntityService,
};
@Module({
imports: [
WorkflowModule.register({
name: 'orderWorkflow',
definition: orderWorkflowDefinition,
}),
],
})
export class AppModule {}
```
#### 2. Inject into WorkflowService
You can also inject your EntityService directly when creating a WorkflowService instance:
```typescript
@Injectable()
export class OrderService {
private workflowService: WorkflowService<Order, any, OrderEvent, OrderStatus>;
constructor(
private readonly moduleRef: ModuleRef,
private readonly orderEntityService: OrderEntityService
) {
const workflowDefinition = {
states: {
finals: [OrderStatus.Completed, OrderStatus.Failed],
idles: [OrderStatus.Pending, OrderStatus.Processing, OrderStatus.Completed, OrderStatus.Failed],
failed: OrderStatus.Failed,
},
transitions: [
// Your transitions here
],
// You can still include entity here, but it will be overridden by the injected service
entity: {
new: () => new Order(),
// other methods...
}
};
this.workflowService = new WorkflowService(
workflowDefinition,
this.moduleRef,
this.orderEntityService // Inject the entity service
);
}
// Your service methods using workflowService
}
```
### Benefits of Using EntityService
Using a dedicated EntityService provides several advantages:
1. **Separation of Concerns**: Keep entity management logic separate from workflow definitions
2. **Dependency Injection**: Leverage NestJS dependency injection for your entity operations
3. **Reusability**: Use the same EntityService across multiple workflows
4. **Testability**: Easier to mock and test your entity operations
5. **Database Integration**: Cleanly integrate with your database through repositories
This approach is particularly useful for complex applications where entities are stored in databases and require sophisticated loading and persistence logic.
## Advanced Usage
For more advanced usage, including custom actions, conditions, and event handling, please check the documentation.
```