Skip to content
intermediate Phase 2 · SOLID Principles and Clean Architecture

Clean Architecture Layers

Organize code into inward rings where domain rules depend on nothing outward.

1h
0 problems
Topic Progress 0%

Entities at the Center, Frameworks at the Edge: Reading the Concentric Diagram

Dependency inversion gave you one module with an inside and an outside. Clean architecture applies the same logic to the whole system as concentric rings, and the diagram has a single governing sentence: dependencies point inward, so domain logic never imports web or database packages — outer rings may know about inner rings, never the reverse.

┌──────────────────────────────────────────────┐
│  Frameworks & Drivers   (web, DB, buses, UI) │  ← outermost
│ ┌──────────────────────────────────────────┐ │
│ │  Interface Adapters  (controllers,       │ │
│ │  gateways, presenters, mappers)          │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │  Application / Use Cases             │ │ │
│ │ │  (application-specific business rules│ │ │
│ │ │   + orchestration of entity flows)   │ │ │
│ │ │  ┌────────────────────────────────┐  │ │ │
│ │ │  │  Entities  (enterprise rules)  │  │ │ │  ← innermost
│ │ │  └────────────────────────────────┘  │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘

Each ring owns a distinct kind of rule. Entities hold the domain model and its invariants — Order.refund() refuses a double refund, a Money value object never mixes currencies. These rules survive any framework change; if you rewrite the web stack next year, this code should not notice. Use cases carry application-specific rules: the sequence for placing an order — validate the cart, ask the pricing engine, reserve inventory through a port, persist, publish. RefundOrder and PlaceOrder are use cases; each one expresses what this application does with the entities, orchestrating flows rather than redefining them. Interface adapters translate at the seams: controllers turn HTTP requests into use-case commands, gateways turn entities into database rows or Kafka messages, presenters shape output for the wire. Frameworks and drivers are the outermost and most volatile layer: the web framework, the ORM, the queue client — details, wired in last and replaceable first.

The rings' value is that they rank volatility. The entity rules change when the business changes, which is rare; use cases change when product behavior changes, monthly; adapters change when integrations change; frameworks change when vendors or fashion change. Code placement then follows change rate, so the highest-churn code lives at the rim and cannot leak inward. A practical consequence for teams: the inner rings get the strictest tests (entities with pure unit tests, use cases with fakes at the ports), while the outer rings get integration tests — the test strategy falls out of the ring structure instead of being chosen separately.

Two honest caveats, because this pattern has a reputation for over-application. First, the rings are a dependency direction, not a package-count mandate: a small service can express all four rings in a handful of modules, and forcing forty directories on a CRUD app is the classic way to discredit the pattern. Second, everything valuable about the structure rides on the dependency rule being enforced; a beautiful diagram with arrows pointing the wrong way in the source tree is decoration. Enforcement is the next chapter's subject, and the walkthrough of a request through the rings — Use Case Flows style — comes after.

The Dependency Rule, Enforced by Machines Instead of Good Intentions

Layered codebases do not decay because people draw the arrows wrong in documents; they decay because one urgent hotfix at 4 p.m. imports the ORM session into a domain service, and nothing objects. The fix is mechanical: make the dependency rule checkable in CI, so a violation fails the build instead of surviving the review that everyone skimmed.

The rule restated precisely, because tooling needs exactness: source-code dependencies may only point toward the center. Domain entities import nothing from the project. Use cases may import entities. Adapters may import use cases and entities. The outer ring may import everything; nothing may import it except the composition root. Translated to what the linter sees:

// .dependency-cruiser.js — one rule per forbidden arrow
module.exports = {
  forbidden: [
    { name: 'domain-cannot-import-outer',
      from: { path: '^src/domain' },
      to:   { path: '^(src/web|src/infra|node_modules/(?!yourcompany))' } },
    { name: 'usecases-cannot-import-web',
      from: { path: '^src/usecases' },
      to:   { path: '^src/web' } },
  ],
};

