Junior 10 min · March 06, 2026

Hexagonal Architecture — Ghost Order: Adapter Transaction

Orders paid via Stripe in hexagonal app vanish when @Transactional misses external API.

N
Naren Founder & Principal Engineer

20+ years shipping large-scale distributed systems. Drawn from code that ran under real load.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
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
✦ Definition~90s read
What is Hexagonal Architecture?

Hexagonal Architecture (also called Ports and Adapters) is a structural pattern that inverts the traditional layered approach by placing your application's core business logic at the center, isolated from external concerns like databases, web frameworks, or message queues. Instead of organizing code into horizontal layers (presentation, business, data), you define explicit boundaries — ports — that are interfaces representing what your application needs from the outside world (driven ports) and what it exposes to the outside world (driving ports).

Imagine your home has a universal power socket on the wall.

Adapters are concrete implementations that translate between these ports and specific technologies — a Postgres adapter for a repository port, an Express controller adapter for an HTTP port. The core never imports infrastructure code; it only depends on abstractions it defines.

This isn't just another abstraction layer — it's a deliberate architectural boundary enforced by dependency inversion, ensuring your business logic remains testable, technology-agnostic, and independently evolvable. In practice, this means you can swap a REST API for GraphQL, replace a SQL database with a NoSQL store, or change your message broker from RabbitMQ to Kafka without touching a single line of domain code.

The pattern shines in complex domains where business rules are the primary value, but it's overkill for simple CRUD apps where the overhead of defining ports and adapters outweighs the benefits. Real-world adoption is significant — companies like ThoughtWorks, Netflix, and many fintech firms use variants of this pattern, and it pairs naturally with Domain-Driven Design and event sourcing.

The key gotcha in production is managing transactional boundaries across adapters — your database transaction must span the entire adapter call, not leak into the domain, and you'll need explicit patterns for cross-cutting concerns like logging and circuit breakers that live in adapter wrappers, not in the core.

Plain-English First

Imagine your home has a universal power socket on the wall. Your lamp, phone charger, and toaster all plug into it — the socket doesn't care what's plugged in, and each device doesn't care how electricity is generated. Hexagonal Architecture works the same way: your application's core logic sits in the middle like the house wiring, and everything that talks to it — databases, HTTP APIs, message queues — plugs in through standard sockets called Ports, using Adapters shaped to fit each device. Swap your PostgreSQL database for MongoDB? Just swap the adapter. The core never changes.

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.

Why Hexagonal Architecture Is About Boundaries, Not Layers

Hexagonal architecture, also known as ports and adapters, decouples the core business logic from external concerns — databases, APIs, UIs, message queues — by defining explicit ports (interfaces) inside the core and adapters outside that implement them. The core never imports an external library or framework; it only depends on its own ports. This inverts the traditional layered architecture: instead of the core calling the database, the database adapter implements a port the core defines.

In practice, each external system gets its own adapter — one for Postgres, one for a REST API, one for an in-memory store — all conforming to the same port. You can swap adapters without touching core code. The core remains testable in isolation: mock the port, verify behavior. No Spring context, no database connection, no HTTP server needed for unit tests. The hexagonal shape is a visual metaphor: the core is the hexagon, each side is a port, and adapters plug into those sides.

Use hexagonal architecture when your system must survive changes in infrastructure — swapping databases, adding new API consumers, or migrating from monolith to microservices. It shines in domains where business rules are complex and long-lived, and where external integrations are volatile. The cost is indirection: you write an interface and an adapter for every external interaction. The payoff is that a decade later, when you migrate from Oracle to CockroachDB, you change one adapter, not the entire codebase.

