Clean / Hexagonal Architecture#
Problem statement (interviewer prompt)
Structure the codebase of a payment service so the domain (money, accounts, invariants) is independent of frameworks, databases, and third-party APIs. Use Hexagonal / Ports & Adapters. Show what tests look like at each layer.
flowchart LR
UI([UI / API])
APP([Application services<br/>use cases])
DOM([Domain<br/>entities, rules])
PORT([Ports - interfaces])
ADAPT([Adapters<br/>DB, MQ, HTTP])
UI --> APP --> DOM
APP --> PORT
PORT --> ADAPT
classDef p fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef s fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
class UI,ADAPT p;
class APP,PORT s;
class DOM s;
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
class UI,APP,DOM,PORT service;
class ADAPT queue;
Put domain logic at the centre; let everything else (UI, DB, frameworks) plug in via interfaces (ports + adapters). The domain depends on nothing; everything depends on the domain.
Three names, one idea: keep business rules independent of frameworks, databases, and delivery mechanisms.
flowchart TB
subgraph Outer[Outer ring - drivers]
UI[Web / REST / gRPC]
CLI[CLI]
JOB[Scheduler]
end
subgraph App[Application layer - use cases]
UC[Use case interactors]
end
subgraph Domain[Domain layer - core]
ENT[Entities]
VO[Value objects]
DS[Domain services]
EV[Domain events]
end
subgraph Ports[Ports]
PIN[Inbound port - use case]
POUT[Outbound port - repo, gateway]
end
subgraph Adapt[Adapters - driven]
DB[(SQL repo adapter)]
MQ[Message bus adapter]
EXT[3rd-party gateway adapter]
end
UI --> PIN --> App
CLI --> PIN
JOB --> PIN
App --> Domain
App --> POUT
POUT --> Adapt
Adapt --> DB
Adapt --> MQ
Adapt --> EXT
classDef d fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef e fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
class Domain,App,Ports d;
class Outer,Adapt e;
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
class UI client;
class POUT,EXT edge;
class CLI,UC,ENT,VO,DS,EV,PIN service;
class DB datastore;
class MQ queue;
class JOB compute;
The dependency rule#
Source code dependencies point inwards only:
The domain layer has zero imports of frameworks, persistence, or HTTP. It models the business in pure code that you could run anywhere.
Ports and adapters#
| Direction | Port (interface) | Adapter (impl) |
|---|---|---|
| In (driver) | use-case input boundary | HTTP controller, gRPC handler, CLI |
| Out (driven) | repository, gateway, notifier | SQL repo, Stripe client, Kafka producer |
The domain defines ports it needs. Adapters implement them. The composition root wires the right adapter to the right port at startup.
A worked checkout#
sequenceDiagram
participant HTTP as HTTP Controller
participant UC as PlaceOrder Use Case
participant Repo as OrderRepository<br/>port
participant SQL as SqlOrderRepo<br/>adapter
participant Pay as PaymentGateway<br/>port
participant Strp as StripeGateway<br/>adapter
HTTP->>UC: place(orderRequest)
UC->>Repo: save(order)
Repo->>SQL: INSERT
UC->>Pay: charge(amount)
Pay->>Strp: HTTPS call
Strp-->>UC: ok
UC-->>HTTP: ResultOK
The use case calls ports. Adapters live outside. Swap Stripe for Adyen by writing a new adapter - no domain change.
Layer rules of thumb#
| Layer | Allowed deps | Forbidden |
|---|---|---|
| Domain | language stdlib | frameworks, DB driver, HTTP, JSON |
| Application | domain, ports | adapter classes |
| Ports | domain types only | adapter types |
| Adapters | their port + tech | reaching into domain internals |
| Composition root | everything | called from anywhere else |
Why bother#
- Tests run in milliseconds - domain logic is pure; mock adapters or use fakes.
- Tech swaps - Postgres → DynamoDB without touching the domain.
- Long-lived codebases - the domain rarely needs rewrites; frameworks come and go.
- Onboarding - a new dev reads the domain layer first; it's the smallest, most stable code.
When it's overkill#
- Toy / prototype project.
- Pure CRUD with no business rules ("admin tool").
- Single-developer code expected to live < 6 months.
Variants (mostly the same)#
- Hexagonal (Cockburn, 2005) - original "ports and adapters" diagram.
- Onion (Palermo, 2008) - layered rings, same dependency rule.
- Clean (Uncle Bob, 2012) - adds "use case" + "interface adapter" rings, hardens the dependency rule.
Pitfalls#
- "Anaemic domain" - entities with only getters/setters and all logic in services. Push behaviour into the entity.
- "Repository returns DTOs" - repository returns domain entities; controllers map to DTOs.
- "Use case calls another use case" - composition belongs at the orchestrator (controller/saga); use cases shouldn't reach sideways.
Glossary & fundamentals#
Concepts referenced in this design. Each row links to its canonical page; the tag column shows whether it is a high-level (HLD) or low-level (LLD) concept.
| Tag | Concept | What it is | Page |
|---|---|---|---|
HLD |
Pub/Sub & message brokers | topics, consumer groups, delivery semantics | pub-sub-pattern |
HLD |
Distributed transactions | 2PC, TCC, sagas, outbox/inbox | distributed-transactions |
LLD |
Clean / Hexagonal architecture | ports & adapters, dependency rule | clean-architecture |
LLD |
DDD tactical | entity / value object / aggregate / event | ddd-tactical |
LLD |
REST API design | verbs, statuses, pagination, errors | rest-api-design |
LLD |
Creational patterns | Singleton, Factory, Builder, Prototype | creational-patterns |
LLD |
Structural patterns | Adapter, Decorator, Facade, Proxy, Composite | structural-patterns |
LLD |
Dependency injection | DI / IoC, constructor injection, containers | dependency-injection |
LLD |
Immutability | immutable types, persistent collections | immutability |
Quick reference#
Packaging#
- One module per layer (
domain,application,infra-sql,web). - The build tool enforces the dependency rule (
domaincannot depend oninfra-sql).
Mapping#
- Adapters map outward types (DTO, ORM entity) ↔ domain types.
- Domain never sees JSON / SQL row types.
Cross-cutting concerns#
- Tracing / metrics / logging → in adapters or via decorators around use cases.
- Auth → adapter (HTTP) verifies token; use case receives a principal value object.
Tests#
- Domain: pure unit tests, no IO.
- Application: in-memory port fakes; assert use-case outcomes.
- Adapter: integration tests against the real tech (Postgres, Stripe sandbox).
- End-to-end: a small handful covering critical paths.
Refs#
- Robert C. Martin: Clean Architecture (2017).
- Alistair Cockburn: "Hexagonal Architecture" (2005, alistair.cockburn.us).
- Jeffrey Palermo: "The Onion Architecture" (2008).
- Vaughn Vernon: Implementing DDD (DDD + Hexagonal).
FAQ#
What is the core idea of clean architecture?#
Source code dependencies point inward toward the domain. Frameworks, databases, and UI live at the outer edge and are plugged in through interfaces, so the core is isolated and testable.
What are ports and adapters?#
Ports are interfaces the domain defines for what it needs, like a repository or notifier. Adapters are concrete implementations that fulfill those ports using a specific framework or database.
Clean architecture vs hexagonal vs onion?#
They are variants of the same idea: keep the domain free of infrastructure concerns. Hexagonal emphasizes ports and adapters; onion stacks layers; clean architecture popularized the dependency rule.
Is clean architecture worth the boilerplate?#
For long-lived services with complex domains, yes. For tiny CRUD apps it adds layers without much benefit. The tradeoff is upfront complexity against long-term flexibility.
How do tests look in clean architecture?#
Domain logic is tested with no framework, using fakes for ports. Adapter tests verify integration with the database or HTTP layer. End-to-end tests cover the whole vertical slice.
Related Topics#
- SOLID Principles: dependency inversion and single-responsibility underpin the clean architecture layers
- Dependency Injection: the primary mechanism for wiring adapters to ports
- Repository Pattern: the canonical data-access adapter in clean and hexagonal architecture
Further reading#
Curated, high-credibility sources for going deeper on this topic.