Java shops express the same intent with ArchUnit (noClasses().that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..persistence..")), Python with import-linter, .NET with NetArchTest. Whatever the ecosystem, the habit is the same: the rings live as one rule file, reviewed like code, and the build refuses the regression. Teams that adopt this consistently report the same side effect — the rule file becomes the documentation of the architecture, because it states the permitted arrows in an form no prose diagram can match.

What the machine catches, in the order violations usually appear:

Violation How it sneaks in Why it must not ship
Entity imports ORM decorator 'just for the @Entity annotation' domain now needs the persistence stack to load
Use case formats HTTP responses controller logic 'reused' product rule cannot be called from a batch job
Adapter leaks row objects upward skip the mapper for speed DB schema becomes the de-facto domain model
Entity depends on use case cyclic convenience import inner ring now cannot compile alone

Two boundaries deserve explicit treatment because the rule alone does not specify them. Crossing data: entities crossing outward should travel as plain domain objects, with mappers at the adapter edge converting to and from rows, DTOs, and messages — the moment a Row or an SDK type appears in a use-case signature, the outer world has moved in. Crossing errors: exceptions are mapped at the boundary too — an entity's domain exception (RefundNotAllowed) becomes an HTTP 409 in the controller, never an ORM error type surfacing to a client. Both are one-mapper-class disciplines, and both keep the inner rings compilable and testable with zero infrastructure on the classpath — the property the previous topic's fakes depend on. The discipline costs a mapper per boundary and buys the ring structure's entire promise: replaceable frameworks, portable use cases, and a codebase whose arrows point the way the diagram claims. Rollout advice for adopting the rules on an existing codebase: start with the innermost forbidden arrow (domain may not import anything outward), because it is the highest-value and least controversial, and grandfather existing violations with a baseline file so the build fails only on new ones. Teams then burn the baseline down a package per sprint. Attempting to fix every historical arrow before enabling the linter is the common way the effort dies — the goal is to stop the bleeding first and heal gradually.

One Request End to End: Controller, Use Case, Entity, Gateway, and Back

The rings become concrete when you follow a single request through them. The endpoint is POST /orders/{id}/refund with a JSON body naming an amount; the application rule is that a refund may not exceed the refundable remainder of the order; the entity rule is that an order cannot go below zero refundable. Watch which layer answers which question.

Controller (interface adapter). Parse HTTP into a command object, authenticate into a caller identity, and nothing else. Framework types stop here:

@RestController
class RefundController {
  private final RefundOrder useCase;

  @PostMapping("/orders/{id}/refund")
  ResponseEntity<RefundResponse> refund(
      @PathVariable UUID id, @RequestBody RefundRequest req,
      @AuthenticationPrincipal Caller caller) {
    try {
      RefundResult r = useCase.execute(
          new RefundCommand(id, req.amount(), caller.id()));
      return ResponseEntity.ok(RefundResponse.from(r));
    } catch (RefundNotFound e)  {
      return ResponseEntity.notFound().build();
    } catch (RefundNotAllowed e) {
      return ResponseEntity.status(409).body(RefundResponse.error(e));
    }
  }
}

Use case (application layer). RefundOrder owns the workflow: load the order through its port, ask the entity to perform the refund, save it, notify downstream, return a result shaped for the caller. It contains no HTTP and no SQL — only orchestration plus application rules such as authorization scope ('a caller may refund only their own orders') and idempotency keys:

class RefundOrder {
  private final OrderRepository orders;
  private final OrderEventPublisher events;

  RefundResult execute(RefundCommand cmd) {
    Order order = orders.get(cmd.orderId());
    order.refund(cmd.amount());              // entity rule, throws
    orders.save(order);
    events.orderRefunded(order, cmd.amount());
    return new RefundResult(order.id(), order.refundableRemaining());
  }
}

Entity. Order.refund(amount) enforces the invariant that makes double refunds and over-refunds impossible regardless of caller — this is the rule that must hold for every entry point, including the admin tool and the batch job, which is exactly why it cannot live in the controller:

void refund(Money amount) {
  if (amount.greaterThan(refundable))
    throw new RefundNotAllowed("exceeds refundable " + refundable);
  refunds.add(new Refund(refunds.size(), amount, clock.now()));
}

Gateway (adapter). SqlAlchemyOrderRepository.get() runs the query, builds the entity from rows through the mapper, and save() persists the updated aggregate; KafkaOrderEventPublisher wraps the broker client. The outer ring's frameworks are named here and nowhere inward.

Read the division of labor off the trace: the controller decides representation (status codes, JSON shape), the use case decides flow (order of operations, transactions, notifications), the entity decides invariants (what is true of an order, always), and the gateway decides persistence mechanics. Bug placement follows the same map — a wrong status code is an adapter fix, a missing notification is a use-case fix, an over-refund is an entity fix — and that predictability is the daily payoff of the rings. When a new entry point arrives (a CLI, a gRPC service, a message consumer), it reuses the same use cases unchanged, which is the sentence interviewers want to hear when they ask what clean architecture actually buys: one business rule, many delivery mechanisms, each thin. The structural smells that appear when these boundaries start drifting are the next topic: Code Smells as Architecture Signals. Two questions the walkthrough raises in real reviews. Where do transactions begin and end? The use case owns the transactional boundary conceptually — save plus publish must succeed or be compensatable — while the adapter decides the mechanical details (a database transaction around the save, an outbox row written with it). Where does authorization live? Two levels: the controller authenticates (who is calling), the use case authorizes (may this caller perform this operation on this entity) — because an authenticated caller is not automatically allowed to refund any order, and that rule is application policy, not transport concern. Both answers follow the same test as everything else: which layer would change if this policy changed?

Quiz

1. Which dependency is permitted by the clean architecture dependency rule?

Question 1 options

2. Why should the dependency rule be enforced by a tool in CI rather than by review discipline?

Question 2 options

3. A refund request returns HTTP 409 when the amount exceeds the refundable remainder. Which layer should own that behavior, and why?

Question 3 options

4. What is the pragmatic caveat about applying ring structure to a small CRUD service?

Question 4 options

Flashcards

Question

Name the four clean architecture rings from inside out and what each owns.

Answer

Entities (enterprise/domain invariants — Order.refund refuses over-refunds), Use cases (application-specific rules and orchestration of entity flows — RefundOrder), Interface adapters (controllers, gateways, presenters, mappers — translation at seams), Frameworks & drivers (web framework, ORM, bus clients — volatile details wired last). Dependencies point inward; source code may only import toward the center.

Question

What does the ring structure imply about volatility, and how does that shape test strategy?

Answer

Change rate rises outward: entity rules change when the business does (rare), use cases monthly, adapters per integration, frameworks with fashion. Placement follows change rate, so the strictest and fastest tests target the inner rings (pure entity units, use-case tests on port fakes), while outer rings get integration tests — the test pyramid falls out of the layering instead of being chosen separately.

Question

Give two boundary-crossing disciplines the dependency rule alone does not specify.

Answer

Data: entities cross outward as plain domain objects, with mappers at adapter edges converting to/from rows, DTOs, and messages — a Row or SDK type in a use-case signature means the outer world moved in. Errors: domain exceptions (RefundNotAllowed) are mapped to protocol responses (HTTP 409) at the controller boundary, never ORM error types surfacing to clients.

Question

For a POST /orders/{id}/refund request, what does each layer decide?

Answer

Controller: representation (parse HTTP → command, map domain exceptions to status codes). Use case: flow (load via port, invoke entity, save, publish event, order of operations and transactions). Entity: invariants (refundable cannot be exceeded — holds for every entry point). Gateway: persistence mechanics (query, row mapping, broker send). One business rule, many thin delivery mechanisms.

Question

How is the dependency rule enforced mechanically, and what side benefit does the rule file provide?

Answer

One checkable rule per forbidden arrow: ArchUnit (Java), dependency-cruiser (JS/TS), import-linter (Python), NetArchTest (.NET) — CI fails on 'domain imports persistence' instead of relying on skimming reviewers. The side benefit: the rule file becomes unambiguous architecture documentation, stating permitted arrows better than any prose diagram.

Revision Notes

Key Takeaways

  • 1. The dependency rule is the whole pattern: arrows point inward, so entities compile and test with zero infrastructure on the classpath and frameworks stay replaceable
  • 2. Each ring answers a different question — invariants, flow, representation, persistence mechanics — and bug placement becomes predictable because of it
  • 3. Enforcement must be mechanical (ArchUnit-style CI rules), because real violations arrive as 4 p.m. hotfixes too small for anyone to question in review
  • 4. Mappers and error mapping are the unwritten fifth layer: they are what keeps rows, DTOs, and SDK types from drifting inward and re-coupling the domain
  • 5. The payoff sentence for interviews: one business rule, many delivery mechanisms — a new CLI or gRPC entry point reuses the use cases unchanged

Interview Tips

  • • 'What does clean architecture buy you?' — one business rule with many thin delivery mechanisms; add the volatility ranking (change rate rises outward) and how the test strategy falls out of it
  • • 'Walk me through a request through the layers' — controller parses and maps errors, use case orchestrates, entity enforces the invariant, gateway persists; be concrete about where a 409 status is decided (entity throws, controller maps)
  • • 'How do you stop the layers from eroding?' — CI-enforced dependency rules with real tool names for your stack, mappers at every boundary, and a rule file treated as reviewed documentation
  • • 'Isn't this over-engineering for a CRUD app?' — concede the directory-count version is; the dependency direction alone captures the value, so a small service expresses the rings in a few modules — shows you optimize for the buyer, not the diagram

Cheat Sheet

Clean Architecture Layers

Rings (inside out) and their question:

Ring Owns Example
Entities invariants, always-true rules Order.refund refuses over-refund
Use cases app rules + orchestration RefundOrder: load→refund→save→notify
Interface adapters translation controllers, gateways, mappers, presenters
Frameworks/drivers volatile details web, ORM, Kafka client

Dependency rule: source arrows point inward only. Enforce in CI: ArchUnit / dependency-cruiser / import-linter — one rule per forbidden arrow; the rule file doubles as documentation.

Trace of POST /orders/{id}/refund:
Controller: HTTP → RefundCommand; catch RefundNotFound → 404, RefundNotAllowed → 409
Use case: orders.get(id) → order.refund(amount) → save → publish → RefundResult
Entity: amount > refundable → throw (invariant for all entry points)
Gateway: row query + mapper; Kafka send — frameworks named here only

Boundary disciplines: mappers at edges (no Row/SDK types inward); domain exceptions mapped to protocol at the controller.

Bug placement map: wrong status → adapter · missing notification → use case · invalid state → entity · slow query → gateway.

Caveat: rings are a dependency direction, not a directory mandate — express them in a handful of modules for small services; the promise is one business rule, many thin delivery mechanisms.

Continue Learning