Ports and Adapters (Hexagonal)
The Insight That Changed How I Thought About Interfaces
When I was working on the chatbot service in my POS system, I had a problem: the same core logic needed to work in three different contexts. In production, it received messages via HTTP from the API. In background processing, it received work from a Redis queue. In tests, it was called directly from Python functions. Three different entry points, same domain logic.
Without hexagonal architecture, I had three versions of the same wiring code, and each new "way to trigger the chatbot" required touching the business logic. With hexagonal architecture, the core logic became a library that any driver could call through a defined port. Adding a new entry point meant writing a new adapter, not touching the core.
The name "hexagonal" is a diagram choice β Alistair Cockburn drew the core as a hexagon to emphasise that all sides are equal. The real concept is ports and adapters: the core defines ports (interfaces), and adapters plug into those ports from the outside.
Table of Contents
The Core Idea
The hexagonal model divides the application into three zones:
Left side (Driver Adapters): How the world calls the application (HTTP, CLI, queue messages)
Core: The application's business logic, completely isolated from delivery mechanisms
Right side (Driven Adapters): What the application calls (databases, external APIs, file systems)
Ports vs Adapters
Port
An interface defined by the core
ChatbotPort, ConversationRepository
Driver Adapter
Translates incoming calls to port invocations
FastAPIAdapter that calls ChatbotPort.chat()
Driven Adapter
Implements a port that the core calls
MongoConversationRepository implementing ConversationRepository
Ports are owned by the core. Adapters are owned by the infrastructure/UI rings. Adapters depend on ports; ports never depend on adapters.
Driver Ports and Driven Ports
Driver Ports (Inbound)
Driver ports are the interfaces through which external actors call the application. They are defined in the core and implemented by the application services/use cases.
Driven Ports (Outbound)
Driven ports are the interfaces the core uses when it needs something from the outside world. The core defines what it needs; adapters implement it.
Practical Example: Chatbot Service
The chatbot service in my POS system handles natural language queries about orders, menu items, and restaurant status. The core logic is the same regardless of whether the query comes from HTTP or a queue.
Multiple Adapters for the Same Port
The real power: I can swap adapters without touching the core.
In development, I use LocalLLMAdapter with Ollama. In production, I use OpenAIAdapter. The ChatbotService core does not know or care which one is wired in.
Same ChatbotService, two completely different drivers. The core wrote zero lines of adapter code.
Testing with Test Adapters
No HTTP server, no MongoDB, no OpenAI API key needed.
When to Choose Hexagonal over Onion
Both achieve domain isolation. The difference is emphasis:
Multiple entry points (HTTP + queue + CLI)
Hexagonal β the multiple driver adapter model makes this explicit
Multiple external dependencies that may change
Hexagonal β driven ports make swapping easy
Rich domain with complex rules
Onion β the ring model expresses domain centrality clearly
Team coming from DDD background
Onion β maps naturally to DDD concepts
Need to test the same use case from multiple triggers
Hexagonal
In practice, the two patterns are compatible and often combined.
Lessons Learned
The port is the contract; the adapter is the implementation. Never let the adapter's concerns bleed into the port definition.
Driver adapters are thin. Their only job is to translate the outside world's format into the core's port interface.
Driven adapters are where the technical complexity lives. Database connection pooling, API rate limiting, retries β all of that belongs in the adapter.
Naming ports after capabilities, not technologies.
LLMProvider, notOpenAIProvider.ConversationRepository, notMongoRepository. The port name should survive a technology change.The wiring happens at the composition root. Dependency injection, configuration, and adapter selection happen in one place β not scattered across the application.
Last updated