Skip to content
StackPractices
advanced By Mathias Paulenko

Clean Architecture

A practical guide to Uncle Bob's Clean Architecture: organize code into layers so that frameworks, UI, and databases are details, not dependencies.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Overview

Clean Architecture, introduced by Robert C. Martin (Uncle Bob), is a software design philosophy that organizes code into concentric layers. The central rule — the Dependency Rule — states that source code dependencies can only point inward. Nothing in an inner layer can know anything about something in an outer layer. This makes frameworks, databases, and UI replaceable details rather than core dependencies.

The Four Layers

┌──────────────────────────────────────┐
│         Frameworks & Drivers         │
│    (Web, UI, External APIs, DB)      │
├──────────────────────────────────────┤
│         Interface Adapters           │
│  (Controllers, Presenters, Gateways) │
├──────────────────────────────────────┤
│       Application Business Rules     │
│    (Use Cases, Application Services) │
├──────────────────────────────────────┤
│         Enterprise Business Rules    │
│    (Entities, Domain Logic)         │
└──────────────────────────────────────┘

Entities (Innermost)

Enterprise-wide business rules. They are the most general and reusable layer. In many applications, entities are simple data structures with behavior.

export class User {
  private constructor(
    private readonly id: UserId,
    private email: Email,
    private status: UserStatus
  ) {}

  static create(email: Email): User {
    return new User(UserId.generate(), email, UserStatus.PENDING);
  }

  activate(): void {
    this.status = UserStatus.ACTIVE;
  }

  isActive(): boolean {
    return this.status === UserStatus.ACTIVE;
  }
}

Use Cases

Application-specific business rules. They orchestrate entities and define the operations the application supports.

export class RegisterUserUseCase {
  constructor(
    private userRepository: UserRepository,
    private emailService: EmailService
  ) {}

  async execute(command: RegisterUserCommand): Promise<Result<User>> {
    const existing = await this.userRepository.findByEmail(command.email);
    if (existing) {
      return Result.failure('Email already registered');
    }

    const user = User.create(Email.create(command.email));
    await this.userRepository.save(user);
    await this.emailService.sendWelcome(user.email);

    return Result.success(user);
  }
}

Interface Adapters

Convert data from the format most convenient for use cases and entities, to the format most convenient for frameworks and drivers.

@RestController()
export class UserController {
  constructor(private registerUser: RegisterUserUseCase) {}

  @Post('/users')
  async register(@Body() dto: RegisterUserDto): Promise<UserResponse> {
    const result = await this.registerUser.execute(dto.toCommand());
    return result.isSuccess()
      ? UserResponse.from(result.value)
      : UserResponse.error(result.error);
  }
}

Frameworks & Drivers

The outermost layer — web frameworks, databases, UI, external devices. This layer contains minimal code and should be easy to swap.

The Dependency Rule

Source code dependencies must point only inward, toward higher-level policies.

This means:

  • The web framework imports the controller, not the other way around
  • The database imports the repository interface, not the other way around
  • The UI imports the presenter, not the other way around

Crossing Boundaries

At each layer boundary, data crosses as simple structures (DTOs) to prevent leaking implementation details:

// Domain layer — knows nothing about HTTP
interface UserRepository {
  findById(id: UserId): Promise<User | null>;
  save(user: User): Promise<void>;
}

// Infrastructure layer — implements the interface
class PostgresUserRepository implements UserRepository {
  constructor(private db: Knex) {}

  async findById(id: UserId): Promise<User | null> {
    const row = await this.db('users').where('id', id.value).first();
    return row ? this.toDomain(row) : null;
  }

  async save(user: User): Promise<void> {
    await this.db('users').insert(this.toRow(user));
  }
}

Testing Strategy

LayerTest ApproachSpeed
EntitiesPure unit tests< 10ms
Use CasesUnit tests with in-memory repositories< 50ms
AdaptersIntegration tests with real DB< 500ms
E2EFull stack testsseconds

Common Mistakes

  • Framework lock-in — importing Spring or Express inside use cases
  • Leaky abstractions — passing HTTP request objects into the domain
  • Anemic models — treating entities as data bags with no behavior
  • Over-abstraction — adding interfaces for things that never change

When to Use

  • For alternatives, see Hexagonal Architecture — Ports, Adapters, and Testability.

  • Medium to large applications with long lifespans

  • Applications where the domain logic is more complex than data access

  • Teams that value testability and independent deployability

  • Projects where framework churn is likely

When NOT to Use

  • Simple CRUD with no business rules
  • Scripts, prototypes, or MVPs where speed matters more than structure
  • Teams without the discipline to maintain boundaries

Troubleshooting

  • High latency between services: trace the request path. Look for synchronous chains, missing caching, and oversized payloads that cross network boundaries.
  • Single point of failure: identify components without redundancy. Add replicas, failover, or circuit breakers before scaling traffic.
  • Unexpected coupling between services: review shared databases, libraries, and schemas. Bound contexts should own their data and expose stable interfaces.
  • Cost spikes after scaling: right-size instances and use autoscaling with limits. Reserved capacity or spot instances can reduce steady-state spend.
  • Difficult to reason about the system: maintain architecture decision records and service dependency maps. Use observability to validate the diagrams.