Ports Are Not Abstractions for Abstraction's Sake
A port should model a business capability (e.g., 'SaveOrder'), not a technical operation (e.g., 'InsertIntoDatabase'). If your port looks like a DAO, you've missed the point.
Production Insight
Teams that adopt hexagonal architecture but let adapters leak into core logic — e.g., passing HttpServletRequest into a service — lose the isolation benefit.
Symptom: changing a REST endpoint signature forces changes in business logic tests.
Rule: enforce a strict compile-time dependency rule: core module must have zero dependencies on adapter modules.
Key Takeaway
The core owns the ports; adapters are replaceable implementations.
Test business logic without infrastructure — mock ports, not databases.
Swap adapters without recompiling core: that's the proof you've done it right.
Hexagonal Architecture: Ports & Adapters Flow THECODEFORGE.IO Hexagonal Architecture: Ports & Adapters Flow From driving adapters through ports to driven adapters Driving Adapter Primary actor (UI, test, API) Port (Contract) Interface defining use case Application Core Business logic, no framework Port (Contract) Interface for external service Driven Adapter Secondary actor (DB, queue) ⚠ Circular dependency via shared entities Keep ports as pure contracts, no domain leak THECODEFORGE.IO
thecodeforge.io
Hexagonal Architecture: Ports & Adapters Flow
Hexagonal Architecture

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.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
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 & Plug Metaphor
  • 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.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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());
    }
}
Test Pyramid with Hexagonal
  • 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.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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.

Real-World Hexagonal: What Shopify and Netflix Actually Do

Theory is cheap. Let's talk about where this pattern survives production traffic.

Shopify doesn't have a "hexagonal architecture." They have a core domain that processes orders, and that domain talks to payment gateways through contracts. When they add a new payment provider, they write an adapter. The core logic never changes. This isn't about being clever — it's about not rewriting your checkout flow every time Stripe changes their API.

Netflix does the same thing with content delivery. Their recommendation engine doesn't know it's talking to a CDN. It knows it needs to fetch a video stream. Whether that stream comes from Akamai, AWS, or their own Open Connect appliance is irrelevant to the business logic.

The pattern succeeds because it solves a concrete operational problem: external dependencies change. Databases get migrated. APIs get deprecated. If your business logic is tangled with your infrastructure, every external change becomes a rewrite. If it's behind ports, it's a ticket.

This isn't academic. It's survival.

PaymentAdapter.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// io.thecodeforge — system-design tutorial

from abc import ABC, abstractmethod

class PaymentPort(ABC):
    @abstractmethod
    def charge(self, amount: Decimal, token: str) -> TransactionResult:
        pass

class StripeAdapter(PaymentPort):
    def charge(self, amount: Decimal, token: str) -> TransactionResult:
        try:
            response = stripe.Charge.create(
                amount=int(amount * 100),
                currency='usd',
                source=token
            )
            return TransactionResult(
                success=True,
                transaction_id=response.id
            )
        except stripe.error.StripeError as e:
            return TransactionResult(
                success=False,
                error_code=e.code
            )

class PayPalAdapter(PaymentPort):
    def charge(self, amount: Decimal, token: str) -> TransactionResult:
        paypal_response = paypalrestsdk.Payment.execute(token)
        return TransactionResult(
            success=paypal_response.success,
            transaction_id=paypal_response.id
        )
Output
> Both adapters implement the same port.
> The core order service never imports stripe or paypalrestsdk.
> When PayPal changes their SDK, you edit one file.
The Adapter Trap:
Don't let domain errors leak through adapters. If Stripe returns 'card_declined', translate that to your domain's 'PaymentDeclined' event. Otherwise, your core logic depends on Stripe's error schema — exactly what the pattern was supposed to prevent.
Key Takeaway
Ports protect you from vendor lock-in. If you can't swap a database or payment provider by changing one adapter file, you're doing it wrong.

The Four Components That Actually Matter (Skip the Yoga Pants Diagrams)

Every blog post draws a hexagon with labels. I'm going to tell you what each piece actually does in production.

Entities — Your business objects with real behavior. Not anemic structs. An Order should know if it can be shipped. A Subscription should validate its own billing cycle. If your entities are just data bags with getters, you've built an ORM wrapper, not a domain.

Ports — Interfaces that define what the domain needs. Not what tech stack can provide. A UserRepository port has methods like find_by_email() and save(). It does NOT have query_raw_sql() or execute_stored_procedure(). The port is the contract. The adapter is the implementation.

Application Services — These orchestrate. They don't contain business rules. A CreateOrderService fetches the user, validates inventory, creates the order, and dispatches events. The business logic lives in the entities, not the services. If your services have if statements about pricing, you've smuggled domain logic into the wrong layer.

Adapters — The ugly infrastructure glue. They translate between your pristine domain and the grimy real world of SQL, HTTP, and message queues. Adapters should be thin. If your adapter has more logic than the entity it serves, you're leaking abstraction.

These four components. That's the architecture. Everything else is ceremony.

OrderService.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// io.thecodeforge — system-design tutorial

class OrderEntity:
    def __init__(self, items: list[OrderItem], status: str):
        self.items = items
        self.status = status

    def can_be_shipped(self) -> bool:
        return (
            self.status == 'confirmed'
            and all(item.in_stock for item in self.items)
        )

class CreateOrderService:
    def __init__(self, user_repo: UserRepository, payment_port: PaymentPort):
        self.user_repo = user_repo
        self.payment_port = payment_port

    def execute(self, user_id: str, items: list[OrderItem]) -> OrderEntity:
        user = self.user_repo.find_by_id(user_id)
        if not user.is_verified:
            raise OrderValidationError("Unverified user cannot create orders")

        order = OrderEntity(items=items, status='pending')
        result = self.payment_port.charge(order.total, user.payment_token)

        if result.success:
            order.status = 'confirmed'
        else:
            order.status = 'failed'

        return order
Output
> The service only calls ports and entities.
> No SQL. No HTTP. No Stripe imports.
> Business rules (verified user check) live in the domain layer.
Senior Shortcut:
When reviewing a PR, check if the domain code imports anything from infra, db, or external packages. If it does, that import is a leak. The domain package should only import the standard library and abstractions.
Key Takeaway
Entities own business rules. Services orchestrate. Ports abstract. Adapters translate. Mix these responsibilities and you get a ball of mud with a hexagon drawn on it.

Why Hexagonal Pays Off: Speed, Safety, and Swap Costs

Hexagonal architecture delivers three measurable benefits. First, test speed. When business logic depends on interfaces instead of databases or HTTP clients, you substitute mocks or in-memory adapters in unit tests — no spinning up Postgres, no network calls. Second, swap cost. The same port (contract) can have a PostgreSQL adapter today and a DynamoDB adapter tomorrow without touching domain code. Changing payment providers? Write one adapter, plug it in. Third, isolation. Adapter failures (timeouts, rate limits) don't cascade into core logic because ports define the contract, and the adapter handles retries. Teams adopting hexagonal report 40-60% faster test suites for domain modules and lower regression risk when swapping infrastructure. The pattern forces you to put boundaries where they belong: at the system edge, not between your business rules.

AdapterSwapExample.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — system-design tutorial

from abc import ABC, abstractmethod

class PaymentPort(ABC):
    @abstractmethod
    def charge(self, amount: float) -> str: ...

class StripeAdapter(PaymentPort):
    def charge(self, amount: float) -> str:
        return f"Stripe charge ${amount}"

class PayPalAdapter(PaymentPort):
    def charge(self, amount: float) -> str:
        return f"PayPal charge ${amount}"

# Domain never knows which adapter it's using
def checkout(payment: PaymentPort, total: float) -> str:
    return payment.charge(total)
Output
checkout(PayPalAdapter(), 50.0)
# Returns: PayPal charge $50.00
Production Trap:
Don't write one port per adapter method. Keep ports coarse — one charge method, not chargeCard, chargeWallet, chargeCrypto. The entire point is the domain doesn't care.
Key Takeaway
Adapters are swappable plug-ins. Ports freeze the contract, adapters handle the details.

Start with the Port, Not the Framework

Getting started with hexagonal architecture means ignoring the database, web framework, and external APIs — resist that instinct. Step one: write the domain interface (port) that your business logic needs. If you're building an order service, define an OrderRepository port with methods like save(order) and findById(id). Step two: implement a fake adapter in-memory — a dict-backed stub. Step three: write your domain logic against the port, passing the adapter. Verify the logic works with a unit test. Only then write the real adapter (Postgres, Redis). This sequence forces you to think about what the domain actually requires, not what the ORM provides. Common pitfall: starting with Django models or Spring repositories leads to leaky abstractions. The port defines the contract; the adapter is just a driver. Start with a test, a port, and a fake.

StartWithPort.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — system-design tutorial

from abc import ABC, abstractmethod

class InventoryPort(ABC):
    @abstractmethod
    def reserve(self, sku: str, qty: int) -> bool: ...

class FakeInventoryAdapter(InventoryPort):
    def __init__(self):
        self._stock = {"SKU-123": 10}
    def reserve(self, sku: str, qty: int) -> bool:
        if self._stock.get(sku, 0) >= qty:
            self._stock[sku] -= qty
            return True
        return False

# Domain logic — pure, testable
def create_order(inventory: InventoryPort, sku: str, qty: int) -> str:
    return "confirmed" if inventory.reserve(sku, qty) else "denied"
Output
create_order(FakeInventoryAdapter(), "SKU-123", 3)
# Returns: 'confirmed'
Production Trap:
Don't let your ORM define the port return types. If the domain needs a list of product IDs, return List[str], not a QuerySet. The adapter translates.
Key Takeaway
Port first, fake adapter second, real adapter last. That order guarantees your domain stays framework-free.

Overview

Hexagonal Architecture, also called Ports and Adapters, was introduced by Alistair Cockburn in 2005 to solve a fundamental problem: how do we build software that remains decoupled from infrastructure? Traditional layered architectures often leak framework or database concerns into business logic, creating tight coupling that makes testing painful and replacement expensive. The hexagonal pattern inverts this by placing the business domain at the center and expressing all external interactions—databases, APIs, message queues, UIs—through abstract ports. Adapters on the peripheral implement those ports, translating between the outside world and the core. The name comes from the visual metaphor of a hexagon, though the number of sides is arbitrary; the key insight is that the system has multiple, symmetric entry points, not a top-heavy layer stack. This overview sets the stage: hexagonal is about protecting your business rules from change, not about drawing pretty shapes. Every port is a boundary where ownership flips—you control the contract, the adapter handles the how.

order_system.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — system-design tutorial
// 25 lines max

# Core domain — no database, no HTTP
class Order:
    def __init__(self, id, items):
        self.id = id
        self.items = items
        self.status = "pending"

    def approve(self):
        if not self.items:
            raise ValueError("Empty order cannot be approved")
        self.status = "approved"

# Port (interface)
class OrderRepository:  # abstract port
    def save(self, order): ...
    def find_by_id(self, order_id): ...
Output
No output — static definition
Production Trap:
Don't let the diagram dictate your code. Engineers often create hexagonal-shaped packages and three adapter folders before writing a single domain class. Start with the port interface, not the folder structure.
Key Takeaway
Hexagonal architecture centers business logic and abstracts external systems behind ports.

Principles

Three principles govern hexagonal architecture. First, Dependency Rule: dependencies point inward. The domain core never imports frameworks, databases, or UI libraries—only plain language abstractions. Second, Port Boundary: a port is a contract defined by the core, not the infrastructure. It specifies what the core needs (e.g., "save this order"), not how the adapter will implement it. Third, Adapter Symmetry: each external actor gets its own adapter. A MySQL adapter implements the OrderRepository port; an HTTP adapter implements the Notifier port. Adapters can be hot-swapped without altering core logic. These principles enable testability—mock the port, test the domain—and change tolerance. When Stripe replaces PayPal, you swap one PaymentPort adapter for another; the approval logic in the core never blinks. The real art: resist the temptation to let the ORM's query patterns dictate your port signatures. Own the contract, own your business rules.

adapter_example.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — system-design tutorial
// 25 lines max

from core.ports import OrderRepository, PaymentGateway

class PostgresOrderRepo(OrderRepository):
    def save(self, order):
        # ORM specifics here — core stays clean
        print(f"INSERT INTO orders VALUES ({order.id})")

def confirm_order(order_id, repo, payment):
    order = repo.find_by_id(order_id)
    payment.debit(order.total)
    order.approve()
    repo.save(order)
Output
No output — adapter wiring example
Design Hint:
A port should fit on one index card. If your port signature needs three parameters and a callback, the boundary is likely wrong—you're exposing implementation details.
Key Takeaway
Dependencies point inward; ports are core contracts; adapters are swappable implementations.
● Production incidentPOST-MORTEMseverity: high

The Ghost Order: When a Transaction Spans Two Adapters

Symptom
Orders 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.
Assumption
The team assumed that the @Transactional annotation on the service method covered both the database write and the Stripe API call.
Root cause
The 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.
Fix
Move 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 guideSymptom → Action grid for the most common breakages when using Ports and Adapters.5 entries
Symptom · 01
Adapter throws 'No suitable bean' or class not found at startup
Fix
Check 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.
Symptom · 02
Core logic calls port method, but nothing happens (no error)
Fix
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.
Symptom · 03
Performance degradation after adding a new adapter
Fix
Adapter 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.
Symptom · 04
Transaction unexpectedly rolled back
Fix
Check 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.
Symptom · 05
Unit tests pass but integration tests fail with adapter errors
Fix
Your 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.
★ Quick Debug Cheat Sheet for Hexagonal ArchitectureCommon symptoms when ports and adapters are misconfigured, with the exact commands to diagnose and fix.
No adapter found for port
Immediate action
Check DI configuration
Commands
grep -r '@Component\|@Service' src/main/java/io/thecodeforge/adapter/
mvn dependency:tree -Dincludes=io.thecodeforge
Fix now
Add missing @Component or @Service annotation; ensure component scan includes the adapter package
Port method returns null but adapter is present+
Immediate action
Check 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 now
Add logging in the adapter method and verify it's being invoked; check for early returns
Transaction boundary violation (partial commit)+
Immediate action
Identify 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 now
Move external I/O after transaction commit using @TransactionalEventListener(phase = AFTER_COMMIT)
Circular dependency between adapters+
Immediate action
Break 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 now
Redesign to avoid adapter-to-adapter calls; core should mediate all communication
Hexagonal vs Layered Architecture
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

1
Hexagonal Architecture decouples core business logic from infrastructure through ports (interfaces) and adapters (implementations).
2
Driving adapters initiate calls into the core; driven adapters are called by the core. Keep them in separate packages.
3
Dependency inversion is the engine
core defines ports, adapters implement them. The core never imports adapter code.
4
Testability improves dramatically
core unit tests with mocks, adapter integration tests with real I/O.
5
Production gotchas
transaction boundaries, logging abstraction, and circuit breaker placement require careful design.

Common mistakes to avoid

4 patterns
×

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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between Driving (Primary) and Driven (Secondary) ...
Q02SENIOR
How do you handle a transaction that spans a database write and an exter...
Q03SENIOR
When would you NOT use Hexagonal Architecture?
Q04SENIOR
How do you prevent the core from knowing about adapters' implementation ...
Q05JUNIOR
What is the role of the 'configuration' or 'assembly' adapter in Hexagon...
Q01 of 05SENIOR

Explain the difference between Driving (Primary) and Driven (Secondary) adapters. Give an example of each.

ANSWER
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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is Hexagonal Architecture in simple terms?
02
Is Hexagonal Architecture the same as Ports & Adapters?
03
How many adapters should I have?
04
Does Hexagonal Architecture work with microservices?
05
What is the main disadvantage of Hexagonal Architecture?
N
Naren Founder & Principal Engineer

20+ years shipping large-scale distributed systems. Drawn from code that ran under real load.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's Architecture. Mark it forged?

10 min read · try the examples if you haven't

Previous
Strangler Fig Pattern
9 / 13 · Architecture
Next
Domain-Driven Design Basics