Back to blog

Clean Architecture in Node.js: Structuring Scalable and Testable Express APIs

When starting a Node.js project, developers often write all routing, validation, database queries, and business logic inside a single file or standard MVC controllers. As the application scales, this tight coupling makes writing unit tests difficult and refactoring database engines a high-risk task.

To build sustainable software, engineering teams adopt Robert C. Martin's Clean Architecture (also known as Onion Architecture).

The primary objective of Clean Architecture is the separation of concerns and decoupling core business rules from external infrastructure details (such as web frameworks, ORMs, and third-party libraries).

In this guide, we will examine Clean Architecture layers, explore the Dependency Inversion Principle, and structure a decoupled user creation flow in TypeScript.

The Inner Core: The Dependency Rule

The core rule of Clean Architecture is the Dependency Rule: source code dependencies can only point inward.

Modules located in inner circles cannot know anything about modules declared in outer circles. The business rules do not depend on Express, Prisma, or MongoDB; instead, those frameworks depend on the business rules.

The Architectural Layers

Clean Architecture divides software into four logical layers (from the inside out):

  1. Entities (Domain): Encapsulates enterprise-wide business rules. This contains pure domain objects and business calculations, written in vanilla TypeScript with zero external package imports.
  2. Use Cases (Application): Contains application-specific business logic. It orchestrates the flow of data to and from the entities. It defines interfaces (ports) for external resources (like database operations) using Dependency Inversion.
  3. Interface Adapters (Adapters): Converts data between the format convenient for use cases and entities, and the format convenient for external agents. This contains Controllers (Express route handlers) and Repositories (Prisma or TypeORM database operations).
  4. Frameworks & Drivers (Infrastructure): The outermost layer containing databases, web servers (Express), and system log tools.

Implementing User Creation (The Decoupled Way)

Let's write a clean user registration flow.

Step 1: The Entity (Domain Layer)

A pure TypeScript class with validation logic:

export class User {
  constructor(
    public readonly id: string,
    public readonly email: string,
    public readonly name: string
  ) {
    if (!email.includes('@')) {
      throw new Error('Invalid email format');
    }
  }
}

Step 2: The Repository Interface (Use Case Layer)

Instead of importing a database helper directly, we define a contract interface. This is the Dependency Inversion Principle:

import { User } from './User';

// The port contract
export interface UserRepository {
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

Step 3: The Create User Use Case (Use Case Layer)

The use case executes business steps. It accepts the UserRepository interface via constructor injection:

import { User } from './User';
import { UserRepository } from './UserRepository';

export class CreateUserUseCase {
  constructor(private userRepository: UserRepository) {}

  async execute(email: string, name: string): Promise<User> {
    const existingUser = await this.userRepository.findByEmail(email);
    if (existingUser) {
      throw new Error('User already exists');
    }

    const id = Math.random().toString(36).substring(2);
    const user = new User(id, email, name);
    
    await this.userRepository.save(user);
    return user;
  }
}

Step 4: The Database Repository (Adapter Layer)

Here, we implement the UserRepository contract using a specific database engine (e.g., MongoDB):

import { UserRepository } from '../use-cases/UserRepository';
import { User } from '../entities/User';

export class MongoUserRepository implements UserRepository {
  async findByEmail(email: string): Promise<User | null> {
    // Imaginary mongo DB query execution
    const doc = await db.collection('users').findOne({ email });
    if (!doc) return null;
    return new User(doc._id, doc.email, doc.name);
  }

  async save(user: User): Promise<void> {
    await db.collection('users').insertOne({
      _id: user.id,
      email: user.email,
      name: user.name,
    });
  }
}

Step 5: The Express Controller (Adapter Layer)

The controller handles incoming network requests, extracts payload fields, triggers the use case, and returns response payloads:

import { Request, Response } from 'express';
import { CreateUserUseCase } from '../use-cases/CreateUserUseCase';

export class UserController {
  constructor(private createUserUseCase: CreateUserUseCase) {}

  async handle(req: Request, res: Response): Promise<void> {
    try {
      const { email, name } = req.body;
      const user = await this.createUserUseCase.execute(email, name);
      res.status(201).json(user);
    } catch (error: any) {
      res.status(400).json({ error: error.message });
    }
  }
}

The Dependency Injection Assembly

In the entry point server script (Frameworks & Drivers layer), you assemble the dependencies and start the application:

import express from 'express';
import { MongoUserRepository } from './adapters/MongoUserRepository';
import { CreateUserUseCase } from './use-cases/CreateUserUseCase';
import { UserController } from './adapters/UserController';

const app = express();
app.use(express.json());

// Assembly dependencies via construction injection
const userRepository = new MongoUserRepository();
const createUserUseCase = new CreateUserUseCase(userRepository);
const userController = new UserController(createUserUseCase);

// Bind Express to the controller adapter
app.post('/users', (req, res) => userController.handle(req, res));

app.listen(3000, () => console.log('Server running on 3000'));

If you decide to migrate from MongoDB to PostgreSQL, you only write a new PostgresUserRepository implementing UserRepository. The core CreateUserUseCase and User Entity code files remain completely untouched.

Conclusion

Clean Architecture isolates core business domains from fluctuating technologies. By structuring your Node.js apps into distinct Entity, Use Case, and Adapter layers, injecting dependencies through constructor interfaces, and avoiding direct framework integrations inside business files, you build systems that are easily unit-tested and refactored over time.