ccidental mutation at runtime. Fail-fast validation ensures the application rejects bad deployments before accepting traffic.
Step 2: Transport Layer (Routing & Middleware)
The transport layer handles HTTP specifics: parsing bodies, validating headers, applying security policies, and routing requests. It should never contain business logic.
// src/transport/router.ts
import { Router } from 'express';
import { accountController } from '../application/account.controller';
import { validatePayload, authenticateToken } from '../transport/middleware';
const router = Router();
router.post(
'/accounts',
validatePayload(['email', 'displayName', 'credentials']),
accountController.register
);
router.get(
'/accounts/:accountId',
authenticateToken,
accountController.fetchProfile
);
router.put(
'/accounts/:accountId',
authenticateToken,
validatePayload(['displayName']),
accountController.updateProfile
);
export { router as accountRouter };
Rationale: Middleware is applied declaratively. Validation and authentication are isolated concerns that can be reused across routes without duplication. The controller receives a clean request object.
Step 3: Application Layer (Controllers & Services)
Controllers translate HTTP requests into service calls. Services contain the actual business rules and orchestrate domain operations. Services must remain framework-agnostic.
// src/application/account.controller.ts
import { Request, Response } from 'express';
import { accountService } from './account.service';
import { standardResponse } from '../shared/response.helper';
export const accountController = {
async register(req: Request, res: Response) {
const payload = req.body;
const result = await accountService.createAccount(payload);
return standardResponse(res, 201, result);
},
async fetchProfile(req: Request, res: Response) {
const accountId = req.params.accountId;
const profile = await accountService.retrieveAccount(accountId);
return standardResponse(res, 200, profile);
},
async updateProfile(req: Request, res: Response) {
const accountId = req.params.accountId;
const updates = req.body;
const updated = await accountService.modifyAccount(accountId, updates);
return standardResponse(res, 200, updated);
}
};
// src/application/account.service.ts
import { accountRepository } from '../domain/account.repository';
import { DomainFault, FaultCode } from '../shared/fault.boundary';
export const accountService = {
async createAccount(payload: { email: string; displayName: string; credentials: string }) {
const exists = await accountRepository.findByEmail(payload.email);
if (exists) {
throw new DomainFault('Account already registered', FaultCode.CONFLICT);
}
const hashed = await hashCredentials(payload.credentials);
return accountRepository.insert({ ...payload, credentials: hashed });
},
async retrieveAccount(id: string) {
const record = await accountRepository.findById(id);
if (!record) {
throw new DomainFault('Account not found', FaultCode.NOT_FOUND);
}
return record;
},
async modifyAccount(id: string, updates: Partial<{ displayName: string }>) {
const record = await this.retrieveAccount(id);
return accountRepository.patch(id, updates);
}
};
Rationale: Controllers handle HTTP status codes and response formatting. Services enforce business rules and throw domain-specific faults. Notice that req and res never cross into the service layer. Only primitive data moves upward.
Step 4: Domain Layer & Fault Boundary
The domain layer manages data persistence and defines error contracts. A centralized fault boundary ensures consistent error responses across the entire application.
// src/shared/fault.boundary.ts
export enum FaultCode {
VALIDATION = 'VALIDATION_ERROR',
NOT_FOUND = 'NOT_FOUND',
CONFLICT = 'CONFLICT',
UNAUTHORIZED = 'UNAUTHORIZED',
INTERNAL = 'INTERNAL_FAULT'
}
export class DomainFault extends Error {
public readonly statusCode: number;
public readonly code: FaultCode;
public readonly details?: Record<string, unknown>;
constructor(message: string, code: FaultCode, details?: Record<string, unknown>) {
super(message);
this.statusCode = this.mapCodeToStatus(code);
this.code = code;
this.details = details;
this.name = 'DomainFault';
}
private mapCodeToStatus(code: FaultCode): number {
const map: Record<FaultCode, number> = {
[FaultCode.VALIDATION]: 422,
[FaultCode.NOT_FOUND]: 404,
[FaultCode.CONFLICT]: 409,
[FaultCode.UNAUTHORIZED]: 401,
[FaultCode.INTERNAL]: 500
};
return map[code] ?? 500;
}
}
// src/transport/error.handler.ts
import { Request, Response, NextFunction } from 'express';
import { DomainFault, FaultCode } from '../shared/fault.boundary';
import { config } from '../config/env.loader';
export function faultHandler(err: Error, _req: Request, res: Response, _next: NextFunction) {
console.error(`[FAULT] ${err.message}`, err.stack);
if (err instanceof DomainFault) {
return res.status(err.statusCode).json({
error: { code: err.code, message: err.message, details: err.details }
});
}
const isProd = config.isProduction;
const safeMessage = isProd ? 'An unexpected system fault occurred' : err.message;
return res.status(500).json({
error: { code: FaultCode.INTERNAL, message: safeMessage }
});
}
Rationale: Custom error classes replace generic Error throws. The fault boundary distinguishes between operational faults (expected business rule violations) and unexpected system failures. Production environments never leak stack traces or internal state.
Step 5: Application Bootstrap
The entry point wires middleware, routes, and error handling. It remains minimal and focused on initialization.
// src/app.bootstrap.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import compression from 'compression';
import { accountRouter } from './transport/router';
import { faultHandler } from './transport/error.handler';
import { config } from './config/env.loader';
export function createApplication() {
const app = express();
app.use(helmet());
app.use(cors({ origin: config.security.corsOrigins }));
app.use(compression());
app.use(express.json({ limit: '2mb' }));
app.use('/api/v1', accountRouter);
app.get('/health', (_req, res) => {
res.json({ status: 'operational', timestamp: new Date().toISOString() });
});
app.use(faultHandler);
app.use((_req, res) => {
res.status(404).json({ error: { code: 'ROUTE_MISSING', message: 'Path not registered' } });
});
return app;
}
// src/server.entry.ts
import { createApplication } from './app.bootstrap';
import { config } from './config/env.loader';
const app = createApplication();
if (require.main === module) {
app.listen(config.port, () => {
console.log(`Transport layer active on port ${config.port}`);
console.log(`Runtime environment: ${process.env.NODE_ENV ?? 'development'}`);
});
}
export { app };
Rationale: Separating createApplication() from the listener enables clean integration testing. The server module only handles process lifecycle. All configuration and routing are resolved before the port opens.
Pitfall Guide
1. Controller Bloat
Explanation: Developers place database queries, validation logic, and response formatting directly inside route handlers. This couples HTTP transport to data access and makes unit testing impossible without mocking the entire request cycle.
Fix: Extract all non-HTTP logic into a service layer. Controllers should only parse inputs, invoke services, and map outputs to HTTP responses.
2. Implicit Configuration Access
Explanation: Reading process.env directly inside services or utilities creates hidden dependencies and makes testing unpredictable. Different modules may interpret missing values differently.
Fix: Centralize configuration in a single loader. Validate required variables at startup. Export a frozen, typed object. Inject or import the config explicitly where needed.
3. Error Type Confusion
Explanation: Throwing generic Error instances for business rule violations forces middleware to parse messages or rely on fragile string matching. It also blurs the line between expected faults and system crashes.
Fix: Implement a custom error hierarchy with explicit codes. Use an isOperational or equivalent flag to distinguish expected business faults from unexpected runtime exceptions.
4. Over-Abstraction Early
Explanation: Creating repository interfaces, factory patterns, and dependency injection containers for a three-route application introduces unnecessary complexity. It slows development and obscures data flow.
Fix: Start with direct model/repository calls. Extract abstractions only when duplication appears three times or when swapping data sources becomes a requirement.
5. Leaking HTTP Context
Explanation: Passing req or res objects into service or domain layers ties business logic to Express. It prevents reusing services in CLI tools, background workers, or alternative frameworks.
Fix: Extract only the primitives needed (e.g., userId, payload, locale). Pass plain objects or primitives upward. Keep transport details confined to the routing layer.
6. Missing Startup Validation
Explanation: Applications that defer configuration checks until the first request often crash in production with cryptic errors. Missing database credentials or malformed ports surface too late.
Fix: Implement fail-fast validation in the configuration loader. Throw descriptive errors before the server binds to a port. Use environment-specific schemas if necessary.
7. Test Coupling
Explanation: Integration tests that hit production databases or external APIs create flaky suites, slow feedback loops, and accidental data corruption.
Fix: Use in-memory adapters, test containers, or mocked repositories for unit tests. Reserve integration tests for contract validation against isolated test databases. Clean state between runs.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| MVP / Prototype (< 5 routes) | Inline controllers with direct model calls | Speed outweighs structure; refactoring cost is low | Minimal overhead |
| Mid-scale product (5-20 routes) | Layered architecture with service isolation | Prevents controller bloat; enables parallel frontend/backend work | Moderate initial setup |
| Enterprise / Multi-team (> 20 routes) | Full separation + contract testing + dependency injection | Enforces boundaries; supports independent deployments and framework swaps | Higher initial investment, lower long-term maintenance |
| Background workers / CLI tools | Reuse service layer; swap transport for event/message handlers | Business logic remains framework-agnostic | Near-zero duplication |
Configuration Template
// src/config/schema.validator.ts
import { config } from './env.loader';
export function validateRuntimeConfig() {
const checks = [
{ key: 'database.host', value: config.database.host, required: true },
{ key: 'database.port', value: config.database.port, required: true },
{ key: 'security.jwtSecret', value: config.security.jwtSecret, required: true, sensitive: true },
{ key: 'port', value: config.port, required: true, type: 'number' }
];
for (const check of checks) {
if (check.required && (check.value === undefined || check.value === null || check.value === '')) {
throw new Error(`Configuration validation failed: ${check.key} is required`);
}
if (check.type === 'number' && isNaN(Number(check.value))) {
throw new Error(`Configuration validation failed: ${check.key} must be a valid number`);
}
}
console.log('Configuration validated successfully');
}
Quick Start Guide
- Initialize the project: Run
npm init -y and install core dependencies: express, helmet, cors, compression, typescript, @types/node, @types/express, ts-node, nodemon.
- Create the directory structure: Set up
src/config, src/transport, src/application, src/domain, and src/shared folders. Add tsconfig.json with strict mode enabled.
- Wire the bootstrap: Implement the configuration loader, fault boundary, and application factory. Export the app instance for testing.
- Add lifecycle scripts: Configure
package.json with dev (ts-node/nodemon), build (tsc), start (node dist/server.entry.js), and test scripts.
- Verify locally: Run
npm run dev, hit /health, and confirm the application starts without configuration errors. Proceed to route implementation.