Writing

NestJS Workflow & State Machine Module

An in-depth analysis of the NestJS Workflow module — how declarative workflows, event-driven actions, and Nest-native dependency injection turn complex business processes into auditable, low-maintenance code.

March 19, 2025·18–22 min read·English
NestJSState MachineWorkflowArchitectureOpen SourceDDD

NestJS Workflow is an open-source module that introduces a flexible workflow engine and state machine for NestJS applications. This paper provides an in-depth analysis of the repository, explaining how it is used to define and enforce business processes within a NestJS architecture.

We demonstrate how the module's declarative workflow definitions and event-driven design improve development efficiency and code maintainability. The analysis highlights that a well-structured architecture using NestJS Workflow can reduce maintenance costs and development time by centralizing state management logic and preventing invalid state transitions. Through technical insights, code examples, and real-world use cases, we show how it streamlines complex workflows, eases integration with external systems, and minimizes bugs by eliminating "impossible" states in application logic.

01Premise

Why workflow patterns matter

Modern software architectures frequently need to manage complex sequences of operations or state changes — collectively known as workflows. Without proper structural patterns, handling these workflows can lead to code that is difficult to maintain and error-prone. In large enterprise applications, business processes often evolve into intricate conditional logic scattered across the codebase.

Workflow automation and state machine patterns have emerged as important solutions to bring order and clarity to this complexity. A workflow represents a series of well-defined steps or states and the allowed transitions between them, while a state machine formalizes the states an entity can be in and the events that trigger transitions.

By defining all possible states and transitions explicitly, the system can only evolve in predictable, valid ways — making it virtually impossible to enter an invalid state if the model does not allow it.
02Methodology

Module implementation

Design and architecture

NestJS Workflow is built on top of the NestJS framework, leveraging Nest's features such as modules, providers, and an event-driven architecture. At its core, the module introduces a Workflow abstraction that encapsulates a finite state machine for a specific domain entity or process. Workflows are defined declaratively using a WorkflowDefinition object that specifies states, events, and transitions in a single, central definition — the source of truth for that workflow's behavior.

Module integration

Developers install the npm package and integrate the provided module into their application. Registration uses Nest's standard pattern: WorkflowModule.forRoot() for global setup and WorkflowModule.register({...}) for each specific workflow. Each workflow is identified by a unique name and can be injected via @Inject(name). The design fits naturally into existing NestJS applications without requiring a separate workflow server or drastic architectural changes.

Defining workflows

States are typically TypeScript enums (e.g. OrderStatus) and events are another enum (e.g. OrderEvent). Consider an order processing workflow:

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 {
  urn: string;
  name: string;
  price: number;
  items: string[];
  status: OrderStatus;
}

With domain events and states defined, we declare valid transitions. Each rule specifies a starting state, an ending state, and the event that triggers the transition. Optional conditions guard whether a transition is allowed; optional actions execute side effects during the transition.

const orderWorkflowDefinition: WorkflowDefinition<
  Order, any, OrderEvent, OrderStatus
> = {
  FinalStates: [OrderStatus.Completed, OrderStatus.Failed],
  Transitions: [
    {
      from: OrderStatus.Pending,
      to:   OrderStatus.Processing,
      event: OrderEvent.Submit,
      conditions: [(order) => order.price > 10],
    },
    {
      from: OrderStatus.Pending,
      to:   OrderStatus.Pending,
      event: OrderEvent.Update,
      actions: [
        (order, payload) => {
          order.price = payload.price;
          order.items = payload.items;
          return Promise.resolve(order);
        }
      ],
    },
    { from: OrderStatus.Processing, to: OrderStatus.Completed,
      event: OrderEvent.Complete },
    { from: OrderStatus.Processing, to: OrderStatus.Failed,
      event: OrderEvent.Fail },
  ],
  FailedState: OrderStatus.Failed,
};

Triggering transitions

Once registered, the workflow exposes a service API. Inject by name into your service:

@Injectable()
export class OrderService {
  constructor(
    @Inject('orderWorkflow')
    private readonly orderWorkflow: Workflow<Order, OrderEvent, any, OrderStatus>,
  ) {}

