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;
}
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;
}
}
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 };
}
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 },
];
}
}
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>;
}
What You Get
Per-operation timing - Every response includes a breakdown:
{
"success": true,
"duration": 45,
"metrics": {
"validate": 5,
"create": 28,
"notify": 12
}
}
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');
});
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.
Top comments (0)