Skip to content

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.

Concept illustration
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:

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

Quick reference#

Packaging#

  • One module per layer (domain, application, infra-sql, web).
  • The build tool enforces the dependency rule (domain cannot depend on infra-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.

Further reading#

Curated, high-credibility sources for going deeper on this topic.