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.
When agents use itUse 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.
Install
Copilot (VS Code, Visual Studio, Copilot CLI and github.com) reads skills from the repository — commit them so the whole team gets them.
npx skills add AGCO-Global/org-skills --skill nodejs-architecture -a github-copilot
# or with the org installer (adds .github/skills/nodejs-architecture):
npx -y github:AGCO-Global/org-skills add skill nodejs-architecture
Uses the open skills CLI. Works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, OpenCode, Windsurf and 60+ others — it asks which agent to install into.
Installs the backend-nodejs-skills plugin, which bundles all Backend / Node.js skills and keeps them updated.
/plugin marketplace add AGCO-Global/org-skills
/plugin install backend-nodejs-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill nodejs-architecture -a claude-code
Use Download .zip above, then upload it under Customize → Skills → Upload skill. Team and Enterprise admins can sync this repo as a plugin marketplace instead.
Installs into .agents/skills/, which Codex reads.
npx skills add AGCO-Global/org-skills --skill nodejs-architecture -a codex
Installs into Cursor's skills folder.
npx skills add AGCO-Global/org-skills --skill nodejs-architecture -a cursor
Installs into Gemini CLI's skills folder.
npx skills add AGCO-Global/org-skills --skill nodejs-architecture -a gemini-cli
Any tool with rules, instructions or custom prompts: use Copy SKILL.md above and paste it in. It is plain Markdown.
Commands use your normal git sign-in to GitHub, so they work while the repository is private. Node.js 20+ required.
Skill contents
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.
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.
---
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.