We've all been there. You start with a simple business process — like fulfilling an e-commerce order — and build a NestJS service to handle it. It works beautifully at first. But then, the real world intervenes. New requirements roll in: complex failure handling, payment retries, a "fraud check" step, confirmation emails…
Suddenly, that once-clean service devolves into a monolithic beast of nested if/else statements, tightly-coupled dependencies, and manual state management. This is "spaghetti code," and it's a nightmare to maintain, test, and scale. The core business logic gets lost in a sea of implementation details, and every new feature request feels like performing open-heart surgery.
But what if you could define your entire business process as a clean, declarative state machine, completely decoupled from your business logic?
The wrong way: the monolithic service
Imagine an OrderService that handles everything: payments, inventory, shipping, and notifications. It probably looks something like this — a long, procedural method where state is managed manually and every service is a direct dependency.
@Injectable()
export class MonolithicOrderService {
constructor(
private readonly paymentService: PaymentService,
private readonly inventoryService: InventoryService,
private readonly shippingService: ShippingService,
) {}
async processOrder(order: Order): Promise<Order> {
// A long, brittle chain of if-statements...
if (order.status === OrderStatus.Pending) {
// ... call payment service
}
if (order.status === OrderStatus.Paid) {
// ... call inventory service
}
// ... and so on.
}
}This approach is fragile and creates significant technical debt:
- Testing nightmare. To unit-test the shipping logic you have to mock payment and inventory and manually construct an order in the correct state.
- Hidden logic. The actual business process is obscured. To understand the flow, a new developer has to read and trace the entire implementation.
- Difficult to change. Adding a fraud check between payment and inventory means cracking open this method and carefully grafting in more logic — increasing the blast radius of every change.
Declarative workflows with nestjs-workflow
Now, let's refactor this using nestjs-workflow, a library I created to solve this exact problem by enforcing a clean separation of concerns.
Step 1 · Define the workflow as a blueprint
Instead of procedural code, we use a declarative configuration object. This becomes the single source of truth for your business process. It's a blueprint that anyone — even non-developers — can read to understand the flow.
// order.workflow.ts
export const orderWorkflowDefinition: WorkflowDefinition<
Order, any, OrderEvent, OrderStatus
> = {
states: {
finals: [OrderStatus.Completed, OrderStatus.Failed],
failed: OrderStatus.Failed,
},
transitions: [
{ from: OrderStatus.Pending, to: OrderStatus.Paid,
event: OrderEvent.ProcessPayment },
{ from: OrderStatus.Paid, to: OrderStatus.InventoryReserved,
event: OrderEvent.ReserveInventory },
// ... all other valid transitions, including failure paths
],
entity: {
update: async (entity, status) => { /* ... save to DB */ },
load: async (urn) => { /* ... load from DB */ },
}
};Step 2 · Decouple business logic with event listeners
The actual work — calling the payment service, checking inventory — is handled by event listeners. They are completely separate from the workflow definition and from each other.
// order-processor.service.ts
@Injectable()
export class OrderProcessor {
constructor(private readonly workflowService: WorkflowService<Order>) {}
@OnEvent(OrderEvent.ProcessPayment)
async handlePayment(order: Order) {
try {
// ... call payment service
await this.workflowService.apply(order, OrderEvent.ReserveInventory);
} catch (error) {
await this.workflowService.apply(order, OrderEvent.FailPayment);
}
}
// ... other isolated event handlers for inventory, shipping, etc.
}Step 3 · A trivial service to start the process
The OrderService is now trivial. Its only job is to create an order and kick off the very first event. The workflow and its listeners take over from there.
// order.service.ts (Refactored)
@Injectable()
export class OrderService {
constructor(private readonly eventEmitter: EventEmitter2) {}
async createAndStartOrder(orderData: any): Promise<Order> {
// ... create and save the initial order
// Trigger the first event. Fire-and-forget.
this.eventEmitter.emit(OrderEvent.ProcessPayment, order);
return order;
}
}The transformative results
- Truly decoupled architecture. The workflow is pure configuration. The business logic lives in isolated, independently testable listeners. Different teams can work on different steps of the workflow without conflict.
- A single, readable source of truth. The
WorkflowDefinitionbecomes living documentation. Onboarding a developer or explaining the process to a business analyst is as simple as showing them this file. - Robust and scalable by design. Adding a new step is no longer a surgical procedure. You update the definition with a new state/transition and add a listener. The existing code remains untouched, eliminating the risk of regressions.
Stop wrestling with spaghetti code. By adopting a declarative, event-driven state machine pattern, you can build more resilient, maintainable, and understandable applications in NestJS.
Process orchestration is configuration. Business logic is code. They should not live in the same method.