Node.js Backend Architecture: Patterns That Scale
Reading time: 5 minutes
Last modified:
Most Node.js backends start as Express apps with a flat folder structure. A routes/ folder, a controllers/ folder, maybe a models/ folder. That works until the codebase has 20+ routes and 3+ developers. The patterns below don’t require a rewrite — they’re the structure to start with.
Module organisation with NestJS
Express leaves structure entirely to the team. NestJS enforces module boundaries through its dependency injection system, which makes those boundaries visible and enforceable in code review.
The mistake most teams make is organising by layer: /src/controllers/, /src/services/, /src/repositories/. You end up navigating between three folders to understand one feature. Organise by domain instead:
src/
orders/
orders.module.ts
orders.controller.ts
orders.service.ts
orders.repository.ts
dto/
create-order.dto.ts
order-response.dto.ts
entities/
order.entity.ts
products/
...
users/
...
Each layer has one job:
- Controller — HTTP boundary only. Parse the request, validate input with class-validator DTOs, format the response. No business logic.
- Service — Business logic. No SQL queries, no direct HTTP calls to other services.
- Repository — Database queries. Returns domain objects, not raw query results.
- DTO — Validated shapes for incoming requests and outgoing responses. Use
class-validatordecorators on every field.
A NestJS module exports only what other modules need. If OrdersModule needs to look up product prices, it imports ProductsModule and injects ProductsService — not ProductsRepository. That boundary keeps the database layer internal.
For Swagger documentation within this structure, see NestJS Modules + Swagger: API Documentation Best Practices.
Database patterns
TypeORM vs Prisma in 2026: Prisma has better type safety and migration tooling — the generated client types match your schema exactly. TypeORM gives more control over query construction and has a longer track record with NestJS. Either works; pick Prisma for greenfield projects, TypeORM if the team already knows it.
Keep the repository pattern strict: never import EntityManager or DataSource directly into a service. Create a typed repository class:
@Injectable()
export class OrderRepository {
constructor(@InjectRepository(Order) private repo: Repository<Order>) {}
findByUser(userId: string): Promise<Order[]> {
return this.repo.find({ where: { userId } });
}
findWithItems(orderId: string): Promise<Order | null> {
return this.repo.findOne({
where: { id: orderId },
relations: ['items', 'items.product'],
});
}
}
The service calls this.orderRepository.findByUser(userId) — it never writes SQL. When you switch from TypeORM to Prisma, you rewrite the repository class, not the service.
Database per service only makes sense with genuine microservices. A shared PostgreSQL database for a monolith is fine — and avoids distributed transaction complexity.
Connection pooling: run PgBouncer in front of PostgreSQL for APIs handling more than a few hundred requests per second. Node.js opens many short-lived connections; PgBouncer pools them at the database level.
API design
REST vs GraphQL: REST for resource-based APIs where clients know what they need. GraphQL when multiple front-ends (web, mobile, third-party integrations) need different subsets of the same data.
Versioning: prefix every route with /api/v1/ from day one. Changing that prefix once clients depend on it is painful. NestJS makes this a one-line config: app.setGlobalPrefix('api/v1').
Error format: NestJS returns { statusCode, message, error } by default from HttpException. Keep that shape for all errors — including business logic errors thrown as BadRequestException or UnprocessableEntityException. Clients parse one error format, not three.
Authentication: JWT for stateless APIs. Session cookies for web apps with server-side rendering. Don’t mix the two in the same application — pick one and apply it everywhere.
Rate limiting: @nestjs/throttler with default in-memory storage for single-instance deployments. For multiple instances behind a load balancer, back it with Redis so rate limits apply per-user across all instances, not per-instance.
Scalability without microservices
A stateless Node.js process plus shared Redis scales horizontally. Add instances behind a load balancer; sessions and cache live in Redis, not in process memory. This gets most applications to thousands of concurrent users without touching the code architecture.
Background jobs: use BullMQ with Redis for anything that doesn’t need to complete within the HTTP response window — sending emails, generating reports, calling third-party webhooks, processing uploads. The HTTP handler enqueues the job and returns immediately; a separate worker process handles it. BullMQ gives you retries, delay, priority, and job visibility out of the box.
Caching: add a Redis cache at the repository layer for read-heavy endpoints. Product catalog, user profiles, and configuration data are good candidates — data that changes infrequently but is queried on every request. Cache with a TTL that matches how stale the data can be (60 seconds for product prices; 24 hours for static configuration).
Event-driven coupling inside the monolith: NestJS EventEmitter2 lets services react to domain events without direct dependencies. OrdersService emits OrderCreated; NotificationsService, InventoryService, and WebhooksService each subscribe independently. Adding a new subscriber doesn’t touch OrdersService. This pattern gives you the loose coupling of a message broker without the operational overhead.
Deployment
Docker: multi-stage Dockerfile. Build stage installs all dependencies and compiles TypeScript. Production stage copies the compiled output and runs npm install --production. The production image stays under 200 MB and has no compiler or devDependencies.
Configuration: never hardcode config values. Use @nestjs/config with .env files and validate every variable at startup with a Joi schema. If DATABASE_URL is missing, the process exits with a clear error before serving any requests.
Health checks: expose a /health endpoint that checks database connectivity and Redis connectivity. Your load balancer probes this before sending traffic to a new instance. A pod that can’t reach the database never receives production traffic.
Logging: structured JSON with Pino (faster than Winston, same API surface). Every log line is a JSON object with level, timestamp, requestId, and message. Log aggregators (Datadog, Grafana Loki) parse these automatically — no custom parsing rules needed.
Need a senior Node.js team to design or review your backend architecture? CimpleO’s full-stack development team builds NestJS backends from schema design to production deployment. Tell us what you’re building.
Часто задаваемые вопросы
Should I use NestJS or Express for a new Node.js project?
NestJS for anything beyond a simple API or script. Express is minimal and flexible — which means every team invents their own structure. NestJS gives you that structure out of the box: modules, dependency injection, decorators, and built-in Swagger integration. The learning curve is real but pays off in teams of 2+ people.
How do I structure a NestJS application for a team?
Organise by domain, not by layer. Instead of /controllers, /services, /repositories as top-level folders, use /orders, /products, /users — each with its own module, controller, service, and repository. Modules import only what they need and export only what other modules should access.
When should I move from a monolith to microservices?
When you have independent scaling requirements (checkout service needs 10× the resources of the catalog service), separate deployment cadences (mobile team ships weekly; payments team ships monthly), or team boundaries that benefit from service ownership. Not because the codebase got large — large monoliths are fine.
How do I handle database migrations safely in Node.js?
Use a migration tool (TypeORM migrations, Prisma migrate, or raw SQL via node-pg-migrate). Never apply schema changes directly to production — run them through CI against a staging database first. Migrations should be reversible: always write a down() function. Test the rollback before you need it.