Guide

Clean architecture explained

Clean architecture is a way of structuring applications so business rules sit at the center and everything else — databases, web frameworks, UI toolkits, message brokers — wraps around them as replaceable details. Robert C. Martin ("Uncle Bob") popularized the name, but the idea is older: your domain should not import Express, Django, or PostgreSQL; those tools should depend on your abstractions. The signature rule is simple: source-code dependencies point inward only. Outer layers know about inner layers; inner layers never know about HTTP status codes or ORM annotations. This guide walks through the four concentric rings (entities, use cases, interface adapters, frameworks), how clean architecture relates to hexagonal and onion models, practical folder layouts, why tests get faster, common over-engineering traps, and a checklist for adopting layers without turning a CRUD app into a cathedral.

Why frameworks should not own your business logic

Most tutorials teach "Rails way" or "NestJS way" first: controllers call services that call repositories that call the ORM. That works until you need to swap Postgres for DynamoDB, run the same pricing rules from a CLI and a queue worker, or unit-test a refund policy without spinning up Docker. When business logic imports framework types, every change ripples outward and tests require heavy fixtures.

Clean architecture inverts the typical dependency graph. A RefundOrder use case declares it needs a PaymentGateway interface; an adapter in the outer ring implements that interface with Stripe. Swap Stripe for Adyen by writing a new adapter — the use case file stays untouched. That separation is what dependency injection and adapter/strategy patterns enable in practice.

The dependency rule

Picture concentric circles. The innermost ring holds entities — pure business objects and invariants (a Money value object that rejects negative amounts, an Order that cannot ship before payment clears). The next ring holds use cases (application services) — orchestration scripts that load entities, enforce a single application-specific workflow, and persist results through interfaces. The third ring is interface adapters — controllers, presenters, gateways, repository implementations that translate between the outside world and use-case inputs/outputs. The outermost ring is frameworks and drivers — Express routes, SQL drivers, React components, Kafka clients.

The dependency rule: nothing in an inner circle may reference a name defined in an outer circle. Entities cannot import DTOs. Use cases cannot import Request from Express. Violations are patched with dependency inversion — inner layers define interfaces; outer layers implement them. Data crossing a boundary uses simple structures (input/output models) so inner code never sees JSON field names tied to a particular API version.

The four layers in detail

Entities (enterprise business rules)

Entities encode rules that would exist even if you had no software — interest accrual, inventory non-negativity, subscription state machines. They should be framework-agnostic plain objects (or language equivalents). Keep them small; not every database row needs to be an entity class. Value objects from domain-driven design often live here.

Use cases (application business rules)

A use case represents one thing the application does: "Place order," "Approve expense," "Generate monthly statement." It coordinates entities and calls gateway interfaces (OrderRepository, EmailNotifier) without knowing implementations. Use cases are the sweet spot for fast unit tests — inject fakes, assert outputs, no network.

Interface adapters

Controllers parse HTTP, validate auth tokens, map request bodies to use-case input structs, and map results to HTTP responses. Presenters format data for a specific UI. Repository classes implement persistence interfaces with SQL or document queries. Gateways wrap third-party APIs. Adapters are allowed to know about both frameworks and use-case contracts; they translate between them.

Frameworks and drivers

The outer shell: web server bootstrap, ORM configuration, dependency-injection container wiring, migration runners, build tooling. This is where main() lives — the composition root that binds interfaces to concrete classes. Framework churn is isolated here; swapping Fastify for Hono should not touch entity code.

Clean, hexagonal, onion — how they relate

These names describe the same family of ideas with different metaphors:

ModelMetaphorEmphasis
Clean architecture Concentric circles Explicit layers (entities → use cases → adapters → frameworks) and the dependency rule
Hexagonal (ports & adapters) Hexagon with plugs Application core with inbound/outbound ports; adapters plug in from any side
Onion architecture Layered onion Domain model center; application services; infrastructure on the outside
Traditional layered (3-tier) UI → BL → DAL stack Top-down dependencies; business layer often leaks DB concerns inward

