system-design

Layered, Hexagonal, and Clean Architecture

How to structure code inside a service so the business logic doesn’t depend on the database, the web framework, or anything else you’ll want to replace.

Prerequisites: Service Decomposition Time to read: ~20 minutes


The problem

Every application has code that falls into three categories:

  1. Business rules — “an order over $500 gets free shipping,” “a refund is only allowed within 30 days.” This is what the software is for, and it’s the only part your competitors don’t have.
  2. Application flow — “when an order is placed: validate, charge, reserve stock, notify.”
  3. Technical mechanism — HTTP, SQL, JSON, Kafka, the payment provider’s SDK.

🚨 The problem is that (3) tends to infect (1). Business logic ends up inside HTTP controllers, tied to ORM entities, coupled to a vendor’s SDK. Then:

The answer, in every architecture below: the business rules must not depend on the mechanisms.


Layered architecture

The traditional structure. Each layer depends only on the one below.

┌─────────────────────────────┐
│  Presentation (controllers) │
├─────────────────────────────┤
│  Application (use cases)    │
├─────────────────────────────┤
│  Domain (business rules)    │
├─────────────────────────────┤
│  Data access (repositories) │
└─────────────────────────────┘

✅ Familiar, easy to explain, better than no structure.

❌ 🚨 The domain depends on the data layer, which is exactly backwards. Your business rules end up importing your ORM, so they can’t be tested or reasoned about without a database. Change the database and the domain changes.

❌ In practice, layered code often degrades into an anaemic domain model: entities are bags of getters and setters, and all the actual logic lives in “service” classes that manipulate them. That’s procedural code with object syntax, and the business rules are as scattered as before.


Hexagonal architecture (ports and adapters)

Alistair Cockburn’s model, and the one worth understanding properly.

🚨 The core move: invert the dependency. Instead of the domain depending on the database, the domain defines an interface and the database implements it.

flowchart LR
    W[Web adapter] --> P1[[Port:<br/>OrderService]]
    C[CLI adapter] --> P1
    Q[Queue adapter] --> P1
    P1 --> D((Domain<br/>business rules))
    D --> P2[[Port:<br/>OrderRepository]]
    D --> P3[[Port:<br/>PaymentGateway]]
    P2 --> PG[Postgres adapter]
    P2 --> MEM[In-memory adapter<br/>for tests]
    P3 --> ST[Stripe adapter]
    P3 --> FK[Fake adapter<br/>for tests]

Ports are interfaces the domain owns:

# Domain layer — defines what it needs, in its own terms
class OrderRepository(Protocol):
    def save(self, order: Order) -> None: ...
    def find_by_id(self, order_id: OrderId) -> Order | None: ...

class PaymentGateway(Protocol):
    def charge(self, amount: Money, token: str) -> ChargeResult: ...

Adapters implement them, in the infrastructure layer:

# Infrastructure — depends on the domain, never the reverse
class PostgresOrderRepository:
    def save(self, order: Order) -> None:
        self.db.execute("INSERT INTO orders ...", ...)

class StripePaymentGateway:
    def charge(self, amount: Money, token: str) -> ChargeResult: ...

Two kinds of port, and the distinction matters:

📐 The whole point in one line: all dependencies point inward. The domain knows nothing about Postgres, HTTP, or Stripe. It defines what it needs and something else provides it.


Clean architecture

Uncle Bob’s version — the same idea with more prescribed layers:

Entities        → enterprise-wide business rules
Use cases       → application-specific rules
Interface       → controllers, presenters, gateways
Frameworks      → the web, the database, external services

The dependency rule: source code dependencies point only inward. An inner circle knows nothing about an outer one.

⚖️ Honestly: hexagonal, clean, and onion architecture are the same idea with different vocabulary and different numbers of circles. Knowing that — and saying so — is better than pretending they’re distinct. The shared insight is dependency inversion applied at the architectural scale.


What you actually gain

1. Testability without infrastructure. 🚨 The biggest practical benefit:

