DEV Community

Cover image for BaseOrchestrator: A Pattern for Complex Async Pipelines in Node/Fastify
Scott Waddell
Scott Waddell

Posted on

BaseOrchestrator: A Pattern for Complex Async Pipelines in Node/Fastify

The Problem

You're building a backend that processes requests through multiple stages: validate input, check permissions, create records, send notifications, update analytics.

Each stage can fail. Some failures should stop everything (payment failed), others shouldn't (notification service down). You need per-stage timing for debugging. You need tests that don't require mocking the entire world.

The typical evolution:

Week 1: Clean

async function createOrder(input: OrderInput) {
  validate(input);
  const order = await db.orders.create(input);
  await sendEmail(order);
  return order;
}
Enter fullscreen mode Exit fullscreen mode

Week 8: Reality

async function createOrder(input: OrderInput) {
  try {
    const errors = validate(input);
    if (errors.length) throw new ValidationError(errors);

    let order;
    try {
      order = await db.orders.create(input);
    } catch (dbError) {
      logger.error('DB failed', dbError);
      throw new DatabaseError(dbError);
    }

    try {
      await sendEmail(order);
    } catch (emailError) {
      logger.warn('Email failed', emailError);
      metrics.increment('email_failures');
    }

    try {
      await analytics.track('order_created', order);
    } catch (analyticsError) {
      logger.warn('Analytics failed', analyticsError);
    }

    return order;
  } catch (error) {
    metrics.increment('order_failures');
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

Built this while dealing with a 16-stage pipeline. Extracted the pattern into a starter template.


The Pattern

1. Operations - Functions that do one thing. Validate, fetch, create, notify - pick one. They take context, return context. They can hit the database, call APIs, whatever. But each one owns one job.

export async function createOrder(ctx: OrderContext) {
  if (ctx.errors.length > 0) return ctx;

  const order = await ctx.prisma.order.create({
    data: {
      customerId: ctx.input.customerId,
      items: ctx.input.items,
      total: ctx.input.total,
    },
  });

  return { ...ctx, order };
}
Enter fullscreen mode Exit fullscreen mode

2. Orchestrator - Defines the pipeline. Runs each operation in sequence.

export class CreateOrderOrchestrator extends BaseOrchestrator
  OrderContext,
  Order,
  CreateOrderInput
> {
  protected getPipeline() {
    return [
      { name: 'validate', operation: validateInput, critical: true },
      { name: 'create', operation: createOrder, critical: true },
      { name: 'notify', operation: sendNotification, critical: false },
    ];
  }
}
Enter fullscreen mode Exit fullscreen mode

critical: true means stop if it fails. critical: false means log the error and keep going. Your order gets created even if the notification service is down.

3. Context - Typed object that gets passed through. Operations read from it and add to it.

interface OrderContext extends OperationContext {
  input: CreateOrderInput;
  prisma: PrismaClient;
  order?: Order;
  errors: Error[];
  results: Record<string, unknown>;
}
Enter fullscreen mode Exit fullscreen mode

What You Get

Per-operation timing - Every response includes a breakdown:

{
  "success": true,
  "duration": 45,
  "metrics": {
    "validate": 5,
    "create": 28,
    "notify": 12
  }
}
Enter fullscreen mode Exit fullscreen mode

Swagger docs - TypeBox schemas become OpenAPI. Hit /documentation and your API is documented. No YAML files.

Grafana dashboards - Run npm run generate:dashboards. It reads your orchestrators and builds dashboards with the right Prometheus queries.

Testable operations - Each operation does one thing, so you mock one thing:

it('creates order with correct customer', async () => {
  const mockPrisma = { 
    order: { 
      create: vi.fn().mockResolvedValue({ id: 'order-123' }) 
    } 
  };

  const ctx = { 
    input: { customerId: 'cust-1', items: [], total: 100 },
    prisma: mockPrisma,
    errors: [],
    results: {},
  };

  const result = await createOrder(ctx);

  expect(mockPrisma.order.create).toHaveBeenCalledWith(
    expect.objectContaining({ 
      data: expect.objectContaining({ customerId: 'cust-1' }) 
    })
  );
  expect(result.order.id).toBe('order-123');
});
Enter fullscreen mode Exit fullscreen mode

What's in the Starter

  • BaseOrchestrator with TypeScript generics
  • Prometheus metrics per operation
  • Grafana dashboard generator
  • Swagger from TypeBox
  • JWT auth
  • Prisma + migrations
  • Tests next to the code they test
  • CLI to scaffold new services

Run npm run generate, pick "Service", answer the prompts. You get an orchestrator, operations, types, tests, and routes. About 600 lines across 14 files.


When to Skip This

Simple CRUD - If it's just prisma.user.findUnique(), use Prisma directly. Don't add layers you don't need.

One step - If there's no pipeline, don't pretend there is.

The pattern pays off at 3+ stages, or when you need to mix "must succeed" and "nice to have" operations, or when you're debugging and need to know which stage is slow.


Why Not Pure Functions?

I tried "operations return intents, orchestrator executes them." Sounds clean. In practice:

  • Intent types explode - Every side effect needs a type. The union grows forever.
  • Context gets weird - The operation knows why it's creating a record, but now it's disconnected from the actual create. Error handling gets awkward.
  • Read-then-write breaks - What if you need to fetch something, then decide what to write based on what you fetched? Now you need multi-step intents or nested orchestration.

Current approach: operations own their side effects. You get isolation without purity. If you want actual purity, look at Effect-TS or fp-ts. This is for people who want structure without changing how they write TypeScript.


What's wrong with this?

This is my first real backend project - I've been mostly frontend/product before this. Built it to solve a real problem I had, but I'm sure there are patterns I'm missing or things I've overcomplicated.

Repo: github.com/DriftOS/fastify-starter

Top comments (0)