Advanced System Design Tutorial 0/70 lessons ~6 min read Lesson 5

    Clean Architecture

    Dependency direction is an architectural decision once a module is large enough to outlive its framework. TechLearningPro's course-payment code started as a Spring service whose en

    Course progress0%
    Focus
    15 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Clean Architecture

    Advanced System Design & Software Architecture · Lesson 05 · Software Architecture Foundations

    Lesson Overview

    Dependency direction is an architectural decision once a module is large enough to outlive its framework. TechLearningPro's course-payment code started as a Spring service whose entities were JPA classes. A vendor SDK upgrade and a Hibernate upgrade arrived in the same quarter. Business rules about when a payment may be captured lived in the entity lifecycle callbacks, so the upgrades and the rules could not be reasoned about separately. Tests that should have checked "a declined payment cannot be captured" booted a database and a stub HTTP server. The modifiability and testability scenarios from lesson 2 were already failing, quietly, before any new feature.

    Clean architecture, as Robert C. Martin describes it, is a way to arrange those rules in concentric layers so that source-code dependencies point inward. Entities and use cases know nothing about Spring, JDBC, or the card vendor. Adapters at the edge translate HTTP and rows into the vocabulary the use cases already own. The same idea appears, with different emphasis, in hexagonal architecture. Lesson 6 is the hexagonal lesson: ports, adapters, and the primary versus secondary distinction. This lesson stops at the dependency rule, the four layers, and an honest Spring Boot 3 mapping, including the trade-off of where @Transactional is allowed to live.

    The worked system is payment authorization for a course purchase. It is the capture path's inner half: not the price quote, not the receipt email. Card data stays at a gateway behind a port this codebase defines. There is no vendor SDK in the domain, and the sample gateway is a test double with a threshold, labeled as such, not a fictional processor API.

    /learn/design-principles and /learn/design-patterns do not specify package direction. /learn/enterprise-architecture will not tell you whether Payment may import jakarta.persistence. This lesson will.

    Learning Objectives

    By the end of this lesson you will be able to:

    1. Name the four clean-architecture layers and what each is allowed to know.
    2. Apply the dependency rule to a Spring Boot payment slice, including a use case that does not import Spring.
    3. Place entities, use cases, interface adapters, and framework code in packages whose arrows you can test.
    4. Define a gateway port the domain owns, and implement it with a test double that is obviously not a processor.
    5. Decide where a database transaction sits so the use case stays free of Spring and the connection does not cover a remote call.
    6. Explain what clean architecture shares with hexagonal architecture and what lesson 6 still has to add.
    7. Recognize when the layering is a tax: a one-class CRUD admin screen with no rule that could outlive the controller.

    Prerequisites

    • Lesson 4's dependency inversion at object scale. Clean architecture is that rule applied to packages and layers, with entities distinguished from use cases.
    • Lesson 2's separation of quote and capture, and the decision that a charge is recomputed and then authorized, not taken from a stale browser total.
    • Java 21, including sealed interfaces and records. Spring Boot 3: @RestController, @Service, @Transactional, and constructor injection. Jakarta Persistence appears only as an example of what the entity package must not import.
    • No prior clean-architecture project. If you have used "controller, service, repository" layers, the technical explanation compares that habit to the dependency rule directly.

    Detailed Core Concepts

    The problem the layers exist to solve

    Business rules change because finance, risk, and product change. Frameworks change because a project upgrades Spring Boot, replaces a database client, or switches the company that actually talks to the card network. When those two change-rates are tangled in one class, every upgrade is a business-rule review and every rule change risks an upgrade. The cost is paid as slow tests and as fear.

    Clean architecture buys independence of the rules from the delivery mechanism. The independence is not free. You pay in mapping types: a request DTO, a command, an entity, a row. You pay in discipline: a reviewer has to reject an import. You should buy it when a rule is architecturally significant in the lesson 3 sense — capture-after-decline is such a rule, because getting it wrong takes money — and when the mechanism is likely to be replaced or tested without. You should not buy it for a form that saves three fields and has no invariant.

    The four layers

    Martin's diagram is concentric. Spoken as layers from the inside out:

    1. Entities. Enterprise or domain rules that would still be true in a batch job or a different application. For payments: money has a currency, a declined authorization is not capturable, an amount is non-negative. Entities do not know whether they are stored in PostgreSQL.
    2. Use cases. Application-specific rules that orchestrate entities. "Authorize this course purchase once per idempotency key; on gateway rejection, record a decline; on gateway silence, do not record an approval." A use case is the policy lesson 4 called PlaceOrder, promoted to a layer with its own input and output types.
    3. Interface adapters. The translation ring. A web controller turns JSON into a command and a result back into JSON. A persistence adapter turns an entity into rows and back. A gateway adapter turns a port call into HTTP. Presenters, in the original formulation, shape output for a UI. A JSON API has a thin version of the same job.
    4. Frameworks and drivers. Spring MVC, the servlet container, the JDBC driver, the HTTP client, the clock of the operating system. This is the outermost ring. It is a detail.

    The dependency rule: source code dependencies point inward. A use case may call an entity. An entity may not call a use case. An adapter may call a use case. A use case may not call an adapter. When a use case must reach a database or a gateway, it calls an interface it defines (or that the entities' layer defines with it). The adapter implements the interface. The arrow of the implements clause points inward. That is dependency inversion from lesson 4, made structural.

    Frameworks sit outside even when they are convenient inside. @Entity on a domain type is an outward dependency dressed as an annotation. The class file imports jakarta.persistence, so the rule is already broken, and every rule test that boots JPA pays the tax.

    Entities versus use cases

    The split feels fussy until two applications share a rule. "Cannot capture a declined payment" is true for the course marketplace and for a later invoicing tool. It belongs on Payment. "This HTTP route uses an Idempotency-Key header and returns 409 when the key is reused with a different amount" is a use-case and adapter concern of this application. Putting the header name on the entity would drag servlet vocabulary inward. Putting the decline rule only in the controller would let a second caller, a batch reconciliation job, capture a declined payment because it forgot the if.

    Use case input and output are simple types the use case owns: AuthorizeCommand, AuthorizeReceipt. They are not the JSON DTO and not the JPA row. Adapters map. The mapping is the price of the rule. Skipping it "because the fields match today" is how the JSON annotation migrates onto the command and the command migrates onto the entity until there is one type and no rule left.

    Ports without the hexagon lecture

    A port here means an interface the inner layer defines for a capability it needs and does not implement: PaymentStore, CardGateway. The use case is the caller. The adapter is the implementation. Hexagonal architecture uses the same mechanism and then classifies ports as primary (driving the application: HTTP, a queue consumer) or secondary (driven by the application: database, gateway). Clean architecture's controller is a primary adapter; its gateway implementation is a secondary adapter. The difference in this lesson is mostly vocabulary and the explicit entity versus use-case split, which hexagon diagrams sometimes fold into a single "inside."

    Lesson 6 will draw the hexagon, name primary and secondary ports, and show a second driving adapter (a job, not only HTTP) against the same use case. If you leave this lesson able to keep Spring and JDBC outside AuthorizePayment, you are ready for that drawing. If this lesson had started with the hexagon, the dependency rule would have been a slogan on a poster. The packages below are the rule.

    What "inward" means for data

    The rule is about source code, not about the direction of runtime calls. At runtime the controller calls the use case, which calls the gateway adapter, which calls the network. The call stack goes outward. The imports do not. The use case imports CardGateway from its own package. It does not import the class that uses RestClient. People reverse this in argument ("but the use case calls the database, so it depends on the database"). It calls an interface. The database driver is loaded by the adapter, composed by Spring, in the outermost module.

    Spring's usual layers are not the same picture

    A common Spring layout is controller, service, repository, with the service annotated @Service and the repository a Spring Data interface that returns JPA entities. It can be clean. It often is not, because:

    • The "entity" is a @jakarta.persistence.Entity with lazy relations, so the domain import is the framework.
    • The service takes the JPA type as its API, so the controller and the rule share a persistence model.
    • @Transactional on the service wraps every dependency the method happens to call, including a gateway HTTP call.
    • Spring Data's repository is the port, but it is declared in a way that pulls Spring Data into the use case's signature (Page, Sort, query-derivation names). Those types are framework vocabulary.

    The mapping this lesson uses keeps a Spring-free domain and application set of packages, and a adapter set that is allowed to see Spring. The composition root, also outer, defines the beans. You may still use Spring Data or JdbcClient in the adapter. You may not mention them in Payment.

    Transactions, as a trade-off rather than a slogan

    A database transaction is a framework service. The purist reading says @Transactional belongs on the adapter that performs the writes, and the use case passes a unit-of-work abstraction. That extra port is justified when one use case writes through several stores and must not know they share a connection.

    For a single PaymentStore.save, a pragmatic TechLearningPro choice is a thin outer service:

    • AuthorizePayment is plain Java. It calls payments.save and gateway.authorize with no annotation.
    • AuthorizePaymentAction is a @Service in the adapter or bootstrap package. Its method is @Transactional and delegates to the use case.

    This works only if the store implementation uses the thread-bound connection Spring's transaction manager opened, which JdbcClient and JPA do when they run on the same thread inside that method. It fails the spirit of the rule in one controlled place: the outer method's transaction will also be open during the gateway call if the gateway is invoked inside the use case while the outer method is active. That is the bug the purist reading exists to prevent.

    So the walkthrough sequences the effects: call the gateway before opening the write transaction when the gateway is the slow or failure-prone part, and persist the outcome in a short transaction afterward. The use case stays in charge of the sequence. The outer service can expose two transactional methods, save via the store's own @Transactional, rather than one transaction around the world. The store implementation annotates save. The use case calls gateway then store.save. No transaction is open during HTTP. The cost is a crash window: the gateway approved and the process died before save. The payment is then unknown locally and approved remotely. The idempotency key and a reconciliation job are the mitigation. Pretending one @Transactional method made that window disappear is how teams wrap HTTP in a transaction and still have the crash. Lesson 37 treats the deeper exactly-once problem. This lesson leaves the window visible and refuses to hide it inside an annotation.

    When not to use this shape

    A small internal tool with no invariant, one table, and a lifespan measured in a quarter does not need four packages and a mapping layer. A single Spring service will be easier to delete. Record that as a decision, with the reopen condition "a rule appears that a second adapter must share, or a test needs a database to prove a boolean." Clean architecture as a mandate on every endpoint produces mappers nobody can explain. The payment slice has a rule that takes money. It qualifies. A certificate-color admin screen from lesson 3's non-drivers does not.

    Testability is the driver you can feel this week

    The use case test constructs AuthorizePayment with an in-memory store and a ThresholdGateway, calls authorize, and asserts a declined payment throws on capture or that capture is refused by the entity. No Spring, no network, no database. That test is the evaluation of the dependency rule. If it cannot be written, the rule is already violated, whatever the package diagram says.

    An adapter test can use @SpringBootTest or a slice test (@WebMvcTest for the controller) when you care about JSON and status codes. Those tests do not replace the entity test. Teams that only have the Spring test will refactor the rule into the controller during the next fire drill and not notice until production.

    Architecture and Design Diagrams

    Imports point inward. Runtime calls may step out through an interface.

    mermaid
    flowchart TB
    subgraph outer [Frameworks and drivers]
    mvc[Spring MVC]
    jdbc[JDBC driver]
    http[HTTP client]
    end
    subgraph adapters [Interface adapters]
    web[AuthorizePaymentController]
    storeAdapter[JdbcPaymentStore]
    gatewayAdapter[ThresholdGateway]
    end
    subgraph app [Use cases]
    usecase[AuthorizePayment]
    portStore[PaymentStore]
    portGateway[CardGateway]
    end
    subgraph domain [Entities]
    payment[Payment]
    end
    mvc --> web
    web --> usecase
    usecase --> payment
    usecase --> portStore
    usecase --> portGateway
    storeAdapter --> portStore
    gatewayAdapter --> portGateway
    storeAdapter --> jdbc
    gatewayAdapter --> http

    ThresholdGateway in the sample does not actually open a socket. The diagram still places it outside the use case, which is where a future RestClient implementation would sit. Replacing the threshold double with a real HTTP adapter must not edit Payment or AuthorizePayment. That sentence is the modifiability scenario for this lesson.

    The authorize sequence keeps the remote call outside the database transaction and treats "unavailable" as "no approval recorded."

    mermaid
    sequenceDiagram
    participant Client
    participant Controller
    participant Use as AuthorizePayment
    participant Gateway as CardGateway
    participant Store as PaymentStore
    Client->>Controller: POST /payments, Idempotency-Key
    Controller->>Use: AuthorizeCommand
    Use->>Store: find by idempotency key
    alt Already decided
    Store-->>Use: existing payment
    Use-->>Controller: receipt of the existing decision
    else New key
    Use->>Gateway: authorize amount
    alt Approved or rejected
    Gateway-->>Use: decision
    Use->>Store: save AUTHORIZED or DECLINED
    else Unavailable
    Gateway-->>Use: unavailable
    Use-->>Controller: receipt with state UNCONFIRMED and no approval
    end
    end
    Controller-->>Client: JSON mapped from the receipt

    A second driving adapter, not drawn as a framework, is the reason the use case is not a controller method. Lesson 6 will add it. The dependency rule already requires it: a reconciliation job must call Payment.capture rules without copying them.

    mermaid
    flowchart LR
    httpDrv[HTTP adapter]
    jobDrv[Future reconciliation job]
    use[AuthorizePayment and CapturePayment]
    httpDrv --> use
    jobDrv --> use

    Both drivers depend on the use case. The use case does not depend on either. If capture logic lived in the controller, the job would depend on the controller or would reimplement the rule. Both outcomes have been the production bug.

    Technical Explanation

    Package layout

    com.techlearningpro.payments.domain          entities, value objects, domain exceptions
    com.techlearningpro.payments.application     use cases, commands, receipts, ports
    com.techlearningpro.payments.adapter.web     controller, request and response records
    com.techlearningpro.payments.adapter.store   JDBC implementation of PaymentStore
    com.techlearningpro.payments.adapter.gateway test double or HTTP implementation of CardGateway
    com.techlearningpro.payments.bootstrap       Spring @Configuration
    

    domain imports the JDK only. application imports domain and the JDK. adapter.* imports application and domain and Spring or JDBC as required. bootstrap imports everything it wires. Nothing in domain or application imports adapter or org.springframework.

    A single Gradle or Maven module can enforce this with ArchUnit. Multiple build modules enforce it with the compiler, which is stronger and worth it when the slice is stable. Start with one module and a test if the payment code is new. Split the build when someone bypasses the test "just this once."

    Idempotency at this layer

    The command carries an idempotency key supplied by the client. The store's uniqueness constraint is the real guard: two requests with the same key cannot both insert. The use case checks first for the readable case (a retry after success) and still handles a conflict if two requests race. The receipt for a replay is the stored decision, not a second approval.

    The key is not a security boundary. A caller who can authorize at all and can guess another caller's key could receive that caller's receipt. Authorization on the route, and binding the key to the authenticated principal inside the store lookup, closes that. The lookup method is find(principalId, key), not find(key). That is the sensitivity point, the same shape as the tenant predicate in lesson 3.

    Gateway failures

    The port's result is a sealed type the use case understands:

    • Approved with an approval code the gateway minted. We store AUTHORIZED.
    • Rejected with a reason safe to show the learner. We store DECLINED. The reason is a code we define (INSUFFICIENT_FUNDS, CARD_DECLINED), not a free-form vendor paragraph, because the adapter does the translation. If you store the vendor's raw text you have coupled support tooling to a vendor and you may have stored data the PCI review did not expect.
    • Unavailable when the call times out or the connection fails. We do not store AUTHORIZED. We return UNCONFIRMED and we do not invent an approval code. A retry with the same key may call the gateway again. That is at-least-once toward the gateway. Say it in the decision record. Do not name it exactly-once.

    ThresholdGateway implements this port by approving amounts at or below a configured limit and rejecting the rest. It exists so the use case test and a local demo have a gateway. It must not be the production @Bean once a real adapter exists. Bootstrap profiles (@Profile("local")) are the usual way to keep it out of production. The class name is intentionally dull. A class named StripeClient that does not speak that company's API would be a false dependency. We do not invent one.

    Capture as entity behavior

    Payment.capture() requires state AUTHORIZED. DECLINED, UNCONFIRMED, and a second capture are refused with a domain exception. The exception type lives in domain and carries no HTTP status. The web adapter maps it to 409. A job adapter maps it to a log and a metric. Mapping status codes inside Payment would import the web driver's vocabulary into the entity, and the job would then speak HTTP for no reason.

    Mapping and validation

    Request validation (@NotBlank, positive amount) is an adapter concern: bad JSON should not become a command. The entity repeats the invariants it owns (non-negative money, currency present) because adapters are not the only callers. Duplicated checks at a trust boundary are intentional. A single check "because DRY" usually keeps the one in the controller and loses the one a job needs.

    Money in this lesson is the same idea as in lesson 4. If both slices live in one system they should share one type in a small shared kernel, not grow two currencies classes that drift. Sharing the value object is not a layering violation. Sharing the JPA annotation on it is.

    ArchUnit

    ArchUnit expresses the dependency rule as a test. The library's rule DSL is ArchRuleDefinition.noClasses. The test fails the build when someone adds import org.springframework under domain or application. It does not prove the design is good. It proves one class of cheat is visible.

    java
    import com.tngtech.archunit.junit.AnalyzeClasses;
    import com.tngtech.archunit.junit.ArchTest;
    import com.tngtech.archunit.lang.ArchRule;
    import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
    @AnalyzeClasses(packages = "com.techlearningpro.payments")
    class PaymentDependencyRuleTest {
    @ArchTest
    static final ArchRule innerLayersDoNotSeeSpringOrAdapters =
    noClasses()
    .that().resideInAnyPackage("..domain..", "..application..")
    .should().dependOnClassesThat().resideInAnyPackage(
    "org.springframework..",
    "jakarta.persistence..",
    "com.techlearningpro.payments.adapter..");
    }

    The JUnit 5 support comes from com.tngtech.archunit:archunit-junit5. A failing test names the offending class. That is the review comment, automated.

    Comparison, briefly, with the hexagonal preview

    QuestionClean architecture, this lessonHexagonal, lesson 6
    What is protected?Entities and use cases, distinguishedThe inside of the hexagon, often one application boundary
    How does the outside connect?Adapters depend inward on use cases and portsPrimary and secondary ports, adapters outside
    What will a Spring controller be?An interface adapterA primary adapter
    What will JDBC be?An interface adapter implementing a portA secondary adapter
    What is easy to miss?A use case that still returns an HTTP typeA "port" defined in the adapter package, which inverts nothing

    You can satisfy both descriptions with one codebase. The failure mode is different. Clean-architecture write-ups sometimes grow a use case that is only a pass-through to a repository, with no entity behavior. Hexagonal write-ups sometimes grow a port per framework method and call it the domain. Lesson 6 will be allowed to assume you have seen a real rule live on Payment, not only a folder diagram.

    Practical Implementation or Design Walkthrough

    Problem

    Authorize a course payment and later capture it, with the rule "declined and unconfirmed payments cannot be captured" enforced in one place. HTTP is one driver. A future job is a second. The gateway is replaceable. Tests of the rule do not boot Spring.

    Assumptions: one currency per payment; the caller is already authenticated and principalId is taken from the token, not from the body; amounts are in major units with scale 2, which is a conscious simplification (some currencies are not scale 2, and a shared kernel would encode that when those currencies become a driver).

    Domain

    java
    package com.techlearningpro.payments.domain;
    import java.math.BigDecimal;
    import java.util.Objects;
    public record Money(BigDecimal amount, String currency) {
    public Money {
    Objects.requireNonNull(amount, "amount");
    Objects.requireNonNull(currency, "currency");
    if (currency.isBlank() || amount.signum() <= 0) {
    throw new IllegalArgumentException("a payment amount must be positive");
    }
    }
    }
    public record PaymentId(String value) {
    public PaymentId {
    if (value == null || value.isBlank()) {
    throw new IllegalArgumentException("payment id");
    }
    }
    }
    public enum PaymentState {
    UNCONFIRMED,
    AUTHORIZED,
    DECLINED,
    CAPTURED
    }
    public final class Payment {
    private final PaymentId id;
    private final String principalId;
    private final String idempotencyKey;
    private final Money amount;
    private PaymentState state;
    private String approvalCode;
    private Payment(PaymentId id, String principalId, String idempotencyKey,
    Money amount, PaymentState state, String approvalCode) {
    this.id = id;
    this.principalId = Objects.requireNonNull(principalId);
    this.idempotencyKey = Objects.requireNonNull(idempotencyKey);
    this.amount = Objects.requireNonNull(amount);
    this.state = state;
    this.approvalCode = approvalCode;
    }
    public static Payment authorized(PaymentId id, String principalId, String idempotencyKey,
    Money amount, String approvalCode) {
    if (approvalCode == null || approvalCode.isBlank()) {
    throw new IllegalArgumentException("approval code");
    }
    return new Payment(id, principalId, idempotencyKey, amount, PaymentState.AUTHORIZED, approvalCode);
    }
    public static Payment declined(PaymentId id, String principalId, String idempotencyKey, Money amount) {
    return new Payment(id, principalId, idempotencyKey, amount, PaymentState.DECLINED, null);
    }
    public void capture() {
    if (state != PaymentState.AUTHORIZED) {
    throw new IllegalStateException("only an authorized payment can be captured, was " + state);
    }
    state = PaymentState.CAPTURED;
    }
    public PaymentId id() { return id; }
    public String principalId() { return principalId; }
    public String idempotencyKey() { return idempotencyKey; }
    public Money amount() { return amount; }
    public PaymentState state() { return state; }
    public String approvalCode() { return approvalCode; }
    }

    IllegalStateException is a JDK type, used here so the domain does not invent a hierarchy. A named PaymentNotCapturableException extends RuntimeException in the same package is better once adapters must distinguish it from a programming error. The walkthrough's controller maps IllegalStateException from capture only on the capture route. A named type is the reopen condition if a second exception starts to collide.

    There is no UNCONFIRMED factory that persists. Unconfirmed means "we have no row." The state exists so a receipt can say it without pretending a row was written. That avoids a stored approval. It accepts the retry-to-gateway behavior described above.

    Ports and use case

    java
    package com.techlearningpro.payments.application;
    import com.techlearningpro.payments.domain.Money;
    import com.techlearningpro.payments.domain.Payment;
    import com.techlearningpro.payments.domain.PaymentId;
    import com.techlearningpro.payments.domain.PaymentState;
    import java.util.Optional;
    public record AuthorizeCommand(String principalId, String idempotencyKey, Money amount) {}
    public record AuthorizeReceipt(PaymentId id, PaymentState state, String approvalCode) {
    public static AuthorizeReceipt from(Payment payment) {
    return new AuthorizeReceipt(payment.id(), payment.state(), payment.approvalCode());
    }
    public static AuthorizeReceipt unconfirmed() {
    return new AuthorizeReceipt(null, PaymentState.UNCONFIRMED, null);
    }
    }
    public sealed interface GatewayDecision {
    record Approved(String approvalCode) implements GatewayDecision {}
    record Rejected(String reasonCode) implements GatewayDecision {}
    record Unavailable() implements GatewayDecision {}
    }
    public interface CardGateway {
    GatewayDecision authorize(String principalId, Money amount, String idempotencyKey);
    }
    public interface PaymentStore {
    Optional<Payment> find(String principalId, String idempotencyKey);
    Optional<Payment> findById(String principalId, PaymentId id);
    void save(Payment payment);
    }
    public interface PaymentIds {
    PaymentId next();
    }
    public final class AuthorizePayment {
    private final PaymentStore payments;
    private final CardGateway gateway;
    private final PaymentIds ids;
    public AuthorizePayment(PaymentStore payments, CardGateway gateway, PaymentIds ids) {
    this.payments = payments;
    this.gateway = gateway;
    this.ids = ids;
    }
    public AuthorizeReceipt authorize(AuthorizeCommand command) {
    var existing = payments.find(command.principalId(), command.idempotencyKey());
    if (existing.isPresent()) {
    var stored = existing.get();
    if (!stored.amount().equals(command.amount())) {
    throw new IllegalArgumentException("idempotency key reused with a different amount");
    }
    return AuthorizeReceipt.from(stored);
    }
    var decision = gateway.authorize(command.principalId(), command.amount(), command.idempotencyKey());
    return switch (decision) {
    case GatewayDecision.Approved approved -> {
    var payment = Payment.authorized(
    ids.next(), command.principalId(), command.idempotencyKey(),
    command.amount(), approved.approvalCode());
    payments.save(payment);
    yield AuthorizeReceipt.from(payment);
    }
    case GatewayDecision.Rejected rejected -> {
    var payment = Payment.declined(
    ids.next(), command.principalId(), command.idempotencyKey(), command.amount());
    payments.save(payment);
    yield AuthorizeReceipt.from(payment);
    }
    case GatewayDecision.Unavailable unavailable -> AuthorizeReceipt.unconfirmed();
    };
    }
    }

    The rejected binding is unused on purpose except to document the arm; a reviewer may name it ignored or log the code in the adapter after mapping. The use case stores a decline without storing the vendor wording. If product needs the reason code on the receipt, add it to AuthorizeReceipt as one of our codes, translated in the adapter before the decision is built. The port already carries reasonCode for that translation. The entity does not persist it in this slice, which keeps PCI and support narratives smaller. Extending the receipt is a use-case change with a clear diff.

    CapturePayment is the second use case, short, and the one a job will call:

    java
    package com.techlearningpro.payments.application;
    import com.techlearningpro.payments.domain.PaymentId;
    public final class CapturePayment {
    private final PaymentStore payments;
    public CapturePayment(PaymentStore payments) {
    this.payments = payments;
    }
    public void capture(String principalId, PaymentId id) {
    var payment = payments.findById(principalId, id)
    .orElseThrow(() -> new IllegalArgumentException("unknown payment"));
    payment.capture();
    payments.save(payment);
    }
    }

    Add Optional<Payment> findById(String principalId, PaymentId id) to PaymentStore. The principal is required on both methods so a captured id from another learner is the same as a missing id at this boundary. The controller may map both to 404. That choice matches the non-disclosure habit from lesson 3.

    Gateway test double

    java
    package com.techlearningpro.payments.adapter.gateway;
    import com.techlearningpro.payments.application.CardGateway;
    import com.techlearningpro.payments.application.GatewayDecision;
    import com.techlearningpro.payments.domain.Money;
    import java.math.BigDecimal;
    /** Local stand-in. Approves amounts at or below the limit. Not a card processor. */
    public final class ThresholdGateway implements CardGateway {
    private final BigDecimal limit;
    public ThresholdGateway(BigDecimal limit) {
    this.limit = limit;
    }
    @Override
    public GatewayDecision authorize(String principalId, Money amount, String idempotencyKey) {
    if (amount.amount().compareTo(limit) <= 0) {
    return new GatewayDecision.Approved("LOCAL-" + idempotencyKey);
    }
    return new GatewayDecision.Rejected("CARD_DECLINED");
    }
    }

    A production adapter would implement the same interface, set a connect and read timeout on its HTTP client, and map timeouts to Unavailable. If you use Spring's RestClient (Spring Framework 6.1), it lives entirely in that adapter class. AuthorizePayment does not change. Timeouts are ClientHttpRequestFactory configuration on the RestClient builder, not a domain concern, except that the use case must already handle Unavailable. It does.

    Web adapter

    java
    package com.techlearningpro.payments.adapter.web;
    import com.techlearningpro.payments.application.AuthorizeCommand;
    import com.techlearningpro.payments.application.AuthorizePayment;
    import com.techlearningpro.payments.application.AuthorizeReceipt;
    import com.techlearningpro.payments.domain.Money;
    import jakarta.validation.Valid;
    import jakarta.validation.constraints.NotBlank;
    import jakarta.validation.constraints.NotNull;
    import java.math.BigDecimal;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestHeader;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    record AuthorizeRequest(@NotNull BigDecimal amount, @NotBlank String currency) {}
    record AuthorizeResponse(String paymentId, String state, String approvalCode) {
    static AuthorizeResponse from(AuthorizeReceipt receipt) {
    var id = receipt.id() == null ? null : receipt.id().value();
    return new AuthorizeResponse(id, receipt.state().name(), receipt.approvalCode());
    }
    }
    @RestController
    @RequestMapping("/payments")
    class AuthorizePaymentController {
    private final AuthorizePayment authorizePayment;
    AuthorizePaymentController(AuthorizePayment authorizePayment) {
    this.authorizePayment = authorizePayment;
    }
    @PostMapping
    ResponseEntity<AuthorizeResponse> authorize(
    @RequestHeader("Idempotency-Key") @NotBlank String idempotencyKey,
    @RequestHeader("X-Principal-Id") @NotBlank String principalId,
    @Valid @RequestBody AuthorizeRequest body) {
    var receipt = authorizePayment.authorize(new AuthorizeCommand(
    principalId,
    idempotencyKey,
    new Money(body.amount(), body.currency())));
    return ResponseEntity.ok(AuthorizeResponse.from(receipt));
    }
    }

    X-Principal-Id is a teaching stand-in so the slice is readable. In the platform the principal comes from the JWT Spring Security already validated, via the Authentication argument or a small accessor, and the client cannot choose it. A header the browser can set is an authorization bug. The production controller deletes the header and reads authentication.getName(). The command still takes principalId, because the use case must not depend on Spring Security types either. The adapter is the only class that sees Authentication. That is the rule, applied to identity.

    Store and transaction

    JdbcPaymentStore.save carries @Transactional. The use case calls it after the gateway returns. JdbcClient participates in the transaction Spring opens for that method when a PlatformTransactionManager is on the classpath (spring-boot-starter-jdbc configures one). No annotation is placed on AuthorizePayment.

    java
    package com.techlearningpro.payments.adapter.store;
    import com.techlearningpro.payments.application.PaymentStore;
    import com.techlearningpro.payments.domain.Payment;
    import org.springframework.jdbc.core.simple.JdbcClient;
    import org.springframework.transaction.annotation.Transactional;
    public final class JdbcPaymentStore implements PaymentStore {
    private final JdbcClient jdbc;
    public JdbcPaymentStore(JdbcClient jdbc) {
    this.jdbc = jdbc;
    }
    @Override
    @Transactional
    public void save(Payment payment) {
    jdbc.sql("""
    insert into payment
    (id, principal_id, idempotency_key, amount, currency, state, approval_code)
    values
    (:id, :principal, :idem, :amount, :currency, :state, :approval)
    """)
    .param("id", payment.id().value())
    .param("principal", payment.principalId())
    .param("idem", payment.idempotencyKey())
    .param("amount", payment.amount().amount())
    .param("currency", payment.amount().currency())
    .param("state", payment.state().name())
    .param("approval", payment.approvalCode())
    .update();
    }
    }

    find is a parameterized query on principal_id and idempotency_key using jdbc.sql(...).param(...).query(...).optional(). The row mapper builds a Payment through its factories, not through a public constructor that could mark a declined row authorized. If the factories cannot express a loaded AUTHORIZED payment, add a package-visible rehydrate method in the domain that checks the stored state and still refuses illegal combinations (an AUTHORIZED row with a null approval code does not rehydrate). The adapter does not new Payment by bypassing those checks with reflection.

    A unique index on (principal_id, idempotency_key) is part of the design, not a performance extra. Without it the check-then-insert in the use case races.

    Bootstrap

    java
    package com.techlearningpro.payments.bootstrap;
    import com.techlearningpro.payments.adapter.gateway.ThresholdGateway;
    import com.techlearningpro.payments.application.AuthorizePayment;
    import com.techlearningpro.payments.application.CardGateway;
    import com.techlearningpro.payments.application.PaymentIds;
    import com.techlearningpro.payments.application.PaymentStore;
    import java.math.BigDecimal;
    import java.util.UUID;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.context.annotation.Profile;
    @Configuration
    class PaymentBeans {
    @Bean
    @Profile("local")
    CardGateway thresholdGateway() {
    return new ThresholdGateway(new BigDecimal("1000.00"));
    }
    @Bean
    PaymentIds paymentIds() {
    return () -> new com.techlearningpro.payments.domain.PaymentId(UUID.randomUUID().toString());
    }
    @Bean
    AuthorizePayment authorizePayment(PaymentStore payments, CardGateway gateway, PaymentIds ids) {
    return new AuthorizePayment(payments, gateway, ids);
    }
    }

    PaymentStore is satisfied by JdbcPaymentStore annotated @Component, or by an in-memory implementation in tests. The local profile never claims to be a processor. Production supplies a different CardGateway bean and does not activate local.

    Use case test

    java
    import static org.junit.jupiter.api.Assertions.assertEquals;
    import static org.junit.jupiter.api.Assertions.assertThrows;
    import java.math.BigDecimal;
    import java.util.HashMap;
    import java.util.Optional;
    import org.junit.jupiter.api.Test;
    class AuthorizePaymentTest {
    @Test
    void declinedPaymentIsNotAuthorizedAndCaptureFailsIfForced() {
    var store = new MemoryStore();
    var useCase = new AuthorizePayment(
    store,
    new ThresholdGateway(new BigDecimal("10.00")),
    () -> new PaymentId("pay-1"));
    var receipt = useCase.authorize(new AuthorizeCommand(
    "learner-1", "key-1", new Money(new BigDecimal("40.00"), "USD")));
    assertEquals(PaymentState.DECLINED, receipt.state());
    var stored = store.find("learner-1", "key-1").orElseThrow();
    assertThrows(IllegalStateException.class, stored::capture);
    }
    }

    MemoryStore is a test-local PaymentStore backed by a HashMap. It is ordinary code, omitted here only for length; the production lesson repository includes it next to the test. The assertion that matters is capture on a declined payment. That line is the business rule. It did not start Spring.

    Expected result

    A declined authorization is durable, replay of the same key and amount returns the same state without a second gateway decision (extend ThresholdGateway with a counter in a later test to prove it), a different amount on the same key fails, and an unavailable gateway yields UNCONFIRMED with nothing saved. Capture of an authorized payment changes state once. None of those sentences require a vendor sandbox.

    Enterprise Real-World Scenarios

    The JPA entity that owned capture

    TechLearningPro's first payment module mapped Payment with @Entity and @OneToMany of attempts. capture() was a method on that class, and it touched a lazy collection of attempts to "be safe." Calling it outside a session threw a lazy-initialization exception, so every caller, including a reconciliation job, opened a transaction and a persistence context to ask a boolean question. When the team upgraded Hibernate, a change in default lazy behavior made the job capture twice: the first call appeared to fail, the retry ran in a new session, and the guard that should have seen CAPTURED saw a stale instance. The rule was correct on paper and coupled to a cache of rows.

    The rewrite followed this lesson. Payment lost every jakarta.persistence import. Attempts, which the domain did not need for the capture decision, stayed in the adapter as rows the support tool reads. capture() became a state check a unit test can run. The job and the controller both call CapturePayment. The Hibernate upgrade the following quarter touched JdbcPaymentStore's successor and not the rule. The modifiability measure was "a persistence upgrade does not diff the entity package." It held.

    The cost was a week of mapping code and a set of rehydrate tests so a corrupt row cannot become an authorized payment. That week was the price of the earlier shortcut. The team wrote the decision down: new payment states are added in the domain first, then in the row mapper, never the other way around.

    A gateway double that caught a double capture

    A second incident was quieter. An engineer testing against a sandbox approved a payment and, on a timeout of the capture call, retried capture. The sandbox had in fact captured the first time. The domain's capture() was idempotent in the engineer's head and not in the type: it set state to CAPTURED without refusing a second call after a reload, because the first version stored capture as an insert of an event and treated "capture" as "insert another event." Finance saw two settlement records.

    The clean-architecture test, once Payment.capture refused a state that was already CAPTURED, failed in CI when a well-meaning change made capture "tolerant of retries" by ignoring the state. Tolerant retries belong to the idempotency key of the capture command, which should return the existing captured payment, not to a domain method that applies the transition twice. The use case looks up the payment and, if it is already captured, returns success without calling capture again. The entity stays strict. The adapter that talks to the gateway uses the same key so the processor, when it is a real one, can recognize the retry.

    The test double never accepted a second authorize for a key it had approved inside one test, which is how the team noticed the use case calling the gateway on replay. None of that required the sandbox. The sandbox remained necessary for the HTTP adapter's mapping of vendor codes. It stopped being necessary for the rule. That split is the practical benefit the layers are for: scarce end-to-end environments stay focused on the adapter, and the rule runs on every commit.

    Best Practices

    • Put invariants on the entity and orchestration on the use case. Put JSON, SQL, and HTTP in adapters.
    • Let the inner layer own the port interfaces. An interface that lives in the adapter package and is "implemented" by the use case has the arrow backward.
    • Map at the boundary. Do not annotate the entity with JPA, JSON, or validation framework metadata.
    • Repeat trust-boundary checks in the adapter and invariants in the entity. They fail in different ways and serve different callers.
    • Call remote gateways outside a database transaction. Annotate the store method that writes, not the use case that also knows about HTTP.
    • Bind idempotency keys to the authenticated principal. Take the principal from the security adapter, not from a client-supplied tenant or user field.
    • Translate vendor texts into your own reason codes in the gateway adapter. Keep raw processor payloads out of the entity and out of learner-visible errors.
    • Keep a local gateway double obviously local, on a non-production profile.
    • Enforce the dependency rule with ArchUnit or with a separate build module. A diagram does not fail the build.
    • Share value objects such as Money across slices. Do not share framework-annotated models.
    • Add a second driving adapter only when a real caller exists, and keep the use case free of the first adapter so the second one is possible.
    • Skip the four layers when there is no rule to protect. Write that decision down so the next endpoint does not copy a cathedral.

    Common Mistakes and Anti-Patterns

    JPA as the domain. Annotations, lazy collections, and session-bound behavior become part of the rule. Upgrades and jobs inherit a persistence context they should not need. The repair is a persistence model in the adapter and a domain type with factories. Mapping tests keep the two honest.

    The pass-through use case. A class whose only method calls the repository and returns the entity, with all rules in the controller "for now." The layer exists and protects nothing, while the mapping tax is still paid or, worse, skipped until the controller is the only place that knows about capture. The repair is to move the invariant inward or delete the empty layer until there is an invariant.

    Ports defined beside their adapters. CardGateway lives in adapter.gateway next to the HTTP client. The use case depends on the adapter package. You have packages and no rule. The repair is to move the interface next to the use case and leave the HTTP types where they are.

    One transaction around the gateway. @Transactional on the outer method that calls the processor and then inserts. Pool exhaustion appears as payment latency, and a rollback after a successful processor call creates the ghost approval the sequence diagram warned about. The repair is the order of operations in the walkthrough, plus an explicit reconciliation note for the crash window.

    Clean architecture on every table. Four packages, three mappers, and no invariant, copied because the payment slice did it. Features slow down and engineers route around the layers with a new "simple" controller that becomes the real design. The repair is the qualification test: a rule that must outlive the framework, or a second driver. Otherwise one Spring service, deleted when the screen is deleted.

    A fake vendor client. Classes named for a real processor, with methods nobody can find in that processor's documentation, used as if they were the integration. Tests pass against a fiction, and the adapter is written twice under schedule pressure. The repair is a port you defined and a double whose name says it is local. Write the real HTTP adapter against the vendor's actual contract when you have one.

    Identity in the body. principalId accepted from JSON because the command needs it. Any caller can authorize as anyone else, and the domain looks fine. The repair is the adapter reading the authenticated principal and a store lookup that always includes it. The use case keeps the parameter so it stays framework-free. The adapter is not allowed to take it from the payload.

    Troubleshooting and Design Review

    Symptom: The capture rule test starts an application context.

    Likely cause: Payment or AuthorizePayment imports Spring, JPA, or the web DTO, so the test cannot construct them cheaply. Someone reached for @SpringBootTest.

    Investigation: the imports of the domain and application packages, and the ArchUnit result.

    Corrective action: move the framework types outward. Construct the use case with an in-memory store in the test. Keep a slice test only for JSON.

    Symptom: Hibernate lazy exceptions in a reconciliation job.

    Likely cause: the job received a persistence entity and touched a relation outside a session. The rule never left the JPA model.

    Investigation: the parameter type of the job. If it is an @Entity, the dependency rule is broken.

    Corrective action: the job loads through PaymentStore, receives a Payment, and calls CapturePayment. Relations the rule does not need stay in the adapter.

    Symptom: Duplicate settlements after a capture retry.

    Likely cause: capture() is not strict, or the use case applies it again instead of recognizing an already captured payment for that principal and id.

    Investigation: the entity test for a second capture, and the use case branch for CAPTURED.

    Corrective action: strict entity, idempotent use case. Do not weaken the entity to make the retry "easy."

    Symptom: Database connections exhausted during a gateway incident.

    Likely cause: the transaction is open for the remote call, and slow or hung calls hold connections.

    Investigation: where @Transactional sits relative to CardGateway.authorize.

    Corrective action: transaction only on PaymentStore.save and on the capture write. Timeouts live on the HTTP client in the adapter and map to Unavailable.

    Symptom: A second product feature cannot reuse "declined cannot capture" without depending on the web module.

    Likely cause: the rule is in the controller, or the entity is in the adapter module's build graph backward.

    Investigation: call graph of capture and the Gradle/Maven dependencies.

    Corrective action: move the rule to domain, depend from the new feature inward, and add the ArchUnit test so the next shortcut fails CI.

    Review questions: Which package may import Spring? Where is the port interface declared? What does capture do when the state is not AUTHORIZED? Is any transaction open across a gateway call? Where does the principal id come from at runtime? What happens on Unavailable? Which profile can construct ThresholdGateway? What second driver could call the use case without copying a rule?

    Interview Questions and Answers

    Q1. What is the dependency rule in clean architecture?

    Source-code dependencies point inward. Entities know only their own rules and the JDK types they need. Use cases know entities and the ports they declare. Adapters know use cases and frameworks. Frameworks do not get imported by the inner two layers. At runtime a use case may invoke a database through a port; the import still points from the JDBC class toward the port. I would reject a review that treats "we have a service layer" as the same statement. The test is an import list, and ArchUnit can fail the build on it.

    Q2. How do you decide what is an entity versus a use case?

    An entity holds a rule you would still want if the delivery mechanism changed or a second application appeared. "Only an authorized payment can be captured" is an entity rule. A use case sequences a gateway call, an idempotency lookup, and a save for this application's authorize flow. HTTP header names, status codes, and SQL are neither. If I am unsure, I ask whether a batch job should be forced to duplicate the logic. If yes, it is not yet inward enough.

    Q3. Where do Spring @Transactional and @RestController belong, and where do they not?

    @RestController belongs on the web adapter. @Transactional belongs on the store method that performs one consistent write, or on a thin outer method that does not also perform remote calls. Neither annotation belongs on the entity or on AuthorizePayment. Putting @Transactional on the use case couples the policy to Spring and tends to hold a connection during the gateway call. I would rather have an explicit crash window and a reconciliation note than a transaction that pretends the gateway and the database commit together. They do not.

    Q4. What is a port in this design, and who is allowed to define it?

    A port is an interface the inner layer needs and does not implement: CardGateway, PaymentStore. The use case's package owns the interface. An adapter implements it. If the interface is declared next to the HTTP client, the use case depends on the adapter and the rule is gone even though a diagram still says "port." I keep framework types (Page, request DTOs, vendor payload classes) out of the port's signature so the inner layer stays compilable without those libraries.

    Q5. How do you test a capture rule without a database or a processor?

    I construct a Payment in the DECLINED state through its factory and assert that capture throws. I construct AuthorizePayment with an in-memory PaymentStore and a local gateway double and assert the receipt's state. I do not start Spring. Adapter tests, separately, cover SQL and JSON. If the only test that understands capture is a @SpringBootTest, the rule is in the wrong layer and the next framework upgrade will own it.

    Q6. What should the system do when the card gateway times out?

    The adapter maps the timeout to GatewayDecision.Unavailable. The use case does not store an approval and returns an unconfirmed receipt. A retry with the same idempotency key may call the gateway again, which is at-least-once toward the processor and must be in the decision record. I do not mark the payment authorized "so the user is not stuck," and I do not wrap the call in a database transaction. Reconciliation for the case "processor approved, we crashed before save" is a named follow-up, not a slogan about exactly-once. That topic has its own lesson.

    Q7. How does idempotency interact with authorization of the caller?

    The key is unique per principal, and the principal comes from the authenticated token in the web adapter. The store lookup is find(principalId, key). A replay returns the stored decision only when the amount matches. A replay with a different amount is rejected. A key alone, without the principal, lets one caller read another's outcome if they can guess the key. The use case accepts principalId as data so it does not import Spring Security. The adapter is responsible for the data's origin. I treat a body field named principalId as a defect.

    Q8. What is the difference between this layering and a hexagonal style, and what would you still ask lesson 6 to teach?

    Both keep the application independent of Spring and JDBC by depending on interfaces it owns. Clean architecture emphasizes the entity and use-case split and the concentric dependency rule. Hexagonal architecture emphasizes primary and secondary ports and the ability to drive the same inside from several adapters. I still want lesson 6 to show a second driving adapter, a job, against CapturePayment, and the failure mode where a port is defined in the wrong package. I do not need two competing folder trees. I need one rule and a clear vocabulary for driving versus driven adapters.

    Q9. When would you argue against introducing these layers?

    When the endpoint has no invariant a second caller must share, and the code is expected to be deleted with the screen. A certificate-color form is my usual example. Four packages and a mapper would slow the only change we will ever make, which is to remove the form. I record the decision and the reopen condition: the appearance of a real rule or a second driver. Applying the payment skeleton everywhere is how the structure becomes something engineers bypass.

    Q10. A teammate annotates the domain entity with @Entity to "save a mapper." What do you tell them?

    I tell them the mapper is the point. The annotation imports Jakarta Persistence into the rule, drags lazy loading into every caller, and makes the capture test need a session. The mapper in the store adapter is the seam that let a Hibernate upgrade avoid the entity package in our own payment code. I will accept a rehydrate method that is strict about illegal rows. I will not accept reflection that writes AUTHORIZED onto a declined object to avoid the factory. Saving a mapper and losing the rule is a bad trade. The scenario we are protecting is a persistence change that must not be a business-rule change.

    Q11. How do you share a Money type across the order slice and the payment slice?

    I put it in a small shared kernel that depends on the JDK only, and both slices depend on that kernel. I do not put it in the payment adapter and make orders depend on payments, and I do not give each slice its own Money that rounds differently. Sharing a JPA-annotated base entity is the mistake to avoid: the kernel would then depend on the framework, and every slice would inherit the dependency rule violation. Value objects are allowed to be shared. Persistence models are not.

    Q12. What evidence would show this architecture is working six months later?

    A framework or gateway-adapter change whose diff stays out of domain and application. A use-case test suite that still runs without a database and fails if capture is weakened. A second driver, if one appeared, that called the use case rather than the controller. And the absence of a shadow path: no "simple" controller that updates the payment table on its own. If every change still edits a JPA class with business if statements, the packages were decorative. I would rather delete the layers than maintain a diagram that lies.

    Hands-On Exercises

    Exercise: Clean architecture for a course-payment authorization

    Problem statement. Design and implement the authorize-and-capture slice for a course payment. The rule "only an authorized payment can be captured" must live in one place. A second caller (a job) must be able to capture without copying the rule. Do not integrate a real card processor. Provide a port and a local double.

    Tasks.

    1. Draw the four layers and the direction of source-code dependencies. Mark one import that is illegal.
    2. Implement Payment, AuthorizePayment, the ports, and a web adapter. Principal id comes from an authenticated context you define, not from the JSON body.
    3. Implement a gateway double whose name and profile cannot be mistaken for production, and specify how Unavailable is represented.
    4. Place @Transactional so a slow gateway cannot hold a database connection. Describe the crash window you accept.
    5. Write a test that a declined payment cannot be captured, without starting Spring.
    6. Show an ArchUnit rule or a module boundary that enforces the dependency rule.
    7. Explain, in a short paragraph, what you are leaving for a hexagonal treatment (a second driving adapter, primary versus secondary).
    8. State one design you refuse: JPA on the entity, a transaction around the gateway, or a pass-through use case. Say why in terms of a stakeholder.

    Expected deliverables. Package diagram, the Java types, the test, the dependency-rule check, and the refusal paragraph.

    Evaluation rubric.

    Assessment areaWeightWhat "good" looks like
    Dependency rule25%Domain and application compile without Spring, JPA, and the adapter package
    Entity behavior20%Capture is impossible from the wrong state, in one type
    Adapters and identity15%JSON and SQL sit outside; principal id is not client-supplied
    Failure and transactions20%Unavailable does not approve; the transaction does not cover the gateway
    Tests10%The rule test does not boot a context
    Judgment10%The hexagonal preview is accurate, and a useless layer would be refused

    An exercise that returns a generic success wrapper instead of a receipt with a state has hidden the rule the layers were meant to expose.

    Lesson Summary

    Clean architecture places business rules in entities and use cases and points every source dependency inward. Adapters translate HTTP, SQL, and gateway I/O. The framework is a detail, including Spring's annotations, which stay on controllers and on store methods that do not wrap remote calls. The payment slice uses that split so a declined payment cannot be captured, a use case test can say so without a database, and a later job can share the rule.

    The shape is the object-level dependency inversion of lesson 4, drawn at package scale, and only where a rule is worth a mapper. Hexagonal architecture, next, names primary and secondary adapters and drives the same inside from more than one outside. The dependency rule you enforced here is the part that must already be true before that diagram helps.

    Further Reading


    Ready to mark this lesson complete?Track your journey across the entire course.