Hexagonal Architecture
Hexagonal Architecture, also called Ports and Adapters, is an application-architecture style proposed by Alistair Cockburn in 2005 in which the application’s domain logic sits at the centre of a hexagonal boundary, exposes a set of ports (technology-agnostic interfaces) describing what it offers and what it needs, and is connected to the outside world only through adapters — concrete implementations of those ports for specific technologies (HTTP, AMQP, JDBC, Kafka, gRPC). The structural rule: all dependencies point inward. The domain depends on nothing; ports are defined by the domain; adapters depend on the ports they implement. The domain never imports a web framework, an ORM (Object-Relational Mapper), or a message-broker client — those concerns live exclusively in adapters. The hexagon is just a drawing convention with six sides; nothing is significant about the number six. Cockburn picked the shape because (a) a hexagon has multiple, symmetric sides, visually emphasising that there is no preferred direction of dependency (unlike the layered “stack” image where the database sits at the bottom), and (b) six sides give enough room to place several adapters around the perimeter without crowding. The deeper claim of the architecture is technology agnosticism: swapping the database from MySQL to Postgres, or the message bus from RabbitMQ to Kafka, or the inbound transport from REST to gRPC, should be a peripheral change that touches only the relevant adapter and leaves the domain untouched. This note covers Cockburn’s original motivation; the structural mechanics (inbound vs outbound ports, the dependency rule, the testability dividend); the typical implementation in modern stacks (Spring/.NET/Node); the variants and siblings (Clean Architecture, Onion Architecture, “Functional Core, Imperative Shell”); a worked order-processing example; the comparison with Layered Architecture (N-Tier); the pitfalls (over-abstraction, the “anemic hexagon”, duplicate-DTO sprawl); and the canonical interview discussion points.
1. When to Use, When Not To
Hexagonal Architecture earns its keep when the domain is the product — when the value the system delivers comes from rules, calculations, workflows, and policies that are themselves the differentiator, and the technology surrounding the domain (web, persistence, messaging) is interchangeable infrastructure. Insurance underwriting, banking core ledgers, supply-chain optimisation, healthcare claims adjudication, payment-routing engines, e-commerce order processing — all are domain-heavy. In these systems the cost of letting framework concerns leak into business logic is high: the business logic outlives the framework (Spring will be superseded eventually; the rules of credit-card interchange will not), and the testing burden of a framework-bound domain is suffocating (every unit test ends up booting a Spring context and a database container, taking 30+ seconds per test class). Hexagonal isolates the domain so it ages independently of the surrounding technology and is fast to test in isolation.
The architecture is also a strong fit when multiple inbound channels need to drive the same business operations. A modern application is rarely “the REST API”; the same domain typically also drives a message-queue consumer (Kafka topic listeners), a scheduled-job runner (cron-style triggers), an admin CLI, and possibly a GraphQL gateway. Without hexagonal discipline the same business operation tends to be re-implemented in each channel — the Place Order logic appears in the REST controller, again in the queue listener, again in the CLI handler, drifting subtly out of sync as bug fixes get applied to one but not the others. This channel-drift is one of the most common sources of “weird production behaviour” in real systems: a customer support engineer reproduces the bug only via the CLI, the on-call engineer cannot reproduce it via the REST endpoint, hours are lost to the discrepancy. Hexagonal forces the Place Order operation to be a single domain-side use case that all four inbound adapters call into, eliminating the drift by construction. Vernon (Implementing Domain-Driven Design, 2013, ch. 4) emphasises this multi-channel argument explicitly as the main pragmatic rationale.
A third strong indicator is long-lived systems with anticipated technology churn. A system whose deployment lifetime is measured in five-to-ten years will outlive at least one major framework version; many will outlive their original database choice. Hexagonal architecture lets that churn be absorbed at the boundary. Banking core systems exemplify this: the COBOL ledgers of the 1980s were replaced by Java services in the 2000s and are now being replaced by event-sourced microservices, and the business rules (debit/credit balancing, end-of-day settlement) are fundamentally identical. Teams that kept those rules in a hexagonal core were able to migrate; teams that intermixed rules with the technology of the day had to rewrite from scratch every cycle.
Hexagonal is the wrong choice when the system is not domain-heavy. A CRUD (Create-Read-Update-Delete) admin tool whose entire job is to expose a database table over HTTP gains nothing from ports and adapters and pays a tax of indirection that obscures the trivial logic. The same applies to scripting glue, ETL (Extract, Transform, Load) pipelines that are mostly data movement with little decisioning, and ML (Machine Learning) serving wrappers where the “logic” is a model invocation and a feature lookup. Khononov’s Learning Domain-Driven Design (2021, ch.9) is explicit on this: hexagonal layers exist to protect business logic that is worth protecting; if the logic is one if-statement, the architecture is dead weight. A common organisational anti-pattern is “the framework is hexagonal, so all our services are hexagonal whether or not they need to be,” which forces simple services into a multi-module structure that adds tens of files for no benefit.
A subtler counter-indication is small services with a single inbound channel and a single team. If the service is a 1,000-line REST API owned by one team, the architectural overhead of inbound/outbound port interfaces and adapter modules is rarely repaid. The team can read all the code; the test suite is small; the persistence is one database. Spring’s plain “controller-service-repository” idiom serves these systems better, and the team can adopt hexagonal incrementally if the service grows beyond the threshold where its clarity benefits outweigh the indirection costs.
A final consideration is team familiarity. Hexagonal is a learned architecture — engineers do not arrive at it by intuition; they arrive at it after reading Cockburn, Vernon, or Khononov, or after working in a codebase where the previous generation imposed it. A team without that background will produce a syntactically hexagonal codebase that breaks the discipline in subtle ways: the inbound ports will accept HTTP-shaped objects, the outbound ports will return JPA entities, the domain will quietly grow Spring annotations. The architecture in those codebases is theatre. A useful pre-flight question for a team considering hexagonal adoption: “Has anyone here written hexagonal code in production before?” If the answer is no, plan for either a six-week onboarding investment (book club, code-review intensives, ArchUnit-enforced rules) or a different architecture. The middle option — adopting hexagonal without the investment — is the worst of the three because the team pays the architectural cost without earning the benefits.
2. Structure
flowchart LR The hexagon subgraph H["Application / Domain Hexagon"] InPort1{{"Inbound Port:<br/>PlaceOrder use case"}} InPort2{{"Inbound Port:<br/>CancelOrder use case"}} Domain["Domain Model<br/>(Order, Customer, Payment;<br/>aggregates, value objects,<br/>domain services, domain events)"] OutPort1{{"Outbound Port:<br/>OrderRepository"}} OutPort2{{"Outbound Port:<br/>EventPublisher"}} OutPort3{{"Outbound Port:<br/>PaymentGateway"}} end %% Outbound side (driven adapters) Postgres["Postgres Repository<br/>Adapter"] KafkaPub["Kafka Publisher<br/>Adapter"] Stripe["Stripe HTTP Client<br/>Adapter"] REST --> InPort1 Kafka_in --> InPort1 CLI --> InPort2 InPort1 --> Domain InPort2 --> Domain Domain --> OutPort1 Domain --> OutPort2 Domain --> OutPort3 OutPort1 --> Postgres OutPort2 --> KafkaPub OutPort3 --> Stripe
What this diagram shows. The application is the hexagon in the middle. Inside the hexagon, the domain sits at the centre and is surrounded by ports — interfaces drawn here as hexagonal callout shapes. Two flavours of ports exist. Inbound ports (sometimes called driving or primary ports) describe operations the application offers to the outside world: PlaceOrder, CancelOrder. Outbound ports (driven or secondary ports) describe what the domain needs from outside: OrderRepository (a place to persist orders), EventPublisher (a way to broadcast that something happened), PaymentGateway (a way to charge a card). On the left of the hexagon are driving adapters — concrete code that calls into inbound ports: a REST controller, a Kafka consumer, a CLI handler. On the right are driven adapters — concrete code that implements outbound ports: a Postgres repository implementation, a Kafka publisher, a Stripe HTTP client. The arrows are critical and they all point toward the centre: the REST controller depends on the inbound port (not vice versa); the Postgres repository implements (and therefore depends on) the outbound port (not vice versa). The domain in the centre depends on nothing in this diagram except its own ports. The visual symmetry — adapters on both sides feeding the same domain — is the defining feature that distinguishes hexagonal from layered architectures, where the dependency arrow forms a single top-to-bottom stack with the database implicitly at the foundation.
The hexagon shape is not load-bearing. Cockburn’s revised 2008 essay (https://alistair.cockburn.us/hexagonal-architecture/) is unambiguous: “The hexagon is not a hexagon because the number six is important, but rather to allow the people doing the drawing to have room to insert ports and adapters as they need.” Treat it as “the application” with a permeable boundary; six sides is a drawing convenience, not a constraint on how many ports you can have. A real codebase typically has 3–8 inbound ports (one per business operation exposed externally) and 5–15 outbound ports (one per category of external collaborator: persistence, eventing, third-party APIs, scheduling, identity).
A common confusion: the domain in the middle of the hexagon is not a single class; it is the domain model — a cluster of entities, value objects, aggregates, domain services, and domain events that together encode the business rules of one bounded context. The hexagon is the boundary of one bounded context; a system with multiple bounded contexts has multiple hexagons, possibly running as separate microservices, each with its own ports and adapters. Hexagonal is therefore commonly the internal architecture of a service, with the cross-service composition handled by Microservices Architecture or Event-Driven Architecture at a larger scale.
3. Core Principles
The architecture rests on three invariants that, taken together, produce its testability and technology-agnosticism properties.
The Dependency Rule. Source-code dependencies must point inward. The domain package imports neither the web framework nor the database driver nor the messaging client — at compile time it does not even know they exist. Adapters are free to import the domain (specifically, the port interfaces the domain defines), and they import the technology-specific libraries they wrap. The build system enforces this: a Maven multi-module project might have a domain module whose pom.xml declares no dependency on spring-boot-starter-web or spring-data-jpa, and an adapter-rest module that depends on both domain and Spring. A code review or, better, an architecture-fitness function — a programmatic test that asserts package-level dependency rules — catches violations automatically. ArchUnit (https://www.archunit.org/) is the standard tool in Java; NetArchTest fills the same role in .NET. A typical ArchUnit rule reads noClasses().that().resideInAPackage("com.acme.domain..").should().dependOnClassesThat().resideInAPackage("org.springframework.."), and the build fails if any domain class accidentally imports Spring.
Ports Are Owned by the Domain. A port is an interface defined inside the domain module describing an operation in domain vocabulary. Crucially, the signatures speak the domain’s language: OrderRepository.save(Order) takes a domain Order, not a JPA OrderEntity or a JSON OrderDto. The adapter on the other side is responsible for the translation between the domain object and whatever representation the technology demands. This ownership rule is what enforces that the domain remains pure: if a port signature contains a ResultSet or an HttpServletRequest, the abstraction has already failed. Port signatures are the seam between the technology-agnostic core and the technology-bound shell. A reviewer checking a pull request that adds a port should ask, “Could a Cobol mainframe team understand this signature?” — not because they will, but because the answer indicates whether the signature has accidentally absorbed the local technology. If findOrders(Pageable pageable) appears (where Pageable is a Spring Data type), the port has leaked.
Adapters Are Replaceable. The architecture’s payoff is realised when an adapter is swapped without rippling into the domain. Switching from MySQL to Postgres replaces the JDBC dialect inside the persistence adapter; switching from Kafka to RabbitMQ replaces the publisher adapter; running the application as a unit test replaces real adapters with in-memory fakes that implement the same ports. The domain code does not change in any of these scenarios, because the domain only knows about ports — abstract contracts — and ports are stable across infrastructure choices. The replaceability is not always exercised in production (most teams do not actually swap their database every quarter), but the latent substitutability shows up daily in tests: every unit test substitutes an in-memory fake for the database, the message bus, and external HTTP services, and the tests run in milliseconds because no external system is touched.
The combined effect: the domain is fast to test (no Spring boot context, no test-containers, just instantiate the domain objects), it is portable across runtimes (the same domain JAR could run inside Spring Boot, Quarkus, or a plain main()), and it is durable (the domain code survives technology refresh cycles). These three properties are the whole reason to pay the architectural overhead; if the team is not getting them, the hexagonal investment is not paying off and either the architecture is being mis-applied or the discipline has eroded.
4. Inbound vs Outbound Ports — Direction of Control
Beginners often conflate the two flavours of ports because both are interfaces. The distinguishing question: who calls whom?
For an inbound port, the adapter calls the domain. The REST controller receives an HTTP request, parses it into a domain-friendly command, and invokes the inbound port (placeOrderUseCase.execute(cmd)); the domain receives control and runs. The interface lives inside the domain because the domain wants to advertise this entry point. In Java, PlaceOrderUseCase is an interface in the domain.usecase package; the implementation, PlaceOrderService, is also in the domain (or in an application sub-layer of the domain). The naming convention varies — some teams call them use cases (after Clean Architecture), some application services (after Vernon), some commands handlers (after CQRS-influenced codebases). The substance is the same: a public, top-level domain operation that adapters can invoke.
For an outbound port, the domain calls the adapter. Mid-execution of placeOrder, the domain calls orderRepository.save(order) and eventPublisher.publish(orderPlacedEvent); control flows out to the persistence adapter and the messaging adapter, which talk to Postgres and Kafka respectively, and return. The interface lives inside the domain because the domain wants to describe what it needs in its own vocabulary; the implementation lives in an adapter module that imports the port and provides a concrete class. The Dependency Inversion Principle (DIP, the D in SOLID, Robert C. Martin 1996) is the mechanism that makes this work: the high-level module (domain) defines an abstraction, and the low-level module (adapter) depends on that abstraction. Without DIP, the domain would have to import the persistence module to call into it, and the dependency direction would invert — the very pathology hexagonal exists to prevent.
A common error is to write outbound ports that leak the technology of their intended adapter. An OrderRepository whose method signature is findOne(Specification spec) where Specification is a JPA Criteria type has imported a JPA concept into the domain port. Spring Data Repository — which extends JpaRepository — is technically a port-implementation hybrid that fails the purity test, because the interface itself comes from Spring and its inherited methods (findAll(Pageable), findAll(Sort), findById) leak Spring types. Many real Spring codebases knowingly accept this compromise because Spring Data is too productive to forgo: declaring interface OrderRepository extends JpaRepository<OrderEntity, UUID> and getting CRUD-plus-pagination for free saves dozens of lines of boilerplate per repository. Vernon (2013, ch. 12 Repositories) discusses the trade-off explicitly, and Khononov (2021, ch. 6) treats it as pragmatic but non-ideal. The discipline-preserving fix is to define a domain-pure OrderRepository interface in the domain module, and have a JpaOrderRepositoryAdapter adapter class that internally uses Spring Data but exposes only the domain-pure interface — at the cost of a thin extra wrapper class.
A useful diagnostic: imagine a parallel adapter implemented over a completely different technology. If the outbound port is EventPublisher, can you imagine implementing it over (a) Kafka, (b) RabbitMQ, (c) AWS SNS, (d) writing rows to a database outbox table, (e) calling a REST webhook? If yes, the port is well-shaped. If the only conceivable implementation is one of those, the port has accidentally bound itself to that technology. The concrete check is the signature — publish(Event) is technology-agnostic; produceTo(String topic, byte[] key, byte[] value, Headers headers) is recognisably Kafka and signals leakage.
5. Request Flow
sequenceDiagram autonumber participant Client as Browser/Client participant REST as REST Controller<br/>(inbound adapter) participant UC as PlaceOrderUseCase<br/>(inbound port impl) participant Order as Order aggregate<br/>(domain) participant Repo as OrderRepository<br/>(outbound port) participant DB as Postgres Adapter<br/>(outbound impl) participant Pub as EventPublisher<br/>(outbound port) participant Kafka as Kafka Adapter<br/>(outbound impl) Client->>REST: POST /orders {items, address} REST->>REST: parse JSON → PlaceOrderCommand REST->>UC: placeOrder(cmd) UC->>Order: new Order(customerId, items, address) Order-->>UC: order with OrderPlacedEvent UC->>Repo: save(order) Repo->>DB: INSERT INTO orders ... DB-->>Repo: ack Repo-->>UC: ok UC->>Pub: publish(OrderPlacedEvent) Pub->>Kafka: produce("orders.events", payload) Kafka-->>Pub: ack (offset) Pub-->>UC: ok UC-->>REST: OrderId REST-->>Client: 201 Created {orderId}
Walkthrough. A client POSTs JSON to /orders. The REST adapter (1–2) parses the JSON into a domain-friendly command object, deliberately built from primitive and value-object types so the use case below it never sees an HTTP-specific construct. The command is a Data Transfer Object in the precise sense: a passive, transport-stage representation of the request, with fields named in domain vocabulary (customerId, items, shippingAddress) rather than HTTP vocabulary (requestBody, pathParam). (3) The adapter invokes the inbound port PlaceOrderUseCase.placeOrder, passing the command. (4–5) The use case constructs a fresh Order aggregate, delegating the rules (minimum line items, shipping-address validation, applicable discounts) to the domain code. The constructor or factory method on Order is where the invariants are enforced; the use case does not duplicate them. The newly-constructed order carries with it any domain events it produced — an OrderPlacedEvent is registered on the aggregate as a side effect of construction, following the pattern Vernon describes in Implementing DDD (ch. 8 Domain Events).
(6–8) The use case asks the outbound OrderRepository to persist the order; the repository port has no idea Postgres exists, but the Postgres adapter implementing that port issues an INSERT (or, more typically, lets JPA flush-on-transaction-commit handle it). The order’s persistent identity (its OrderId) was assigned at construction and so is already present; the adapter does not have to round-trip a generated key. (9–11) The use case then publishes the OrderPlacedEvent via the outbound EventPublisher port, whose Kafka-adapter implementation produces the event onto the orders.events topic with the order ID as the partition key. The Kafka adapter is responsible for serialisation (Avro/Protobuf/JSON), schema-registry lookup, and the produce-acknowledgement protocol; none of that is visible to the use case. (12) The use case returns the generated OrderId to the controller; the controller (13) maps it to a 201 response. The control flow leaves the domain twice (once for persistence, once for publishing) and re-enters via outbound ports; both excursions are domain-initiated and adapter-implemented. Notice the adapters never call each other directly: there is no path from the REST adapter to the Postgres adapter that bypasses the domain. This is the discipline that lets the same use case be invoked from a Kafka listener or a CLI handler with zero domain change.
A subtlety worth naming: the order in which repo.save and pub.publish happen matters. In the simple flow above, save precedes publish; if the publish fails after the save succeeds, the database has the order but no event was produced — a dual-write problem with at-most-once semantics on the event. Production-grade systems handle this with the Transactional Outbox Pattern — the event is written to an outbox table in the same database transaction as the order, and a separate process drains the outbox onto Kafka — converting the dual-write into a single-write atomic operation. Pure hexagonal does not mandate which strategy; the strategy lives in the persistence adapter and is invisible to the domain.
6. Variants and Siblings
Hexagonal has spawned and overlapped with several closely related architectures, and seasoned engineers use the names interchangeably more often than is technically correct.
Clean Architecture (Robert C. Martin, 2012). Martin’s blog post The Clean Architecture (https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) explicitly synthesised hexagonal, Onion Architecture, and DCI (Data, Context, Interaction) into a four-ring concentric model: Entities → Use Cases → Interface Adapters → Frameworks & Drivers. Clean is more prescriptive about layer count and naming than Cockburn’s hexagonal — Hexagonal does not mandate four rings; it just says “domain inside, adapters outside.” For most practical purposes Hexagonal and Clean produce the same code; Clean’s idiom of a usecase package containing one class per business operation is a useful concretisation that many hexagonal codebases adopt. Clean Architecture is treated in depth in Clean Architecture. The community-level distinction is essentially this: a team that says “we use Clean Architecture” is committing to four named rings and a strong use-case convention; a team that says “we use Hexagonal Architecture” is committing only to inward dependency direction and is freer in how they organise the inside.
Onion Architecture (Jeffrey Palermo, 2008). Palermo’s four-part series on jeffreypalermo.com predates Clean and concentrically layers Domain Model → Domain Services → Application Services → Infrastructure & UI. The diagram is concentric like an onion (rather than a hexagon). Architecturally, Onion and Hexagonal are kin — both enforce inward-pointing dependencies; both place infrastructure on the outside — but Onion is more layered (each ring is a layer with rules about which inner rings it may import) where Hexagonal is more symmetric (no preferred direction; just inside-vs-outside the hexagon). In practice, .NET shops tend to use the Onion vocabulary; Java/Spring shops tend to use the Hexagonal/Ports-and-Adapters vocabulary; the resulting code is broadly the same. The rule of thumb that distinguishes them in practice: an Onion codebase has visible concentric layering in the package structure (Domain.Model, Domain.Services, Application, Infrastructure); a Hexagonal codebase has a flatter domain package with ports and adapters siblings.
Functional Core, Imperative Shell (Gary Bernhardt, 2012). Bernhardt’s RailsConf talk Boundaries (https://www.destroyallsoftware.com/talks/boundaries) describes a similar dependency-direction discipline in functional style: the functional core contains pure, side-effect-free domain logic (no I/O, no clock, no randomness); the imperative shell surrounds the core, performing all I/O and feeding inputs into the core and writing the core’s outputs back out. The mapping to Hexagonal: the functional core is the hexagon; the shell is the adapter layer. Bernhardt’s framing emphasises purity of the core (the core is a function state → state, deterministic and trivially testable) where Hexagonal emphasises isolation of the core (the core may be stateful and OO, just not entangled with technology). Both philosophies converge on the same structural rule: technology touches the boundary, never the centre. Functional Core / Imperative Shell tends to predominate in Haskell, Elixir, and Clojure communities; Hexagonal in OO communities; the codebases produced are recognisably similar.
Spring Modulith. A 2022-onwards Spring framework (https://docs.spring.io/spring-modulith/reference/) explicitly designed to support hexagonal-shaped modular monoliths. It enforces module boundaries with package-naming conventions, generates module-dependency reports, and integrates with ArchUnit to fail builds when a module’s hexagon leaks. Modulith is the most concrete and supported toolchain for hexagonal in the Spring world as of 2026, and the framework’s own documentation uses “Hexagonal architecture” as a first-class organising concept. Quarkus (Red Hat’s GraalVM-native JVM framework) and Micronaut (Object Computing’s competitor) ship analogous templates and documentation, reinforcing hexagonal as the house style for modern JVM service applications.
7. Real-World Examples
Hexagonal is widely adopted but rarely advertised by name; it tends to appear as “our application architecture” without the label. Several public references illustrate adoption.
Netflix. The Netflix Tech Blog and conference talks (e.g., the Application Architecture at Netflix posts on https://netflixtechblog.com/, 2015–2020) repeatedly describe internal Spring Boot services structured with a domain-centred core and infrastructure adapters, citing testability and substitutability of inbound transports (HTTP/gRPC/queue) as motivations. Netflix’s open-source projects (Eureka the service registry, Zuul the gateway, Mantis the stream-processing platform) are not literal hexagonal implementations, but the pattern of dependency-inverting infrastructure away from business code is evident in those codebases, particularly in newer services where the team had latitude to set conventions from scratch.
Spring Boot industry default. A representative-modern-enterprise Java application built around Spring Boot 3, Spring Modulith, and Spring Data JPA is typically hexagonal-shaped whether or not the team uses the word: a domain package with entities and use-case interfaces, a web package with REST controllers, a persistence package with JPA-backed repositories implementing domain-defined repository interfaces. The Spring Modulith reference documentation (https://docs.spring.io/spring-modulith/reference/) explicitly names “Hexagonal architecture / Ports & Adapters” as the supported style and the Modulith JUnit assertions (ApplicationModules.of(Application.class).verify()) check that no domain code imports infrastructure packages.
.NET equivalents. Microsoft’s eShopOnContainers reference architecture (https://github.com/dotnet-architecture/eShopOnContainers) implements the Order microservice with a hexagonal-style separation of Ordering.Domain, Ordering.Infrastructure, and Ordering.API projects. The naming is closer to Onion / Clean (which dominate .NET vocabulary), but the structure is hexagonal: domain is unaware of EF Core (Entity Framework Core, Microsoft’s ORM); infrastructure depends inward on domain. The accompanying e-book .NET Microservices: Architecture for Containerized .NET Applications devotes a chapter to “Designing the microservice application layer and Web API” that walks through the application of CQRS plus DDD inside a hexagonal frame.
Khononov 2021 examples. Vlad Khononov’s Learning Domain-Driven Design (O’Reilly) builds two case-study services (a marketing-attribution and a campaign-publishing service) explicitly as Ports-and-Adapters, with full source code on GitHub (https://github.com/vladikk/learning-ddd-code). These are among the cleanest publicly-available hexagonal codebases and are often referenced in interviews. The Khononov code is particularly useful as a teaching example because it includes both the inbound and outbound port modules as separate Maven projects, making the dependency-direction rule visible in the build files.
Quarkus and Micronaut frameworks. Both modern Java frameworks (Quarkus from Red Hat, Micronaut from Object Computing) ship project templates and documentation explicitly oriented toward hexagonal/clean structure. Their AOT-compilation models (GraalVM native images) reward the testability of a hexagonal core because compile-time verification needs the core’s behaviour to be fully exercisable in unit tests; running every test through the full framework would prohibitively slow the CI loop. The frameworks therefore encourage hexagonal as the path of least resistance.
Pivotal Tracker / VMware Tanzu Labs. The consultancy formerly known as Pivotal Labs has long taught Hexagonal Architecture as the default for enterprise Java engagements, and many of its alumni have spread the practice into their subsequent employers. The book Designing Cloud Native Applications (VMware press) uses hexagonal as a baseline assumption.
8. Tradeoffs
The architecture’s benefits fall into three buckets. Testability: with the domain isolated, unit tests instantiate domain objects directly and substitute in-memory fakes for outbound ports, achieving sub-millisecond test runtimes and full coverage of the domain rules. A hexagonal codebase typically reports 80%+ unit-test coverage on the domain module while the adapter modules are tested by narrow integration tests; a non-hexagonal codebase reports lower coverage at higher test runtime, because every test must boot a Spring context (5–30 seconds) and possibly a Postgres test-container (additional 5–10 seconds). For a 1,000-test suite the difference is the difference between a 60-second CI run and a 30-minute CI run, which directly affects the team’s ability to iterate. Technology agnosticism: swapping infrastructure (database, framework, transport) is a peripheral edit. Many codebases that started on MongoDB and migrated to Postgres did so by writing a new persistence adapter and removing the old one with the domain unchanged. Multi-channel support: the same business operation is exposed simultaneously via REST, gRPC, queue listener, and CLI by writing four adapters that all call the same inbound port; the operation’s logic is implemented once.
The costs are equally real. Indirection tax. Every persistence call goes through an interface boundary and a mapper between the JPA entity and the domain entity; this is two extra files per persisted aggregate and a maintenance burden when a field is added — the field must be added to the domain entity, the JPA entity, and the mapper. Boilerplate. A small bounded context can produce 6–10 source files (port interface, port impl, mapper, JPA entity, repository interface, repository impl, DTO, controller) where a “service-and-repository” Spring app would have 3–4. For an aggregate with five fields this is fine; for one with fifty fields and many sub-entities, the bookkeeping is substantial. Mitigation tools — MapStruct in Java, AutoMapper in .NET — generate mapper code from annotations, reducing the burden but not eliminating it. Onboarding friction. Engineers unfamiliar with the pattern need a half-day of orientation before they can confidently add a feature, because the right place to put new code is non-obvious: is this validation in the domain or in the inbound adapter? Is this lookup an outbound port or just a domain helper? Decision fatigue. “Where does X go?” recurs constantly — is this a domain service or an application service? Is that a port or just a private collaboration? Khononov (2021, ch. 9) and Vernon (2013, ch. 4) both acknowledge that the boundary calls are judgement, and reasonable teams disagree.
A second-order cost is the risk of cargo-cult adoption. A team that adopts hexagonal “because it’s the modern architecture” without internalising the rationale ends up with the surface form (the package layout, the port interfaces) without the substance (the dependency discipline, the testing discipline). The result is worst-of-both-worlds: the boilerplate of hexagonal without the testability or substitutability. Spotting this is straightforward — look at the unit test runtime and the adapter-swap history. If unit tests are slow and no adapter has ever been swapped, the architecture is ceremonial.
The honest framing is that Hexagonal is front-loaded: the architecture costs more upfront and pays back over years through change-resilience and testability. For a system whose lifespan is measured in years and whose domain is the source of value, this is a good trade. For a system whose lifespan is six months or whose domain is “expose the database table,” the upfront cost is wasted. The trade-off is similar in shape to choosing a typed language over an untyped one — typed languages cost more in declaration overhead and pay back in long-term maintainability — and the same teams that prefer typed languages tend to prefer hexagonal, which is no coincidence.
9. Migration Path
A typical migration to Hexagonal from a Spring-Boot-default-shaped codebase proceeds in stages, each shippable.
The first stage is to introduce the domain package. Create a domain Maven module (or top-level package, depending on monorepo conventions) and migrate one feature at a time: pick a feature, copy the entity, the service, and the relevant logic into the domain module, and rewrite the original Spring service to be a thin façade over the domain code. The Spring service still exists; it just now delegates. This is non-disruptive — clients call the same endpoint, get the same response — and validates the team’s ability to extract domain code without breaking behaviour. The signal that this stage has succeeded is that the new domain module’s pom.xml (or build.gradle) lists no dependency on Spring or any web framework; the module compiles in isolation.
The second stage is to invert persistence dependence. The domain module now contains the entity but still imports JPA for the @Entity annotations. Define an outbound port OrderRepository in the domain module with methods save(Order), findById(OrderId), findByCustomer(CustomerId). Move the JPA-decorated OrderEntity and the Spring Data interface into a new persistence module. Write a JpaOrderRepositoryAdapter in persistence that translates between OrderEntity and the domain Order, and registers as the Spring bean implementing OrderRepository. Now the domain module compiles without JPA on its classpath. This is the load-bearing stage; once persistence is inverted, the domain is genuinely portable. The mapper code is the most painful part — for a complex aggregate it can run to hundreds of lines — but is a one-time cost. Many teams use MapStruct here, which generates mapper implementations from interface declarations and reduces the manual code to just the interface plus a handful of @Mapping annotations.
The third stage is to invert inbound transport. Move the controller into a web module that depends on the domain and on Spring Web. Introduce inbound port interfaces (PlaceOrderUseCase, CancelOrderUseCase) in the domain module; rewrite the controller to invoke them; rewrite the application service in the domain module to implement them. The pattern is now complete for the migrated feature.
Stages four and beyond are expansion: bring more features into the domain module; introduce additional adapters (Kafka listener, CLI, scheduled jobs) as more inbound channels emerge; gradually shrink the legacy Spring service layer until it disappears. The migration can run for quarters; what matters is that each step keeps the system shippable. A pragmatic technique is to keep an “anti-corruption layer” between the new hexagonal domain and the legacy code so that the migration can proceed asymmetrically — see Anti-Corruption Layer Pattern for the canonical treatment.
The reverse migration from Hexagonal back to a flatter architecture is rare but happens when a team realises the indirection is unjustified for the system’s needs (typically a smaller CRUD application that became hexagonal “because that’s what we do here”). In that case the path is to inline the use cases into the controllers and the repository ports into Spring Data interfaces; the resulting code is more compact at the cost of the testability the hexagon provided. Reverse-migrating is uncommon enough that few public case studies exist, but the inverse decision is sometimes documented in Architecture Decision Records when a team explicitly reverses an earlier hexagonal-adoption ADR.
10. Pitfalls
-
Over-abstraction — adapters for things that will never be swapped. Defining an outbound port
LoggerPortso that the logger can be “swapped” from SLF4J (Simple Logging Facade for Java, the de facto standard logging API) to something else is a textbook example of premature abstraction. SLF4J already is the abstraction; wrapping it adds nothing but indirection. The cost is concrete: every domain class now has aLoggerinjected via a port, the port has its own SLF4J adapter, and a developer reading the code has two extra hops to follow before reaching the actual logging call. The rule of thumb: introduce a port when (a) at least two implementations are plausible, or (b) the production implementation requires test substitution. Otherwise, just call the library directly. Khononov (2021, ch. 9) is explicit that “ports for everything” is a common new-adopter failure mode. The opposite extreme — no ports, everything direct — is less dangerous because at least the code is simple and the over-abstraction tax is not paid. -
The Anemic Hexagon — domain objects that are DTOs and all logic is in services. A team adopts hexagonal but writes domain entities with only getters, setters, and no behaviour, while putting all the business logic into “domain services” that read entities, mutate them, and save them back. The result is procedural code wearing OO clothing — a violation of the Anemic Domain Model anti-pattern Martin Fowler named in 2003 (https://martinfowler.com/bliki/AnemicDomainModel.html). The damage: the rules of the domain are spread across many services and are no longer attached to the entities that should own them; an
Orderobject cannot answer “can I be cancelled?” without help; the encapsulation that makes OO reasonable is gone. The fix is to push behaviour onto the entities themselves: anOrderobject should havecancel(),addItem(),applyDiscount()methods that enforce invariants, not aOrderService.cancel(order)that does the same work externally. Spotting the anti-pattern is easy: look for entities that have only fields and getters/setters; if there are no methods that do anything in the domain vocabulary, the domain is anemic. -
Writing-everything-twice. A naive hexagonal implementation introduces a domain
Order, a JPAOrderEntity, a RESTOrderResponse, and a requestOrderRequest, with mappers between every pair. For five fields the duplication is harmless; for fifty fields and twenty entities it becomes a maintenance burden, and worse, an error-prone burden — every field added must be added in four places, and forgetting one place produces silent data loss at the boundary. Pragmatic practice is to (a) accept some duplication as the tax for isolation, but (b) use code-generation (MapStruct in Java, AutoMapper in .NET) to make the mappers cheap and (c) be willing to share immutable value objects (e.g.,Money,Address) directly across the boundaries when they are stable enough. The third strategy is the most controversial — strict hexagonalists insist on never sharing types across boundaries — but pragmatists hold that immutable, behaviour-rich value objects are part of the domain language and can safely cross. -
Hexagons within hexagons — fractal abstraction. A team that is enthusiastic about ports starts treating every internal module as its own hexagon, layering ports between domain sub-packages until the dependency graph is incomprehensible. Hexagons should sit at application boundaries — typically one per bounded context (see Domain-Driven Design Strategic Patterns). Inside the hexagon, the domain code is just well-factored OO or functional code; it does not need ports between its own classes. The practical test: a port should mark a boundary between code that may want to substitute the implementation and code that does the substituting. A boundary purely within the domain — between, say,
OrderRulesandDiscountRules— does not have a substitution scenario and so should not be a port. -
Leaking framework concerns into the domain. The single most common violation: a domain class is annotated
@Entityfor JPA, or@JsonPropertyfor Jackson, or@RestControllerfor Spring MVC. The annotation imports the framework into the domain, and the whole point of the hexagon — that the domain compiles without the framework — is broken. The discipline is to keep annotations on adapter classes, even when this means a parallelOrderEntityexists alongside the domainOrder. The cost is the duplication called out in pitfall 3; the benefit is that the domain truly is framework-free, and the architecture’s promises hold. ArchUnit rules can enforce this automatically; relying on discipline alone is unreliable because the annotations are seductive (one annotation makes ten lines of glue code disappear). -
Forgetting that ports are the domain’s vocabulary, not the framework’s. Defining
OrderRepository.save(OrderEntity)(taking the JPA entity) is a leak; the port should besave(Order)(the domain class) and the adapter does the translation. Reviewing port signatures for technology vocabulary is a recurring code-review concern in mature hexagonal codebases. A useful checklist: does any parameter or return type come from a framework package? If yes, the port has leaked. Does any method name use technology jargon (“findByCriteria”) rather than domain vocabulary (“findActiveOrdersForCustomer”)? If yes, the port has leaked. The leakage is rarely deliberate; it tends to creep in as “convenience” — “I’ll just take the Pageable here so I don’t have to translate” — and accumulates until the port is unrecognisable. -
Asymmetric testing — only adapters are tested. A team writes integration tests against the REST adapter and the Postgres adapter but neglects unit tests against the domain. The integration tests pass, the team feels covered, and yet the most testable part of the codebase — the domain — is untested. Reverse the priority: domain unit tests cover business rules exhaustively (every branch of every aggregate’s behaviour, every invariant, every edge case); adapter integration tests verify only the technology binding (does the JPA mapping work; does the REST response shape match the contract). The test pyramid should be 70%+ domain unit tests by count, with adapter integration tests filling the middle and a small number of end-to-end tests at the apex. If the pyramid inverts, the architecture is not paying for itself.
-
One adapter per technology, not one adapter per use case. Some teams write a single
PostgresAdapterclass that implements every outbound persistence port, putting all SQL into one place. This appears clean but couples unrelated persistence concerns: changing the schema for orders ripples into the adapter that also handles customers and products. The cleaner pattern is one adapter class per outbound port, even when they all share a database. The cost is more files; the benefit is cleaner change-isolation.
11. Comparison With Sibling Architectures
The most informative comparison is against Layered Architecture (N-Tier). In a classic three-tier layered architecture, dependencies cascade top to bottom: the presentation layer depends on the business-logic layer, which depends on the data-access layer, which depends on the database. The data layer is at the bottom of the stack — implicitly the most fundamental. This produces a coupling problem: business-layer interfaces are often shaped by the data layer’s needs (an entity has the shape SQL Server expects), and changing the database ripples upward. Hexagonal inverts this: there is no top or bottom; there is only inside (domain) and outside (adapters); the database is just one of many adapters and is replaceable. The dependency arrows form an inward-pointing star instead of a downward-pointing line. The practical outcome is that a hexagonal codebase can be tested without a database (an in-memory adapter substitutes) and ported to a different database with a single new adapter, while a layered codebase typically cannot do either without significant refactoring. A worked example: in a layered architecture, a UserService.changeEmail(userId, newEmail) method probably calls UserRepository.findById(id).setEmail(newEmail).save(), with the repository’s findById returning a JPA-managed entity. To change to Mongo, every method that depends on JPA’s lifecycle semantics (lazy loading, dirty tracking) must change. In a hexagonal architecture, the same method would go through a domain User aggregate with a changeEmail(NewEmail) method that enforces invariants (uniqueness, format), and the repository port has plain save(User) semantics; switching to Mongo touches only the adapter.
Compared with Clean Architecture, hexagonal is less prescriptive: Clean specifies four named rings (Entities, Use Cases, Interface Adapters, Frameworks & Drivers) and a use-case-as-class convention; Hexagonal specifies “domain inside, adapters outside” and lets the team decide further structure. For most production codebases the resulting code is indistinguishable. A team that is rigorous about Clean’s four rings will produce a codebase a hexagonal-trained engineer recognises as hexagonal and vice versa. Compared with Onion Architecture, hexagonal is more symmetric: Onion preserves the visual layering metaphor (concentric rings, like a layered onion sliced) and is particularly common in .NET; hexagonal emphasises bidirectional symmetry (driving adapters on one side, driven adapters on the other; no ring is more important than another). The differences are mostly cosmetic; substantively all three architectures are the same idea in three vocabulary frames.
Against Microservices Architecture, hexagonal is orthogonal. Hexagonal describes the internal structure of a service; microservices describes how multiple services compose into a system. A microservice can be hexagonal internally; it can equally be a 200-line Express handler. The two architectures answer different questions and are not alternatives. In fact, mature microservice teams typically apply hexagonal within each service to keep the service’s domain logic insulated from the service’s transport (HTTP today, gRPC tomorrow, NATS the day after). The combination — hexagonal services composed via microservices boundaries aligned to bounded contexts — is the most common “modern” enterprise architecture and is the implicit default in books like Sam Newman’s Building Microservices (2nd ed., 2021).
Against Event Sourcing Pattern and Command Query Responsibility Segregation, hexagonal is again orthogonal but synergistic: an event-sourced aggregate naturally lives at the centre of a hexagon, with an outbound port EventStore whose adapter is implemented over Kafka or EventStoreDB; a CQRS read model sits in another hexagon with a different port shape (typically a denormalised view database). The patterns compose — many large codebases combine all three. A canonical Axon framework application (https://axoniq.io/) is hexagonal, event-sourced, and CQRS-shaped simultaneously.
Against Service-Oriented Architecture (SOA), hexagonal is at a different abstraction layer entirely. SOA is about cross-system composition through service contracts and an enterprise service bus; hexagonal is about within-system isolation between domain and infrastructure. A SOA service’s implementation may be hexagonal; the SOA service is not itself hexagonal in any meaningful sense.
12. Common Interview Discussion Points
Hexagonal Architecture surfaces in mid-to-senior software-engineering interviews along several reliable lines.
“Walk me through how you would structure a new microservice.” A strong answer names the architecture (Hexagonal / Ports and Adapters), describes the package structure (domain, application, adapters/web, adapters/persistence, adapters/messaging), and explains the dependency direction. A weak answer is “Spring Boot, controller calls service calls repository” — which is the layered default. Interviewers value the explicit naming because it reveals whether the candidate has thought about why the structure is what it is.
“How would you make this code testable?” If the candidate proposes mocking a repository interface, follow-up: “Where does that interface live?” The hexagonal answer is “in the domain module, defined in the domain’s vocabulary, with the implementation in an infrastructure module.” A candidate who places the interface in the infrastructure module (next to the implementation) has not internalised the inversion. The placement matters because it determines the dependency direction: an interface next to its implementation is just an extracted-then-discarded abstraction; an interface owned by the layer that consumes it is dependency inversion.
“How would you support a Kafka listener for the same operation as your REST API?” The hexagonal answer is “I’d call the same inbound port from both adapters.” A candidate who copies the controller logic into the listener has not understood the multi-channel benefit and is signalling that they would write the duplication-then-drift bug into the next system they build.
“What’s the difference between an inbound port and an outbound port?” A precise answer names direction of control: inbound means the adapter calls into the domain; outbound means the domain calls into the adapter. Many candidates conflate them. A follow-up worth asking: “Where does each interface live?” Both live in the domain module; only the implementations are in different modules (the inbound impl in the domain, the outbound impl in an adapter).
“When wouldn’t you use Hexagonal?” A confident answer cites CRUD admin tools, simple ETL, scripting glue, and short-lived prototypes. A candidate who claims “always use Hexagonal” has not internalised the trade-off and is likely to over-architect the next system they build.
“How do you keep the domain pure?” Strong answers cite ArchUnit (Java), NetArchTest (.NET), or convention-based linting that fails the build when domain imports org.springframework. Talking about discipline alone is weaker; talking about an enforcement mechanism is stronger. A candidate who cites ArchUnit signals they have worked in a codebase where the discipline is not optional.
“How does this relate to DDD?” Hexagonal is the canonical implementation pattern for a DDD bounded context. Vernon (2013, ch. 4) makes this explicit. A candidate who connects the two — “one bounded context, one hexagon, with the ubiquitous language at the centre” — signals senior fluency. This is a frequent question because hexagonal is useful in proportion to the domain’s richness and DDD is the discipline for getting that richness right.
“Have you ever seen this architecture fail?” A great answer cites the Anemic Hexagon pitfall (pitfall 2 above) or the over-abstraction pitfall (pitfall 1) by name, with a concrete example from the candidate’s experience. Candidates who have worked in actual hexagonal codebases have these examples; candidates who have only read about hexagonal generally do not.
The signal value: hexagonal vocabulary suggests the candidate has worked in domain-heavy systems with deliberate architecture; pure layered/MVC vocabulary suggests CRUD-application background. Neither is disqualifying, but the former indicates more architectural exposure. The depth of the candidate’s answers to the failure-mode questions is the strongest signal of practical experience versus textbook learning.
13. Worked Example — The Order Hexagon in Java
To anchor the abstract structure, here is the skeleton of an order-processing hexagon in Java/Spring vocabulary. Code is illustrative — production code would have validation, error handling, and observability concerns elaborated — but the shape is what matters.
13.1 The Domain Module (order-domain)
The domain module’s pom.xml declares no dependency on Spring, JPA, Jackson, or any framework — only standard Java and possibly a small library like vavr or lombok for boilerplate reduction.
// package com.acme.order.domain.model
public final class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderLine> lines;
private final ShippingAddress shippingAddress;
private OrderStatus status;
private final List<DomainEvent> pendingEvents = new ArrayList<>();
public static Order place(CustomerId customerId,
List<OrderLine> lines,
ShippingAddress address) {
if (lines == null || lines.isEmpty()) {
throw new DomainException("order must have at least one line");
}
Order order = new Order(OrderId.generate(), customerId, lines, address);
order.pendingEvents.add(new OrderPlacedEvent(order.id, customerId, Instant.now()));
return order;
}
public void cancel(CancellationReason reason) {
if (status == OrderStatus.SHIPPED) {
throw new DomainException("cannot cancel a shipped order");
}
this.status = OrderStatus.CANCELLED;
this.pendingEvents.add(new OrderCancelledEvent(id, reason, Instant.now()));
}
public List<DomainEvent> pendingEvents() { return List.copyOf(pendingEvents); }
public void clearPendingEvents() { pendingEvents.clear(); }
// ... constructor, getters, equality on id ...
}The Order class enforces invariants at construction (place) and at state-changing operations (cancel); it is a rich domain object, not a DTO. Domain events are accumulated on the aggregate and drained by the use case after a successful save.
// package com.acme.order.domain.port.in
public interface PlaceOrderUseCase {
OrderId placeOrder(PlaceOrderCommand cmd);
}
public record PlaceOrderCommand(
CustomerId customerId,
List<OrderLineSpec> lines,
ShippingAddress shippingAddress
) {}The inbound port PlaceOrderUseCase and its companion command live in the domain. The command’s fields use domain types only — no HttpServletRequest, no MultiValueMap. This is what lets a Kafka listener call the same use case as a REST controller.
// package com.acme.order.domain.port.out
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(OrderId id);
List<Order> findActiveOrdersForCustomer(CustomerId customerId);
}
public interface EventPublisher {
void publish(DomainEvent event);
}
public interface PaymentGateway {
PaymentReceipt charge(CustomerId customer, Money amount);
}Outbound ports — also in the domain — describe what the domain needs from outside, in domain vocabulary. None of these signatures contain a Spring, JPA, or Kafka type.
// package com.acme.order.domain.usecase
public final class PlaceOrderService implements PlaceOrderUseCase {
private final OrderRepository orderRepo;
private final EventPublisher events;
private final PaymentGateway payments;
public PlaceOrderService(OrderRepository orderRepo,
EventPublisher events,
PaymentGateway payments) {
this.orderRepo = orderRepo;
this.events = events;
this.payments = payments;
}
@Override
public OrderId placeOrder(PlaceOrderCommand cmd) {
List<OrderLine> lines = cmd.lines().stream()
.map(spec -> new OrderLine(spec.productId(), spec.quantity(), spec.unitPrice()))
.toList();
Money total = lines.stream()
.map(OrderLine::lineTotal)
.reduce(Money.ZERO, Money::add);
PaymentReceipt receipt = payments.charge(cmd.customerId(), total);
Order order = Order.place(cmd.customerId(), lines, cmd.shippingAddress());
orderRepo.save(order);
order.pendingEvents().forEach(events::publish);
order.clearPendingEvents();
return order.id();
}
}The use case orchestrates: it calls the payment gateway, constructs the aggregate, persists it, publishes the events, and returns the new order’s ID. Notice it has no Spring annotations; in the wiring layer, a @Configuration class will instantiate this with the adapter beans Spring provides.
13.2 The Persistence Adapter (order-persistence)
This module imports JPA and Spring Data; it implements OrderRepository.
// package com.acme.order.persistence
@Entity @Table(name = "orders")
public class OrderEntity {
@Id private UUID id;
private UUID customerId;
@OneToMany(cascade = CascadeType.ALL) private List<OrderLineEntity> lines;
@Embedded private ShippingAddressEmbeddable address;
@Enumerated(EnumType.STRING) private OrderStatusEnum status;
// ... JPA-required no-arg constructor, getters, setters ...
}
@Mapper(componentModel = "spring")
public interface OrderMapper {
OrderEntity toEntity(Order domain);
Order toDomain(OrderEntity entity);
}
interface OrderJpaRepository extends JpaRepository<OrderEntity, UUID> {
List<OrderEntity> findByCustomerIdAndStatus(UUID customerId, OrderStatusEnum status);
}
@Component
public class JpaOrderRepositoryAdapter implements OrderRepository {
private final OrderJpaRepository jpa;
private final OrderMapper mapper;
public JpaOrderRepositoryAdapter(OrderJpaRepository jpa, OrderMapper mapper) {
this.jpa = jpa; this.mapper = mapper;
}
@Override public void save(Order order) { jpa.save(mapper.toEntity(order)); }
@Override public Optional<Order> findById(OrderId id) {
return jpa.findById(id.value()).map(mapper::toDomain);
}
@Override public List<Order> findActiveOrdersForCustomer(CustomerId c) {
return jpa.findByCustomerIdAndStatus(c.value(), OrderStatusEnum.ACTIVE)
.stream().map(mapper::toDomain).toList();
}
}The adapter does three things: it owns the JPA-shaped OrderEntity, it owns the mapper (MapStruct generates the implementation from the interface), and it wires the JPA repository to the domain port. The domain class Order is converted at the boundary; the mapper is the only place the two representations meet.
13.3 The Web Adapter (order-web)
// package com.acme.order.web
@RestController @RequestMapping("/orders")
public class OrderController {
private final PlaceOrderUseCase placeOrder;
public OrderController(PlaceOrderUseCase placeOrder) { this.placeOrder = placeOrder; }
@PostMapping
public ResponseEntity<PlaceOrderResponse> create(@Valid @RequestBody PlaceOrderRequest req) {
PlaceOrderCommand cmd = new PlaceOrderCommand(
new CustomerId(req.customerId()),
req.lines().stream().map(this::toSpec).toList(),
new ShippingAddress(req.address().line1(), req.address().city(), req.address().postalCode())
);
OrderId id = placeOrder.placeOrder(cmd);
return ResponseEntity.created(URI.create("/orders/" + id.value()))
.body(new PlaceOrderResponse(id.value()));
}
private OrderLineSpec toSpec(PlaceOrderRequestLine l) {
return new OrderLineSpec(new ProductId(l.productId()), l.quantity(), Money.of(l.unitPriceCents()));
}
}The controller maps HTTP shapes to the domain command. The PlaceOrderRequest and PlaceOrderResponse are HTTP-DTOs that exist only in this module. Nothing JSON-shaped enters the domain.
13.4 The Wiring Module (order-application)
A small @Configuration class instantiates the use-case service with the adapter beans:
@Configuration
public class DomainConfig {
@Bean
PlaceOrderUseCase placeOrderUseCase(OrderRepository repo,
EventPublisher events,
PaymentGateway payments) {
return new PlaceOrderService(repo, events, payments);
}
}The @Bean method returns the interface type — PlaceOrderUseCase — so consumers (the controller, the listener) inject the interface and never see the implementation class. This is the moment where Spring’s dependency injection meets the hexagonal port discipline; the framework cooperates with the architecture rather than imposing on it.
The complete project layout for the order hexagon:
order/
├── order-domain/ # no Spring, no JPA, no Jackson
│ └── src/main/java/com/acme/order/domain/
│ ├── model/ # Order, OrderLine, Money, ShippingAddress
│ ├── port/in/ # PlaceOrderUseCase, CancelOrderUseCase
│ ├── port/out/ # OrderRepository, EventPublisher, PaymentGateway
│ ├── usecase/ # PlaceOrderService, CancelOrderService
│ └── event/ # OrderPlacedEvent, OrderCancelledEvent
├── order-persistence/ # JPA + Spring Data
│ └── src/main/java/com/acme/order/persistence/
│ ├── OrderEntity.java
│ ├── OrderJpaRepository.java
│ ├── JpaOrderRepositoryAdapter.java
│ └── OrderMapper.java
├── order-messaging/ # Kafka
│ └── src/main/java/com/acme/order/messaging/
│ ├── KafkaEventPublisherAdapter.java
│ └── OrderEventListenerAdapter.java
├── order-payment/ # Stripe HTTP client
│ └── src/main/java/com/acme/order/payment/
│ └── StripePaymentGatewayAdapter.java
├── order-web/ # Spring Web
│ └── src/main/java/com/acme/order/web/
│ ├── OrderController.java
│ └── PlaceOrderRequest.java
└── order-application/ # Spring Boot main + wiring
└── src/main/java/com/acme/order/
├── OrderApplication.java
└── config/DomainConfig.java
Each Maven module’s pom.xml makes the dependency direction visible: order-domain has no dependency on the other modules; order-persistence depends only on order-domain; order-application depends on all of them and is responsible for wiring.
13.5 Why This Layout Earns Its Cost
The unit tests in order-domain/src/test/java/... instantiate PlaceOrderService with hand-rolled in-memory implementations of the three outbound ports. They run in milliseconds with no Spring context. They cover every branch of the domain rules — minimum line items, invalid addresses, insufficient stock, payment failure handling. The integration tests in order-persistence use Testcontainers to bring up Postgres, exercise the adapter against the real database, and verify the JPA mapping is correct. The integration tests in order-web use @WebMvcTest to exercise the controller against a mock PlaceOrderUseCase. End-to-end tests bring everything up together but are few. The test pyramid is correctly shaped: many fast unit tests on the domain, fewer integration tests on adapters, a handful of end-to-end tests at the top.
If the team later decides to migrate from Postgres to DynamoDB, they write a DynamoOrderRepositoryAdapter in a new order-persistence-dynamo module, swap the wiring, and run the same domain tests unchanged. If they want to add a Kafka-listener inbound channel that places orders from another service’s events, they write a listener in order-messaging that calls PlaceOrderUseCase, no controller changes required. These are the moments where the architectural investment pays back; without them the structure is overhead.
14. Anti-Patterns Catalogue
Beyond the pitfalls in §10, several recurring anti-patterns deserve their own naming because they tend to appear together as a cluster in codebases where hexagonal has been adopted but not internalised.
The Spring Data Pretender. The team declares an interface OrderRepository extends JpaRepository<OrderEntity, UUID> in the domain module and calls it the outbound port. The interface inherits dozens of Spring-typed methods (findAll(Pageable), findAll(Sort), findById(ID) returning a JpaSpecificationExecutor-aware result), and the domain module now imports Spring at compile time. The team feels they have hexagonal because there is an interface; in fact the domain is welded to Spring Data. Diagnosis: search the domain module’s imports for org.springframework.*; if any appear, the architecture has degraded to layered.
The DTO Highway. Every layer has its own DTO and the use cases do nothing but translate: WebOrderRequest → PlaceOrderCommand → Order → OrderEntity → OrderRow. The application is mostly mapper code. The cause is usually a literal reading of “no type may cross a layer” combined with reluctance to share value objects. The fix is to share immutable, behaviour-rich value objects (Money, OrderId, ProductId) directly across boundaries while keeping aggregate-shaped objects (Order itself) on the domain side only, with translations limited to those.
The God Service. A single OrderApplicationService class implements every inbound port for the bounded context — placeOrder, cancelOrder, updateAddress, applyDiscount, requestRefund — and grows to 2,000 lines. The code reads Spring service classes pre-DDD, just relabelled. The fix is one use-case class per business operation, following the Clean Architecture convention; this constrains each class’s responsibility and aligns with the Single Responsibility Principle.
The Test-Container Test Pyramid Inversion. The team writes mostly Testcontainers-backed integration tests because that is what they have done before, and the unit-test directory in the domain module is sparse. The CI pipeline takes 25 minutes; engineers stop running tests locally; bugs slip through. The fix is a deliberate audit: identify domain rules with no unit-test coverage; write the unit tests; over time the integration tests can be reduced to those that genuinely exercise the technology binding.
Hexagon-Shaped Routing Layer. The team applies hexagonal to a service whose only logic is “validate and forward to a backend.” There is no domain to protect, but there is a hexagon, with ports and adapters and use cases that simply call other ports. The hexagon is empty; the indirection is the only thing in it. The fix is to recognise the service as a gateway (see API Gateway Pattern) and use a flatter structure that matches its actual responsibility.
The Monolithic Hexagon. The team puts the entire monolith into one giant hexagon — every aggregate from every business area, sharing a single domain module. The result is a domain module that knows about Customers, Orders, Products, Inventory, Shipments, Returns, Promotions, and so on, with an implicit assumption that all of them are one bounded context. They are not. The fix is to apply Domain-Driven Design Strategic Patterns context-mapping and split into multiple hexagons (multiple bounded contexts), each with its own domain module and its own ports.
15. See Also
- Clean Architecture — Robert C. Martin’s 2012 synthesis; concentric and more prescriptive sibling
- Onion Architecture — Palermo’s 2008 layered concentric variant
- Layered Architecture (N-Tier) — the predecessor pattern that hexagonal explicitly contrasts itself against
- Domain-Driven Design Tactical Patterns — entities, value objects, aggregates that populate the hexagon’s centre
- Domain-Driven Design Strategic Patterns — bounded contexts, each typically realised as one hexagon
- Microservices Architecture — orthogonal architecture often applied per service
- Event Sourcing Pattern — composes naturally with hexagonal (aggregates at centre, EventStore as outbound port)
- Command Query Responsibility Segregation — write-side and read-side each fit a hexagonal mould
- Saga Pattern — orchestrators are inbound use cases; participants expose outbound ports
- Anti-Corruption Layer Pattern — a specific kind of adapter for legacy/external integrations
- Architecture Decision Records — adoption of hexagonal is an ADR-worthy decision
- Conway’s Law — a hexagonal codebase shaped per bounded context aligns with team boundaries
- Service-Oriented Architecture (SOA) — a higher-level composition pattern that often contains hexagonal services internally
- Transactional Outbox Pattern — typically lives in the persistence adapter to solve dual-write problems
- System Architectures MOC
- SWE Interview Preparation MOC