<< Home

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' };
  }
}
💡 Keep DTOs tiny; push complex rules into application services where you can test logic without HTTP concerns.

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' } }));
}
⚠️ Avoid reusing HTTP DTOs inside the domain. If you mix concerns, you will couple the core to specific transports and validation libraries.

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.

StyleProsTrade offs
HTTP JSONEasy to inspect; broad toolingPayload size; schema drift if types are not enforced
tRPCEnd to end typing; client generation at compile timeTight coupling to TypeScript runtime assumptions
gRPC or ConnectStrong schemas; streaming; fastExtra tooling; binary frames are harder to debug
EventsLoose coupling; natural for fan outEvent 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));
}
💡 Put 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' };
}
⚠️ Never reuse a type name for a different meaning. If the shape changes in a breaking way, publish a new name or a new version to avoid confusion in downstream code.

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.