In practice, teams mix terms. A "hexagonal" service might use clean-architecture folder names. What matters is the invariant: domain and application rules do not depend on delivery or persistence mechanisms. Pick one vocabulary per repo and document where controllers, use cases, and repositories live.

Folder layout that teams actually ship

There is no single canonical tree, but feature-based layouts age better than technology-based ones (/controllers, /models, /views alone tell you nothing about the product). A pragmatic structure:

src/
  domain/           # entities, value objects, domain services
  application/      # use cases, input/output ports (interfaces)
  infrastructure/   # ORM repos, HTTP clients, queue publishers
  interfaces/       # HTTP controllers, CLI commands, GraphQL resolvers
  main.ts           # composition root / DI wiring

For larger systems, slice by bounded context instead of layer globally — billing/domain, billing/application — so service boundaries align with folder boundaries. A modular monolith can enforce import rules with lint plugins (e.g., "domain cannot import infrastructure") so violations fail CI instead of accumulating silently.

Testing and change isolation

Clean architecture pays off most in the test pyramid's base. Use-case tests run in milliseconds: construct entities, call RefundOrder.execute() with an in-memory repository, assert balance and emitted domain events. Integration tests cover adapters (does the SQL repository honor the interface contract?). End-to-end tests stay few — they validate wiring, not every branch.

When requirements change — new tax rule, different idempotency key — you edit entities or one use case. When infrastructure changes — migrate ORM, add Redis cache — you edit adapters. That separation shortens code review scope and reduces regression risk during refactors that would otherwise touch controller files mixed with SQL strings.

When clean architecture helps — and when it hurts

SituationRecommendation
Complex domain rules, multiple delivery channels (API + workers + CLI) Adopt — use cases amortize duplication
Long-lived product, frequent infra swaps Adopt — adapters absorb churn
Prototype, hackathon, throwaway demo Skip — framework-first is faster
Simple CRUD admin with one UI and stable stack Light layers — repository interface maybe; skip ceremony
Microservice with a single endpoint and no shared domain Thin service — full clean rings are often overkill

The failure mode is architecture astronautics: six interfaces and four DTO mappers for "get user by ID." Start with the dependency rule on new features; extract use cases when controllers grow past ~40 lines or when the same logic is copy-pasted into a cron job.

Common anti-patterns

  • Anemic domain model — entities are data bags; all logic sits in "service" god classes. Push invariants back into entities where they belong.
  • Leaky repositories — repository interfaces expose ORM types or QueryBuilder methods. Return domain objects; hide SQL shapes.
  • DTO explosion — identical structs copied across layers. Share simple input/output types at use-case boundaries only; do not map entity → DTO → entity for every field.
  • Framework in use cases — importing @Transactional, logging decorators, or HTTP exceptions inside application services. Keep use cases pure; let adapters handle cross-cutting concerns.
  • Skipping the composition root — use cases call new PostgresOrderRepo() directly. Wire dependencies in main or the DI container.

Production checklist

  • Dependency rule documented — inner layers have zero imports from infrastructure or framework packages.
  • Use cases named after user-visible actions, one primary path per class or module.
  • Ports (interfaces) defined by the application layer; adapters implement them in infrastructure.
  • Composition root is the only place that instantiates concrete adapters and passes them inward.
  • Lint or arch-unit tests enforce forbidden imports (domain → infrastructure).
  • Unit tests cover use cases with fakes; no database required for business-rule regressions.
  • API versioning handled in controllers — not by branching inside entities.
  • CI pipeline runs fast use-case tests on every push before slower integration suites.

Key takeaways

  • Clean architecture puts business rules at the center and treats frameworks, UI, and databases as outer, replaceable details.
  • The dependency rule — inward-only source dependencies — is the non-negotiable invariant; use interfaces to invert dependencies.
  • Use cases orchestrate workflows and are the primary unit of application logic and fast testing.
  • Hexagonal and onion models share the same goal; pick one vocabulary and enforce it with folders plus lint rules.
  • Apply proportionally — full layering for complex domains; thin structure for simple CRUD until pain appears.

Related reading