  async submitOrder(orderId: string): Promise<Order> {
    return this.orderWorkflow.emit({ urn: orderId, event: OrderEvent.Submit });
  }

  async updateOrder(orderId: string, newPrice: number, newItems: string[]): Promise<Order> {
    return this.orderWorkflow.emit({
      urn: orderId,
      event: OrderEvent.Update,
      payload: { price: newPrice, items: newItems }
    });
  }
}

Under the hood, the engine loads the entity, evaluates if the event is allowed in the current state, checks conditions, performs actions, persists the entity, and returns the updated object. If an event is not valid for the current state, the module throws — preventing illegal transitions without developer-side checks.

Actions and event handlers

For richer side effects, mark a class with @WorkflowAction() and decorate methods with @OnEvent / @OnStatusChanged. Because the class is a standard Nest provider, you can inject any other service into it.

@Injectable()
@WorkflowAction()
export class OrderActions {
  @OnEvent({ event: OrderEvent.Submit })
  execute({ entity, payload }: { entity: Order; payload: any }) {
    entity.price = entity.price * 100;
    return Promise.resolve(entity);
  }

  @OnStatusChanged({ from: OrderStatus.Pending, to: OrderStatus.Processing })
  onProcessing({ entity }: { entity: Order }) {
    entity.name = 'Processing Order';
    return Promise.resolve(entity);
  }
}

By default, if a handler throws the workflow marks the entity as FailedState. This fail-safe behavior can be turned off per handler with failOnError: false.

03Outcomes

Concrete benefits

  • Improved maintainability. All state transition logic is centralized in one place — anyone can understand the lifecycle of an entity by reading the workflow definition.
  • Enforced consistency. Every developer follows the same paradigm: emit events, define transitions. Code reviews and refactoring become easier because the pattern is recognizable.
  • Reduction in invalid states. The module disallows any transition not in the definition. An Order cannot skip from Pending to Completed unless an explicit rule exists.
  • Faster development. Less boilerplate — no repetitive "if status is X and event is Y" — frees the team to focus on business rules.
  • Extensibility and scalability. The event-driven nature works with distributed architectures. External triggers and Kafka integrations slot in cleanly.
  • Auditability and governance. Explicit transition maps double as compliance documentation, especially valuable in regulated domains.
Adopting this workflow module turns complex, error-prone state transitions into structured, maintainable workflows — improving team efficiency and reducing bugs.
04Comparison

Versus other approaches

1 · Manual state management

The most straightforward but problematic approach. As the codebase grows, scattered conditionals and state flags converge into the classic spaghetti scenario. NestJS Workflow makes illegal transitions impossible by construction, drastically reducing maintenance overhead.

2 · General-purpose state machines (XState)

XState is framework-agnostic. Used in NestJS, you must handle integration yourself: instantiate machines, manage lifecycles, integrate with Nest DI. NestJS Workflow is purpose-built — naming, DI, decorator-based hooks, and event-emitter integration are all built in. XState may offer richer statechart semantics (hierarchical/parallel) that NestJS Workflow does not target; for typical business processes the simplicity is an advantage.

3 · Dedicated engines (Temporal, Camunda)

Temporal, Cadence, Camunda, AWS Step Functions are powerful but heavyweight, requiring servers and worker processes. NestJS Workflow is the middle ground: clear definitions, integration with external events, in-process speed, no extra infrastructure, no new DSL.

05Use cases

Real-world scenarios

E-commerce order processing

An order's lifecycle moves through Pending → Processing → Shipped → Delivered with branches to Cancelled / Failed. By declaring transitions, the system enforces the correct sequence (you cannot deliver an order that hasn't shipped) and attaches side effects via @OnStatusChanged handlers — for example, sending a notification email when an order is marked Shipped. Adding a new state (e.g. OutForDelivery) is a one-line change to the definition rather than hunting through code.

Medical prescription workflow with Kafka

A prescription moves through Requested → Approved/Rejected → Processing → Dispensed → Completed/Failed. Each transition can trigger external integrations: a Kafka listener emits a workflow event when a doctor approves a prescription; an @OnStatusChanged handler produces a Kafka message when the pharmacy dispenses medication. The workflow becomes the central orchestrator for both in-process logic and cross-service communication.

