Node.js API Design: Building Scalable REST APIs with Clean Architecture
After building dozens of production APIs for enterprise clients, we've converged on a set of architectural patterns that consistently deliver maintainable, testable, and scalable Node.js services. This guide covers the patterns we use at UIFlexer for every backend project.
The Clean Architecture Approach
Clean architecture separates your application into concentric layers, each with clear responsibilities. The core principle: dependencies point inward. Business logic never depends on frameworks, databases, or HTTP details.
src/
├── domain/ # Business entities and rules (no dependencies)
│ ├── entities/
│ └── errors/
├── application/ # Use cases / service layer (depends on domain)
│ ├── services/
│ └── interfaces/ # Repository & service contracts
├── infrastructure/ # Database, external APIs, email (implements interfaces)
│ ├── repositories/
│ ├── services/
│ └── middleware/
└── presentation/ # HTTP routes, controllers, validation (entry point)
├── routes/
├── controllers/
└── validators/
Layer 1: Domain Entities
Domain entities encapsulate your core business rules. They have no dependencies on frameworks, databases, or HTTP concerns:
// domain/entities/User.ts
export class User {
constructor(
public readonly id: string,
public readonly email: string,
public readonly name: string,
private passwordHash: string,
public readonly role: UserRole,
public readonly createdAt: Date,
) {}
canAccessResource(resource: Resource): boolean {
return ROLE_PERMISSIONS[this.role].includes(resource.requiredPermission);
}
updateProfile(data: { name?: string; email?: string }): User {
return new User(
this.id,
data.email ?? this.email,
data.name ?? this.name,
this.passwordHash,
this.role,
this.createdAt,
);
}
}
Layer 2: Application Services (Use Cases)
Application services orchestrate domain logic. They depend on repository interfaces, not implementations:
// application/services/UserService.ts
export class UserService {
constructor(
private userRepo: IUserRepository, // interface, not implementation
private emailService: IEmailService, // interface, not implementation
private logger: ILogger,
) {}
async createUser(dto: CreateUserDTO): Promise<User> {
const existing = await this.userRepo.findByEmail(dto.email);
if (existing) throw new ConflictError('Email already registered');
const passwordHash = await hashPassword(dto.password);
const user = await this.userRepo.create({ ...dto, passwordHash });
await this.emailService.sendWelcome(user.email, user.name);
this.logger.info('User created', { userId: user.id });
return user;
}
}
Layer 3: Structured Error Handling
Define a hierarchy of application errors that map cleanly to HTTP status codes:
// domain/errors/AppError.ts
export abstract class AppError extends Error {
abstract readonly statusCode: number;
abstract readonly isOperational: boolean;
}
export class NotFoundError extends AppError {
statusCode = 404;
isOperational = true;
constructor(resource: string) {
super(`${resource} not found`);
}
}
export class ValidationError extends AppError {
statusCode = 400;
isOperational = true;
}
export class UnauthorizedError extends AppError {
statusCode = 401;
isOperational = true;
}
// Global error handler middleware
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
if (err instanceof AppError && err.isOperational) {
return res.status(err.statusCode).json({
status: 'error',
message: err.message,
});
}
// Unexpected errors — log and return generic message
logger.error('Unexpected error', { error: err, path: req.path });
return res.status(500).json({
status: 'error',
message: 'An unexpected error occurred',
});
}
Input Validation with Zod
Validate every input at the presentation layer before it reaches your business logic. We use Zod for its excellent TypeScript integration:
import { z } from 'zod';
export const CreateUserSchema = z.object({
email: z.string().email('Invalid email address'),
name: z.string().min(2).max(100),
password: z.string().min(8).regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Password must contain uppercase, lowercase, and a number'
),
role: z.enum(['user', 'editor', 'admin']).default('user'),
});
// Validation middleware
export function validate(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
throw new ValidationError(result.error.issues);
}
req.body = result.data; // Use validated & typed data
next();
};
}
Rate Limiting and Security
Production APIs must implement rate limiting, CORS, helmet headers, and request size limits. These aren't optional — they're table stakes for any publicly accessible API.
Testing Strategy
The clean architecture pays dividends in testing. Because each layer depends on interfaces, you can test in isolation:
- Unit tests for domain entities and application services (mock repositories)
- Integration tests for repositories (test against a real database)
- API tests for routes (supertest against the full HTTP stack)
Aim for 80%+ coverage on application services (your business logic), and focus API tests on happy paths plus critical error scenarios.
This architecture has served us well across projects ranging from 10 endpoints to 200+. The upfront investment in structure pays off exponentially as the codebase grows.