Hexagonal Architecture and Ports & Adapters
TechLearningPro’s checkout team shipped a Spring Boot service that could place an order, charge a card, and send a receipt. Six months later the same flow had to accept a second ac
Hexagonal Architecture and Ports & Adapters
Advanced System Design & Software Architecture · Lesson 06
Lesson Overview
TechLearningPro’s checkout team shipped a Spring Boot service that could place an order, charge a card, and send a receipt. Six months later the same flow had to accept a second acquirer, a Kafka consumer for partner carts, and a batch replay for a failed night. The domain rules — a placed order has at least one line, the currency is consistent, a declined authorization does not become a sale — were correct. They were also unreachable. OrderService imported RestClient, JdbcClient, and JavaMailSender. A unit test required a database and a stub HTTP server. The second acquirer arrived as a if (provider.equals("acme")) branch inside the service that already knew the HTTP shape of the first one.
Alistair Cockburn’s hexagonal architecture, also called ports and adapters, is the structural answer to that coupling. The application core exposes ports: interfaces it owns. Adapters translate between those interfaces and a specific technology. A driving adapter (HTTP, a Kafka listener, a scheduler) calls in. A driven adapter (JDBC, an acquirer client, a mail sender) is called by the core. Dependencies point inward. Spring’s container is the wiring tool, not the architecture.
This lesson builds that shape on the checkout flow, maps each role onto Spring stereotypes you already use, and shows why the payment call must sit outside the database transaction even though both are “just dependencies.” Clean Architecture gave you the dependency rule. This lesson gives you the port vocabulary, the package layout, and the tests that enforce it. The same checkout reappears in Layered and Modular Monolith Architecture as one module inside a larger deployable, and in Domain-Driven Design Fundamentals as an aggregate with a real invariant. The companion treatment on the enterprise track is Hexagonal Architecture.
Learning Objectives
By the end of this lesson, you should be able to:
- Name a driving adapter, a driven adapter, an inbound port, and an outbound port on a Spring Boot checkout flow, and say which package each one lives in.
- Draw the dependency direction so the domain and the use case compile without Spring Web, Spring JDBC, or an acquirer SDK.
- Place the database transaction on the use case’s persistence steps and keep the remote authorization call outside that transaction.
- Test the place-order use case with in-memory fakes, and test the HTTP adapter with
@WebMvcTestand a stub port, without starting PostgreSQL. - Carry an
Idempotency-Keyfrom the controller through the use case into the payment port, and return the original result on a replay. - Swap a receipt adapter from SMTP to a notification topic by writing a new driven adapter, leaving
PlaceOrderunchanged. - Write an ArchUnit rule that fails the build when the domain package imports Spring or Jakarta Persistence.
- Reject a design that annotates the aggregate with
@Entityor that hides a second acquirer behind a boolean flag inside the use case.
Prerequisites
- Clean Architecture and SOLID Principles in Practice: dependency inversion and the reason a high-level policy owns the interface.
- Architectural Thinking and Quality Attributes: testability and modifiability are the attributes this structure buys; you still have to pay for them in package discipline.
- Java 21, Spring Boot 3.4+ or Boot 4 with Spring Framework 6 or 7, and the ability to read a constructor-injected
@Service. - JDBC at the level of a parameterized
INSERT, and the idea that@Transactionalbinds a JDBC connection to the current thread. You do not need Spring Data JPA for this lesson. - HTTP idempotency as a product requirement: the same
Idempotency-Keymust not authorize a card twice. The header is an application contract, not a Spring feature.
Detailed Core Concepts
The hexagon is a dependency rule with two kinds of edges
Cockburn drew the application as a hexagon so that people would stop lining the world up as “top = UI, bottom = database.” Both the UI and the database are outside. The inside is the policy: what it means to place an order. Each side of the hexagon is a port, a point where the outside world can plug in. The plug is an adapter.
Two directions matter.
| Direction | Who starts the call | Port lives | Adapter example in checkout |
|---|---|---|---|
| Driving (primary) | The outside calls the application | Inbound port, owned by the application | OrderController, a @KafkaListener for partner carts |
| Driven (secondary) | The application calls the outside | Outbound port, owned by the application or the domain | JdbcOrderRepository, AcquirerPaymentAdapter, SmtpReceiptAdapter |
A driving adapter depends on an inbound port and calls it. A driven adapter implements an outbound port. The use case depends on the outbound port’s interface and never on the adapter class. That is the whole pattern. The hexagon metaphor adds nothing beyond “there is no preferred side.”
What a port actually is in Java
A port is a small interface expressed in the core’s types. PaymentGateway speaks AuthorizationRequest and Authorization. It does not speak JSON field names, HTTP status codes, or a vendor’s PaymentIntent. Those belong to the adapter that translates.
An inbound port is the use case as the driving adapter sees it:
textPlaceOrder.handle(PlaceOrderCommand) -> PlaceOrderResult
An outbound port is a capability the use case needs and does not implement:
textPaymentGateway.authorize(AuthorizationRequest) -> AuthorizationOrderRepository.findByIdempotencyKey / insertPending / markAuthorizedReceiptNotifier.notifyPlaced(ReceiptNotice)
java.time.Clock is already a good outbound port for “what time is it.” Inject Clock and use Clock.fixed(...) in tests. Do not invent a TimePort that wraps a single method the JDK already isolated. Do invent a port when the operation is your business concept (authorize) and the technology is incidental (this month’s acquirer HTTP contract).
Where Spring stereotypes sit
Spring does not have a @Port annotation in Framework itself. Stereotypes mark adapters and the use-case implementation. The port stays a plain interface so a test can implement it without the container. Libraries such as jMolecules add @Port and @Adapter for documentation and static checks; they are optional. The compile-time dependency rule does the real work.
| Role | Typical Spring type | Depends on |
|---|---|---|
| Driving adapter | @RestController | Inbound port |
| Use case | @Service implementing the inbound port | Outbound port interfaces, domain types |
| Driven adapter | @Component or @Repository | Outbound port, plus JDBC, RestClient, JavaMailSender, KafkaTemplate |
| Domain model | No stereotype | JDK and your types only |
@Repository on a driven adapter is appropriate: it is a Spring stereotype that translates persistence exceptions into Spring’s DataAccessException hierarchy. @Entity on the domain Order is a different decision. It pulls Jakarta Persistence into the core and makes every field a column-mapping concern. This lesson keeps a persistence model inside the JDBC adapter and a domain Order with behavior.
The core owns vocabulary; the adapter owns protocol
PlaceOrderCommand carries an idempotency key, lines, and a payment-method token that a tokenizer already minted. The controller’s job is to reject a missing header, parse JSON, and map a PlaceOrderResult onto HTTP. A decline is 422 with ProblemDetail (RFC 9457, org.springframework.http.ProblemDetail). An acceptance of a new order is 201 with a Location. A replay of the same key is 200 with the same body. Those status codes are adapter decisions. The use case returns ACCEPTED, REPLAYED, or DECLINED. If a message-driven adapter appears next quarter, it maps those same three results onto an ack, a skip, or a dead-letter decision without teaching the domain about HTTP.
Transactions are a port concern, not a controller concern
@Transactional on the controller holds a JDBC connection for the entire HTTP call, including the acquirer round trip. At 40 orders per second and a 2 second acquirer timeout, a pool of 20 connections is exhausted by slow authorizations while PostgreSQL sits idle. The use case therefore splits the work:
- A short transaction inserts the order as
PENDING_PAYMENTunder a unique idempotency key. - The payment port is called with no transaction open.
- A second short transaction records
AUTHORIZEDorDECLINED.
If the process dies between step 2 and step 3, a reconciler reads authorizations the acquirer knows about and orders still in PENDING_PAYMENT. The port boundary makes that split visible. A single placeOrder() method that both inserts and posts HTTP hides it.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) is a Spring facility a driven adapter may use to send a receipt after the authorizing transaction commits. It is not an outbox. A crash after commit and before the listener runs drops the receipt. Event-Driven Architecture Fundamentals previews the outbox table that closes that gap. This lesson’s receipt port is allowed to be lossy only if the product owner accepts a missing email; a payment fact is not sent that way.
Testability is the point you can measure
A hexagonal checkout has three test bands that do not share a fixture:
| Band | What it constructs | What it refuses to construct |
|---|---|---|
| Domain | Order, Money | Spring, JDBC, HTTP |
| Use case | PlaceOrderService plus in-memory OrderRepository and a fake PaymentGateway | @SpringBootTest |
| Driving adapter | @WebMvcTest(OrderController.class) plus a stub PlaceOrder | PostgreSQL, the acquirer |
| Driven adapter | @JdbcTest or Testcontainers against the real SQL | The HTTP stack, the pricing rules |
If the use-case test needs @SpringBootTest, a port is missing or a concrete adapter leaked into the constructor. That is a design signal, not a testing inconvenience.
How this relates to Clean Architecture and to layers
Clean Architecture states the same dependency rule as concentric circles. This lesson packages checkout as domain, application, and adapter.inbound / adapter.outbound. A service package that imports both JDBC and HTTP does not get that rule for free. Lesson 7 places this packaging inside each module. Ports do not choose bounded contexts, make a remote call reliable, or remove the need for an idempotency key. Add a port when the core would otherwise name a technology, or when a test must replace that technology.
Architecture and Design Diagrams
mermaidflowchart LRsubgraph driving [Driving adapters]HTTP[OrderController]KAFKA[PartnerCartListener]endsubgraph core [Application core]IN[PlaceOrder inbound port]UC[PlaceOrderService]DOM[Order and Money]PAY[PaymentGateway]ORD[OrderRepository]REC[ReceiptNotifier]endsubgraph driven [Driven adapters]JDBC[JdbcOrderRepository]ACQ[AcquirerPaymentAdapter]SMTP[SmtpReceiptAdapter]endHTTP --> INKAFKA --> ININ --> UCUC --> DOMUC --> PAYUC --> ORDUC --> RECJDBC -. implements .-> ORDACQ -. implements .-> PAYSMTP -. implements .-> REC
The solid arrows are compile-time dependencies. They point from adapters into the core and from the use case into port interfaces. The dashed arrows are runtime implementations that Spring injects. PlaceOrderService’s source file does not mention JdbcOrderRepository.
mermaidsequenceDiagramparticipant UI as Checkout UIparticipant C as OrderControllerparticipant U as PlaceOrderServiceparticipant R as OrderRepositoryparticipant P as PaymentGatewayUI->>C: POST /orders, Idempotency-KeyC->>U: PlaceOrderCommandU->>R: findByIdempotencyKeyalt replayR-->>U: prior resultU-->>C: REPLAYEDC-->>UI: 200 same bodyelse first requestU->>R: insert PENDING_PAYMENTU->>P: authorize(token, amount, key)P-->>U: APPROVED and authorizationIdU->>R: mark AUTHORIZEDU-->>C: ACCEPTEDC-->>UI: 201 Locationend
mermaidflowchart TBsubgraph tx1 [Transaction 1 short]I[Insert order PENDING_PAYMENT]K[Insert idempotency row]endsubgraph outside [No transaction]A[PaymentGateway.authorize]endsubgraph tx2 [Transaction 2 short]M[Update AUTHORIZED or DECLINED]endI --> K --> A --> MA -->|timeout| Q[Leave PENDING_PAYMENT for reconciler]
Trust boundary: the payment-method token is opaque to the core. The driving adapter accepts it as a string that a PCI-scoped tokenizer minted in the browser. The acquirer adapter forwards that token. Logs in every adapter print the order id and the idempotency key, and they omit the token. The domain never sees a primary account number, because no port accepts one.
Technical Explanation
A request, end to end
The checkout UI sends POST /api/checkout/orders with Idempotency-Key: 7f3c… and a JSON body of SKU, quantity, and paymentMethodToken. OrderController checks that the key matches ^[A-Za-z0-9_-]{16,80}$, runs Bean Validation on the body, and builds a PlaceOrderCommand. It does not compute totals. A client-supplied total is ignored so a stale page cannot underpay.
PlaceOrderService.handle looks up the key. A hit returns the stored result with replayed = true. A miss builds an Order in DRAFT, calls order.place(), and persists PENDING_PAYMENT together with the key in one transaction. The unique primary key on idempotency_key is the race control: two concurrent retries insert once, and the loser catches DataIntegrityViolationException, re-reads, and returns the winner’s result.
AcquirerPaymentAdapter then calls the acquirer with RestClient, a connect timeout, and a response timeout. The adapter maps HTTP 200 plus an approved code to AuthorizationStatus.APPROVED, a structured decline to DECLINED, and a timeout or HTTP 5xx to AuthorizationStatus.UNKNOWN. UNKNOWN is not a decline. The use case leaves the order in PENDING_PAYMENT and returns a result the controller maps to 503 with a problem type of payment-unknown, telling the client to retry with the same key. Treating unknown as declined would let a retry charge a card the first call actually approved, if the lost response was a success.
On APPROVED, the second transaction stores the authorization id and status AUTHORIZED. On DECLINED, it stores DECLINED and still stores the key, so a retry does not call the acquirer again. The receipt port runs only for AUTHORIZED, after that commit.
Where validation lives
| Check | Owner | Why |
|---|---|---|
| JSON shape, header present, token non-blank | Driving adapter, Bean Validation | Protocol |
| Quantity ≥ 1, currency scale, line currency match, no place from a terminal status | Money and Order | Invariants hold no matter which adapter calls |
| Idempotency key reuse, authorization outcome | Use case | Application policy across ports |
| SQL constraints, HTTP status mapping, acquirer error codes | Driven and driving adapters | Technology |
A domain method that throws IllegalArgumentException for a broken invariant is acceptable inside the core. The driving adapter maps it to 400. Do not catch it inside the use case and return a string error code that then grows a parallel error taxonomy. A declined payment is not an exception. It is a result the business expects on a measurable fraction of traffic. Reserve exceptions for broken invariants and for infrastructure faults.
Failure behavior you must be able to describe
- Duplicate key race. Unique constraint, re-read, return the stored result. The payment port is not called by the loser.
- Acquirer timeout. Status stays
PENDING_PAYMENT. Reconciler, or the client’s retry, resolves it. The retry calls the acquirer with the same idempotency key; a competent acquirer returns the original authorization. - Crash after approval, before the second commit. Same
PENDING_PAYMENTpath. The reconciler matchesauthorizationIdfrom the acquirer to the order id you sent as the merchant reference. - Decline. Order row exists as
DECLINEDfor support lookup. The UI may start a new attempt only with a new idempotency key, because the old key now means “declined.” - Receipt SMTP down. Order is already
AUTHORIZED. The receipt adapter records a failed attempt and a job retries. Payment success does not roll back because email failed.
Security on the port boundary
The driving adapter authenticates the shopper (session or bearer token) and passes a ShopperId into the command. The use case checks that the payment-method token was issued for that shopper. The check is an outbound port, PaymentMethodOwnership, implemented by the tokenizer’s service, or a claim already in the token if you verify a signature in the adapter and pass a verified ShopperId. The use case does not accept “the controller said it was fine” without a value it can re-check when a second driving adapter, the partner-cart listener, calls the same port. Partner carts authenticate a partner client, not a browser session. The inbound port stays the same; each driving adapter fills ShopperId from its own credential.
Modifiability you can estimate
A second acquirer is a new @Component implementing PaymentGateway, selected by a routing adapter that also implements PaymentGateway and delegates by ShopperId or a merchant configuration row. PlaceOrderService keeps one dependency. The routing adapter lives in adapter.outbound.payment and is the only class that names both acquirers. A Kafka driving adapter is a @KafkaListener that builds the same PlaceOrderCommand and shares the idempotency table, so a partner retry and a browser retry cannot both authorize.
Practical Implementation or Design Walkthrough
Problem. TechLearningPro checkout must place an order and authorize a payment method token. Peak is about 40 orders per second. The acquirer p99 is 1.5 seconds. PostgreSQL is the system of record. A second driving adapter and a second acquirer are expected within two quarters. One team owns the module.
Assumptions. The browser talks to a tokenizer you already operate; this module receives a token, never a PAN. Prices are looked up from a catalog port (lesson 7 shows that module boundary; here the command carries a server-side UnitPrice the controller obtained from catalog before calling PlaceOrder, or the use case calls a CatalogPrices port). For this walkthrough the use case calls CatalogPrices so a client cannot set the price. Currency is ISO 4217. The database is PostgreSQL. Spring wires beans by constructor injection.
1. Domain types, no Spring
javapublic record Money(BigDecimal amount, Currency currency) {public Money {if (amount == null || currency == null) {throw new IllegalArgumentException("amount and currency are required");}if (amount.signum() < 0) {throw new IllegalArgumentException("amount must be >= 0");}int digits = currency.getDefaultFractionDigits();if (digits >= 0 && amount.scale() > digits) {throw new IllegalArgumentException("scale exceeds " + currency.getCurrencyCode());}}public Money plus(Money other) {if (!currency.equals(other.currency)) {throw new IllegalArgumentException("currency mismatch");}return new Money(amount.add(other.amount), currency);}public static Money zero(Currency currency) {return new Money(BigDecimal.ZERO.setScale(currency.getDefaultFractionDigits()), currency);}}
Order.place() requires a non-empty line list, one currency, and status DRAFT. It sets PLACED and the total. markAuthorized(String authorizationId) requires PENDING_PAYMENT and a non-blank authorization id. markDeclined() requires PENDING_PAYMENT. A second call to markAuthorized throws. Those throws are the aggregate protecting itself when any adapter mis-sequences calls. The use case still tries to call them in the legal order.
2. Ports
javapublic interface PlaceOrder {PlaceOrderResult handle(PlaceOrderCommand command);}public interface PaymentGateway {Authorization authorize(AuthorizationRequest request);}public interface OrderRepository {Optional<PlaceOrderResult> findByIdempotencyKey(String key);void insertPending(Order order, String idempotencyKey);void markAuthorized(OrderId id, String authorizationId);void markDeclined(OrderId id);}public interface CatalogPrices {Money priceOf(String sku);}
Authorization is a record of authorizationId, AuthorizationStatus (APPROVED, DECLINED, UNKNOWN), and an optional decline code. AuthorizationRequest carries the idempotency key, the order id, the money, and the payment-method token.
3. Use case, two short transactions
java@Servicepublic class PlaceOrderService implements PlaceOrder {private final OrderRepository orders;private final PaymentGateway payments;private final CatalogPrices prices;private final PlatformTransactionManager transactions;public PlaceOrderService(OrderRepository orders, PaymentGateway payments,CatalogPrices prices, PlatformTransactionManager transactions) {this.orders = orders;this.payments = payments;this.prices = prices;this.transactions = transactions;}@Overridepublic PlaceOrderResult handle(PlaceOrderCommand command) {var prior = orders.findByIdempotencyKey(command.idempotencyKey());if (prior.isPresent()) {return prior.get().asReplay();}Order order = Order.draft(OrderId.newId(), command.shopperId(), pricedLines(command));order.place();inTx(() -> orders.insertPending(order, command.idempotencyKey()));Authorization auth = payments.authorize(new AuthorizationRequest(command.idempotencyKey(), order.id(), order.total(), command.paymentMethodToken()));if (auth.status() == AuthorizationStatus.UNKNOWN) {return PlaceOrderResult.unknown(order.id());}if (auth.status() == AuthorizationStatus.DECLINED) {inTx(() -> orders.markDeclined(order.id()));return PlaceOrderResult.declined(order.id(), auth.declineCode());}inTx(() -> {order.markAuthorized(auth.authorizationId());orders.markAuthorized(order.id(), auth.authorizationId());});return PlaceOrderResult.accepted(order.id(), auth.authorizationId());}private void inTx(Runnable work) {new TransactionTemplate(transactions).executeWithoutResult(status -> work.run());}}
TransactionTemplate (org.springframework.transaction.support.TransactionTemplate) keeps the transaction boundary in the use case without annotating the payment call. findByIdempotencyKey can run in its own read, or you can annotate a repository method with @Transactional(readOnly = true). The important property is that payments.authorize is not inside inTx.
A unique-key conflict on insertPending is caught at the repository boundary and rethrown as a domain-level IdempotencyRace. The service then re-reads. That catch belongs in JdbcOrderRepository, which already imports DataIntegrityViolationException. The use case imports neither JDBC nor Spring’s DAO exceptions if you translate them in the adapter. The snippet above leaves that translation as the adapter’s contract: insertPending either succeeds or throws IdempotencyRace after the loser observes the winner’s row.
PlatformTransactionManager is a Spring type on the use case. That is a conscious leak: transaction policy is application policy, and Spring’s transaction manager is the mechanism this modular monolith uses. If you want the core free of Spring, hide inTx behind a one-method Tx port implemented with TransactionTemplate. Teams that already accepted Spring as the application runtime usually keep TransactionTemplate in the application package and keep Spring Web, JDBC, and Kafka out. Both choices are defensible. Mixing them by importing RestClient “because Spring is already there” is how the original OrderService decayed.
4. Driving adapter
java@RestController@RequestMapping("/api/checkout/orders")public class OrderController {private final PlaceOrder placeOrder;public OrderController(PlaceOrder placeOrder) {this.placeOrder = placeOrder;}@PostMappingpublic ResponseEntity<?> place(@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody PlaceOrderBody body,@AuthenticationPrincipal ShopperPrincipal shopper) {PlaceOrderResult result = placeOrder.handle(new PlaceOrderCommand(key, shopper.id(), body.lines(), body.paymentMethodToken()));return switch (result.kind()) {case ACCEPTED -> ResponseEntity.created(location(result.orderId())).body(toBody(result));case REPLAYED -> ResponseEntity.ok(toBody(result));case DECLINED -> ResponseEntity.unprocessableEntity().body(problem(result));case UNKNOWN -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(problem(result));};}}
@AuthenticationPrincipal is Spring Security’s way of handing the driving adapter an authenticated principal. The controller copies shopper.id() into the command. It does not pass the Authentication object inward.
5. Driven adapter, JDBC
java@Repositorypublic class JdbcOrderRepository implements OrderRepository {private final JdbcClient jdbc;public JdbcOrderRepository(JdbcClient jdbc) {this.jdbc = jdbc;}@Overridepublic void insertPending(Order order, String idempotencyKey) {try {jdbc.sql("""insert into checkout_order(id, shopper_id, status, total_amount, currency, placed_at)values (:id, :shopper, 'PENDING_PAYMENT', :total, :ccy, :at)""").param("id", order.id().value()).param("shopper", order.shopperId().value()).param("total", order.total().amount()).param("ccy", order.total().currency().getCurrencyCode()).param("at", order.placedAt()).update();jdbc.sql("""insert into checkout_idempotency (idempotency_key, order_id, result_kind)values (:key, :id, 'PENDING')""").param("key", idempotencyKey).param("id", order.id().value()).update();} catch (DataIntegrityViolationException ex) {throw new IdempotencyRace(idempotencyKey);}}}
JdbcClient is org.springframework.jdbc.core.simple.JdbcClient (Spring Framework 6.1). The two inserts share the TransactionTemplate from the use case because Spring binds one connection for the thread. markAuthorized updates status only when the current status is PENDING_PAYMENT and checks the update count. An update count of 0 means a reconciler already moved the row; the adapter throws a conflict the use case logs and treats as a replay after a re-read.
6. Driven adapter, acquirer
AcquirerPaymentAdapter builds a request DTO the acquirer documents, posts it with RestClient, and maps the response. Timeouts become UNKNOWN. The adapter sets the acquirer’s idempotency header to command.idempotencyKey(). Connection and read timeouts are explicit (RestClient with a configured ClientHttpRequestFactory, for example JdkClientHttpRequestFactory backed by HttpClient.newBuilder().connectTimeout(...)). The token is a request field and is excluded from the access log by a customizer that redacts that header and JSON property. The use case never configures those timeouts; latency policy for the vendor lives next to the vendor.
7. Tests
Domain test: Money of USD 10.00 plus USD 2.50 is USD 12.50; EUR plus USD throws; Order.place() on an empty list throws; markAuthorized twice throws.
Use-case test, no Spring: a HashMap repository and a PaymentGateway that returns APPROVED, then DECLINED, then UNKNOWN. Assert the repository status and that a second handle with the same key does not increment the fake gateway’s call count.
java@WebMvcTest(OrderController.class)@Import(OrderControllerTest.StubPlaceOrder.class)class OrderControllerTest {@Autowired MockMvc mvc;@TestConfigurationstatic class StubPlaceOrder {@Bean PlaceOrder placeOrder() {return command -> PlaceOrderResult.accepted(OrderId.parse("8b2c"), "auth_1");}}@Testvoid createdWhenAccepted() throws Exception {mvc.perform(post("/api/checkout/orders").header("Idempotency-Key", "idem-key-00000001").contentType(MediaType.APPLICATION_JSON).content("{\"lines\":[{\"sku\":\"SKU1\",\"quantity\":1}],\"paymentMethodToken\":\"tok_1\"}")).andExpect(status().isCreated());}}
@WebMvcTest does not start JPA or a web server on a real socket; MockMvc calls the controller in-process. Add @WithMockUser or a test SecurityFilterChain if security is on the slice. The slice’s job is the status-code mapping, not the pricing rule.
ArchUnit (real API in com.tngtech.archunit):
java@ArchTeststatic final ArchRule domain_has_no_framework =noClasses().that().resideInAPackage("..checkout.domain..").should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "jakarta.persistence..", "org.apache.kafka..");
Run it in CI on every pull request. A review comment is optional; a red build is not.
Expected result. A place call persists one order, authorizes once, and returns 201. The same key returns 200 without a second authorize. A decline returns 422 and does not send a receipt. The domain module’s javac classpath for a pure domain test does not include spring-web.
Enterprise Real-World Scenarios
Scenario 1: Two acquirers and a partner feed
A European subsidiary must authorize with a second acquirer because the primary one cannot settle a local scheme. At the same time, a marketplace partner posts carts onto a Kafka topic instead of calling HTTPS. Volume is 40 orders per second at peak on the HTTP path and about 5 per second on the partner path. The same idempotency table serves both.
The routing PaymentGateway reads a merchant_acquirer table (a driven adapter, cached for 30 seconds) and delegates. Each acquirer adapter has its own timeout and its own decline-code map. Decline codes are translated to a small core enum (INSUFFICIENT_FUNDS, DO_NOT_HONOR, OTHER) at the adapter so the UI message table stays stable when a vendor renames a code. The partner listener sets ShopperId from the partner’s signed customer reference after the adapter verifies the signature. The use case calls PaymentMethodOwnership with that id. A partner payload that includes a raw PAN fails validation in the listener and is never mapped to a command. The security control is at the driving adapter because that is the trust boundary; the core still refuses a blank token.
Operational consequence: on-call sees one use-case metric, checkout.place, with tags adapter=http|kafka and acquirer=primary|local. The metric is recorded in the driving adapter (protocol success) and in the use case (business result). Those are different numbers. HTTP 201 counts as protocol success. DECLINED is a business result and a protocol 422, not a server error, so it must not page.
Scenario 2: Receipts move from SMTP to the notification platform
Support can see authorized orders whose shoppers never received a receipt. SMTP from the checkout process is failing during a provider incident, and the failure is invisible because it was a try/catch that logged and continued inside PlaceOrderService. The team replaces the call with a ReceiptNotifier port. The first adapter still uses JavaMailSender. The second, written two sprints later, publishes a receipt request to the notification context (lesson 9 and the notification case study). PlaceOrderService does not change. The SMTP adapter remains for a feature flag during the cutover, selected with a Spring @Profile or a @ConditionalOnProperty on the adapter bean. Only one ReceiptNotifier bean is active. A second bean without @Primary fails startup, which is the outcome you want: silent dual-send is worse than a failed deploy.
The team records a limitation in the port’s Javadoc: the notifier runs after commit, so a crash can skip it. Product accepts a daily job that emails any AUTHORIZED order with no receipt row. A settlement fact would use the outbox in lesson 9 instead. The note stops the next change from moving the send back inside the payment transaction.
Scenario 3: A scheduled replay as a third driving adapter
Finance asks for a job that re-submits PENDING_PAYMENT orders older than five minutes. The job is a driving adapter: a @Scheduled method loads ids through a query port and calls PlaceOrder with the original idempotency key. A newly generated key would authorize a second time. The scheduler depends on the same inbound port as the controller. If the job cannot run without the HTTP DTO, the inbound port was never separated from the web adapter.
Best Practices
- Keep inbound ports as use-case interfaces with request and result types the UI does not own. DTO classes annotated with Jackson and Bean Validation stay in the driving adapter.
- Keep aggregates free of
@Entity,@Table, and Spring stereotypes. Map in the driven adapter. - Inject outbound ports into the use case through the constructor. A field-injected concrete class hides the dependency from tests.
- Translate vendor errors in the driven adapter. Let the core see a closed set of statuses.
- Put timeout, retry, and credential configuration in the adapter
@Configuration. Retry a read against the acquirer status endpoint; do not blindly retryauthorizeunless the same idempotency key is on the wire. - Redact tokens at the adapter. Add a test that the log encoder’s output for a sample request does not contain
tok_. - Enforce the package rule with ArchUnit in CI. Name packages
domain,application,adapter.inbound,adapter.outbound. - Prefer a hand-written fake over a mocking framework in use-case tests so the fake can record call order: insert, then authorize, then mark.
- Store the idempotency key in the same database as the order, with a unique constraint, in the same first transaction as the pending insert.
- When a port method can time out, give it a status for “not known,” and write the reconciler in the same design, even if the first version is a SQL query a human runs.
Common Mistakes and Anti-Patterns
Framework-shaped ports. OrderJpaPort with save(OrderEntity entity) copies the persistence model into the core. It happens because the first draft used Spring Data and the interface was extracted afterward without changing the types. The consequence is that a Mongo adapter, or a plain JDBC adapter, must still speak JPA entities. Fix the port so it accepts and returns Order. Let JdbcOrderRepository map.
Hexagonal theater. Every class has an interface, including OrderController’s private JSON mapper. The package is larger and the dependency rule is no stronger. Add a port when a technology or a test double sits on the other side, and stop there.
Transaction wrapped around the acquirer. @Transactional on handle() including authorize. Under a vendor incident the pool saturates and healthy database work queues. Split the transactions as this lesson does. A code review checklist item: “Is there a remote call inside this @Transactional method?”
Driving adapter contains the rule. The controller computes the total and the service trusts it, because that was faster to demo. A Kafka adapter then reimplements the total, and the two formulas drift by a rounding mode. Pricing and place() live in the core. Adapters transport.
UNKNOWN mapped to DECLINED. The adapter author wanted a simple boolean. A lost approval becomes a decline in your database and an approval at the acquirer. Support refunds a capture the shopper never saw as successful, or the retry double-captures. Model UNKNOWN and reconcile.
Domain events published with a concrete KafkaTemplate inside Order. The aggregate then cannot be unit tested, and every module that loads Order links Kafka. Order returns the fact that it is authorized, or appends an in-memory event the use case passes to a port. Lesson 9 expands the event itself. Keep each vendor’s retry count on that vendor’s adapter, not in a static base class every client inherits.
Troubleshooting and Design Review
| Symptom | Likely cause | What to check | Correction |
|---|---|---|---|
| Use-case test boots a 20-second Spring context | Constructor depends on a concrete @Repository or @SpringBootTest was copied from a tutorial | Constructor parameter types | Depend on the port; new the service in the test |
| Connection pool exhaustion during acquirer slowness | @Transactional open across RestClient | Thread dump shows connections stuck in the HTTP call | TransactionTemplate only around SQL |
| Duplicate authorizations at the acquirer | Idempotency key not forwarded, or a new key generated on retry | Acquirer dashboard, adapter request log | Send the caller’s key; persist it before the call |
| Replay returns 201 and a second order id | Lookup misses because the key is trimmed in HTTP and not in Kafka | Rows in checkout_idempotency | Normalize the key in one place, the driving adapters, before the command |
| ArchUnit passes and the domain still imports Spring | The rule’s package pattern does not match the real package | resideInAPackage string versus package line | Fix the pattern; fail a canary test that imports Service on purpose |
| Decline pages on-call | Controller maps DECLINED to HTTP 500 | Access logs, SLO burn | Map to 422; alert on UNKNOWN and on 5xx only |
| Second acquirer “temporarily” branched inside the service | No routing adapter | if (acquirer) in application | Move the branch to adapter.outbound.payment |
| Receipt sent for a rolled-back order | Notifier called before commit, or inside the transaction that later fails | Log order versus email time | Call the notifier after the authorizing transaction commits |
In review, name the port a new type satisfies, confirm an adapter-only change does not add domain imports, and walk timeout, duplicate key, and a crash after the remote call. No log field may contain a payment token. A fake gateway can assert TransactionSynchronizationManager.isActualTransactionActive() is false during authorize. That Spring API belongs in the test double, not in Order.
Interview Questions and Answers
Q1. What is a port, and what is an adapter, in a Spring Boot service?
A port is an interface the application core owns, expressed in domain or use-case types. An adapter is the concrete code that translates between that interface and one technology. In checkout, PaymentGateway is a port. AcquirerPaymentAdapter, which uses RestClient and maps the vendor’s JSON, is a driven adapter. OrderController is a driving adapter: it translates HTTP into PlaceOrderCommand and calls the PlaceOrder port. Spring stereotypes (@RestController, @Repository, @Component) belong on adapters and on the use-case class. They do not define the port. If the interface mentions HttpStatus or a JPA annotation, it is an adapter detail that leaked inward, and a second technology will have to fake those types.
Q2. Which side calls which: driving versus driven?
Driving adapters start the interaction. The browser, a Kafka consumer loop, or a scheduler calls your process, and the adapter calls an inbound port. Driven adapters are called by the use case. The use case calls OrderRepository and PaymentGateway; JDBC and the acquirer sit behind those interfaces. A common mix-up is to call the repository “inbound” because data comes from it. Direction follows who initiates the call at the port, not the direction the bytes travel. The controller is driving even though the HTTP response goes back out. The JDBC adapter is driven even though it returns rows.
Q3. Why is the payment call outside the database transaction?
An open transaction holds a pooled JDBC connection. An acquirer call can take hundreds of milliseconds and, during an incident, the full timeout. Forty orders per second times a multi-second hold exhausts a normal pool, and unrelated queries time out. The pending order and the idempotency key are committed first, in a short transaction, so a retry is safe. The authorization happens next, with no transaction. A second short transaction records the outcome. The cost is a window where the order is PENDING_PAYMENT and the acquirer may already have approved. That window is handled by the same idempotency key and a reconciler, which you need anyway because processes crash. Wrapping both steps in one @Transactional method makes the happy path look atomic and the failure path operationally worse.
Q4. How do you test a use case without starting Spring?
Construct PlaceOrderService with new, passing an in-memory OrderRepository and a fake PaymentGateway. Assert status transitions and how many times authorize was called. Add a separate @WebMvcTest for the controller that supplies a stub PlaceOrder bean and checks status codes: 201, 200 on replay, 422 on decline, 503 on unknown. Add a JDBC test that runs the real INSERT against PostgreSQL, via Testcontainers or @JdbcTest with an embedded substitute only if the SQL is portable. The split is the design. A single @SpringBootTest that hits a stub server and a database for every pricing rule will be slow, and people will stop running it. You do not need a mocking library to do this; a fake that records calls is easier to read six months later.
Q5. How does the idempotency key cross the hexagon?
The driving adapter reads Idempotency-Key and puts it on PlaceOrderCommand after a format check. The use case looks it up and stores it in the same transaction as the pending order, with a unique constraint. The use case passes the same string on AuthorizationRequest. The acquirer adapter sends it as the vendor’s idempotency header. A replay returns the stored PlaceOrderResult and does not call the gateway. A concurrent duplicate loses the unique insert, re-reads, and returns the same result. Generating a fresh UUID inside the adapter on every HTTP request destroys the guarantee. The key is the caller’s key.
Q6. Where should @Entity live?
On a persistence type inside the driven adapter, if you use JPA at all. This lesson’s adapter uses JdbcClient and has no @Entity. The domain Order enforces invariants in methods. If @Entity is on Order, the domain module depends on Jakarta Persistence, lazy loading reaches into adapters’ sessions, and a unit test boots a persistence provider or avoids the model entirely. Mapping code in JdbcOrderRepository is the price of keeping that independence. For a simple CRUD form with no invariants, the price can be higher than the value, and a layered Spring Data service is honest. Checkout is not that form.
Q7. How do you add a second acquirer without editing the use case?
Introduce a driven adapter that implements PaymentGateway and delegates to PrimaryAcquirerAdapter or LocalSchemeAdapter. Put the selection rule, including configuration and caching, in that routing adapter. Register one PaymentGateway bean so the use case’s constructor stays stable. Map vendor-specific decline codes to the core’s enum inside each leaf adapter. Keep credentials and timeouts out of application.yml keys that the use case reads. If the use case starts importing both leaf classes, the port was bypassed. A feature-flag switch inside PlaceOrderService is the same bypass with a boolean.
Q8. What is the difference between this style and Clean Architecture on a real team?
Both keep the domain independent of frameworks. Clean Architecture emphasizes use-case interactors and concentric layers. Hexagonal architecture emphasizes ports as pluggable edges and treats the UI and the database as peers. In a Spring repository the visible difference is package layout and review vocabulary. The failure mode is the same: a service package that imports RestClient and JdbcClient. Pick one vocabulary and enforce it with ArchUnit. Lesson 5 states the dependency rule; this lesson maps it onto Spring and splits the transaction.
Q9. A teammate publishes a Kafka event from the aggregate constructor’s neighbor, the use case, using KafkaTemplate directly. What do you change?
Move the send behind a port such as ReceiptNotifier or a narrow DomainEventPublisher whose implementation uses KafkaTemplate. Keep KafkaTemplate in adapter.outbound. Then ask whether the send is allowed to be lost on a crash after commit. If it is a receipt, document the gap and reconcile. If it is a fact another context will settle money on, do not use a bare KafkaTemplate after commit. Use the outbox described in lesson 9 so the insert of the fact and the insert of the message are one database transaction. Hexagonal boundaries make that choice a port implementation swap later, but only if the use case did not already name Kafka.
Q10. How do you stop the domain from quietly taking a Spring dependency?
ArchUnit rule: classes in the domain package must not depend on org.springframework.., jakarta.persistence.., or the adapter packages. Run it in CI. Give the domain tests a classpath that only contains the domain sources and the JDK, if the build is modular enough, or at least fail the ArchUnit test. Review is not sufficient; a well-meaning @Component on a domain service compiles and “makes injection easier,” and the next test needs a context. Also watch TransactionSynchronizationManager and ProblemDetail in the domain. Those are framework types. ProblemDetail belongs in the controller. Transaction policy may sit in the application package, as this lesson’s TransactionTemplate does, and that package is allowed to reference Spring transaction APIs on purpose.
Q11. What does a staff engineer look for in a design review of a new port?
A one-sentence business capability (“authorize this payment method for this amount under this key”), types that do not mention a vendor, a defined timeout behavior including unknown versus decline, an idempotency story if the call has a side effect, a test double in the use-case test, and an adapter test that maps at least one success and one failure payload. They also look for the transaction: does this port get called inside inTx? They look for secrets and tokens in logs. They ask which second adapter is plausible. If no second adapter and no test double is plausible, they ask whether the interface should exist. A port with no reason is ceremony. A missing port around a vendor HTTP call is a future incident.
Hands-On Exercises
Exercise 1: Checkout ports for payment and receipt
Problem. Model TechLearningPro checkout so a payment acquirer and a receipt sender sit behind ports. HTTP is the first driving adapter. A second acquirer must be addable without editing the use case.
Tasks.
- List functional requirements (place, replay, decline, unknown) and non-functional ones (pool must survive a 2 second acquirer stall; p95 for the database portion under 50 ms).
- Draw the hexagon: inbound port, three outbound ports, two driving adapters sketched (HTTP and a future Kafka listener).
- Define
PlaceOrder,PaymentGateway,OrderRepository, andReceiptNotifierwith request and result types. IncludeUNKNOWN. - Specify the two SQL transactions and the unique key.
- Describe failure modes: race, timeout, crash after approval, SMTP down.
- State the security rule for the payment token and for
ShopperIdwhen the driving adapter changes. - Name the tests: domain, use case with fakes,
@WebMvcTest, one adapter mapping test. - Record the decision “transaction split versus one
@Transactionalmethod” in five sentences a reviewer can challenge.
Expected deliverables. A one-page context sketch, the port signatures, the status mapping table (result kind to HTTP status), the failure note, and the test list.
Acceptance. A reader who has not seen this lesson can implement the use case and the controller mapping without inventing a new status. The payment port’s signature contains no JSON annotation and no HttpStatus.
Exercise 2: Review a leaking service
You are handed OrderService annotated with @Service and @Transactional. It injects JdbcClient, RestClient, and JavaMailSender. It catches all exceptions and returns null on decline. Write a review: the three seams you would introduce first, the order in which you would extract them, and which test you would add before moving each call. Do not propose a microservice split.
Rubric.
| Area | Weight |
|---|---|
| Requirements and the transaction assumption | 15% |
| Port placement and dependency direction | 25% |
| Status mapping and idempotency correctness | 20% |
| Failure, security, and pool behavior | 20% |
| Trade-off: what stayed in Spring on purpose | 10% |
| Clarity of the note a reviewer can execute | 10% |
Lesson Summary
Hexagonal architecture places the checkout policy in the center and every technology at an adapter. Driving adapters call an inbound port such as PlaceOrder. The use case calls outbound ports for persistence, payment, prices, and receipts. Spring wires the implementations and may own the transaction template; it does not own the pricing rule or the vendor JSON. The payment call sits outside the database transaction so a slow acquirer cannot pin the connection pool, and the idempotency key makes that split safe to retry. Tests follow the same seams: pure domain, use case with fakes, a web slice, a persistence adapter. ArchUnit keeps the next @Entity or RestClient import from landing in the domain. The structure does not decide module boundaries or message delivery guarantees. Those are lessons 7 and 9. It does decide whether the second acquirer and the partner listener are new classes at the edge or new branches in a service that can no longer be tested.
Further Reading
- Alistair Cockburn, Hexagonal architecture — the original port and adapter statement.
- Martin Fowler, Hexagonal Architecture bliki — short orientation and vocabulary.
- Microsoft, Ports and adapters architecture style — when the style fits and the costs.
- Spring Framework, JdbcClient and Transaction management.
- Spring Security, Method security and
@AuthenticationPrincipal— the driving adapter’s principal, kept out of the domain. - ArchUnit user guide —
noClassesand layered rules used in this lesson. - jMolecules — optional
@Port/@Adapterstereotypes; the dependency rule still does the enforcement. - IETF RFC 9457, Problem Details for HTTP APIs — the document behind
ProblemDetail. - Sibling lessons: Clean Architecture, Layered and Modular Monolith Architecture, Domain-Driven Design Fundamentals, Event-Driven Architecture Fundamentals.
- Enterprise track: Hexagonal Architecture, Onion Architecture.