This architecture is designed for production-grade Node.js apps with Express, focusing on scalability, maintainability, testability, and multi-team collaboration. It follows a modular monolith approach that can evolve into microservices when justified.
src/
├── app/
│ ├── server.ts # HTTP server bootstrap, graceful shutdown
│ ├── app.ts # Express app setup, global middlewares, route registration
│ ├── middleware/
│ │ ├── errorHandler.ts # Centralized error-to-HTTP-response mapper
│ │ ├── notFoundHandler.ts # 404 catch-all
│ │ ├── authMiddleware.ts # JWT / session verification
│ │ ├── corsMiddleware.ts # CORS policy configuration
│ │ ├── rateLimiter.ts # Global / route-level rate limiting
│ │ ├── requestId.ts # Attach correlation ID (X-Request-Id) to every request
│ │ └── loggerMiddleware.ts # Structured request/response logging
│ └── routes/
│ └── index.ts # Auto-discovers and mounts feature routes (single entry point)
│
├── features/
│ ├── auth/
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ ├── auth.routes.ts
│ │ ├── dtos/
│ │ │ ├── login.dto.ts
│ │ │ └── register.dto.ts
│ │ └── validators/
│ │ └── auth.validator.ts
│ │
│ ├── users/
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ ├── user.routes.ts
│ │ ├── user.subscriber.ts # Listens to domain events (e.g. UserCreated)
│ │ ├── dtos/
│ │ │ ├── create-user.dto.ts
│ │ │ └── update-user.dto.ts
│ │ └── validators/
│ │ └── user.validator.ts
│ │
│ └── messages/
│ ├── message.controller.ts
│ ├── message.service.ts
│ ├── message.repository.ts
│ ├── message.routes.ts
│ ├── dtos/
│ └── validators/
│
├── domain/
│ ├── entities/
│ │ ├── User.ts # Pure domain model (no ORM decorators)
│ │ └── Message.ts
│ ├── repositories/
│ │ ├── IUserRepository.ts # Port interface
│ │ └── IMessageRepository.ts
│ ├── services/
│ │ └── IUserService.ts
│ ├── events/
│ │ ├── DomainEvent.ts # Base event type
│ │ ├── EventBus.ts # Event bus interface
│ │ └── events/
│ │ ├── UserCreated.ts
│ │ └── MessageSent.ts
│ └── value-objects/
│ ├── Email.ts # Self-validating value object
│ └── UserId.ts
│
├── infrastructure/
│ ├── database/
│ │ ├── connection.ts # DB connection pool setup
│ │ ├── models/ # ORM-specific models / schemas
│ │ │ ├── UserModel.ts
│ │ │ └── MessageModel.ts
│ │ └── migrations/
│ ├── logger/
│ │ └── Logger.ts # Pino / Winston wrapper with child logger support
│ ├── di/
│ │ └── container.ts # Dependency injection (tsyringe / InversifyJS)
│ ├── events/
│ │ └── InMemoryEventBus.ts # EventBus implementation (swap for RabbitMQ/Kafka later)
│ ├── observability/
│ │ ├── metrics.ts # Prometheus / OpenTelemetry metrics
│ │ └── tracing.ts # Distributed tracing (OpenTelemetry SDK)
│ ├── cache/
│ │ └── RedisClient.ts
│ ├── queue/
│ │ └── BullQueue.ts # Background job processing (BullMQ)
│ ├── storage/
│ │ └── S3Client.ts # File uploads / object storage
│ ├── email/
│ │ └── EmailService.ts
│ └── http/
│ └── HttpClient.ts # Outbound HTTP wrapper with retry, timeout, circuit breaker
│
├── shared/
│ ├── utils/
│ │ ├── pagination.ts # Standardized pagination helper
│ │ ├── date.ts # Date formatting / timezone helpers
│ │ └── constants.ts
│ ├── errors/
│ │ ├── AppError.ts # Base application error
│ │ ├── NotFoundError.ts
│ │ ├── ValidationError.ts
│ │ ├── UnauthorizedError.ts
│ │ └── ForbiddenError.ts
│ ├── responses/
│ │ └── ApiResponse.ts # Standardized { success, data, meta, errors } envelope
│ └── types/
│ └── index.d.ts
│
├── tests/
│ ├── unit/
│ ├── integration/
│ ├── contract/ # Consumer-driven contract tests (Pact)
│ ├── e2e/ # End-to-end API tests
│ ├── fixtures/ # Static test data
│ └── factories/ # Dynamic test data builders (factory pattern)
│
├── config/
│ ├── index.ts # Validated env config (loaded once at startup)
│ ├── database.ts
│ ├── cache.ts
│ ├── auth.ts
│ └── logger.ts
│
├── scripts/
│ ├── migrate.ts # DB migration runner
│ ├── seed.ts # Seed data loader
│ └── healthcheck.ts # Container healthcheck script
│
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── .env.example # Documented env var template (never commit real .env)
├── .eslintrc.js
├── .prettierrc
├── package.json
└── tsconfig.json
| Layer | Purpose |
|---|---|
| app/ | Express bootstrap, HTTP server lifecycle (including graceful shutdown), global middleware chain, and a single route index that auto-discovers feature routes. |
| features/ | Feature-based vertical slices. Each feature owns its controller, service, repository, DTOs, validators, routes, and event subscribers. Features never import directly from other features — they communicate via the domain event bus or injected interfaces. |
| domain/ | Pure business logic — entities, value objects, repository interfaces (ports), service interfaces, and domain events. This layer has zero framework dependencies. |
| infrastructure/ | Adapters for external concerns: database, cache, queues, email, object storage, outbound HTTP, event bus implementation, observability, and the DI container. |
| shared/ | Cross-cutting utilities, a structured error hierarchy, a standardized API response envelope, type definitions, and constants. |
| tests/ | Unit, integration, contract, and e2e tests with dedicated fixture and factory directories. |
| config/ | Typed, validated configuration loaded from environment variables at startup. Split by concern (database, cache, auth, logger). |
| scripts/ | Operational tooling: migration runner, seeder, healthcheck. |
Feature routes are the single source of truth. The app/routes/index.ts file acts as a registry that imports and mounts each feature's route file under its versioned prefix:
// app/routes/index.ts
import { Router } from 'express';
import authRoutes from '../../features/auth/auth.routes';
import userRoutes from '../../features/users/user.routes';
import messageRoutes from '../../features/messages/message.routes';
export default function registerRoutes(router: Router) {
router.use('/v1/auth', authRoutes);
router.use('/v1/users', userRoutes);
router.use('/v1/messages', messageRoutes);
}There is no duplication. Features define routes; app/ mounts them.
Use a DI container (tsyringe or InversifyJS) to wire all services, repositories, and infrastructure adapters. Controllers receive services via constructor injection, services receive repositories — never the reverse.
Domain defines repository interfaces (ports). Infrastructure provides implementations (adapters). This lets you swap PostgreSQL for MongoDB — or a test double — without touching business logic.
- Inbound DTOs validate and whitelist incoming request payloads (use Zod or class-validator).
- Outbound DTOs shape API responses, hiding internal fields (passwords, internal IDs).
- Validation runs before the controller via middleware or decorator.
Services encapsulate business rules and orchestrate across repositories, caches, and queues. They are the only layer that emits domain events.
Features must not import each other directly. Instead:
- A service emits a domain event (e.g.
UserCreated). - Other features register subscribers that react asynchronously.
- The event bus starts in-memory (
InMemoryEventBus) and can be swapped for RabbitMQ, Kafka, or Redis Streams when the system scales.
UserService.create() → emit(UserCreated) → [EmailSubscriber, AuditSubscriber, ...]
All application errors extend AppError with an HTTP status code and machine-readable error code:
// shared/errors/AppError.ts
export class AppError extends Error {
constructor(
public readonly message: string,
public readonly statusCode: number = 500,
public readonly code: string = 'INTERNAL_ERROR',
public readonly isOperational: boolean = true,
) {
super(message);
}
}The errorHandler middleware catches these and maps them to a consistent JSON envelope. Non-operational errors (programmer bugs) trigger alerts and return a generic 500.
Every endpoint returns the same shape:
{
"success": true,
"data": { ... },
"meta": { "page": 1, "limit": 20, "total": 142 },
"errors": null
}This is enforced via ApiResponse.ts helpers used in controllers.
Use cursor-based pagination for large / real-time datasets, offset-based for admin/backoffice UIs. The pagination.ts helper standardizes query parsing and meta generation.
Middleware executes in a deliberate order:
requestId → cors → helmet → rateLimiter → logger → auth → [route handler] → errorHandler
- Correlation IDs: Every request gets an
X-Request-Id(generated or forwarded). All logs, traces, and downstream calls propagate it. - Health probes:
GET /health(liveness) andGET /ready(readiness — checks DB, Redis, queues). - Metrics: Latency histograms, error counters, and throughput gauges per route, exposed via
/metricsfor Prometheus scraping. - Distributed tracing: OpenTelemetry auto-instruments Express, HTTP clients, and DB drivers.
- Helmet for HTTP security headers.
- Rate limiting per route, per API key, and per IP (using
express-rate-limit+ Redis store for distributed deployments). - CORS configured per environment (strict in production, permissive in dev).
- Input sanitization via DTOs — never trust raw
req.body. - Secrets loaded from a secrets manager (AWS Secrets Manager, HashiCorp Vault) in production;
.envonly for local development. - Dependency auditing: Run
npm auditin CI; pin major versions.
- 12-factor: all configuration comes from environment variables.
config/index.tsvalidates and types all variables at startup using Zod orenvalid. Missing or invalid config fails the process immediately.- Split config by concern (
database.ts,cache.ts,auth.ts) to avoid a monolithic config object.
server.ts handles SIGTERM and SIGINT:
- Stop accepting new connections.
- Drain in-flight HTTP requests (with a timeout).
- Close DB connection pool, Redis, queue workers.
- Flush pending logs and metric buffers.
- Exit with code 0.
This is critical for Kubernetes rolling deployments and zero-downtime releases.
- Prefix routes with
/v1,/v2at the route registration layer. - Use OpenAPI/Swagger to document each version.
- Maintain backward compatibility within a version; deprecate with sunset headers before removal.
┌──────────────┐
│ HTTP Client │
└──────┬───────┘
│
┌──────▼───────┐
│ Middleware │ requestId → cors → helmet → auth → logger
└──────┬───────┘
│
┌──────▼───────┐
│ Validator │ DTO parsing + validation (reject early)
└──────┬───────┘
│
┌──────▼───────┐
│ Controller │ Thin: delegates to service, builds response
└──────┬───────┘
│
┌──────▼───────┐
│ Service │ Business logic, emits domain events
└──────┬───────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌───────▼───────┐
│ Repository │ │ Cache │ │ Event Bus │
└──────┬──────┘ └─────────────┘ └───────┬───────┘
│ │
┌──────▼──────┐ ┌────────▼────────┐
│ Database │ │ Subscribers │
└─────────────┘ │ (Queue, Email) │
└─────────────────┘
Error propagation: any layer can throw a typed AppError → caught by errorHandler middleware → mapped to JSON response.
| Concern | Primary Pick | Alternative | Why |
|---|---|---|---|
| Runtime | Node.js 20+ LTS | — | Long-term support, native ESM, performance improvements |
| Framework | Express 4.x | Fastify | Express for ecosystem maturity; Fastify for raw throughput |
| Language | TypeScript 5.x (strict mode) | — | Type safety is non-negotiable at scale |
| Database | PostgreSQL | MongoDB | PostgreSQL for relational; Mongo for document-oriented domains |
| ORM | Prisma | TypeORM, Drizzle | Prisma for type-safe queries and migrations |
| Cache | Redis (ioredis) | KeyDB | ioredis for cluster support and pipelining |
| Queues | BullMQ | Agenda, pg-boss | BullMQ for Redis-backed, priority queues with dashboard |
| Validation | Zod | class-validator | Zod for runtime + static type inference from a single schema |
| Logging | Pino | Winston | Pino for structured JSON logs with low overhead |
| Testing | Vitest + Supertest | Jest | Vitest for speed, native ESM, and TypeScript support |
| Contract Tests | Pact | — | Consumer-driven contract testing for service boundaries |
| DI Container | tsyringe | InversifyJS | tsyringe for lightweight decorator-based DI |
| API Docs | Swagger (swagger-jsdoc) | TypeDoc | Auto-generated OpenAPI spec from route annotations |
| Observability | OpenTelemetry | Datadog SDK | Vendor-neutral tracing, metrics, and logs |
| Linting | ESLint + Prettier | Biome | Industry standard; Biome for faster all-in-one alternative |
| Container | Docker + docker-compose | Podman | Standard for local dev and CI parity |
- Each
features/folder is a bounded context with a clear public surface (routes, DTOs) and private internals (services, repositories, subscribers). - Rule: No feature imports from another feature. Cross-feature dependencies go through:
- Domain interfaces injected via the DI container, or
- Domain events consumed by subscribers.
- Code ownership maps 1:1 to feature folders — each team owns one or more features and their domain contracts.
- Use a
CODEOWNERSfile to enforce review gates per feature.
| Level | Scope | What to Test | Speed |
|---|---|---|---|
| Unit | Service, domain logic, value objects | Business rules in isolation with mocked dependencies | Fast (ms) |
| Integration | Repository + real DB, cache | Data access correctness with test containers | Medium (s) |
| Contract | API schemas between services | Request/response shape compatibility (Pact) | Medium (s) |
| E2E | Full HTTP request through the stack | Critical user journeys via Supertest against a running app | Slow (s) |
- Use test factories (
tests/factories/) to build domain entities with sensible defaults. - Use testcontainers to spin up Postgres/Redis in integration tests — never mock the database at this level.
- Aim for high unit test coverage on services and domain, selective integration coverage on repositories, and thin e2e coverage on critical paths.
Before going to production, verify:
- Graceful shutdown handles SIGTERM correctly (drain connections, flush buffers)
- Health probes (
/health,/ready) are implemented and wired into orchestrator - Structured logging with correlation IDs in every log line
- Metrics endpoint exposed for Prometheus/Grafana
- Rate limiting configured per route and globally
- CORS locked down to allowed origins
- Helmet security headers enabled
- Secrets loaded from a secrets manager, not
.env - Docker image uses multi-stage build, runs as non-root, and has a healthcheck
- CI pipeline runs lint → type-check → unit → integration → contract → build → image push
- Database migrations run automatically before deployment (not at app startup)
- Error tracking integrated (Sentry or equivalent)
- Dependency audit runs in CI (
npm audit --audit-level=high) -
.env.exampleis committed and kept up to date; real.envis in.gitignore
Start with this modular monolith. Extract a feature into its own service only when:
- It has fundamentally different scaling requirements (e.g. message processing needs 10x the compute of auth).
- It requires a different persistence model or data lifecycle (e.g. time-series vs relational).
- A team needs to deploy independently with its own release cadence.
- The feature's fault isolation matters (a crash in messaging should not bring down auth).
When extracting:
- The feature's
routes,services,repositories, anddomain eventsbecome the new service's codebase. - Domain event subscribers become message consumers (RabbitMQ / Kafka).
- Contract tests remain and expand to cover the network boundary.
- The shared
domain/interfaces become a published API schema (OpenAPI or protobuf).
This architecture supports large-scale enterprise applications with clean separation of concerns, testable units, scalable feature modules, and a clear path from monolith to microservices.