Clean / Hexagonal / Onion Architecture — Detailed#
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 |