Clean Architecture Explained: Layers & Dependency Rule
A practical guide to Uncle Bob's Clean Architecture: organize code into layers so that frameworks, UI, and databases are details, not dependencies.
Overview
You inherit a codebase where business rules live inside controllers, ORM models, and a few stored procedures. Upgrading the web framework means rewriting half the app, and every unit test needs a running database to pass. Clean Architecture, described by Robert C. Martin (Uncle Bob) in his 2017 book and his earlier Clean Coder blog posts, exists to prevent exactly that situation: it organizes code into concentric layers and enforces a single rule — the Dependency Rule — so that frameworks, databases, and UI become replaceable details instead of load-bearing dependencies.
The rule is simple to state: source code dependencies can only point inward. Nothing in an inner layer may know anything about an outer layer. Your domain never imports your ORM, your use cases never import Express, and your entities don’t know JSON exists. Everything that does point inward — controllers toward use cases, repositories toward domain interfaces — is what keeps the architecture from collapsing back into a tangle.
The Four Layers
Martin describes the architecture as four concentric rings, moving from the most volatile concerns on the outside to the most stable policies on the inside:
┌──────────────────────────────────────┐
│ 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) │
└──────────────────────────────────────┘
The number four isn’t sacred. Martin’s own writing allows for more or fewer layers — what matters is that the Dependency Rule holds at every boundary.
Entities (Innermost)
Enterprise-wide business rules. An entity is an object (or a small set of functions and data structures) that encodes rules that would be true in any application of the business: “a user starts pending and must be activated”, “an order total is the sum of its line items”. They’re the most general and reusable layer, and they change the least often.
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;
}
}
Notice what’s missing: no @Entity() decorators, no extends BaseModel, no JSON serialization attributes. Those concerns belong to outer layers. If your entity file imports anything from your ORM, the dependency points the wrong way.
Use Cases
Application-specific business rules. Each use case orchestrates entities and defines one operation the application supports — “register a user”, “place an order”, “cancel a subscription”. This layer captures what your application does, as opposed to what the business is.
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);
}
}
The use case receives plain input (RegisterUserCommand) and returns plain output (Result<User>). It doesn’t see the HTTP request, the request DTO, or the response object — those are adapter concerns.
Interface Adapters
This layer converts data back and forth: from the format most convenient for use cases and entities, to the format most convenient for frameworks and drivers. Controllers, presenters, view models, and repository implementations all live here.
@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);
}
}
The controller is thin on purpose: it translates RegisterUserDto into a domain command, delegates, and translates the result back into UserResponse. Any logic heavier than translation signals a business rule that leaked into the wrong layer.
Frameworks & Drivers
The outermost layer — web frameworks, databases, UI, external devices. This layer contains minimal code: route registration, connection setup, ORM configuration, and the composition root where real implementations get wired into the ports. Everything here should be easy to swap without touching an inner layer, because that’s the whole point of the exercise.
The Dependency Rule
Source code dependencies must point only inward, toward higher-level policies.
At first this looks impossible: the use case obviously calls the database at runtime. The mechanism that resolves the paradox is dependency inversion. The application layer declares a port — an interface describing what it needs (UserRepository, EmailService) — and an outer layer provides the implementation. The source-code arrow goes from PostgresUserRepository toward UserRepository, which is inward, even though the runtime call flows outward.
In practice, that means:
- The web framework calls the controller, not the other way around
- The database adapter implements a repository interface owned by the inner layer, not the other way around
- The UI renders a view model built by the presenter, not the other way around
Higher-level layers never even name lower-level ones. If a use case mentions “PostgreSQL” or “HTTP”, that’s an architectural bug regardless of whether the code compiles. The same idea shows up across the related styles — see Hexagonal Architecture — Ports, Adapters, and Testability for the port-and-adapter framing this borrows from.
Crossing Boundaries
At each layer boundary, data crosses as simple structures (DTOs) to prevent leaking implementation details. The inner layer also owns the interface: UserRepository is declared in the application layer because the use case is the consumer, not because Postgres happens to implement it.
// Domain layer — knows nothing about HTTP or SQL
interface UserRepository {
findById(id: UserId): Promise<User | null>;
save(user: User): Promise<void>;
}
// Infrastructure layer — implements the port
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));
}
}
Two details matter here. First, the interface returns User, the domain type — never the ORM row. If the port returned UserRow, the database schema would leak into every caller and the boundary would exist only on paper. Second, the mapping (toDomain, toRow) lives inside the adapter, so when the table changes, the domain doesn’t move.
Control flow and dependency flow run in opposite directions at boundaries like this. The use case calls userRepository.findById, but the source-code dependency goes from PostgresUserRepository inward to UserRepository. Getting comfortable with that inversion is most of what “doing Clean Architecture” actually means day to day.
A Request Through the Layers
It helps to watch one HTTP request cross every boundary. POST /users in the registration app:
- Express matches the route and calls
UserController.register(dto)— frameworks layer. - The controller converts
RegisterUserDtointoRegisterUserCommandand callsregisterUser.execute(command)— adapter into application layer. RegisterUserUseCaseasksuserRepository.findByEmail(...)through the port — a call outward that resolves, via DI, toPostgresUserRepository.- The adapter queries Postgres, maps the row to a
User, and returns it — adapter into frameworks, domain type back inward. - The use case creates the entity, calls
save, thenemailService.sendWelcome— again through ports. - Back in the controller,
UserResponse.from(result.value)turns the domain result into an HTTP 201 with a JSON body — presenter work in the adapter layer.
Every arrow of control points outward at step 3-4 and every arrow of dependency points inward. That asymmetry is what lets you delete PostgresUserRepository, drop in MongoUserRepository, and change nothing between the interface and the domain.
Testing Strategy
The layering pays for itself at test time. Inner layers have no I/O, so they’re fast; outer layers get fewer, heavier tests:
| Layer | Test Approach | Speed |
|---|---|---|
| Entities | Pure unit tests | < 10ms |
| Use Cases | Unit tests with in-memory repositories | < 50ms |
| Adapters | Integration tests with real DB | < 500ms |
| E2E | Full stack tests | seconds |
test('register fails when email is taken', async () => {
const repo = new InMemoryUserRepository();
const mailer = new SpyEmailService();
const useCase = new RegisterUserUseCase(repo, mailer);
repo.add(User.create(Email.create('taken@example.com')));
const result = await useCase.execute(new RegisterUserCommand('taken@example.com'));
expect(result.isFailure()).toBe(true);
expect(mailer.sentEmails).toHaveLength(0);
});
The whole business-rule suite runs in milliseconds because InMemoryUserRepository is a map behind an interface. Reserve the slow tests — Postgres in a container, the real HTTP stack — for the adapter layer where they actually verify something.
Clean vs Hexagonal vs Onion vs Layered
These four architectures answer the same problem — keep business rules independent of delivery details — with different vocabulary. They compose well; you don’t have to pick a religion.
| Style | Core metaphor | What it adds |
|---|---|---|
| Traditional layered | UI → Business → Data | Simple mental model; dependencies point down, so business logic tends to know the database |
| Hexagonal (Cockburn) | Ports & adapters | ”Inside vs outside” instead of layers; each external dependency gets a named port |
| Onion (Palermo) | Domain model at center | Adds application services ring and explicit infrastructure around the core |
| Clean (Martin) | Concentric layers + Dependency Rule | Unifies the others, names entities/use cases explicitly, and formalizes the inward-dependency rule |
If you already run ports and adapters well, Clean Architecture reads as the same idea with rings. The differences that matter in practice: Clean separates entities (enterprise rules) from use cases (application rules), and it treats the Dependency Rule as an invariant to enforce mechanically — with architecture tests — rather than a convention to remember.
For a deeper treatment of each, see Hexagonal Architecture, Onion Architecture, and Layered Architecture. For the alternative of slicing by feature instead of by layer, see Vertical Slice Architecture.
Adopting It Incrementally
Rewriting an existing app into Clean Architecture in one pass fails almost every time — the migration outlasts the team’s patience and the product’s tolerance for frozen features. What works is extraction, one vertical slice of behavior at a time:
- Pick one bounded slice. A single feature, use case, or module — ideally one that changes often, because that’s where testability pays back fastest.
- Write the use case first. Extract “register user” into a class whose signature takes plain input and returns plain output. Push business rules down into it from the controller.
- Introduce the port. Define the repository interface in the application layer, make the existing data-access code implement it, and inject it. You haven’t moved the database — you’ve moved the dependency.
- Move framework code outward. Controllers keep routing and DTO translation; nothing else. ORM annotations migrate to a persistence entity separate from the domain entity, with a mapper between them.
- Guard the boundary in CI.
dependency-cruiseroreslint-plugin-boundariesin JavaScript,ArchUnitin Java,NetArchTestin .NET — any of them can fail the build when a use case importsexpressor an entity imports the ORM. Rules enforced by tests outlast rules enforced by code review.
Measure progress by what becomes easy: can you run the domain suite without a database? Can you swap the mailer for a stub in one line? When the answer is yes for the hot paths, stop extracting and let the rest pay its way later.
Best Practices
- Enforce the direction mechanically. The Dependency Rule erodes quietly; an
importin the wrong place compiles fine and ships fine. Architecture tests in CI are the only durable guard. - Let the consumer own the port.
UserRepositorylives in the application layer because the use case needs it, not because Postgres implements it. Move the interface toward whoever calls it. - Keep a single composition root. All wiring — “which repository implementation”, “which mailer” — belongs at the entry point (
main,app.ts), never scattered through inner layers. The Dependency Injection pattern guide covers the wiring mechanics. - Cross boundaries with dumb data. Commands, queries, DTOs, and view models carry fields, not behavior. The moment a boundary object runs business logic, it stops being a boundary.
- Give entities behavior. If
Useris only getters and setters, your rules are homeless — they’ll drift into use cases, controllers, and stored procedures. Put domain invariants on the entity and let use cases orchestrate.
Common Mistakes
- Framework lock-in — importing Spring or Express inside use cases. One
import expressinsideRegisterUserUseCaseand the layer no longer exists; you’ve built a monolith with extra folders. - Leaky abstractions — passing HTTP request objects, ORM entities, or
HttpContextinto the domain. The port returnsUser | null, notUserRow | null; the use case takes aCommand, not aRequest. - Anemic models — treating entities as data bags with no behavior. The rules end up scattered across use cases until two of them disagree on what “active user” means.
- Over-abstraction — adding interfaces for things that never change. A port earns its place when there are two implementations (real + fake, or two reals) or when the boundary protects a business rule.
IStringHelperprotects nothing. - Use-case pass-throughs — a
GetUserUseCasethat only callsrepo.findByIdadds a file and a test for zero rule. Collapse pass-throughs or let the controller call the port directly until real logic shows up.
When to Use
- For alternatives, see Hexagonal Architecture — Ports, Adapters, and Testability.
- Medium to large applications with long lifespans — the overhead amortizes over years of change.
- Applications where domain logic outweighs data access — validation, pricing, workflows, invariants.
- Teams that value testability and independent deployability — fast unit suites without a database.
- Projects where framework churn is likely — you expect to outlive the current web framework or ORM.
When NOT to Use
- Simple CRUD with no business rules — ports and layers add files without buying isolation.
- Scripts, prototypes, or MVPs where speed matters more than structure — you can extract the architecture later if the thing survives.
- Teams without the discipline to maintain boundaries — Clean Architecture enforced by convention alone decays into a worse layered monolith within a year.
- Very small services — a 200-line lambda or single-purpose worker rarely has enough domain to justify four layers.
Troubleshooting
- Boundaries keep getting crossed: an
import expressor ORM type sneaks into a use case after every deadline. Add an architecture test (dependency-cruiser, ArchUnit, NetArchTest) that fails the build on an outward-pointing import — it’s the difference between a rule and a wish. - Every change touches five files: a new field means editing the entity, two DTOs, two mappers, and a use case. You’ve got too many mapping layers; merge DTOs that are structurally identical and keep the domain type as the single shape that matters.
- Use cases turned into god classes:
UserServiceaccumulates register, activate, deactivate, resetPassword, exportCsv. Split by operation — one class per use case — because each operation has its own rules and its own reasons to change. - Tests still boot the database: your repository port leaks ORM types or the fake is too faithful. The port should return domain types only, and the in-memory fake should be a map, not a stubbed SQL client.
- The team routes around the layers: developers call
PostgresUserRepositorydirectly “just this once”. Make the illegal thing hard — put concrete adapters in a package the inner layers can’t import — rather than relying on review comments.
Advanced Topics
Detailed Scenario: User Registration App with Clean Architecture
A registration and authentication service (TypeScript + Node.js) split across the four layers. The file structure puts each layer in its own directory. Every file below, plus the tests, is runnable from the companion repository:
src/
domain/
entities/User.ts
valueobjects/Email.ts
valueobjects/UserId.ts
valueobjects/UserStatus.ts
repositories/UserRepository.ts # port (interface)
services/EmailService.ts # port (interface)
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 # composition root (Express)
The flow for POST /users follows the request trace from A Request Through the Layers: Express hands the body to UserController, which maps it to RegisterUserCommand, calls the use case, and turns the returned Result into a 201 or 400 via the presenter.
Tests live next to what they cover. The entity tests are pure:
// domain/entities/User.test.ts
describe('User', () => {
test('create sets status to PENDING', () => {
const user = User.create(Email.create('test@example.com'));
expect(user.isActive()).toBe(false);
});
test('activate sets status to ACTIVE', () => {
const user = User.create(Email.create('test@example.com'));
user.activate();
expect(user.isActive()).toBe(true);
});
});
The use-case tests swap the ports for in-memory fakes — no database, no network, no container:
// application/usecases/RegisterUserUseCase.test.ts
describe('RegisterUserUseCase', () => {
let repo: InMemoryUserRepository;
let mailer: SpyEmailService;
let useCase: RegisterUserUseCase;
beforeEach(() => {
repo = new InMemoryUserRepository();
mailer = new SpyEmailService();
useCase = new RegisterUserUseCase(repo, mailer);
});
test('registers a new user and sends welcome email', async () => {
const result = await useCase.execute(new RegisterUserCommand('test@example.com'));
expect(result.isSuccess()).toBe(true);
expect(mailer.sentEmails).toHaveLength(1);
});
test('rejects a duplicate email', async () => {
repo.add(User.create(Email.create('test@example.com')));
const result = await useCase.execute(new RegisterUserCommand('test@example.com'));
expect(result.isFailure()).toBe(true);
expect(mailer.sentEmails).toHaveLength(0);
});
});
Domain tests run in single-digit milliseconds, use-case tests stay under ~50ms, and the only slow tests are the adapter suite that hits real Postgres — where slow is acceptable because it’s verifying the integration, not the rules.
Handling data between layers without leaking details
Use DTOs at every boundary. The controller receives a RequestDTO and converts it to a domain Command. The use case returns a Result carrying the domain entity. The presenter converts the entity into a ResponseDTO. Never pass the Express Request into the domain and never return the ORM entity to the controller — each layer speaks its own language and DTOs are the translation layer.
The one place this hurts is small CRUD surfaces where command, entity, and response are structurally identical. Three identical types and two mappers per boundary is worse than one type; collapse the DTOs when they carry no distinct meaning, and keep the mapping only where the shapes actually differ.
Further Reading
- Runnable companion — the registration example as standalone TypeScript files with tests.
- The Clean Architecture — Uncle Bob’s original 2012 post with the concentric-circle diagram.
- Clean Architecture: A Craftsman’s Guide to Software Structure and Design (Robert C. Martin, 2017) — the full treatment, including chapters on boundaries, presenters, and the main component.
- Hexagonal Architecture — Ports, Adapters, and Testability — the sibling style this guide borrows its boundary mechanics from.
- SOLID Principles Guide — dependency inversion is the mechanism that makes the Dependency Rule possible.
Frequently Asked Questions
Is Clean Architecture the same as Hexagonal?
They share the same goal — isolate the domain from delivery details — with different metaphors. Hexagonal talks about ports and adapters; Clean talks about concentric layers and the Dependency Rule, and splits application rules (use cases) from enterprise rules (entities). A hexagonal codebase usually satisfies the Dependency Rule automatically.
How do I handle transactions across use cases?
Keep the transaction boundary at the use-case level with a Unit of Work port in the application layer, or wrap the use case in a transaction decorator that lives in the adapters. What doesn't work: letting the controller open the transaction, because then rollback rules live outside the layer that owns the business flow.
Can I use ORMs in the entities layer?
Not without breaking the Dependency Rule — ORM annotations are persistence concerns. The usual pattern is a separate persistence entity (or schema) plus a mapper that converts rows into domain entities. If your ORM supports annotation-free mapping (e.g., EF Core fluent config, TypeORM entity schemas), you can sometimes keep one class and map it externally.
Where does input validation go?
Split it by kind. "Is this a well-formed request" — required fields, types, lengths — belongs in the adapter, where the DTO lives. "Is this a valid business state" — user must be active, order can't ship twice — belongs in the entity or use case, where the rule is enforced regardless of entry point.
How do I structure the folders?
Layer-first (domain/, application/, infrastructure/, presentation/) works when the app is one bounded context. When you've got several, flip it — feature-first (users/, billing/), each with its own layer subfolders — so deleting a feature deletes one directory. The layers still matter; they're just nested inside the slice.
Is Clean Architecture worth it for a small app?
Usually no. If the whole domain is "save this form", the layers cost files and indirection without buying isolation. The signal that it's time: business rules start appearing in more than one place, or you can't test a rule without standing up infrastructure. Extract then, incrementally — see Adopting It Incrementally above.
Related Resources
Hexagonal Architecture — Ports, Adapters, and Testability
A complete guide to Hexagonal Architecture (Ports and Adapters): structure applications so domain logic is isolated from frameworks, databases, and external services.
GuideOnion Architecture: Domain-Centric Design Guide
A practical guide to Onion Architecture: organize code around the domain, enforce inward dependencies, and isolate infrastructure. Includes C# examples.
GuideLayered Architecture — N-Tier Explained
A practical guide to Layered (N-Tier) Architecture: separating presentation, business logic, and data layers with clear responsibilities and dependency rules.
GuideSOLID Principles Explained with Examples
Learn the five SOLID principles with bad-vs-good code examples and a full before/after refactor of a TypeScript payment service.
PatternDependency Injection Pattern
Supply dependencies from outside rather than creating them internally. An architectural pattern for decoupled, testable code.
GuideVertical Slice Architecture: Feature-First Organization
A practical guide to Vertical Slice Architecture: organize code by feature instead of technical concern, reducing cross-layer navigation and improving cohesion.