---
name: nodejs-architecture
description: Design and review the architecture of Node.js/TypeScript back-end systems — modular monolith and feature modules, ports-and-adapters, NestJS modules vs Fastify plugins, dependency injection, domain vs transport separation, monorepos and shared packages, event-driven messaging with outbox and idempotency, API style choice, versioning, multi-tenancy, OpenTelemetry and deployment trade-offs. Use this whenever the user is structuring a new Node service, asks where code should live, splits or merges services, sets up pnpm workspaces, Nx or Turborepo, edits app.module.ts, nest-cli.json, turbo.json, nx.json, pnpm-workspace.yaml or package.json workspaces, compares REST, GraphQL, tRPC or gRPC, adds Kafka, RabbitMQ, SQS or BullMQ, or asks "microservices or monolith?" or "containers or serverless?". Also apply it when reviewing a Node.js codebase's structure or an architecture proposal or ADR.
metadata:
  technology: Node.js
  type: architecture
---

# Node.js Architecture

Start as a well-bounded modular monolith and earn every network hop. Most Node back-ends fail from tangled modules and hidden coupling, not from lack of microservices; the architecture's job is to keep the domain testable without HTTP, the database or the broker.

## 1. Decide the shape first

| Situation | Default | Change when |
|---|---|---|
| One team, one product, evolving domain | **Modular monolith**: one deployable, feature modules with enforced boundaries | A module needs independent scaling, release cadence or a different runtime |
| Several teams, stable bounded contexts | Services per bounded context, async integration | Teams share one DB schema: fix ownership before splitting |
| Spiky, event-driven, low steady traffic | Serverless functions around a shared domain package | Sustained load, long connections (WebSockets), heavy cold-start cost |
| CPU-heavy pipelines | Separate worker service (queue-fed) | Never put them in the API process |

Rule: a service owns its data. Two services writing the same tables is a distributed monolith.

## 2. Inside a service: feature modules + ports and adapters (lite)

```
src/
  modules/orders/
    domain/        Order entity, value objects, domain errors, pure rules (no I/O imports)
    application/   use cases: PlaceOrder, CancelOrder; depend on ports (interfaces)
    ports.ts       OrderRepository, PaymentGateway, EventPublisher interfaces
    adapters/      http routes, Prisma/Drizzle repository, payment client, message consumer
    index.ts       the module's public API; other modules import only this
  platform/        config, logger, db, telemetry, error mapping (shared infrastructure)
  app.ts           composition root: wires adapters to use cases, registers routes
```

- Transport (HTTP, queue consumer, cron, CLI) is a thin adapter that parses input, calls a use case, maps the result. The same use case serves REST and a Kafka consumer.
- Domain code never imports `fastify`, `express`, `@nestjs/*`, ORM clients or `process.env`.
- Enforce boundaries with `eslint-plugin-boundaries`, `dependency-cruiser` or Nx module boundary rules; CI fails on cross-module deep imports.
- Keep it "lite": ports only where a second implementation exists or tests need it (DB, external APIs, clock, id generation). Don't wrap every function in an interface.

## 3. Framework structure and DI

| Option | Fits | Notes |
|---|---|---|
| **NestJS modules** | Large codebases, teams used to Angular/Spring patterns | One Nest module per feature; export only providers others need; `forwardRef` is a smell signalling a cycle to break with events or a shared module |
| **Fastify plugins** | Lean services | One encapsulated plugin per feature; decorate shared infra (`db`, `config`) via `fastify-plugin`; features don't reach into each other's decorators |
| **Manual composition root** | Any framework; smallest magic | Plain constructor/factory injection in `app.ts`; easiest to read and test |
| **awilix / tsyringe / inversify** | Many dependencies without Nest | Prefer awilix (no decorators) or keep the container at the edge; never call the container from domain code |

Pass dependencies explicitly (constructor or factory args). Module-level singletons imported everywhere (`import { db } from '../db'`) make testing and multi-tenant wiring painful.

## 4. API style

| Style | Choose when | Avoid when |
|---|---|---|
| **REST + OpenAPI** | Public/partner APIs, cacheable resources, broad client mix | Clients need highly variable nested shapes |
| **GraphQL** (Apollo, Mercurius, Yoga) | Many client types aggregating many entities, BFF layer | Simple CRUD; you can't invest in DataLoader, depth/complexity limits, persisted queries |
| **tRPC** | TS front end and back end in the same monorepo, one team | Public or non-TS consumers |
| **gRPC / Connect** | Service-to-service, streaming, strict contracts across languages | Browser clients without a proxy |

