System Design of a Node.js Application

System Design of a Node.js Application
A deep dive into architecting scalable, maintainable, and production-ready Node.js applications — from folder structure to deployment patterns.
1. Introduction
Node.js has become one of the most popular runtimes for building web backends — and for good reason. Its non-blocking, event-driven I/O model makes it ideal for real-time, data-intensive applications. But raw power without structure leads to chaos.
Whether you're building a SaaS product, a REST API, or a microservice cluster, the system design of your Node.js application determines how far it can scale, how quickly your team can move, and how easily bugs can be traced and fixed.
This article walks through the complete architecture of a production-grade Node.js application — the kind you'd find behind a real product, not a tutorial.
2. Core Principles of Node.js Architecture {#core-principles}
Before writing a single line of code, anchor your architecture to these principles:
- Separation of Concerns — Each layer does one thing. Routes don't talk to databases. Business logic doesn't format HTTP responses.
- Single Responsibility — Each module, class, or function should have one job and do it well.
- Dependency Inversion — High-level modules shouldn't depend on low-level implementations. Use interfaces and abstractions.
- Fail Fast — Validate input early. Surface errors loudly and immediately.
- Statelessness — Design your app to be horizontally scalable. Avoid session state on the server.
3. Project Structure
A well-organized directory structure is the first design decision you make. Here's a battle-tested layout for a monolithic Node.js + Express app:
my-app/
├── src/
│ ├── config/ # App configuration (env, db, redis, etc.)
│ ├── controllers/ # Route handlers — thin, delegate to services
│ ├── services/ # Business logic — the heart of the app
│ ├── repositories/ # Data access layer — talks to DB/ORM
│ ├── models/ # DB models / Mongoose schemas / Sequelize models
│ ├── middlewares/ # Auth, validation, rate-limit, error-handler
│ ├── routes/ # Route definitions grouped by domain
│ ├── utils/ # Reusable helpers (logger, pagination, etc.)
│ ├── jobs/ # Background job processors
│ ├── events/ # Event emitters / pub-sub handlers
│ ├── types/ # TypeScript interfaces and type definitions
│ └── app.js # Express app setup (no server.listen here)
├── tests/
│ ├── unit/
│ ├── integration/
│ └── e2e/
├── .env.example
├── Dockerfile
├── docker-compose.yml
└── server.js # Entry point — binds port, starts serverKey insight: app.js sets up Express, registers middleware and routes. server.js is the entry point that calls app.listen(). This separation makes testing far easier — you can import app without starting a live server.
4. Layered Architecture {#layered-architecture}
The most important structural pattern in Node.js apps is the 3-layer architecture:
Request
│
▼
┌─────────────────────────────┐
│ Route Layer │ ← Defines endpoints, calls controllers
├─────────────────────────────┤
│ Controller Layer │ ← Handles req/res, calls services
├─────────────────────────────┤
│ Service Layer │ ← Business logic, orchestrates data
├─────────────────────────────┤
│ Repository Layer │ ← Data access (queries, mutations)
├─────────────────────────────┤
│ Database / ORM │ ← Postgres, MongoDB, MySQL, etc.
└─────────────────────────────┘Example Flow — Create a User
Route:
// routes/user.routes.js
router.post('/users', validate(createUserSchema), userController.createUser);Controller:
// controllers/user.controller.js
async createUser(req, res, next) {
try {
const user = await userService.createUser(req.body);
res.status(201).json({ success: true, data: user });
} catch (err) {
next(err);
}
}Service:
// services/user.service.js
async createUser(data) {
const existing = await userRepository.findByEmail(data.email);
if (existing) throw new ConflictError('Email already in use');
const hashed = await bcrypt.hash(data.password, 10);
return userRepository.create({ ...data, password: hashed });
}Repository:
// repositories/user.repository.js
async create(data) {
return User.create(data); // ORM call
}This clean separation means your business logic is testable in isolation — no HTTP, no DB required.
5. Database Design & ORM Layer
Choosing the Right Database
| Use Case | Recommended DB |
|---|---|
| Relational data with strict schema | PostgreSQL |
| Flexible/nested documents | MongoDB |
| Caching / ephemeral data | Redis |
| Full-text search | Elasticsearch |
| Time-series data | InfluxDB / TimescaleDB |
ORM / Query Builder Options
- Prisma — TypeScript-first, great DX, auto-generated types
- Sequelize — Mature, feature-rich, supports multiple SQL DBs
- TypeORM — Decorator-based, great for TypeScript projects
- Mongoose — Go-to for MongoDB
- Knex.js — Raw SQL query builder, maximum control
Connection Pooling
Never create a new DB connection per request. Use a connection pool:
// config/database.js (Postgres with pg-pool)
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASS,
max: 20, // Max connections in pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
module.exports = pool;Database Migrations
Use a migration tool (Flyway, Knex migrations, Prisma Migrate) to version your schema. Never modify your production DB manually.
6. API Design {#api-design}
RESTful Design Principles
- Use nouns, not verbs:
/users, not/getUsers - Use HTTP methods correctly:
GET,POST,PUT/PATCH,DELETE - Version your API:
/api/v1/users - Use consistent response envelopes:
{
"success": true,
"data": { ... },
"meta": {
"page": 1,
"total": 100
}
}Input Validation
Always validate inputs at the boundary — before they reach your service layer. Use a schema library:
// Using Zod
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8),
});Validation middleware should reject bad data with a 400 Bad Request before any business logic runs.
Rate Limiting
Protect your API from abuse with express-rate-limit:
const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: { error: 'Too many requests, please try again later.' },
});
app.use('/api/', apiLimiter);7. Authentication & Authorization
JWT-Based Auth Flow
Client → POST /auth/login → Server validates credentials
→ Issues Access Token (15m) + Refresh Token (7d)
→ Stores Refresh Token in Redis
Client → GET /api/me (Bearer <access_token>)
→ Middleware verifies JWT signature & expiry
→ Attaches user to req.user
→ Controller handles requestMiddleware Pattern
// middlewares/auth.middleware.js
const authenticate = async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return next(new UnauthorizedError('No token provided'));
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch {
next(new UnauthorizedError('Invalid or expired token'));
}
};Role-Based Access Control (RBAC)
const authorize = (...roles) => (req, res, next) => {
if (!roles.includes(req.user.role)) {
return next(new ForbiddenError('Insufficient permissions'));
}
next();
};
// Usage
router.delete('/users/:id', authenticate, authorize('admin'), userController.delete);8. Caching Strategy {#caching-strategy}
Caching is one of the highest-leverage performance optimizations available. Use Redis as your primary cache.
Cache Layers
Browser Cache → CDN Cache → App-Level Cache (Redis) → DatabaseCache-Aside Pattern
// services/product.service.js
async getProduct(id) {
const cacheKey = `product:${id}`;
// 1. Check cache first
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 2. Cache miss — fetch from DB
const product = await productRepository.findById(id);
if (!product) throw new NotFoundError('Product not found');
// 3. Populate cache (TTL: 10 minutes)
await redis.setex(cacheKey, 600, JSON.stringify(product));
return product;
}Cache Invalidation
When data changes, invalidate related cache keys:
async updateProduct(id, data) {
const updated = await productRepository.update(id, data);
await redis.del(`product:${id}`); // Bust the cache
return updated;
}9. Queue & Background Jobs {#queue-and-jobs}
Never do heavy work inside a request-response cycle. Offload to background workers.
Use Cases for Job Queues
- Sending emails / SMS
- Generating reports or exports
- Processing image uploads
- Webhooks delivery with retries
- Scheduled tasks (cron-style)
BullMQ + Redis
// jobs/emailQueue.js
const { Queue } = require('bullmq');
const emailQueue = new Queue('emails', {
connection: { host: process.env.REDIS_HOST, port: 6379 },
});
// Enqueue a job
await emailQueue.add('welcome', {
to: user.email,
name: user.name,
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
});// workers/emailWorker.js
const { Worker } = require('bullmq');
const worker = new Worker('emails', async (job) => {
if (job.name === 'welcome') {
await sendWelcomeEmail(job.data);
}
}, { connection: { host: process.env.REDIS_HOST } });
worker.on('failed', (job, err) => {
logger.error(`Job ${job.id} failed: ${err.message}`);
});10. Error Handling & Logging {#error-handling}
Custom Error Classes
// utils/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
class NotFoundError extends AppError {
constructor(msg = 'Resource not found') { super(msg, 404); }
}
class UnauthorizedError extends AppError {
constructor(msg = 'Unauthorized') { super(msg, 401); }
}Global Error Handler Middleware
// middlewares/errorHandler.js
const errorHandler = (err, req, res, next) => {
const statusCode = err.statusCode || 500;
const isOperational = err.isOperational || false;
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
statusCode,
});
res.status(statusCode).json({
success: false,
error: isOperational ? err.message : 'Internal Server Error',
});
};Structured Logging with Winston
// config/logger.js
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
],
});
module.exports = logger;Structured JSON logs are parseable by tools like Datadog, Loki, and CloudWatch.
11. Environment Configuration {#configuration}
Never hardcode secrets or environment-specific values. Use .env files with validation.
// config/env.js — using Zod for runtime validation
const { z } = require('zod');
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().default(3000),
DB_URL: z.string().url(),
REDIS_HOST: z.string(),
JWT_SECRET: z.string().min(32),
JWT_EXPIRES_IN: z.string().default('15m'),
});
const env = envSchema.parse(process.env);
module.exports = env;If any required variable is missing, the app refuses to start — a crucial safety net.
12. Scalability Patterns {#scalability}
Horizontal Scaling with PM2
Node.js is single-threaded. Use PM2's cluster mode to utilize all CPU cores:
// ecosystem.config.js
module.exports = {
apps: [{
name: 'my-app',
script: './server.js',
instances: 'max', // One instance per CPU core
exec_mode: 'cluster',
env_production: {
NODE_ENV: 'production',
},
}],
};Stateless Design for Horizontal Scaling
For multiple instances to work behind a load balancer:
- Store sessions in Redis, not in-memory
- Use JWT (stateless) instead of server-side sessions
- Store uploaded files in S3/GCS, not the local filesystem
Load Balancing Architecture
Internet
│
▼
[Nginx / AWS ALB] ← Load Balancer (Round Robin / Least Connections)
│
├── [Node.js Instance 1]
├── [Node.js Instance 2]
└── [Node.js Instance N]
│
├── [PostgreSQL Primary] ←→ [Read Replica 1]
├── [Redis Cluster]
└── [S3 / Object Storage]Circuit Breaker Pattern
Prevent cascading failures when downstream services go down:
const CircuitBreaker = require('opossum');
const breaker = new CircuitBreaker(callExternalService, {
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 30000,
});
breaker.fallback(() => ({ data: null, fromCache: true }));13. Deployment Architecture {#deployment}
Dockerfile
FROM node:20-alpine AS base
WORKDIR /app
# Install dependencies in a separate layer for caching
FROM base AS deps
COPY package*.json ./
RUN npm ci --only=production
# Production image
FROM base AS runner
COPY --from=deps /app/node_modules ./node_modules
COPY src/ ./src/
COPY server.js ./
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "server.js"]Docker Compose (Local Dev)
version: '3.9'
services:
app:
build: .
ports: ["3000:3000"]
environment:
DB_URL: postgres://user:pass@db:5432/mydb
REDIS_HOST: redis
depends_on: [db, redis]
db:
image: postgres:15-alpine
volumes: [pg_data:/var/lib/postgresql/data]
redis:
image: redis:7-alpine
volumes:
pg_data:CI/CD Pipeline (GitHub Actions)
Push to main
│
▼
Run Tests (unit + integration)
│
▼
Build Docker Image
│
▼
Push to Container Registry (ECR / GCR / Docker Hub)
│
▼
Deploy to Kubernetes / ECS / Render / Railway
│
▼
Run DB Migrations
│
▼
Health Check → Swap Traffic14. Conclusion {#conclusion}
System design isn't about finding the "perfect" architecture — it's about making intentional tradeoffs based on your application's specific requirements.
Here's a quick recap of what a production-grade Node.js app needs:
| Concern | Solution |
|---|---|
| Code Organization | 3-layer architecture (Controller → Service → Repository) |
| Database | ORM + connection pooling + migrations |
| API | REST with validation, versioning, rate limiting |
| Auth | JWT + refresh tokens + RBAC |
| Caching | Redis with cache-aside pattern |
| Background Work | BullMQ + Redis queues |
| Error Handling | Custom error classes + global handler |
| Logging | Structured JSON logs (Winston) |
| Scaling | PM2 cluster + stateless design + load balancer |
| Deployment | Docker + CI/CD pipeline |
Start simple. Add complexity only when the problem demands it. A well-designed monolith can take you very far before you ever need microservices.