FAQ

Is Clean Architecture the same as Hexagonal? They share the same goal (domain isolation) but use different metaphors. Hexagonal uses ports and adapters; Clean uses layers and the Dependency Rule. Both work well together.

How do I handle transactions across use cases? Use a Unit of Work pattern at the adapter layer, or wrap use cases in a transaction decorator that lives in the application layer.

Can I use ORMs in the entities layer? No. ORM annotations belong in the infrastructure layer. Keep entities pure.

How do I get started with this in an existing project?

Start with a small, isolated part of your codebase. Apply the concepts from this guide to one module or service. Measure the impact, then expand to other areas.

What tools do I need?

The tools mentioned throughout this guide are listed in each section. Most are open-source and widely adopted. Check the related resources for setup instructions.

How do I measure success after implementing this?

Define clear metrics before starting: performance benchmarks, error rates, or maintainability indicators. Compare before and after. Iterate based on the data, not on assumptions.

Advanced Topics

Detailed Scenario: User Registration App with Clean Architecture

Project: Registration and authentication system (TypeScript + Node.js)
Layers:
  Entities (domain): User, Email, UserId, UserStatus
  Use Cases (application): RegisterUserUseCase, AuthenticateUserUseCase
  Interface Adapters: UserController, UserPresenter, RegisterUserDto
  Frameworks: Express.js, PostgreSQL, SendGrid

File structure:
  src/
    domain/
      entities/User.ts
      valueobjects/Email.ts
      valueobjects/UserId.ts
      valueobjects/UserStatus.ts
      repositories/UserRepository.ts       # Interface (port)
      services/EmailService.ts             # Interface (port)
      errors/DomainError.ts
    application/
      usecases/RegisterUserUseCase.ts
      usecases/AuthenticateUserUseCase.ts
      dto/RegisterUserCommand.ts
      dto/UserResponse.ts
      results/Result.ts
    infrastructure/
      persistence/PostgresUserRepository.ts  # Implements UserRepository
      email/SendGridEmailService.ts          # Implements EmailService
      database/KnexConnection.ts
    presentation/
      controllers/UserController.ts
      presenters/UserPresenter.ts
      routes/userRoutes.ts
    app.ts                                    # Entry point (Express)

Flow: Register user via POST /users
  1. Express receives POST /users with body { email, password }
  2. UserController maps to RegisterUserCommand
  3. Calls RegisterUserUseCase.execute(command)
  4. UseCase:
     a. userRepository.findByEmail(email) -> check if exists
     b. If exists: return Result.failure("Email already registered")
     c. User.create(email) -> create entity with status PENDING
     d. userRepository.save(user)
     e. emailService.sendWelcome(user.email)
     f. Return Result.success(user)
  5. UserPresenter maps Result to UserResponse
  6. UserController returns 201 or 400

Testing per layer:
  // domain/entities/User.test.ts
  describe("User", () => {
    test("create should set status to PENDING", () => {
      const user = User.create(Email.create("test@example.com"));
      expect(user.isActive()).toBe(false);
      expect(user.status).toBe(UserStatus.PENDING);
    });

    test("activate should change status to ACTIVE", () => {
      const user = User.create(Email.create("test@example.com"));
      user.activate();
      expect(user.isActive()).toBe(true);
    });
  });

  // application/usecases/RegisterUserUseCase.test.ts
  describe("RegisterUserUseCase", () => {
    let repo: InMemoryUserRepository;
    let emailService: SpyEmailService;
    let useCase: RegisterUserUseCase;

    beforeEach(() => {
      repo = new InMemoryUserRepository();
      emailService = new SpyEmailService();
      useCase = new RegisterUserUseCase(repo, emailService);
    });

    test("should register new user successfully", async () => {
      const cmd = new RegisterUserCommand("test@example.com");
      const result = await useCase.execute(cmd);
      expect(result.isSuccess()).toBe(true);
      expect(emailService.sentEmails).toHaveLength(1);
    });

    test("should fail if email already registered", async () => {
      repo.add(User.create(Email.create("test@example.com")));
      const cmd = new RegisterUserCommand("test@example.com");
      const result = await useCase.execute(cmd);
      expect(result.isFailure()).toBe(true);
      expect(emailService.sentEmails).toHaveLength(0);
    });
  });

  // Domain tests: < 10ms, no DB or network mocks
  // Use case tests: < 50ms, with in-memory repos
  // Integration tests: < 500ms, with Testcontainers + real PostgreSQL

How do I handle data passing between layers without leaking details?

Use DTOs (Data Transfer Objects) at each layer boundary. The controller receives a RequestDTO and converts it to a domain Command. The use case returns a Result with the domain entity. The presenter converts the entity to a ResponseDTO. Never pass the Express Request object to the domain. Never pass the JPA entity to the controller. Each layer speaks its own language; DTOs are the translation.