Chapter 27: Frameworks and Architectures
Architectural choices shape how your TypeScript and Node services evolve. This chapter focuses on patterns and frameworks that encourage clear boundaries, reliable contracts, and repeatable delivery. The goal is to keep the codebase small in surface area while allowing features to grow in a predictable way.
NestJS and modular design
NestJS offers a structured approach that feels familiar to developers who like dependency injection, explicit modules, and annotations. Under the surface it uses standard Node libraries and TypeScript features, which means you can keep control of primitives while gaining helpful conventions. The core idea is to isolate concerns in modules, export providers that act as stable interfaces, and keep controllers as thin orchestration layers.
Modules and providers
Think of a module as a boundary that groups related use cases, entities, and infrastructure details. Providers inside a module represent capabilities that can be swapped or mocked. Keep module public APIs small; only export what other modules need.
// app.module.ts
import { Module } from '@nestjs/common';
import { BillingModule } from './billing/billing.module';
import { OrdersModule } from './orders/orders.module';
@Module({
imports: [BillingModule, OrdersModule]
})
export class AppModule {}
Controllers and validation
Controllers coordinate input, call application services, and then format output. Validation should occur at the edges with schemas or DTOs. If you prefer schema based validation that stays close to types, wire a library such as zod or class validator adapters.
// orders.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { CreateOrder } from './use-cases/create-order';
import { z } from 'zod';
const CreateOrderDto = z.object({
customerId: z.string().uuid(),
lines: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() }))
});
@Controller('orders')
export class OrdersController {
constructor(private readonly createOrder: CreateOrder) {}
@Post()
async create(@Body() body: unknown) {
const dto = CreateOrderDto.parse(body);
const result = await this.createOrder.execute(dto);
return { id: result.id, status: 'created' };
}
}
Dependency injection and testing
Abstractions help you swap implementations during tests. Define an interface for outbound ports, then bind either a production adapter or a fake. Keep the binding at the module level so tests can rebind easily.
// billing.port.ts
export interface BillingPort {
charge(cents: number, source: string): Promise<{ authId: string }>;
}
// billing.module.ts
import { Module } from '@nestjs/common';
import { BillingPort } from './billing.port';
import { StripeAdapter } from './stripe.adapter';
@Module({
providers: [{ provide: 'BillingPort', useClass: StripeAdapter }],
exports: ['BillingPort']
})
export class BillingModule {}
Hexagonal architecture with types
Hexagonal architecture separates the domain from delivery and infrastructure. The domain holds pure types and logic. Adapters translate between the outside world and those types. Explicit TypeScript interfaces for ports make these boundaries visible and enforceable.
Domain core and ports
Keep the domain free of libraries that deal with I/O. Express domain intent with value objects, entities, and functions. Ports describe what the domain needs from the outside, while adapters satisfy those needs.
// domain/order.ts
export type Money = { cents: number; currency: 'USD' | 'EUR' };
export type OrderLine = { sku: string; qty: number; price: Money };
export function total(lines: OrderLine[]): Money {
const cents = lines.reduce((acc, l) => acc + l.qty * l.price.cents, 0);
return { cents, currency: 'USD' };
}
Adapters and mappers
Adapters convert protocols and storage formats into domain types. Mappers ensure only valid values cross the boundary. If an upstream payload contains optional or unknown fields, prefer explicit mapping to avoid leaking shapes into the core.
// adapters/http-order-mapper.ts
import { z } from 'zod';
import { OrderLine } from '../domain/order';
const HttpLine = z.object({
sku: z.string(),
qty: z.number().int().positive(),
priceCents: z.number().int().nonnegative()
});
export function fromHttp(input: unknown): OrderLine[] {
const arr = z.array(HttpLine).parse(input);
return arr.map(l => ({ sku: l.sku, qty: l.qty, price: { cents: l.priceCents, currency: 'USD' } }));
}
Use cases as application services
Use cases coordinate domain operations and ports. Each use case should expose a single method such as execute that accepts a typed request and returns a typed result. This pattern supports synchronous calls and also fits queues or events.
// app/create-order.ts
import { BillingPort } from '../ports/billing.port';
import { total, OrderLine } from '../domain/order';
export type CreateOrderRequest = { customerId: string; lines: OrderLine[] };
export type CreateOrderResult = { id: string; authId: string };
export class CreateOrder {
constructor(private billing: BillingPort) {}
async execute(req: CreateOrderRequest): Promise<CreateOrderResult> {
const amount = total(req.lines).cents;
const { authId } = await this.billing.charge(amount, req.customerId);
return { id: crypto.randomUUID(), authId };
}
}
Microservices, RPC, and events
Service boundaries are communication boundaries. Choose between request response protocols such as RPC, and asynchronous messaging such as events. Both benefit from shared types and generated clients that prevent drift. Keep the internal domain decoupled even when transport libraries change.
Comparing protocols
Pick a protocol that matches latency, payload shape, and evolution needs. Human readable payloads help during early development. Binary protocols can reduce overhead once schemas stabilize.
| Style | Pros | Trade offs |
| HTTP JSON | Easy to inspect; broad tooling | Payload size; schema drift if types are not enforced |
| tRPC | End to end typing; client generation at compile time | Tight coupling to TypeScript runtime assumptions |
| gRPC or Connect | Strong schemas; streaming; fast | Extra tooling; binary frames are harder to debug |
| Events | Loose coupling; natural for fan out | Event ordering and idempotency require careful design |
Type safe RPC
Define a service contract once, then generate clients and servers. With TypeScript first approaches, you can author the contract in code and derive routers. With schema first approaches, you can compile from .proto or similar into TypeScript definitions.
// trpc/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
charge: t.procedure
.input(z.object({ cents: z.number().int().positive(), source: z.string() }))
.output(z.object({ authId: z.string() }))
.mutation(async ({ input }) => {
return { authId: 'auth_' + input.source };
})
});
export type AppRouter = typeof appRouter;
Event driven contracts
Events represent facts that already happened. Encode a compact, versioned envelope with a type, version, and an immutable data payload. Consumers should treat events as append only signals and handle duplicates safely.
// events.ts
export type EventEnvelope<TType extends string, T> = {
id: string;
type: TType;
version: number;
occurredAt: string;
data: T;
};
export type OrderCreated = EventEnvelope<'order.created', {
orderId: string;
customerId: string;
totalCents: number;
lines: Array<{ sku: string; qty: number }>;
}>;
// publisher.ts
import { OrderCreated } from './events';
export async function publishOrderCreated(e: OrderCreated): Promise<void> {
// send to Kafka or NATS; headers may include tracing ids
await send('order.created', JSON.stringify(e));
}
version in the envelope; bump it only when a breaking change occurs, and keep consumers tolerant to additional fields.
Idempotency and retries
Distributed systems fail in partial ways. Design handlers to be safe under repeated delivery. Use an idempotency key per business action; store results keyed by this value to ignore duplicates.
// idempotency.ts
export async function withIdempotency<T>(key: string, work: () => Promise<T>): Promise<T> {
const existing = await store.get(key);
if (existing) return existing as T;
const result = await work();
await store.set(key, result);
return result;
}
Shared types across services
Strongly typed boundaries depend on shared contracts that evolve deliberately. A monorepo can host shared packages that publish type definitions and small runtime helpers. Even in a polyrepo, you can publish versioned packages with semantic versioning that match API changes.
Monorepo workspaces
Workspaces let you develop shared packages and services together while keeping deploy units independent. Use project references so TypeScript understands build order and emits once per package.
// package.json
{
"private": true,
"workspaces": ["packages/*", "services/*"]
}
// packages/contracts/tsconfig.json
{
"compilerOptions": { "composite": true, "declaration": true, "outDir": "dist" },
"include": ["src"]
}
API extraction and stability
Automated API reports help you detect accidental breaking changes. Generate declarations from contracts, then compare against the previous report in CI. Treat a changed public type as a potential breaking change and bump the major version if required.
// contracts/src/index.ts
export type ChargeRequest = { cents: number; source: string };
export type ChargeResult = { authId: string };
Schema evolution
Forward compatible readers should ignore unknown fields. Backward compatible writers should prefer adding optional fields over renaming or removing. For events, consider an upcaster that upgrades older versions into the newest shape at read time.
// upcast.ts
type V1 = { orderId: string; totalCents: number };
type V2 = { orderId: string; totalCents: number; currency?: 'USD' | 'EUR' };
export function upcastToV2(input: V1 | V2): V2 {
return 'currency' in input ? input : { ...input, currency: 'USD' };
}
Runtime validation for shared packages
Compile time types are not enough at process boundaries. Export parsers along with TypeScript types so both sides share the same single source of truth. The caller validates before sending, and the callee validates upon receipt.
// contracts/src/billing.ts
import { z } from 'zod';
export const ChargeRequestSchema = z.object({
cents: z.number().int().positive(),
source: z.string()
});
export type ChargeRequest = z.infer<typeof ChargeRequestSchema>;
Versioning and compatibility policy
Adopt a clear policy that maps API and event changes to semantic versions. Publish a short document in the repository that describes what counts as a breaking change. Teams move faster when they can trust that a patch means safety and a minor version means additive features.