Support ticket lifecycle

States like Open / InProgress / Resolved / Closed with events Assign / Resolve / Reopen / Close ensure tickets cannot be closed before being resolved or resolved before being assigned. Custom states like "Pending Customer Response" become trivial additions.

06Wiring it up

Code walkthrough

1 · Define states, events, entity, definition

export enum OrderEvent {
  Submit   = 'order.submit',
  Update   = 'order.update',
  Complete = 'order.complete',
  Fail     = 'order.fail',
}

export enum OrderStatus {
  Pending    = 'pending',
  Processing = 'processing',
  Completed  = 'completed',
  Failed     = 'failed',
}

export class Order {
  urn: string;
  customerId: string;
  items: string[];
  total: number;
  status: OrderStatus;
}

const orderWorkflowDef: WorkflowDefinition<
  Order, any, OrderEvent, OrderStatus
> = {
  FinalStates: [OrderStatus.Completed, OrderStatus.Failed],
  Transitions: [
    { from: OrderStatus.Pending,    to: OrderStatus.Processing,
      event: OrderEvent.Submit },
    { from: OrderStatus.Pending,    to: OrderStatus.Pending,
      event: OrderEvent.Update,
      actions: [(order, payload) => {
        if (payload?.items) order.items = payload.items;
        if (payload?.total) order.total = payload.total;
        return Promise.resolve(order);
      }],
    },
    { from: OrderStatus.Processing, to: OrderStatus.Completed,
      event: OrderEvent.Complete },
    { from: OrderStatus.Processing, to: OrderStatus.Failed,
      event: OrderEvent.Fail },
  ],
  FailedState: OrderStatus.Failed,
};

2 · Module setup

@Module({
  imports: [
    WorkflowModule.forRoot({ storage: { type: 'memory' } }),
    WorkflowModule.register({
      name: 'orderWorkflow',
      definition: orderWorkflowDef,
    }),
  ],
  providers: [OrderService, OrderActions],
})
export class OrderModule {}

3 · The service stays trivial

@Injectable()
export class OrderService {
  constructor(
    @Inject('orderWorkflow')
    private readonly orderWorkflow: Workflow<Order, OrderEvent>,
  ) {}

  async submitOrder(orderId: string): Promise<Order> {
    return await this.orderWorkflow.emit({
      urn: orderId, event: OrderEvent.Submit
    });
  }

  async updateOrder(orderId: string, items: string[], total: number) {
    return await this.orderWorkflow.emit({
      urn: orderId,
      event: OrderEvent.Update,
      payload: { items, total }
    });
  }

  async completeOrder(orderId: string) {
    return await this.orderWorkflow.emit({
      urn: orderId, event: OrderEvent.Complete
    });
  }
}

Notice what's not here: any if (order.status !== Pending) throw … check. The workflow engine handles correctness — your service stays focused on intent.

4 · Side effects via handlers (optional)

@Injectable()
@WorkflowAction()
export class OrderActions {
  constructor(private readonly emailService: EmailService) {}

  @OnStatusChanged({ from: OrderStatus.Processing, to: OrderStatus.Completed })
  async onOrderCompleted({ entity }: { entity: Order }) {
    await this.emailService.sendOrderConfirmation(entity.customerId, entity.urn);
    return entity;
  }
}
07Closing

From chaos to clarity

NestJS Workflow brings order to complex state transitions in NestJS applications. By using familiar Nest constructs — modules, providers, decorators — it embeds a workflow engine into the application without forcing developers to learn a new platform or DSL.

  1. Maintainability improves because state transition logic is centralized.
  2. Bugs from invalid states are eliminated by construction.
  3. Integration with external systems becomes a structured, local concern.
  4. Onboarding accelerates — the workflow file is the documentation.
For NestJS-based systems with non-trivial state, the right question is no longer "should we adopt a workflow pattern?" but "why haven't we already?"
José Escrich

Fractional CTO and software architect. Built in Bariloche, Patagonia — working with teams worldwide.

© 2020–2026 José Escrich. All rights reserved.
Designed & built by @jes