Skip to content

Instantly share code, notes, and snippets.

@mubbi
Created March 13, 2026 11:25
Show Gist options
  • Select an option

  • Save mubbi/8a7636f568db81051057e36d8a0bad45 to your computer and use it in GitHub Desktop.

Select an option

Save mubbi/8a7636f568db81051057e36d8a0bad45 to your computer and use it in GitHub Desktop.

Node.js Architecture with Express.js

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.


Folder Structure

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 Explanation

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.

Route Registration Pattern

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.


Key Architectural Practices

1. Dependency Injection

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.

2. Repository Pattern (Ports & Adapters)

Domain defines repository interfaces (ports). Infrastructure provides implementations (adapters). This lets you swap PostgreSQL for MongoDB — or a test double — without touching business logic.

3. DTO & Validation Layer

  • 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.

4. Service Layer

Services encapsulate business rules and orchestrate across repositories, caches, and queues. They are the only layer that emits domain events.

5. Domain Events & Inter-Feature Communication

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, ...]

6. Structured Error Hierarchy

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.

7. Standardized API Response Envelope

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.

8. Pagination

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.

9. Middleware Layer

Middleware executes in a deliberate order:

requestId → cors → helmet → rateLimiter → logger → auth → [route handler] → errorHandler

10. Observability & Reliability

  • 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) and GET /ready (readiness — checks DB, Redis, queues).
  • Metrics: Latency histograms, error counters, and throughput gauges per route, exposed via /metrics for Prometheus scraping.
  • Distributed tracing: OpenTelemetry auto-instruments Express, HTTP clients, and DB drivers.

11. Security

  • 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; .env only for local development.
  • Dependency auditing: Run npm audit in CI; pin major versions.

12. Configuration Management

  • 12-factor: all configuration comes from environment variables.
  • config/index.ts validates and types all variables at startup using Zod or envalid. Missing or invalid config fails the process immediately.
  • Split config by concern (database.ts, cache.ts, auth.ts) to avoid a monolithic config object.

13. Graceful Shutdown

server.ts handles SIGTERM and SIGINT:

  1. Stop accepting new connections.
  2. Drain in-flight HTTP requests (with a timeout).
  3. Close DB connection pool, Redis, queue workers.
  4. Flush pending logs and metric buffers.
  5. Exit with code 0.

This is critical for Kubernetes rolling deployments and zero-downtime releases.

14. API Versioning

  • Prefix routes with /v1, /v2 at the route registration layer.
  • Use OpenAPI/Swagger to document each version.
  • Maintain backward compatibility within a version; deprecate with sunset headers before removal.

Extended Dependency Flow

                          ┌──────────────┐
                          │  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.


Recommended Technologies

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

Team & Module Boundaries

  • 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:
    1. Domain interfaces injected via the DI container, or
    2. 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 CODEOWNERS file to enforce review gates per feature.

Test Strategy

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.

Operational Readiness Checklist

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.example is committed and kept up to date; real .env is in .gitignore

When to Split into Microservices

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:

  1. The feature's routes, services, repositories, and domain events become the new service's codebase.
  2. Domain event subscribers become message consumers (RabbitMQ / Kafka).
  3. Contract tests remain and expand to cover the network boundary.
  4. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment