Skip to content
Home System Design Hexagonal Architecture — Ghost Order: Adapter Transaction

Hexagonal Architecture — Ghost Order: Adapter Transaction

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Architecture → Topic 9 of 13
Orders paid via Stripe in hexagonal app vanish when @Transactional misses external API.
🔥 Advanced — solid System Design foundation required
In this tutorial, you'll learn
Orders paid via Stripe in hexagonal app vanish when @Transactional misses external API.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • 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
🚨 START HERE

Quick Debug Cheat Sheet for Hexagonal Architecture

Common symptoms when ports and adapters are misconfigured, with the exact commands to diagnose and fix.
🟡

No adapter found for port

Immediate ActionCheck DI configuration
Commands
grep -r '@Component\|@Service' src/main/java/io/thecodeforge/adapter/
mvn dependency:tree -Dincludes=io.thecodeforge
Fix NowAdd missing @Component or @Service annotation; ensure component scan includes the adapter package
🟡

Port method returns null but adapter is present

Immediate ActionCheck adapter implementation for missing logic
Commands
docker compose logs --tail 100 app | grep -i 'WARN\|ERROR'
jstack -l <pid> | grep -A 20 'PORT_METHOD'
Fix NowAdd logging in the adapter method and verify it's being invoked; check for early returns
🟡

Transaction boundary violation (partial commit)

Immediate ActionIdentify which adapter performs out-of-transaction I/O
Commands
grep -r 'RestTemplate\|WebClient\|kafkaTemplate' src/main/java/io/thecodeforge/adapter/
cat application.yml | grep -A5 'transaction'
Fix NowMove external I/O after transaction commit using @TransactionalEventListener(phase = AFTER_COMMIT)
🟡

Circular dependency between adapters

Immediate ActionBreak the cycle by introducing an event bus or mediator
Commands
grep -r 'import io.thecodeforge.core.port' src/main/java/io/thecodeforge/adapter/
jdeps -summary target/classes/
Fix NowRedesign to avoid adapter-to-adapter calls; core should mediate all communication
Production Incident

The Ghost Order: When a Transaction Spans Two Adapters

An e-commerce order placement failed silently — the payment was charged but the order was never saved. The root cause? A transaction boundary that crossed a port and a driven adapter.
SymptomOrders that were paid via Stripe appeared in the payment dashboard but never showed up in the order database. Customers received 'Order confirmed' emails but saw 'no orders' in their account.
AssumptionThe team assumed that the @Transactional annotation on the service method covered both the database write and the Stripe API call.
Root causeThe order placement flow was: HTTP controller → OrderService.createOrder → 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.
FixMove the payment capture to a separate outbound adapter with a compensating action. Use the Outbox pattern to reliably place a message after database commit, then have a background worker process the charge. Alternatively, implement a two-phase reservation: hold the payment, write the order, then capture the hold.
Key Lesson
Never assume a transaction boundary covers I/O outside the database; transactions in Java only scope JDBC/JPA interactions.Any side-effectful call to a driven adapter (external API, message queue) must be done after the database transaction commits, or be protected by a compensating operation.Use the Outbox pattern or Saga pattern for distributed transactions across adapters.
Production Debug Guide

Symptom → Action grid for the most common breakages when using Ports and Adapters.

Adapter throws 'No suitable bean' or class not found at startupCheck that the adapter implementation is annotated with @Component/@Service and is on the component scan path. Verify the port interface and adapter class are in different packages if using package-level scanning.
Core logic calls port method, but nothing happens (no error)The adapter might be a no-op stub left from testing. Check the active Spring profile or dependency injection configuration. Enable DEBUG logging for the adapter package.
Performance degradation after adding a new adapterAdapter might be doing synchronous I/O that blocks the core thread. Check if the port method should be asynchronous (CompletableFuture) or if the adapter has a connection pool configured correctly.
Transaction unexpectedly rolled backCheck that the driven adapter (e.g., REST client) does not join the core's transaction. Enable trace logging for TransactionSynchronization and look for calls to non-transactional resources.
Unit tests pass but integration tests fail with adapter errorsYour unit tests probably mock the port, so they never exercise the adapter. Write dedicated integration tests that spin up real infrastructure (testcontainers) for each adapter independently.

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.

io/thecodeforge/core/port/driven/SaveOrderPort.java · JAVA
12345678910111213
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);
}
Mental Model
The Socket & Plug Metaphor
Think of each port as a standard electrical socket mounted on the wall of your core domain.
  • 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.
📊 Production Insight
Port names leak infrastructure intent when they contain words like 'Jpa', 'Rest', or 'Kafka'.
That destroys the whole purpose: you can't swap an adapter without renaming the port.
Rule: If a port interface name references a specific technology, rename it to describe the capability instead.
🎯 Key Takeaway
Ports capture capabilities, not technologies.
Name them after what the core needs, not what the adapter does.
A port named 'SaveOrderPort' survives a database migration. 'JpaOrderRepository' doesn't.
Should I Create a Port for This Dependency?
IfDependency is volatile (network, disk, third-party API)
UseCreate a port — you'll need to mock it for tests and swap providers later.
IfDependency is stable (same VM, pure computation, no I/O)
UseConsider a direct dependency or a simple interface inside the core — no port needed.
IfDependency is a framework that dictates your packaging (e.g., Spring Data Repositories)
UseHide behind a port anyway — frameworks change, and you don't want @Query annotations in your domain.
IfDependency is an OS service that will never change (e.g., file system)
UsePort is optional but recommended for testability. Use an adapter that wraps the native API.

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.

io/thecodeforge/adapter/driven/persistence/OrderRepositoryAdapter.java · JAVA
1234567891011121314151617181920212223242526272829303132
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());
    }
}
🔥Forge Tip:
Don't put the port interface in the same package as the adapter. Keep the port in the core package (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.
📊 Production Insight
Weekly deployments reveal the cost of misclassifying adapters.
If a driving adapter directly calls a driven adapter's port without going through the core, you've created a leaky abstraction.
Rule: Every interaction between two external systems must pass through the core domain logic — otherwise you lose all the benefits of Hexagonal Architecture.
🎯 Key Takeaway
Driving adapters call the core; driven adapters are called by the core.
Keep them in separate packages and never let a driving adapter bypass the core.
If you find an adapter doing both, split it — your architecture will thank you.
Is This Adapter Driving or Driven?
IfThe adapter initiates the call (e.g., receives an HTTP request)
UseDriving adapter — implement a driving port (interface) called by the adapter, then implement the core logic that fulfills the contract.
IfThe core initiates the call (e.g., needs to save data)
UseDriven adapter — implement a driven port defined in the core. The core calls the port; the adapter does the I/O.
IfThe adapter sits in the middle and transforms data bidirectionally
UseThis is likely a facade that should be split into two adapters: one driving and one driven, each with its own port.

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.

io/thecodeforge/core/application/OrderService.java · JAVA
1234567891011121314151617181920212223242526272829303132
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;
    }
}
⚠ Avoid the 'Anemic Service' Trap
If your core service is just calling port methods in a sequence, you've created a procedural script — not domain logic. Hexagonal Architecture works best when the core contains real business rules. If your service is just a pass-through, reconsider whether you need the pattern.
📊 Production Insight
Teams often forget that the configuration layer is an adapter too.
If you hardcode bean wiring in the core (e.g., using @Autowired on a specific adapter implementation), you've coupled the core to infrastructure.
Rule: Keep all wiring in a separate 'config' package that imports both core and adapter modules. The core should never import anything from the config or adapter packages.
🎯 Key Takeaway
Constructor-inject port interfaces, never adapter classes.
Let the DI container wire adapters at the application root.
If you see 'import io.thecodeforge.adapter' in a core file, you've broken the dependency rule.

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.

The recommended approach
  • 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.
io/thecodeforge/test/core/OrderServiceTest.java · JAVA
123456789101112131415161718192021222324
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());
    }
}
Mental Model
Test Pyramid with Hexagonal
The test pyramid flattens: more core unit tests, fewer end-to-end tests.
  • 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 Insight
A common pitfall: teams mock every port in every test, hiding integration bugs.
The database adapter might work in isolation but fail when called in the real flow because of transaction propagation.
Rule: Always include at least one full-cycle integration test that exercises all adapters together for the primary use case.
🎯 Key Takeaway
Hexagonal makes unit testing trivial — but don't neglect adapter integration tests.
Core tests with mocks should be the majority; end-to-end tests should only verify wiring.
If your test suite takes over 10 minutes, you're probably testing adapters in every test instead of isolating them.

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:

  1. Transaction boundaries crossing adapters: As shown in the production incident, a @Transactional in 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.
  2. 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 LogPort with 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.
  3. 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.
io/thecodeforge/adapter/driven/notification/EmailNotificationAdapter.java · JAVA
12345678910111213141516171819202122232425262728293031
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));
    }
}
⚠ Transaction Management: The #1 Production Bug
Never assume that a @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.
📊 Production Insight
At a major retailer, a circuit breaker in the core caused cascading failures.
The core tried to open a circuit for the payment adapter, but the business logic needed to know the state of the circuit to decide whether to allow checkout.
Rule: Circuit breaker state belongs in the adapter. If the core needs to know the system's health, expose a separate health port — don't couple the domain to resilience patterns.
🎯 Key Takeaway
Transactions stop at the core boundary — adapters handle their own I/O.
Circuit breakers and retries belong in adapters, not in the domain.
If your core needs to know about infrastructure health, design a port for that, don't leak the circuit breaker.
Where to Handle External Failures?
IfDrive adapter fails (e.g., HTTP request fails before reaching core)
UseReturn HTTP 502 or appropriate error. Core never sees the failure.
IfDriven adapter fails (e.g., database is down)
UseAdapter throws a domain exception (e.g., OrderSaveFailedException). Core catches it and decides retry or failure.
IfDriven adapter is slow (timeout)
UseConfigure timeout on adapter side (e.g., RestTemplate timeout). Adapter throws exception after timeout.
IfDriven adapter needs retries
UseImplement retry logic inside the adapter, not the core. Core should get a definitive success or failure.
🗂 Hexagonal vs Layered Architecture
Why Hexagonal beats traditional layered design in testability, flexibility, and production resilience.
AspectLayered ArchitectureHexagonal Architecture
Dependency directionTop-down: Presentation → Service → Data AccessCore outward: Core defines ports, adapters implement them
TestabilityRequires heavy mocking of concrete classesPorts are natural mocks; core tests run without infrastructure
Substitution of infrastructureSwap DB? Might need to change service layer tooSwap DB? Just write a new adapter — core unchanged
Production bugsTransaction boundaries are implicitTransaction boundaries are explicit (port/adapter boundaries)
Learning curveFamiliar to most developersRequires 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

    Creating an adapter for every external class
    Symptom

    Massive boilerplate: dozens of tiny port interfaces and adapter classes that just delegate to one-method wrappers. Developers start to question the value of the pattern.

    Fix

    Only create a port when the external dependency is volatile (changes often, has multiple implementations, or is expensive in tests). For stable, standard libraries (e.g., Java's Files API), use it directly in the adapter.

    Letting framework annotations leak into the core
    Symptom

    Core classes have @Autowired, @Entity, @JsonIgnore annotations that tie them to Spring and Jackson. Swapping frameworks becomes a major refactor.

    Fix

    Keep the core free of any framework annotation. Use POJOs and plain interfaces. Use annotation-based definitions only in adapters and configuration. Define @Entity on a separate JPA entity class, not on the domain object.

    Not testing adapters with real I/O
    Symptom

    Tests pass locally with mocks, but fail in staging or production because the real adapter implementation has bugs in SQL queries, HTTP headers, or serialization.

    Fix

    Write integration tests for each adapter using testcontainers for databases and WireMock for external APIs. These tests should be in the adapter module and run as part of CI.

    Over-abstracting the configuration layer
    Symptom

    A separate port for configuration, another for logging, another for monitoring. The core becomes a web of trivial interfaces.

    Fix

    Pragmatically accept some trivial dependencies (e.g., SLF4J for logging). The goal is testability and swapability, not zero dependencies. If you've never swapped logging frameworks and never will, don't abstract it.

Interview Questions on This Topic

  • QExplain the difference between Driving (Primary) and Driven (Secondary) adapters. Give an example of each.Mid-levelReveal
    Driving adapters initiate calls into the core. They are the entry points: HTTP controllers, CLI commands, scheduled tasks, message listeners. Driven adapters are called by the core to perform side effects: database repositories, REST clients, message producers. The distinction matters for testing: driving adapters are replaced by test harnesses, driven adapters are mocked via their ports. Example: An HTTP controller (driving) calls OrderService.createOrder(), which at some point calls SaveOrderPort.save() (driven port) implemented by a JPA adapter.
  • QHow do you handle a transaction that spans a database write and an external API call in a Hexagonal Architecture?SeniorReveal
    You cannot use a single database transaction to cover both. The typical solution is the Outbox pattern: write the event (e.g., 'order created') into an outbox table within the same database transaction. A separate background process reads the outbox table and sends the API call. If the API fails, the event is retried. Alternative: Use the Saga pattern with compensating actions. In Java, with Spring, you can use @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) to defer the API call to after the transaction commits, but this does not guarantee delivery if the app crashes after commit. The Outbox pattern with a retry mechanism is more robust.
  • QWhen would you NOT use Hexagonal Architecture?SeniorReveal
    For a small, short-lived application with a single database and simple logic, Hexagonal Architecture adds unnecessary complexity. Also, if the team is unfamiliar with the pattern, the learning curve can slow down delivery. Another case is when the business domain is trivial and infrastructure is the main value (e.g., a simple CRUD REST API that's just an autogenerated layer over a database). In such cases, a simple layered architecture or even a functional approach may be more efficient.
  • QHow do you prevent the core from knowing about adapters' implementation details (e.g., pagination, sorting)?Mid-levelReveal
    Define the port interface in domain terms. For example, instead of findAll(int page, int size, String sortBy), define findAll(FilterCriteria criteria) where FilterCriteria is a domain object that expresses what to filter and order. The adapter then translates that into a database query with pagination. This keeps the core unaware of SQL LIMIT/OFFSET. For performance, you can add a hint like boolean fetchAll but that blurs the line — it's often acceptable pragmatically.
  • QWhat is the role of the 'configuration' or 'assembly' adapter in Hexagonal Architecture?JuniorReveal
    The configuration adapter is responsible for wiring the application: creating instances of adapters, injecting them into ports, and connecting to external resources. It's typically a Spring @Configuration class or a main function. It resides outside the core and knows about both core interfaces and adapter implementations. This is the only place that imports from both core and adapter packages. It ensures that the core has no compile-time dependency on any adapter.

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.

🔥
Naren Founder & Author

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.

← PreviousStrangler Fig PatternNext →Domain-Driven Design Basics
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged