Skip to content

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:

UI → Application → Domain ← Adapter ← Infrastructure

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