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:

spinner
  • 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

Concept
Definition
Example

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:

Scenario
Better Choice

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, not OpenAIProvider. ConversationRepository, not MongoRepository. 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