A team builds an order processing service. The service accepts orders over HTTP, stores them in PostgreSQL, publishes events to RabbitMQ, and calls a third-party fraud detection API. The business logic — validate the order, apply pricing rules, determine approval — is scattered through the HTTP handlers, interleaved with SQL queries and HTTP client calls.
To test the approval logic, the team must mock the database, mock RabbitMQ, and mock the fraud API. The test setup is 80 lines. The test assertion is three lines. When RabbitMQ is replaced with Kafka, twelve test files change. When the fraud API changes its response format, response-parsing code is mixed with pricing logic.
The application does not have a core. It is all boundary.
Hexagonal architecture (also called Ports and Adapters) is the response. It separates an application into three areas with a strict rule about which direction dependencies are allowed to point.
The Three Areas
The application core contains all business logic. It has no knowledge of how it is deployed, how it receives input, or where it stores data. The core defines what the application can do, not how it does it.
Ports are abstract interfaces defined by the application core. They come in two kinds: driving ports (inbound) — the use cases the application supports — and driven ports (outbound) — the capabilities the application needs (store an order, publish an event, call fraud detection).
Adapters are concrete implementations of ports. An HTTP adapter implements the inbound port by translating HTTP requests into use case calls. A PostgreSQL adapter implements the outbound OrderRepository port. A Kafka adapter implements the outbound EventPublisher port. Adapters live outside the core.
The rule: adapters depend on the core. The core never depends on adapters. This is the Dependency Rule from layered architecture, made architecturally explicit through the port naming convention.
What Ports Look Like
The ports are interfaces in the core, with no implementation:
// Inbound port (the application exposes this)
interface PlaceOrderUseCase:
method execute(command: PlaceOrderCommand) -> OrderResult
// Outbound ports (what the application requires)
interface OrderRepository:
method save(order: Order) -> void
method findById(id: String) -> Order
interface EventPublisher:
method publish(event: DomainEvent) -> void
interface FraudDetectionService:
method assess(order: Order) -> FraudAssessment
The core implements the inbound port. It accepts outbound port dependencies through its constructor:
class PlaceOrderService implements PlaceOrderUseCase:
constructor(repo: OrderRepository, publisher: EventPublisher, fraud: FraudDetectionService):
...
method execute(command: PlaceOrderCommand) -> OrderResult:
order = Order.create(command.items, command.customerId)
assessment = fraud.assess(order)
if assessment.isSuspicious:
return OrderResult.rejected("Fraud risk too high")
order = order.withPricingApplied(command.discountCode)
repo.save(order)
publisher.publish(OrderPlaced(order.id))
return OrderResult.accepted(order)
Notice: no SQL, no HTTP, no Kafka, no JSON. The core knows about Orders, Customers, FraudAssessments. It does not know how any of them are transported, stored, or surfaced.
The Testing Payoff
Testing without infrastructure: replace all adapters with test doubles. The core runs in complete isolation. The test double for OrderRepository is an in-memory map. The test double for EventPublisher is a list that records published events. The test double for FraudDetectionService returns configurable results.
function testOrderPlacedWithValidOrder():
repo = InMemoryOrderRepository()
publisher = CapturingEventPublisher()
fraud = StubFraudService(returnSuspicious = False)
service = PlaceOrderService(repo, publisher, fraud)
result = service.execute(testCommand)
assert result.accepted == True
assert len(publisher.published) == 1
assert publisher.published[0].type == "OrderPlaced"
No database. No Kafka. No HTTP server. Test runs in under one millisecond.
This is the part that makes hexagonal architecture worth the structural overhead in domains where business logic matters: the feedback loop from change to verification collapses from minutes to milliseconds. The test suite becomes a mechanism that continuously verifies the contract between the core and its boundaries.
The Tradeoffs Made Explicit
AT8 (Coupling vs Cohesion). Ports decouple the core from all infrastructure. The core is maximally cohesive — it contains only business logic. Infrastructure adapters are loosely coupled to the core — they implement interfaces, but the core does not know about them. The cost: more interfaces, more files, more wiring. For a simple CRUD service with no significant business logic, this is structural overhead that provides no return. For a service with complex domain logic and multiple possible infrastructure backends, it pays.
AT3 (Simplicity vs Flexibility). Adding a new adapter — a CLI interface, a gRPC adapter, a DynamoDB repository — requires no changes to the core. Flexibility is high. Simplicity is lower: understanding the system requires understanding both the core and the adapter layer, plus the wiring that connects them. This is the right tradeoff when the system's external integrations will change but its business rules will stay stable.
Where It Fails
FM11 (Observability Blindness). Adapter chains that translate between external formats and core types strip context at each translation. An error from the fraud detection API contains an error code, a message, and a risk score. The adapter translates this to a FraudAssessment object. If the original error code is lost in translation, operators cannot diagnose why specific orders are being rejected. Adapters must preserve diagnostic information, not just the happy-path data.
FM8 (Schema/Contract Violation). When an outbound port interface changes — a method signature updated, a return type changed — all adapters implementing that port must be updated. If the interface changes frequently, the adapter layer becomes a maintenance burden. Ports should be defined to be stable; frequent port changes indicate that the port boundary was drawn at the wrong level of abstraction.
Port proliferation is the other common failure: teams create a port for every external interaction, including very stable ones (the local filesystem, the system clock). Twenty inbound ports and fifteen outbound ports make the system harder to understand than the problem it solved. Ports should represent genuine variability — interactions that might be swapped, that differ between test and production, or that represent significant external dependencies.
Where It Lives in Real Systems
Django REST Framework ViewSets as adapters. Viewsets are inbound adapters. They receive HTTP requests and translate them into service calls. In well-designed Django applications, the service layer (use cases) is decoupled from the viewset. In practice, most Django applications merge use-case logic into the viewset — removing the port separation.
AWS Lambda functions as adapters. A Lambda function is an inbound adapter. The event (API Gateway, SQS, S3) is translated into a use case call. The handler should be thin — translate the event, call the use case, format the response. Business logic in the Lambda handler is untestable without invoking Lambda itself.
gRPC service implementation. A gRPC server implements generated stub interfaces — inbound adapters that translate protobuf messages into domain types. The protobuf contract is a port definition. Business logic in the service handler should immediately delegate to a use case layer.
When Not to Use It
If your service is genuinely CRUD — accept requests, write to one database, return responses — hexagonal architecture is overhead. The discipline matters when business logic is non-trivial, when integration points might change, or when test feedback loops are slow because tests touch infrastructure. If none of those apply, a layered architecture (Chapter 11) is enough.
*This article extracts the core of Book 5, Chapter 12 — Hexagonal Architecture (Ports and Adapters). The chapter covers the full code walkthrough (PlaceOrderService, HTTP adapter, PostgreSQL adapter, Kafka adapter, test doubles), the migration playbook for moving a monolith to hexagonal over six months without stopping feature development, the CI import-boundary check that enforces the dependency rule, and the multi-channel customer communication design problem.*