def test_free_shipping_over_500():
    service = OrderService(
        repository=InMemoryOrderRepository(),
        payments=FakePaymentGateway(),
    )
    order = service.place_order(items=[Item(price=Money(600))])
    assert order.shipping_cost == Money(0)

No database, no HTTP server, no Docker, no network. Milliseconds, not seconds. This changes how teams work — a test suite that runs in two seconds gets run constantly; one that takes four minutes gets run at the end.

2. Deferred and reversible decisions. Start with an in-memory repository, add Postgres later. Swap Stripe for a local provider by writing one adapter.

3. Multiple entry points for free. The same use case is exposed over HTTP, a CLI, a queue consumer, and a scheduled job — with no duplicated logic.

4. The business rules are findable. They’re in one place, expressed in domain language, with no SQL or HTTP noise.

5. Framework independence. Upgrading or replacing the web framework touches adapters, not the domain.


What it costs

⚖️ Be honest about this — the criticism is legitimate.

1. More code and indirection. An interface plus an implementation for everything. Simple CRUD becomes several files.

2. It can be ceremony. For a service that reads a row and returns JSON, ports and adapters add files without adding value.

3. Mapping overhead. Domain objects, database rows, and API DTOs are separate types, so you write translation between them. This is real work, and it’s the most-complained-about part.

4. The team must understand it. Half-applied, it’s worse than not applied — you get the indirection without the isolation.

5. Fighting your ORM. Many ORMs want entities that are also database rows. Keeping them separate means giving up conveniences like lazy loading and change tracking.

🚨 The pragmatic middle ground worth stating: apply it where the business logic is genuinely complex, and keep simple CRUD paths simple. A single service can have a rich hexagonal core for the ordering domain and a thin controller-to-database path for reference data. Uniformity is not a virtue here.


The anti-corruption layer

🚨 The most practically useful application of this thinking, and worth knowing by name.

When integrating with a legacy system or a third-party API whose model is a poor fit, put a translation layer at the boundary so their concepts don’t leak into yours.

# Their model: 47 fields, cryptic names, a 1998 data model
# Your model: what your domain actually means

class LegacyBillingAdapter:
    def get_account(self, id: AccountId) -> Account:      # ← your type
        raw = self.legacy_client.fetch_acct_rec(id.value)  # ← their mess
        return Account(
            id=AccountId(raw["ACCT_NO"]),
            balance=Money(cents=int(raw["BAL_AMT_C"])),
            status=self._map_status(raw["STAT_CD"]),       # "A"/"I"/"S" → an enum
        )

Without it, STAT_CD == "A" appears throughout your codebase and you can never migrate off the legacy system, because its model has become your model. → Service Decomposition


Practical guidance

Start with the domain, not the database. Write the business rules and their tests first, with no persistence at all. The interfaces you need will emerge from the logic rather than from a schema.

Keep the domain free of framework annotations. No ORM decorators, no serialization attributes, no HTTP concerns. If your domain class imports the web framework, the isolation is already gone.

Value objects over primitives. Money, EmailAddress, OrderId rather than float, str, int. It prevents an entire category of bug (adding two amounts in different currencies, passing a customer ID where an order ID was expected) and makes the code self-documenting.

One use case per class. PlaceOrderUseCase, not an OrderService with 30 methods that grows forever.

Don’t leak the ORM. Repositories return domain objects, not ORM entities. Otherwise lazy loading, session lifetimes, and change tracking spread through your domain and you’ve achieved nothing.

Test at the right level: unit tests for domain rules (fast, no infrastructure), integration tests for adapters (real database, fewer), and a small number of end-to-end tests.


How this relates to system design interviews

⚖️ Be honest: this is usually not what a system design interview is about. A 45-minute architecture round is about services, data stores, and scaling — not package structure.

But it matters in two places:

  1. Low-level design rounds (very common in Pakistan, India, and at Amazon) are exactly this material. → Low-Level Design
  2. When an interviewer asks how you’d make something replaceable — “what if we need to switch payment providers?” — the answer is a port and an adapter, and having the vocabulary is useful.

