archi3

Hexagonal Architecture

Ports and adapters: isolating the business logic from everything else

Hexagonal architecture (also called "ports & adapters") sets a simple rule: the code that expresses business rules should depend on nothing else. Everything around it - database, web framework, external APIs - becomes an interchangeable detail.

Driving adapters

REST controller

receives an HTTP request

CLI command

receives a command line

Domain

Driven adapters

PostgreSQL repository

persists data

Email client

sends a notification

The problem it solves

In many applications, business logic and technical code end up tangled together: an HTTP controller contains pricing rules, a Doctrine repository decides whether an order can be cancelled, a service class directly imports the Stripe SDK.

The symptom isn't immediate. It shows up when you want to change something: swap PostgreSQL for another store, add a CLI interface next to the web one, or simply write a unit test without spinning up a real database. That's when you discover everything is coupled to everything.

Hexagonal architecture, coined by Alistair Cockburn in 2005, starts from one observation: the business logic should be testable, understandable and changeable without ever touching a line of framework, SQL, or HTTP code.

The core idea: ports and adapters

The name "hexagonal" doesn't mean anything special - Cockburn picked a hexagon simply to have enough sides to draw ports on, with no particular meaning attached to the number six. What matters is the structure: a core in the middle, surrounded by explicit boundaries.

The core is the domain: entities, business rules, use cases. It knows nothing about databases, HTTP, or message queues. To talk to the outside world, it defines ports: interfaces that describe what it needs, or what it offers, in business language.

Adapters are the concrete, technical implementations of those ports. A "send a notification" port can be adapted by an email send, an SMS, or a simple log line in tests. The domain never knows which one is actually plugged in.

Driving ports (primary)

A driving port is an interface the domain exposes to be operated from the outside. It's typically a use case interface: "PlaceOrder", "CancelSubscription", "AuthenticateUser".

Driving adapters call these ports: a REST controller, a CLI handler, a Kafka consumer, an automated test. They translate a technical request (a JSON body, a command line, a message) into a business method call.

Two simple rules help spot a good driving port:

  • Its name and signature use business vocabulary, never HTTP or SQL terms.
  • It could be called just as well from a unit test as from a real controller, with zero difference.

Driven ports (secondary)

A driven port is an interface the domain defines because it needs something from the outside world: persisting an entity, sending an email, calling a payment service. The domain decides the shape of the interface, not the technology chosen to implement it.

This is the point that's often flipped by mistake: the natural reflex is to write the SQL repository first, then extract an interface from it afterwards. Done the other way - domain states the need, infrastructure adapts to it - the port stays stable even if the database changes entirely.

Concrete example: placing an order

Take a simple use case: placing an order. The domain defines what it needs (a driven port for persistence) and what it offers (a driving port for the use case), without ever mentioning PostgreSQL or HTTP.

domain/Ports.kt
// Driven port: defined by the domain, implemented by infrastructure
interface OrderRepository {
    suspend fun save(order: Order)
    suspend fun findById(id: OrderId): Order?
}

// Driving port: what the domain offers to the outside
interface PlaceOrder {
    suspend fun execute(command: PlaceOrderCommand): OrderId
}
infrastructure/PostgresOrderRepository.kt
// Driven adapter: one possible implementation of the port
class PostgresOrderRepository(private val db: DatabaseClient) : OrderRepository {

    override suspend fun save(order: Order) {
        db.query(
            "insert into orders (id, status, total) values (?, ?, ?)",
            order.id.value, order.status, order.total.amount,
        )
    }

    override suspend fun findById(id: OrderId): Order? {
        val row = db.queryOne("select * from orders where id = ?", id.value)
        return row?.let { Order.fromRow(it) }
    }
}

What it really changes: testability

Once the ports are in place, testing the "place an order" use case no longer needs a database. You provide an in-memory implementation of the OrderRepository port, run the use case, and check the final state - in a few milliseconds, no Docker, no SQL fixtures.

That very concrete gain is what justifies the architecture: fast, reliable tests on the business core, plus a small number of targeted integration tests that check each adapter genuinely honors its port.

Common pitfalls

Hexagonal architecture is simple on paper, but a few mistakes come up again and again:

  • Creating a port that exactly mirrors a SQL table (getAll, getById, update): that's CRUD in disguise, not a business interface. The port should express needs ("reserve stock"), not generic operations.
  • Letting an infrastructure type leak into the domain (a Doctrine entity, an HTTP DTO): the moment a domain file imports a technical library, the boundary is broken.
  • Applying this structure to a genuinely trivial application (a plain CRUD with no business rule): the split then adds complexity with no real benefit.