Layering in Express: What Each Layer Owns
A practical layering pattern separates responsibilities so each unit is easier to reason about, test, and change. A common split is:
- Controller: HTTP concerns (request parsing, response formatting, status codes, headers, cookies, auth context extraction).
- Service: business rules and orchestration (validation beyond basic shape, invariants, policies, transactions/workflows, domain errors).
- Repository: data access (queries, persistence mapping, hiding database/ORM details).
The key is to keep dependencies flowing inward: controllers depend on services, services depend on repositories (via interfaces), repositories depend on infrastructure (DB client/ORM). Services should not know how data is stored, and controllers should not contain business rules.
Rule of Thumb: What Should Not Appear in Each Layer
| Layer | Should contain | Should NOT contain |
|---|---|---|
| Controller | Parsing params/body, mapping to DTOs, calling service, mapping errors to HTTP | SQL/ORM calls, business decisions, persistence-specific errors |
| Service | Business rules, domain validation, calling repositories, returning domain results | req/res, status codes, Express types, SQL/ORM models |
| Repository | Queries, persistence mapping, returning domain entities/records | HTTP errors/status codes, Express, business policies |
Define Explicit Interfaces (Contracts) Between Layers
Interfaces make boundaries explicit and enable testing with fakes. In JavaScript you can express interfaces via JSDoc; in TypeScript use interface types. The important part is: the service depends on an abstract repository contract, not a concrete DB implementation.
Domain Types and Error Types
// domain/user.js (plain JS example with JSDoc for clarity)
/** @typedef {{ id: string, email: string, name: string, createdAt: Date }} User */
class DomainError extends Error {
/** @param {string} code */
constructor(code, message, meta = {}) {
super(message);
this.code = code;
this.meta = meta;
}
}
class ValidationError extends DomainError {
constructor(message, meta) { super('VALIDATION_ERROR', message, meta); }
}
class ConflictError extends DomainError {
constructor(message, meta) { super('CONFLICT', message, meta); }
}
class NotFoundError extends DomainError {
constructor(message, meta) { super('NOT_FOUND', message, meta); }
}
module.exports = { ValidationError, ConflictError, NotFoundError };Repository Contract
// repositories/userRepository.contract.js
/**
* @typedef {Object} UserRepository
* @property {(email: string) => Promise<import('../domain/user').User|null>} findByEmail
* @property {(id: string) => Promise<import('../domain/user').User|null>} findById
* @property {(data: {email: string, name: string, passwordHash: string}) => Promise<import('../domain/user').User>} create
*/
// This file is documentation/typing only; the service will accept any object
// that implements these functions.Service Contract
// services/userService.contract.js
/**
* @typedef {Object} UserService
* @property {(input: {email: string, name: string, password: string}) => Promise<{userId: string}>} registerUser
*/Controllers: Map HTTP to Service Inputs (DTO Mapping)
Controllers should translate HTTP-specific data into a service input object. This is where you decide which fields are accepted, how to coerce types, and how to handle missing/invalid shape (often basic checks; deeper business validation belongs in the service).
Step-by-step: Build a Register User Controller
- Extract and normalize input from
req.body. - Call the service with a plain object (no
reqpassed down). - Translate domain errors into HTTP responses.
- Return a response DTO (avoid returning internal domain objects directly if you don’t want to expose fields).
// controllers/userController.js
const { ValidationError, ConflictError, NotFoundError } = require('../domain/errors');
function toHttpError(err) {
// Centralize mapping so controllers stay consistent.
if (err instanceof ValidationError) return { status: 400, body: { error: err.code, message: err.message, details: err.meta } };
if (err instanceof ConflictError) return { status: 409, body: { error: err.code, message: err.message } };
if (err instanceof NotFoundError) return { status: 404, body: { error: err.code, message: err.message } };
return { status: 500, body: { error: 'INTERNAL', message: 'Unexpected error' } };
}
/**
* @param {{ userService: import('../services/userService.contract').UserService }} deps
*/
function makeUserController({ userService }) {
return {
register: async (req, res, next) => {
try {
const email = String(req.body?.email ?? '').trim().toLowerCase();
const name = String(req.body?.name ?? '').trim();
const password = String(req.body?.password ?? '');
// Basic shape checks (HTTP-level). Business rules stay in service.
if (!email || !name || !password) {
return res.status(400).json({ error: 'BAD_REQUEST', message: 'email, name, and password are required' });
}
const result = await userService.registerUser({ email, name, password });
return res.status(201).json({ userId: result.userId });
} catch (err) {
const mapped = toHttpError(err);
// Option A: respond here
return res.status(mapped.status).json(mapped.body);
// Option B: pass to a centralized error handler: next(err)
}
}
};
}
module.exports = { makeUserController };Notice how the controller never touches a database client, never hashes passwords, and never decides whether an email is already taken. It only shapes inputs/outputs and maps errors.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Services: Business Rules and Domain Errors (No HTTP, No ORM)
Services implement business policies and orchestrate repositories and other collaborators (e.g., hashing, email sending). They should:
- Accept plain inputs (DTOs) and return plain outputs.
- Throw domain errors (e.g.,
ConflictError) instead of HTTP errors. - Depend on repository interfaces and small utilities, not on Express or ORM models.
Step-by-step: Implement registerUser
// services/userService.js
const { ValidationError, ConflictError } = require('../domain/errors');
/**
* @param {{
* userRepo: import('../repositories/userRepository.contract').UserRepository,
* passwordHasher: { hash: (plain: string) => Promise<string> },
* idFactory: { newId: () => string }
* }} deps
*/
function makeUserService({ userRepo, passwordHasher, idFactory }) {
return {
registerUser: async ({ email, name, password }) => {
// Business validation (beyond "field exists")
if (!email.includes('@')) throw new ValidationError('Email is invalid', { field: 'email' });
if (name.length < 2) throw new ValidationError('Name is too short', { field: 'name' });
if (password.length < 10) throw new ValidationError('Password must be at least 10 characters', { field: 'password' });
const existing = await userRepo.findByEmail(email);
if (existing) throw new ConflictError('Email is already registered', { email });
const passwordHash = await passwordHasher.hash(password);
// Service does not know SQL columns or ORM models; it passes a simple object.
const created = await userRepo.create({
email,
name,
passwordHash,
// id could be generated here or by the repository/DB; choose one strategy.
// If generated here, keep it domain-level (string), not DB-specific.
id: idFactory.newId()
});
return { userId: created.id };
}
};
}
module.exports = { makeUserService };Preventing persistence leakage: the service does not call userRepo.createUserRow, does not pass an ORM entity instance, and does not catch database-specific errors like SequelizeUniqueConstraintError. If uniqueness is a business rule, the service checks via the repository contract (findByEmail) or the repository translates DB uniqueness into a domain error (see next section).
Repositories: Hide the Database and Map to Domain
Repositories should encapsulate persistence details: query language, ORM models, connection handling, and mapping between DB records and domain objects.
Example: Repository Implementation (SQL-ish Pseudocode)
// repositories/userRepository.pg.js
const { ConflictError } = require('../domain/errors');
function mapRowToUser(row) {
return {
id: row.id,
email: row.email,
name: row.name,
createdAt: new Date(row.created_at)
};
}
/**
* @param {{ db: { query: (sql: string, params?: any[]) => Promise<{ rows: any[] }> } }} deps
*/
function makeUserRepository({ db }) {
return {
findByEmail: async (email) => {
const result = await db.query('SELECT id, email, name, created_at FROM users WHERE email = $1', [email]);
return result.rows[0] ? mapRowToUser(result.rows[0]) : null;
},
findById: async (id) => {
const result = await db.query('SELECT id, email, name, created_at FROM users WHERE id = $1', [id]);
return result.rows[0] ? mapRowToUser(result.rows[0]) : null;
},
create: async ({ id, email, name, passwordHash }) => {
try {
const result = await db.query(
'INSERT INTO users (id, email, name, password_hash) VALUES ($1, $2, $3, $4) RETURNING id, email, name, created_at',
[id, email, name, passwordHash]
);
return mapRowToUser(result.rows[0]);
} catch (e) {
// Translate persistence-specific failures into domain-level errors when appropriate.
// Example: unique constraint violation on users.email
if (e.code === '23505') {
throw new ConflictError('Email is already registered', { email });
}
throw e;
}
}
};
}
module.exports = { makeUserRepository };This repository returns a domain-shaped User object and throws domain errors for business-relevant constraints. The service remains free of DB error codes and ORM exception types.
Error Translation Patterns: Where to Map What
There are two common approaches; pick one and apply consistently:
- Service throws domain errors; controller maps to HTTP (most common). Repositories may also throw domain errors when they detect business-relevant constraints (e.g., unique violations).
- Service returns a Result object (no throwing), controller maps result to HTTP. This can reduce try/catch but requires more boilerplate.
Result Object Alternative (Optional Pattern)
// services/result.js
function ok(value) { return { ok: true, value }; }
function err(error) { return { ok: false, error }; }
module.exports = { ok, err };
// service returns ok/err instead of throwing
// controller checks result.ok and maps accordinglyDependency Injection in Express Without a Framework
Express does not impose a DI container. Two practical approaches are manual wiring and factory functions. Both keep modules decoupled and testable.
Approach 1: Manual Wiring (Composition Root)
Create a single place where you instantiate infrastructure and connect layers. Keep it close to your server startup code.
// app/compositionRoot.js
const { makeUserRepository } = require('../repositories/userRepository.pg');
const { makeUserService } = require('../services/userService');
const { makeUserController } = require('../controllers/userController');
function buildContainer({ db, passwordHasher, idFactory }) {
const userRepo = makeUserRepository({ db });
const userService = makeUserService({ userRepo, passwordHasher, idFactory });
const userController = makeUserController({ userService });
return { userController };
}
module.exports = { buildContainer };// routes/users.js
const express = require('express');
function makeUserRouter({ userController }) {
const router = express.Router();
router.post('/users', userController.register);
return router;
}
module.exports = { makeUserRouter };In your server entrypoint, you build the container once and pass dependencies into routers/controllers.
Approach 2: Factory per Request (When You Need Request-Scoped Dependencies)
Sometimes you need request-scoped data (e.g., a tenant ID, correlation ID) to influence services/repositories. Instead of global singletons, you can build a small factory that creates services per request.
// controllers/userController.requestScoped.js
function makeUserControllerFactory({ makeUserService }) {
return {
register: async (req, res) => {
const tenantId = req.header('x-tenant-id');
const userService = makeUserService({ tenantId });
const email = String(req.body?.email ?? '').trim().toLowerCase();
const name = String(req.body?.name ?? '').trim();
const password = String(req.body?.password ?? '');
const result = await userService.registerUser({ email, name, password });
return res.status(201).json(result);
}
};
}
module.exports = { makeUserControllerFactory };Use this sparingly; request-scoped composition can add overhead and complexity. Prefer passing request-scoped values as explicit parameters to service methods when possible (e.g., registerUser({ tenantId, ... })).
Keeping Services Free of Persistence Details
Persistence leakage usually happens in subtle ways. Here are concrete anti-patterns and fixes:
Anti-pattern: Service Depends on ORM Model
// BAD: service imports ORM model and calls it directly
const UserModel = require('../db/models/User');
async function registerUser(input) {
const existing = await UserModel.findOne({ where: { email: input.email } });
// ...
}Fix: service depends on userRepo contract; repository owns ORM usage.
Anti-pattern: Service Handles DB Error Codes
// BAD: service knows Postgres error codes
try {
await userRepo.create(...);
} catch (e) {
if (e.code === '23505') throw new ConflictError('Email taken');
throw e;
}Fix: repository translates DB-specific errors into domain errors, or repository exposes a method that allows the service to enforce uniqueness without DB codes (e.g., findByEmail check plus transaction semantics where needed).
Anti-pattern: Repository Returns Persistence Shape
// BAD: repository returns raw rows with snake_case and extra fields
return result.rows[0];Fix: map to a domain shape and keep naming consistent across the domain/service layers.
Testing Layers Without an HTTP Server
When controllers, services, and repositories are built as pure functions with injected dependencies, you can test each layer in isolation.
Service Unit Test with a Fake Repository
// services/userService.test.js (example using node:test)
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeUserService } = require('./userService');
const { ConflictError } = require('../domain/errors');
test('registerUser throws ConflictError when email exists', async () => {
const fakeRepo = {
findByEmail: async () => ({ id: 'u1', email: 'a@b.com', name: 'A', createdAt: new Date() }),
create: async () => { throw new Error('should not be called'); }
};
const passwordHasher = { hash: async () => 'hash' };
const idFactory = { newId: () => 'new-id' };
const service = makeUserService({ userRepo: fakeRepo, passwordHasher, idFactory });
await assert.rejects(
() => service.registerUser({ email: 'a@b.com', name: 'Alice', password: 'longenoughpassword' }),
(err) => err instanceof ConflictError
);
});This test runs without Express, without a database, and without network calls.
Controller Test with Stubbed Service (No Server Listen)
You can test controllers by calling the handler with stubbed req/res objects.
// controllers/userController.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const { makeUserController } = require('./userController');
function makeRes() {
return {
statusCode: 200,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; }
};
}
test('register returns 201 with userId', async () => {
const userService = { registerUser: async () => ({ userId: 'u123' }) };
const controller = makeUserController({ userService });
const req = { body: { email: 'Test@Email.com', name: 'Test', password: 'longenoughpassword' } };
const res = makeRes();
await controller.register(req, res);
assert.equal(res.statusCode, 201);
assert.deepEqual(res.body, { userId: 'u123' });
});This style keeps tests fast and focused on mapping logic. If you prefer more realism, you can still use an HTTP testing library later, but it’s not required to validate controller behavior.
Practical Checklist for Maintainable Layering
- Controllers: only HTTP concerns; never import DB/ORM; map request to service input; map domain errors to HTTP.
- Services: no Express types; no status codes; depend on repository contracts; throw domain errors or return Result objects.
- Repositories: hide DB/ORM; map persistence records to domain objects; translate DB-specific failures when they represent domain constraints.
- Composition root: one place to wire dependencies; keep constructors/factories small and explicit.
- Tests: unit test services with fake repos; unit test controllers with stubbed services and minimal
req/res.