Skip to content
SP StackPractices
advanced By StackPractices

Clean Architecture — The Dependency Rule and Layered Boundaries

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

  • 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

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.