Back to Blog
Engineering

Node.js API Design: Building Scalable REST APIs with Clean Architecture

A practical guide to designing and building production-grade Node.js REST APIs using clean architecture principles, proper error handling, validation, rate limiting, and testing strategies.

UIFlexer TeamJanuary 29, 20264 min read
Node.js API Design: Building Scalable REST APIs with Clean Architecture

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.

Node.js programming and backend development

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();
  };
}
API architecture and server design patterns

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.

Node.jsAPI designRESTarchitecturebackend

Have a similar project in mind?

Let's discuss how we can help build it.

Get in Touch