Versioning: additive changes by default; breaking changes via URL (`/v2`) or media-type version for REST, field deprecation for GraphQL, new package/service version for gRPC. Publish a deprecation window and measure old-version traffic before removal.

## 5. Messaging and event-driven design

- **Queue (BullMQ, SQS, RabbitMQ)** for work distribution/commands; **log (Kafka, Redpanda, Kinesis)** for event streams with replay and multiple consumer groups.
- **Transactional outbox**: write the domain change and an `outbox` row in one DB transaction; a relay publishes and marks sent. Never "save then publish" as two independent steps: one will eventually fail alone.
- **Idempotent consumers**: delivery is at-least-once. Dedupe by message id in a processed-messages table (same transaction as the effect) or make the effect naturally idempotent (upserts, conditional updates).
- Events are facts in past tense (`OrderPlaced`) with a versioned schema (JSON Schema, Avro, Protobuf + registry); consumers tolerate unknown fields.
- Ordering only per key (partition by aggregate id); design for retries, poison messages and a dead-letter queue with alerting.
- Sagas/process managers for multi-service workflows; consider a workflow engine (Temporal) when compensation logic grows.

## 6. Multi-tenancy

| Model | Isolation | Cost |
|---|---|---|
| Shared tables + `tenant_id` | Lowest; enforce with row-level security or a mandatory repository filter | Cheapest, simplest ops |
| Schema per tenant | Medium | Migration fan-out, connection handling |
| Database per tenant | Highest, per-tenant backup/residency | Most ops overhead |

Resolve tenant once at the edge (token claim, subdomain), carry it in `AsyncLocalStorage` request context, and make it impossible to query without it. Per-tenant rate limits and noisy-neighbour protection belong in the design.

## 7. Operability: 12-factor, observability, runtime

- 12-factor: config from env (validated once), stateless processes, logs to stdout, backing services as attached resources, dev/prod parity, admin tasks as one-off processes.
- **OpenTelemetry** from day one: `@opentelemetry/sdk-node` with auto-instrumentations loaded before the app (`node --import ./instrumentation.js`); propagate W3C trace context across HTTP and message headers; correlate pino logs with `trace_id`. RED metrics per route, queue depth and consumer lag per topic.
- Health: separate liveness (process alive) and readiness (dependencies reachable, not shutting down).
- **Containers vs serverless**: containers for steady traffic, long-lived connections, connection pools and background consumers; serverless for bursty, event-triggered work, accepting cold starts, per-invocation limits and the need for DB poolers. Keep the domain runtime-agnostic so the choice stays reversible.

## 8. Monorepos and shared packages

- **pnpm workspaces** as the base; **Turborepo** for task caching with minimal opinion; **Nx** when you want generators, affected-graph CI and enforced module boundaries.
- Layout: `apps/<service>`, `packages/<domain-or-lib>`, `packages/tsconfig`, `packages/eslint-config`; internal deps via `workspace:*`; TypeScript project references for incremental builds.
- Share contracts (schemas, generated clients, event types) and platform utilities. Do **not** share domain entities between services; that recouples them.
- Build and deploy only affected apps; each app gets its own Docker image built from a pruned workspace (`turbo prune`, `pnpm deploy`).

## Deliverable format

Produce an **ADR** (Architecture Decision Record) plus a structure sketch:

```
# ADR-NNN: <decision>
Status: Proposed | Accepted     Date: <yyyy-mm-dd>
## Context       forces, constraints, team size, load, SLAs
## Options       2–4 options with the decision-table trade-offs
## Decision      the choice and the one condition that would flip it
## Consequences  what gets easier, what gets harder, migration steps
## Structure     folder tree / module map / sequence of messages
```

## Anti-patterns to reject

- Microservices from day one for one team; services sharing a database.
- Domain logic in route handlers, NestJS controllers or ORM entities with framework decorators leaking inward.
- Cross-module deep imports; `utils/` dumping ground; circular modules patched with `forwardRef`.
- Publishing events outside the DB transaction without an outbox; non-idempotent consumers.
- Shared "common" package containing entities used by every service.
- GraphQL without DataLoader and query cost limits; tRPC exposed to third parties.
- Tenant filtering left to each query author's memory.
- Observability added after the first incident; one health endpoint for both liveness and readiness.
