Architecture & Design Basics
8 examples to get you started with Architecture & Design for Node.js backends - 6 basic and 2 intermediate.
Prerequisites
These examples assume Node.js 24.18.0, TypeScript 5.6+, and a small Express 5 or Fastify 5 API.
mkdir orders-api && cd orders-api
npm init -y
npm install express@5 zod
npm install -D typescript@5.6 @types/node @types/express tsx
npx tsc --initBasic Examples
1. Start With a Modular Monolith
One deployable, clear module boundaries inside the repo.
src/
├── modules/
│ ├── orders/
│ │ ├── domain/
│ │ ├── application/
│ │ └── infrastructure/
│ └── billing/
├── shared/
└── main.ts- One process, one database, one CI pipeline - lowest operational cost at small scale
- Modules communicate through application services or domain events, not direct DB table access
- Boundaries you draw now become service seams later if evidence demands it
Related: Modular Monolith - package-by-feature inside one deployable
2. Layer Domain Logic Away From HTTP
Routes translate HTTP; domain code stays framework-agnostic.
// src/modules/orders/application/create-order.ts
export type CreateOrderInput = { customerId: string; sku: string; qty: number };
export function createOrder(input: CreateOrderInput) {
if (input.qty <= 0) throw new Error("qty must be positive");
return { id: crypto.randomUUID(), ...input, status: "pending" as const };
}// src/modules/orders/infrastructure/http/routes.ts
import express from "express";
import { createOrder } from "../../application/create-order";
export const ordersRouter = express.Router();
ordersRouter.post("/", (req, res) => {
const order = createOrder(req.body);
res.status(201).json({ data: order });
});createOrderhas noRequestorResponseimports - unit tests run without HTTP- Validation belongs at the boundary (Zod) and in domain invariants
- Swapping Express for Fastify touches only the infrastructure folder
Related: Hexagonal Architecture in Node - ports and adapters layout
3. Define a Port for External Systems
A port is an interface the domain depends on; adapters implement it.
// src/modules/orders/domain/ports/order-repository.ts
export type Order = { id: string; customerId: string; sku: string; qty: number };
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}// src/modules/orders/infrastructure/postgres-order-repository.ts
import type { Order, OrderRepository } from "../domain/ports/order-repository";
export class PostgresOrderRepository implements OrderRepository {
constructor(private readonly pool: { query: (sql: string, params: unknown[]) => Promise<unknown> }) {}
async save(order: Order): Promise<void> {
await this.pool.query(
"INSERT INTO orders (id, customer_id, sku, qty) VALUES ($1,$2,$3,$4)",
[order.id, order.customerId, order.sku, order.qty]
);
}
async findById(id: string): Promise<Order | null> {
const rows = await this.pool.query("SELECT * FROM orders WHERE id = $1", [id]);
return (rows as Order[])[0] ?? null;
}
}- Domain imports the port, never Prisma,
pg, or Redis clients - Tests swap in an in-memory adapter without Docker
- One adapter per technology keeps migration paths obvious
4. Package by Feature, Not by Layer
Group everything for "orders" together instead of a global controllers/ tree.
src/modules/orders/
├── domain/
│ ├── order.ts
│ └── ports/
├── application/
│ ├── create-order.ts
│ └── list-orders.ts
└── infrastructure/
├── http/routes.ts
└── postgres-order-repository.ts- A change to order creation touches one folder, not five top-level directories
- Onboarding engineers find all order code in one place
- ESLint
import/no-restricted-pathscan enforce module boundaries
Related: Modular Monolith - enforcing boundaries in one repo
5. Know When Microservices Are Premature
Distributed systems buy independence at the cost of network, ops, and consistency.
| Signal | Stay monolith | Consider services |
|---|---|---|
| Team size | 1-3 squads on one product | 4+ squads stepping on same deploy |
| Deploy cadence | Weekly or daily shared release | Need hourly deploys per domain |
| Data coupling | Shared transactions across features | Clear bounded contexts with rare cross-joins |
| Ops maturity | Single on-call rotation | Platform team for mesh, tracing, SLOs |
- "We might scale" is not evidence - measure p95 latency and team wait time first
- Extract the hottest module first; keep the rest monolithic
- Every service boundary is a distributed transaction problem waiting to happen
Related: Microservices When Worth It - team and cost trade-offs
6. Record the Decision in an ADR
Architecture Decision Records survive reorgs and memory loss.
# ADR-003: Stay Modular Monolith for 2026
## Status
Accepted
## Context
Three squads, 40k RPS peak, shared Postgres. No dedicated platform team.
## Decision
Remain a modular monolith. Revisit when billing needs independent deploy cadence.
## Consequences
+ Faster feature delivery, simpler debugging
- Billing deploys wait on orders releases- One ADR per significant fork: framework, database, sync vs async
- Link ADRs in PR templates so reviewers see context
- Supersede old ADRs instead of deleting them
Related: ADR: Monolith vs Services - Node-specific template
Intermediate Examples
7. Wire Composition Root at Boot
The composition root is the only place that knows concrete implementations.
// src/main.ts
import express from "express";
import { PostgresOrderRepository } from "./modules/orders/infrastructure/postgres-order-repository";
import { ordersRouter } from "./modules/orders/infrastructure/http/routes";
const pool = { query: async () => [] }; // replace with real pg.Pool
const orderRepo = new PostgresOrderRepository(pool);
const app = express();
app.use(express.json());
app.use("/orders", ordersRouter);
app.listen(3000);- Factories live in
main.tsor a dedicatedcomposition-root.ts - Application services receive ports via constructor injection
- Integration tests build a separate composition root with test doubles
8. Audit Before You Split
Run a structural checklist before extracting a service.
// scripts/architecture-audit.ts - grep for cross-module imports
import fs from "node:fs";
import path from "node:path";
const modulesDir = "src/modules";
for (const mod of fs.readdirSync(modulesDir)) {
const files = fs.readdirSync(path.join(modulesDir, mod), { recursive: true }) as string[];
const tsFiles = files.filter((f) => f.endsWith(".ts"));
console.log(`${mod}: ${tsFiles.length} files`);
}- Count cross-module imports and god files before any network boundary
- If
index.tsre-exports everything, fix modularity inside the monolith first - Extraction without clean seams creates two tangled deployables
Related: Refactoring Checklist - god
index.tsand route sprawl audits
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.