Hexagonal Architecture — Ghost Order: Adapter Transaction
- Hexagonal Architecture decouples core business logic from infrastructure through ports (interfaces) and adapters (implementations).
- Driving adapters initiate calls into the core; driven adapters are called by the core. Keep them in separate packages.
- Dependency inversion is the engine: core defines ports, adapters implement them. The core never imports adapter code.
- Hexagonal Architecture isolates business logic from infrastructure: Ports (interfaces) define boundaries, Adapters implement them
- Driving (primary) adapters: HTTP controllers, CLI commands, message listeners that initiate calls into the core
- Driven (secondary) adapters: database repositories, REST clients, message producers that the core calls
- Performance impact: abstraction adds <5% overhead in most cases; the real cost is premature abstraction when you don't need it yet
- Production insight: transactions that span multiple adapters (e.g., DB write + external API call) are the #1 cause of partial commits and data corruption
- Biggest mistake: over-abstracting every external dependency before you have a proven need for substitution
Quick Debug Cheat Sheet for Hexagonal Architecture
No adapter found for port
grep -r '@Component\|@Service' src/main/java/io/thecodeforge/adapter/mvn dependency:tree -Dincludes=io.thecodeforgePort method returns null but adapter is present
docker compose logs --tail 100 app | grep -i 'WARN\|ERROR'jstack -l <pid> | grep -A 20 'PORT_METHOD'Transaction boundary violation (partial commit)
grep -r 'RestTemplate\|WebClient\|kafkaTemplate' src/main/java/io/thecodeforge/adapter/cat application.yml | grep -A5 'transaction'Circular dependency between adapters
grep -r 'import io.thecodeforge.core.port' src/main/java/io/thecodeforge/adapter/jdeps -summary target/classes/Production Incident
@Transactional annotation on the service method covered both the database write and the Stripe API call.OrderRepository.save() (database) → PaymentAdapter.charge() (Stripe API). The @Transactional only wrapped the database calls; the external API call was outside the transaction. When Stripe succeeded but the database commit failed (e.g., due to a constraint violation), the payment was already captured. No compensation was implemented.Production Debug GuideSymptom → Action grid for the most common breakages when using Ports and Adapters.
Every codebase eventually hits the same wall: the business logic is so tangled up with database calls, HTTP frameworks, and third-party SDKs that changing one thing breaks three others. A sprint that should take a day takes a week because your OrderService knows about Hibernate annotations, Spring's @Autowired, and Stripe's API all at once. This isn't a skill problem — it's an architecture problem, and it's exactly the pain that Alistair Cockburn designed Hexagonal Architecture to eliminate in 2005.
The core idea is radical in its simplicity: your application should have no knowledge of the outside world. It shouldn't know whether it's being called by an HTTP request, a CLI command, or a test suite. It shouldn't know whether it's persisting data to PostgreSQL, a flat file, or an in-memory map. All that external detail is pushed behind interfaces — called Ports — and concrete implementations of those interfaces — called Adapters — live entirely outside the application's core domain. The result is a domain model that's pure business logic, nothing else.
By the end of this article you'll understand how to decompose a real feature (an e-commerce order placement flow) into a proper Hexagonal Architecture using Java, why the distinction between Driving (Primary) and Driven (Secondary) adapters matters for testing, how to handle production gotchas like transaction boundaries that cut across adapter layers, and what the honest performance trade-offs are. You'll be able to apply this pattern from day one on a new service and refactor an existing one without a full rewrite.
Core Concept: Ports Are Contracts, Not Implementation Hints
A Port is a Java interface that lives in the core domain package, typically under io.thecodeforge.core.port. It defines what the core needs from the outside world (driven port) or what the outside world can ask the core to do (driving port). The key rule is: the port interface must be expressed in the language of the domain, not in the language of infrastructure.
For example, a SaveOrderPort driven interface should have a method like OrderId save(Order order) rather than void insertIntoOrderTable(OrderRow row). The core shouldn't know about tables, SQL, or even that the data is persisted. This pure abstraction is what makes swapping adapters trivial.
package io.thecodeforge.core.port.driven; import io.thecodeforge.core.domain.Order; import io.thecodeforge.core.domain.OrderId; /** * Driven port: the core tells the outside world to save an order. * The adapter decides how (DB, file, queue, etc.). */ public interface SaveOrderPort { OrderId save(Order order); void delete(OrderId orderId); }
- The socket shape (interface) never changes — but you can plug different devices in.
- Your house wiring (core logic) doesn't care if you plug in a toaster or a phone charger.
- Each adapter is a plug shaped for one specific socket — but the plug's back end connects to a different external system.
- Don't put universal sockets everywhere — only where you genuinely need to swap.
Driving vs Driven Adapters — The Primary/Secondary Split
Adapters come in two flavours, and the distinction matters for testing, packaging, and debugging.
Driving (Primary) Adapters: Initiate calls into the core. They are the entry points — HTTP controllers, CLI commands, queue listeners, scheduled tasks. They translate external messages into domain commands. In testing, you replace them with your test code (or mock the HTTP layer).
Driven (Secondary) Adapters: Called by the core to perform side effects. They are the exit points — database repositories, REST client proxies, message producers. The core doesn't know about them directly; it only knows the port interface. In testing, you mock or stub the port, never the adapter.
The rule of thumb: driving adapters push calls into the core; driven adapters are pulled by the core.
package io.thecodeforge.adapter.driven.persistence; import io.thecodeforge.core.domain.Order; import io.thecodeforge.core.domain.OrderId; import io.thecodeforge.core.port.driven.SaveOrderPort; import org.springframework.stereotype.Repository; /** * Driven adapter: implements SaveOrderPort using Spring Data JPA. * The core knows nothing about JPA, EntityManager, or transactions. */ @Repository public class OrderRepositoryAdapter implements SaveOrderPort { private final SpringDataOrderRepository jpaRepo; public OrderRepositoryAdapter(SpringDataOrderRepository jpaRepo) { this.jpaRepo = jpaRepo; } @Override public OrderId save(Order order) { OrderEntity entity = OrderEntity.fromDomain(order); OrderEntity saved = jpaRepo.save(entity); return saved.getId(); } @Override public void delete(OrderId orderId) { jpaRepo.deleteById(orderId.value()); } }
io.thecodeforge.core.port.driven) and the adapter in the infrastructure package (io.thecodeforge.adapter.driven). This enforces the dependency rule: core never imports from adapter.Dependency Inversion in Practice — The Real Power of Hexagonal
Hexagonal Architecture is a concrete application of the Dependency Inversion Principle (DIP): high-level modules should not depend on low-level modules; both should depend on abstractions. In Hexagonal terms, the core domain defines the abstractions (ports), and adapter modules implement them. The core has no compile-time dependency on any adapter. This means you can develop, test, and deploy the core independently of infrastructure.
To achieve this in Java, use constructor injection. The core's service classes depend only on port interfaces. A configuration class or a dependency injection container (like Spring) wires the adapters at runtime. This wiring layer is itself an adapter — often called the 'Application Configuration' or 'System Assembly' adapter.
package io.thecodeforge.core.application; import io.thecodeforge.core.domain.Order; import io.thecodeforge.core.domain.OrderId; import io.thecodeforge.core.port.driven.SaveOrderPort; import io.thecodeforge.core.port.driven.NotifyCustomerPort; import io.thecodeforge.core.port.driving.CreateOrderUseCase; /** * Core service — depends only on ports, never on adapters. */ public class OrderService implements CreateOrderUseCase { private final SaveOrderPort saveOrder; private final NotifyCustomerPort notify; public OrderService(SaveOrderPort saveOrder, NotifyCustomerPort notify) { this.saveOrder = saveOrder; this.notify = notify; } @Override public OrderId createOrder(Order order) { // Business logic: validate, calculate totals, apply discounts order.validate(); order.applyDiscounts(); OrderId id = saveOrder.save(order); notify.orderCreated(order.getCustomerEmail(), id); return id; } }
Testing Hexagonal Systems — Where the Pattern Shines
One of the biggest wins of Hexagonal Architecture is testability. Because the core depends only on interfaces, you can unit-test all business logic with mocks or in-memory adapters — no database, no network, no startup time. For integration tests, you test each adapter independently against a real resource, using testcontainers or in-memory variants.
- Core unit tests: pure JUnit + mocks for every port. These run in milliseconds and cover all business rules.
- Adapter integration tests: test the adapter against a real instance of the external system (e.g., testcontainers for PostgreSQL, wiremock for REST APIs). These verify serialization, error handling, and timeouts.
- Sliced system tests: compose a real core with real adapters (or a mix) to test the wiring. Don't test every adapter combination — just the critical paths.
package io.thecodeforge.test.core; import io.thecodeforge.core.application.OrderService; import io.thecodeforge.core.domain.Order; import io.thecodeforge.core.port.driven.SaveOrderPort; import io.thecodeforge.core.port.driven.NotifyCustomerPort; import org.junit.jupiter.api.Test; import static org.mockito.Mockito.*; class OrderServiceTest { @Test void shouldSaveOrderAndNotifyOnCreation() { SaveOrderPort saveOrder = mock(SaveOrderPort.class); NotifyCustomerPort notify = mock(NotifyCustomerPort.class); OrderService service = new OrderService(saveOrder, notify); Order order = new Order("customer@example.com", 100.00); service.createOrder(order); verify(saveOrder).save(order); verify(notify).orderCreated("customer@example.com", order.getId()); } }
- Unit tests (70%): core logic with mocked ports — fast, reliable, high coverage.
- Integration tests (20%): adapter tests against real infrastructure — slow but necessary.
- E2E tests (10%): full wiring test for the most critical user journeys.
- Never test the same business rule in both unit and integration tests — wasted effort.
Production Gotchas: Transactions, Logging, and Circuit Breakers
Real production use reveals several pitfalls that are easy to miss in tutorials. Three of the most common:
- Transaction boundaries crossing adapters: As shown in the production incident, a
@Transactionalin the service only covers the database adapter. If a driven adapter calls an external API, the API call is outside the transaction. If the API fails after the DB commit, you have an inconsistent state. Use the Outbox pattern or Saga pattern. - Logging in the core: Logging is a cross-cutting concern. Don't make the core depend on SLF4J or Log4j directly. Use a port like
LogPortwith an adapter that logs. But most teams accept SLF4J as a standard interface and include it in the core — it's a pragmatic trade-off. - Circuit breakers in adapters: A driven adapter that calls an external API should implement retry and circuit-breaking. Do this inside the adapter, not the core. The core doesn't know about retries; it just calls the port and expects success or gets an exception.
package io.thecodeforge.adapter.driven.notification; import io.thecodeforge.core.domain.OrderId; import io.thecodeforge.core.port.driven.NotifyCustomerPort; import io.github.resilience4j.circuitbreaker.CircuitBreaker; import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; import org.springframework.stereotype.Service; @Service public class EmailNotificationAdapter implements NotifyCustomerPort { private final EmailClient emailClient; private final CircuitBreaker circuitBreaker; public EmailNotificationAdapter(EmailClient emailClient) { this.emailClient = emailClient; this.circuitBreaker = CircuitBreaker.of("emailService", CircuitBreakerConfig.custom() .failureRateThreshold(50) .waitDurationInOpenState(Duration.ofSeconds(30)) .build()); } @Override public void orderCreated(String email, OrderId orderId) { Supplier<Void> decorated = CircuitBreaker.decorateSupplier(circuitBreaker, () -> { emailClient.send(email, "Order " + orderId + " confirmed"); return null; }); Try<Void> result = Try.ofSupplier(decorated); result.onFailure(throwable -> log.warn("Failed to send email for order {}", orderId, throwable)); } }
@Transactional annotation on a core service class also covers calls to driven adapters that use different resources (HTTP, messaging). Use @TransactionalEventListener(phase = AFTER_COMMIT) to defer operations or use the Outbox pattern.OrderSaveFailedException). Core catches it and decides retry or failure.| Aspect | Layered Architecture | Hexagonal Architecture |
|---|---|---|
| Dependency direction | Top-down: Presentation → Service → Data Access | Core outward: Core defines ports, adapters implement them |
| Testability | Requires heavy mocking of concrete classes | Ports are natural mocks; core tests run without infrastructure |
| Substitution of infrastructure | Swap DB? Might need to change service layer too | Swap DB? Just write a new adapter — core unchanged |
| Production bugs | Transaction boundaries are implicit | Transaction boundaries are explicit (port/adapter boundaries) |
| Learning curve | Familiar to most developers | Requires mindset shift but pays off in large projects |
🎯 Key Takeaways
- Hexagonal Architecture decouples core business logic from infrastructure through ports (interfaces) and adapters (implementations).
- Driving adapters initiate calls into the core; driven adapters are called by the core. Keep them in separate packages.
- Dependency inversion is the engine: core defines ports, adapters implement them. The core never imports adapter code.
- Testability improves dramatically: core unit tests with mocks, adapter integration tests with real I/O.
- Production gotchas: transaction boundaries, logging abstraction, and circuit breaker placement require careful design.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QExplain the difference between Driving (Primary) and Driven (Secondary) adapters. Give an example of each.Mid-levelReveal
- QHow do you handle a transaction that spans a database write and an external API call in a Hexagonal Architecture?SeniorReveal
- QWhen would you NOT use Hexagonal Architecture?SeniorReveal
- QHow do you prevent the core from knowing about adapters' implementation details (e.g., pagination, sorting)?Mid-levelReveal
- QWhat is the role of the 'configuration' or 'assembly' adapter in Hexagonal Architecture?JuniorReveal
Frequently Asked Questions
What is Hexagonal Architecture in simple terms?
Hexagonal Architecture is a design pattern where your application's core business logic is isolated from external systems (databases, APIs, UIs). The core defines interfaces (ports) that external system implement (adapters). This lets you swap technologies without changing business logic.
Is Hexagonal Architecture the same as Ports & Adapters?
Yes. 'Hexagonal Architecture' and 'Ports & Adapters' are often used interchangeably. Alistair Cockburn coined the term 'Hexagonal Architecture' because he drew the core as a hexagon to fit multiple adapters around it, but the structure is the same.
How many adapters should I have?
As many as you need, but start small. One adapter per external system you interact with. Common adapters: database (JPA, JDBC), REST client, messaging queue (Kafka, RabbitMQ), file system, email, CLI. You can also have multiple adapters per port if you need different implementations (e.g., test vs production).
Does Hexagonal Architecture work with microservices?
Absolutely. Each microservice can use Hexagonal Architecture internally, which makes them testable and easy to modify. The pattern also helps with distributed transactions (Saga) and event-driven communication.
What is the main disadvantage of Hexagonal Architecture?
Increased complexity for simple applications. It requires more upfront design, more interfaces, and more files. If your application is unlikely to change its infrastructure, the extra abstraction adds overhead without benefit.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.