users/:userId",
method: "GET",
params: z.object({ userId: z.string().uuid() }),
response: z.object({
id: z.string(),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]),
lastLogin: z.string().datetime()
})
} as const;
**Rationale:** Declarative contracts enable static type inference, automated OpenAPI generation, and consistent validation. By separating the contract from execution, the runtime can pre-validate requests before they reach business logic, eliminating defensive coding inside handlers.
### Step 2: Construct a Composable Execution Pipeline
Cross-cutting concerns should flow through a predictable chain. Each stage handles one responsibility and passes control forward or short-circuits on failure.
```typescript
type PipelineStage<TContext> = (ctx: TContext) => Promise<TContext | void>;
class ExecutionPipeline<TContext> {
private stages: PipelineStage<TContext>[] = [];
use(stage: PipelineStage<TContext>) {
this.stages.push(stage);
return this;
}
async run(initialContext: TContext): Promise<TContext> {
let ctx = { ...initialContext };
for (const stage of this.stages) {
const result = await stage(ctx);
if (result === undefined) break;
ctx = result;
}
return ctx;
}
}
Rationale: A pipeline architecture enforces separation of concerns. Validation, authentication, caching, and telemetry become independent, testable units. The linear execution model makes debugging deterministic and allows teams to inject or remove stages without touching endpoint logic.
Step 3: Bind Contracts to the Runtime Engine
The runtime engine maps incoming requests to their contracts, executes the pipeline, and normalizes the output.
type Contract = typeof GetUserContract;
class RequestOrchestrator {
private contracts: Map<string, Contract> = new Map();
private pipeline: ExecutionPipeline<any>;
constructor(pipeline: ExecutionPipeline<any>) {
this.pipeline = pipeline;
}
register(contract: Contract) {
const key = `${contract.method}:${contract.path}`;
this.contracts.set(key, contract);
}
async handle(req: Request, res: Response) {
const key = `${req.method}:${req.path}`;
const contract = this.contracts.get(key);
if (!contract) {
res.status(404).json({ error: "Route not registered" });
return;
}
const validation = contract.params.safeParse(req.params);
if (!validation.success) {
res.status(400).json({ error: "Invalid parameters", details: validation.error.flatten() });
return;
}
const context = await this.pipeline.run({
request: req,
params: validation.data,
response: res
});
if (context.responseSent) return;
res.json(context.payload);
}
}
Rationale: The orchestrator acts as a thin transport layer. It never contains business logic. Its sole responsibility is contract resolution, parameter extraction, pipeline invocation, and response serialization. This guarantees that every request follows the same execution path, regardless of the underlying domain operation.
Step 4: Implement Domain Adapters
Domain logic should describe intent, not transport mechanics. Adapters translate high-level operations into backend-specific queries.
class UserRepository {
constructor(private orchestrator: RequestOrchestrator) {}
async fetchById(userId: string) {
return this.orchestrator.execute({
contract: GetUserContract,
handler: async (ctx) => {
const record = await db.users.findUnique({ where: { id: ctx.params.userId } });
if (!record) {
ctx.response.status(404).json({ error: "User not found" });
ctx.responseSent = true;
return ctx;
}
ctx.payload = record;
return ctx;
}
});
}
}
Rationale: Adapters decouple the consuming layer from infrastructure details. The repository method declares what it needs (fetchById) and delegates execution to the orchestrator. Transport formatting, validation, and error normalization are handled upstream. This creates clean boundaries between domain intent and system mechanics.
Pitfall Guide
1. The God Runtime Trap
Explanation: Teams overload the execution pipeline with business logic, feature flags, and domain-specific transformations. The runtime becomes a monolithic bottleneck that's difficult to test and version.
Fix: Keep the pipeline strictly infrastructural. Validation, auth, caching, and telemetry belong in the pipeline. Business rules belong in domain adapters or service layers. Enforce this boundary through code reviews and architectural linting.
2. Contract-Implementation Drift
Explanation: Contracts are defined but never enforced, or they diverge from actual behavior as endpoints evolve. This creates false confidence in type safety and validation guarantees.
Fix: Treat contracts as immutable contracts. Use schema validation at runtime, not just compile time. Implement integration tests that verify contract compliance. Reject deployments where handler output doesn't match the declared response schema.
3. Leaking Transport Semantics into Domain Logic
Explanation: Domain adapters start checking HTTP status codes, manipulating headers, or formatting JSON directly. This breaks the separation of concerns and makes the domain layer tightly coupled to the transport layer.
Fix: Domain adapters should only return data or throw domain-specific errors. The runtime pipeline should catch these errors and map them to appropriate HTTP responses. Use error boundary middleware to handle translation.
4. Ignoring Type Inference in Contracts
Explanation: Contracts are treated as runtime-only artifacts. TypeScript types are manually duplicated, leading to maintenance overhead and type mismatches.
Fix: Derive TypeScript types directly from contract schemas using inference utilities. Example: type GetUserInput = z.infer<typeof GetUserContract.params>. This ensures compile-time and runtime validation stay synchronized.
5. Over-Abstracting Simple Endpoints
Explanation: Teams apply the full runtime architecture to trivial health checks or static asset routes. The overhead of contract registration and pipeline execution outweighs the benefits.
Fix: Maintain a bypass mechanism for low-complexity routes. Allow direct handler registration for endpoints that don't require validation, auth, or complex error handling. Use the runtime selectively where cross-cutting concerns justify the abstraction.
6. Skipping Observability Hooks
Explanation: The pipeline executes requests silently. Teams lack visibility into latency, error rates, and stage-specific bottlenecks.
Fix: Inject observability stages early in the pipeline. Use correlation IDs, structured logging, and metrics collection at each stage boundary. Ensure telemetry data includes contract metadata for accurate routing analysis.
7. Misusing Adapter Caching
Explanation: Caching is implemented inside domain adapters without considering cache invalidation strategies or TTL consistency. This leads to stale data and unpredictable behavior.
Fix: Centralize caching in the pipeline as a dedicated stage. Use contract metadata to define cache keys, TTLs, and invalidation triggers. Ensure cache behavior is consistent across all endpoints that share the same data source.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small CRUD API (<20 endpoints) | Traditional handlers with shared middleware | Low overhead, faster initial delivery | Low upfront, moderate long-term maintenance |
| High-throughput public API | Contract-driven runtime with pipeline caching | Predictable execution, consistent error handling, easier scaling | Higher initial setup, significantly lower operational cost |
| Multi-tenant SaaS platform | Runtime with tenant-aware pipeline stages | Centralized auth, isolation, and telemetry per tenant | Moderate setup, high ROI on security and compliance |
| Legacy migration project | Hybrid approach with gradual contract adoption | Minimizes rewrite risk, allows incremental refactoring | Phased cost, reduced migration friction |
Configuration Template
import { z } from "zod";
import { ExecutionPipeline, RequestOrchestrator } from "./runtime";
// 1. Define contract
const ListProductsContract = {
path: "/products",
method: "GET",
params: z.object({
category: z.string().optional(),
limit: z.coerce.number().min(1).max(100).default(20),
offset: z.coerce.number().min(0).default(0)
}),
response: z.object({
data: z.array(z.object({ id: z.string(), name: z.string(), price: z.number() })),
meta: z.object({ total: z.number(), limit: z.number(), offset: z.number() })
})
} as const;
// 2. Build pipeline
const pipeline = new ExecutionPipeline<any>()
.use(async (ctx) => {
// Validation stage
const result = ListProductsContract.params.safeParse(ctx.request.query);
if (!result.success) {
ctx.response.status(400).json({ error: "Invalid query parameters" });
ctx.responseSent = true;
return;
}
ctx.params = result.data;
return ctx;
})
.use(async (ctx) => {
// Auth stage
const token = ctx.request.headers.authorization;
if (!token) {
ctx.response.status(401).json({ error: "Missing authorization" });
ctx.responseSent = true;
return;
}
ctx.user = await verifyToken(token);
return ctx;
})
.use(async (ctx) => {
// Telemetry stage
ctx.start = Date.now();
return ctx;
});
// 3. Initialize orchestrator
const orchestrator = new RequestOrchestrator(pipeline);
orchestrator.register(ListProductsContract);
// 4. Domain adapter
class ProductRepository {
async list(query: z.infer<typeof ListProductsContract.params>) {
const records = await db.products.findMany({
where: query.category ? { category: query.category } : {},
take: query.limit,
skip: query.offset
});
const total = await db.products.count();
return { data: records, meta: { total, limit: query.limit, offset: query.offset } };
}
}
// 5. Wire to transport layer
app.get("/products", async (req, res) => {
await orchestrator.handle(req, res);
});
Quick Start Guide
- Install dependencies: Add
zod for schema validation and set up your preferred HTTP framework.
- Create your first contract: Define path, method, params, and response schemas using Zod. Export as
const.
- Build a minimal pipeline: Implement validation and error-handling stages. Keep it under 50 lines initially.
- Register and bind: Pass the pipeline to the orchestrator, register the contract, and attach the orchestrator to your framework's route handler.
- Test end-to-end: Send requests with valid, invalid, and missing parameters. Verify that validation, error mapping, and response formatting align with the contract. Iterate on pipeline stages as cross-cutting requirements emerge.