TechLead
Lesson 1 of 27
7 min read
Software Architecture

Clean Architecture

Learn Clean Architecture principles, layers, and the dependency rule for building maintainable software systems

What is Clean Architecture?

Clean Architecture, introduced by Robert C. Martin (Uncle Bob), is a software design philosophy that separates concerns into concentric layers. The core idea is that business logic should be independent of frameworks, databases, and delivery mechanisms. This independence makes systems testable, maintainable, and adaptable to change.

Key Benefits of Clean Architecture

  • Independent of Frameworks: The architecture does not depend on any library or framework — frameworks are tools, not constraints
  • Testable: Business rules can be tested without UI, database, web server, or any external element
  • Independent of UI: The UI can change without changing the business rules
  • Independent of Database: You can swap Oracle for PostgreSQL or MongoDB without touching business logic
  • Independent of External Agencies: Business rules know nothing about the outside world

The Dependency Rule

The Dependency Rule is the foundational principle of Clean Architecture: source code dependencies must point only inward. Nothing in an inner circle can know anything about something in an outer circle. This includes functions, classes, variables, or any software entity. Data formats used in an outer circle should not be used by an inner circle.

The Concentric Layers

Layer Contains Depends On
EntitiesEnterprise business rules, domain objectsNothing
Use CasesApplication-specific business rulesEntities
Interface AdaptersControllers, presenters, gatewaysUse Cases, Entities
Frameworks & DriversWeb frameworks, DB, UI, devicesInterface Adapters

Entities Layer

Entities encapsulate enterprise-wide business rules. An entity can be an object with methods, or a set of data structures and functions. They are the least likely to change when something external changes. No operational change to any particular application should affect the entity layer.

// entities/User.ts - Enterprise business rules
export class User {
  constructor(
    public readonly id: string,
    public readonly email: string,
    private _name: string,
    private _role: UserRole,
    private _isActive: boolean
  ) {
    this.validateEmail(email);
  }

  get name(): string {
    return this._name;
  }

  get role(): UserRole {
    return this._role;
  }

  get isActive(): boolean {
    return this._isActive;
  }

  promote(newRole: UserRole): void {
    if (!this._isActive) {
      throw new Error("Cannot promote inactive user");
    }
    if (!this.canBePromotedTo(newRole)) {
      throw new Error(`User cannot be promoted to ${newRole}`);
    }
    this._role = newRole;
  }

  deactivate(): void {
    this._isActive = false;
  }

  private canBePromotedTo(newRole: UserRole): boolean {
    const hierarchy: UserRole[] = ["viewer", "editor", "admin", "superadmin"];
    return hierarchy.indexOf(newRole) > hierarchy.indexOf(this._role);
  }

  private validateEmail(email: string): void {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new Error("Invalid email format");
    }
  }
}

export type UserRole = "viewer" | "editor" | "admin" | "superadmin";

Use Cases Layer

Use cases contain application-specific business rules. They orchestrate the flow of data to and from entities, and direct those entities to use their enterprise-wide business rules to achieve the goals of the use case. Changes in this layer should not affect the entities. They should also not be affected by changes to externalities such as the database, UI, or frameworks.

// use-cases/CreateUser.ts
import { User, UserRole } from "../entities/User";

// Input port - interface that the use case implements
export interface CreateUserInput {
  email: string;
  name: string;
  role: UserRole;
}

// Output port - interface for the presenter
export interface CreateUserOutput {
  id: string;
  email: string;
  name: string;
  role: UserRole;
}

// Repository interface (dependency inversion)
export interface UserRepository {
  save(user: User): Promise;
  findByEmail(email: string): Promise;
  nextId(): Promise;
}

// The use case itself
export class CreateUserUseCase {
  constructor(private readonly userRepository: UserRepository) {}

  async execute(input: CreateUserInput): Promise {
    // Check if user already exists
    const existing = await this.userRepository.findByEmail(input.email);
    if (existing) {
      throw new Error("User with this email already exists");
    }

    // Create the entity
    const id = await this.userRepository.nextId();
    const user = new User(id, input.email, input.name, input.role, true);

    // Persist through the repository interface
    await this.userRepository.save(user);

    // Return output DTO
    return {
      id: user.id,
      email: user.email,
      name: user.name,
      role: user.role,
    };
  }
}

Interface Adapters Layer

This layer contains adapters that convert data from the format most convenient for use cases and entities to the format most convenient for external agencies like databases and the web. Controllers, presenters, and gateways live here. The MVC architecture of a GUI lives entirely in this layer.

// adapters/controllers/UserController.ts
import { CreateUserUseCase, CreateUserInput } from "../../use-cases/CreateUser";
import { Request, Response } from "express";

export class UserController {
  constructor(private readonly createUser: CreateUserUseCase) {}