🎙️ “I’d put the payment provider behind an interface the domain owns, with a Stripe adapter implementing it. Switching providers or adding a local one becomes a new adapter rather than a change to the ordering logic.”


⚖️ Trade-offs

Decision Gain Cost
Layered Familiar, simple Domain depends on infrastructure; anaemic models
Hexagonal / clean Testable without infrastructure; replaceable dependencies More code, indirection, mapping
Domain objects ≠ ORM entities True isolation Explicit mapping to write and maintain
Value objects Type safety; self-documenting More types
Applying it uniformly Consistency Ceremony on simple CRUD paths
Applying it selectively Complexity where it pays Two styles in one codebase

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Invert a dependency. Take a service class that directly uses an ORM. Extract an interface for what it needs, move the interface into the domain package, and implement it in an infrastructure package. Then write a test using an in-memory implementation. Compare the test’s runtime to the original integration test.

2. Add a second entry point. Take a use case exposed over HTTP and add a CLI command that invokes the same use case object. If you have to duplicate any logic, your boundaries are wrong.

3. Swap an adapter. Build a PaymentGateway port with a Stripe adapter and a fake. Write the business logic tests against the fake. Then add a second real adapter (any provider) and confirm no domain code changes.

4. Introduce value objects. Replace float amounts with a Money type that carries currency and refuses to add mismatched currencies. Count how many latent bugs the compiler or type checker immediately finds.


Check yourself

1. What is the dependency rule, and why does it matter? All source-code dependencies point inward, toward the domain. The domain layer defines interfaces (ports) describing what it needs — a repository, a payment gateway — and infrastructure implements them; the domain never imports the database, the web framework, or a vendor SDK. It matters because it makes the business rules independent of the mechanisms: you can test them without infrastructure, replace the database or payment provider by writing a new adapter, and change the web framework without touching business logic. It also keeps the rules findable and expressed in domain language rather than buried in SQL and HTTP handling.
2. What's the difference between a driving and a driven port? **Driving (primary) ports** are how the outside world invokes the application — the use case interfaces called by an HTTP controller, a CLI command, a queue consumer, or a scheduled job. The adapter drives the application. **Driven (secondary) ports** are what the application calls out to — a repository, a payment gateway, an email sender — where the application drives the adapter. The distinction matters because it shows the same use case can be reached through many entry points with no duplicated logic, and that the application's outbound dependencies are all interfaces it owns rather than concrete external systems.
3. What's an anaemic domain model and why is it a problem? Domain entities that are just data containers — fields with getters and setters and no behaviour — with all the actual business logic living in separate "service" classes that manipulate them. It's a problem because it's procedural code wearing object-oriented syntax: the rules governing an Order aren't in `Order`, they're scattered across `OrderService`, `OrderValidator`, and `OrderManager`, so you can't find them, can't guarantee they're all applied, and can't prevent an entity from being put into an invalid state. It typically emerges from layered architectures where entities are ORM row mappings, and it defeats the point of having a domain model at all.
4. When is hexagonal architecture not worth the cost? When the business logic is thin. For a service whose job is to read a row and return JSON, or a straightforward CRUD admin panel, ports and adapters add interfaces, implementations, and mapping code without isolating anything valuable — there's no complex domain to protect. It's also poor value when the team doesn't understand it (half-applied, you get the indirection without the isolation), when you're fighting an ORM that insists entities are rows, and in throwaway or prototype code. The pragmatic position is selective application: a rich hexagonal core where the domain is complex, thin direct paths where it isn't, within the same service.
5. What's an anti-corruption layer and when would you use one? A translation layer at the boundary with an external system, converting their model into yours so their concepts don't leak into your domain. You'd use it whenever integrating with something whose model you didn't choose and wouldn't want: a legacy system with a 1998 schema and cryptic column names, a third-party API with a different domain model, or a vendor SDK with its own types. Without it, their representations spread through your codebase — status codes like `"A"`/`"I"` appearing in business logic — and you become permanently coupled to a system you were planning to replace. With it, migrating off the legacy system means rewriting one adapter.

Further reading