  async handleCreateUser(req: Request, res: Response): Promise {
    try {
      const input: CreateUserInput = {
        email: req.body.email,
        name: req.body.name,
        role: req.body.role || "viewer",
      };

      const output = await this.createUser.execute(input);
      res.status(201).json(output);
    } catch (error) {
      if (error instanceof Error) {
        res.status(400).json({ error: error.message });
      }
    }
  }
}

// adapters/repositories/PostgresUserRepository.ts
import { User } from "../../entities/User";
import { UserRepository } from "../../use-cases/CreateUser";
import { Pool } from "pg";
import { v4 as uuidv4 } from "uuid";

export class PostgresUserRepository implements UserRepository {
  constructor(private readonly pool: Pool) {}

  async save(user: User): Promise {
    await this.pool.query(
      "INSERT INTO users (id, email, name, role, is_active) VALUES ($1, $2, $3, $4, $5)",
      [user.id, user.email, user.name, user.role, user.isActive]
    );
  }

  async findByEmail(email: string): Promise {
    const result = await this.pool.query("SELECT * FROM users WHERE email = $1", [email]);
    if (result.rows.length === 0) return null;
    const row = result.rows[0];
    return new User(row.id, row.email, row.name, row.role, row.is_active);
  }

  async nextId(): Promise {
    return uuidv4();
  }
}

Frameworks & Drivers Layer

The outermost layer contains frameworks and tools such as the database, the web framework, and other third-party libraries. Generally, you do not write much code in this layer other than glue code that communicates to the next circle inward. This is where Express, React, PostgreSQL drivers, and other external tools are configured and wired together.

// frameworks/main.ts - Composition root
import express from "express";
import { Pool } from "pg";
import { CreateUserUseCase } from "../use-cases/CreateUser";
import { PostgresUserRepository } from "../adapters/repositories/PostgresUserRepository";
import { UserController } from "../adapters/controllers/UserController";

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

// Wire dependencies
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const userRepository = new PostgresUserRepository(pool);
const createUserUseCase = new CreateUserUseCase(userRepository);
const userController = new UserController(createUserUseCase);

// Routes
app.post("/api/users", (req, res) => userController.handleCreateUser(req, res));

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

Common Pitfalls

  • Skipping layers: Having controllers directly access entities bypasses the use case layer and couples your UI to business logic
  • Leaking ORM models: Using ORM entities (like TypeORM entities) as domain entities violates the dependency rule
  • Over-engineering: Not every project needs full Clean Architecture — weigh complexity against team size and project lifespan
  • Shared DTOs: Reusing the same data transfer objects across all layers creates hidden coupling

Project Structure

src/
├── entities/              # Enterprise business rules
│   ├── User.ts
│   └── Order.ts
├── use-cases/             # Application business rules
│   ├── CreateUser.ts
│   ├── GetUserById.ts
│   └── interfaces/
│       └── UserRepository.ts
├── adapters/              # Interface adapters
│   ├── controllers/
│   │   └── UserController.ts
│   ├── presenters/
│   │   └── UserPresenter.ts
│   └── repositories/
│       └── PostgresUserRepository.ts
└── frameworks/            # Frameworks & drivers
    ├── main.ts
    ├── database.ts
    └── routes.ts

Testing in Clean Architecture

One of the biggest advantages of Clean Architecture is testability. Because each layer only depends on the layer below it through interfaces, you can easily mock dependencies and test each layer in isolation.

// __tests__/use-cases/CreateUser.test.ts
import { CreateUserUseCase, UserRepository } from "../../use-cases/CreateUser";
import { User } from "../../entities/User";

class InMemoryUserRepository implements UserRepository {
  private users: User[] = [];
  private counter = 0;

  async save(user: User): Promise {
    this.users.push(user);
  }

  async findByEmail(email: string): Promise {
    return this.users.find(u => u.email === email) || null;
  }

  async nextId(): Promise {
    return String(++this.counter);
  }
}

describe("CreateUserUseCase", () => {
  it("should create a user successfully", async () => {
    const repo = new InMemoryUserRepository();
    const useCase = new CreateUserUseCase(repo);

    const result = await useCase.execute({
      email: "test@example.com",
      name: "Test User",
      role: "editor",
    });

    expect(result.email).toBe("test@example.com");
    expect(result.name).toBe("Test User");
    expect(result.role).toBe("editor");
  });

  it("should reject duplicate emails", async () => {
    const repo = new InMemoryUserRepository();
    const useCase = new CreateUserUseCase(repo);

    await useCase.execute({ email: "dup@example.com", name: "First", role: "viewer" });

    await expect(
      useCase.execute({ email: "dup@example.com", name: "Second", role: "viewer" })
    ).rejects.toThrow("User with this email already exists");
  });
});

When to Use Clean Architecture

  • Long-lived projects: Systems expected to be maintained for years benefit from the separation of concerns
  • Large teams: Multiple teams can work on different layers independently
  • Complex domains: When business logic is intricate, isolating it improves clarity
  • Technology migrations: When you anticipate swapping frameworks, databases, or UIs

Continue Learning