Honeycomb hexagon tile pattern with brand overlay

How to Apply Hexagonal Architecture

Hexagonal architecture keeps an app’s core logic clear of the tech around it. Alistair Cockburn named it in 2005. The core holds the domain rules and the use cases. All the code that speaks to HTTP, SQL, email, or the CLI lives outside.

Hexagonal architecture is often grouped with dependency inversion. The two are related. Dependency inversion is a rule about single interfaces. Hexagonal architecture makes a broader claim, about where business logic lives across the whole app.

This post walks through the pattern. It explains why the direction of dependency matters. It looks at three common variations on the shape.

Two worked examples follow. Both book a meeting room. One is in C#, the other in Python.

What hexagonal architecture really is

Cockburn first used the name Ports and Adapters in 2005. He wanted a precise term for a specific idea. ‘Layered architecture’ had grown too broad to fit. The hexagon itself is just a drawing. Any shape would do.

What matters is the boundary. Inside lives the application core. It holds two things.

The domain encodes the business rules. The use cases describe what the app can do. Both use the language of the business. Neither refers to HTTP, SQL, or any specific tech stack.

Outside the boundary lives every piece that speaks to a tech stack. An HTTP controller sits outside. So does a database client, an email gateway, a CLI, and a test fixture.

Each one calls into the core or is called by it. The core sets the terms. The outside adapts.

Hexagonal architecture diagram showing driving adapters on the left, the core (domain and use cases) in the central hexagon, and driven adapters on the right, with ports on the hexagon edges

A games console is a hexagon

Hexagonal architecture is easier to see on a cartridge-era games console. Inside the case, the mainboard runs the game logic. It knows nothing about what is plugged into it. A cartridge slot loads the game. Every other socket on the case is a port.

Isometric line-art illustration of a games console with a cartridge hovering above the top-loading slot, ready to be inserted, with dashed alignment guides from the cartridge contact pins down to the slot and a purple 'insert' arrow in the clear space behind the cartridge

The cartridge is the clearest example. It carries the game, but it does not decide how the console runs. It fits a shape the console exposes and hands its contents through that shape. Any cartridge that fits the slot works. The mainboard does not care which one it is.

Controller sockets are driving ports. A gamepad, a light gun, a dance mat, and a set of Donkey Konga bongos all speak different physical input. They all turn it into the same controller protocol.

The video and audio outputs are driven ports. The console sends out pixels and samples. An RF box, an S-Video cable, or an HDMI converter takes them to a display the console knows nothing about. A memory card slot is a driven port too, and any compatible card sits behind it.

Diagram showing a games console mainboard as a hexagon with a cartridge, gamepad, and light gun on the driving side, and a TV display, speakers, and memory card on the driven side, each connected to the hexagon through a port

Swapping any of these does not change the console. A new controller does not require a new SNES. Moving from a CRT to an LCD does not require a new PlayStation. A larger memory card fits the same slot, and the game behind it runs the same way.

Hexagonal architecture does the same thing in software. Use cases sit in the middle like the mainboard. Controllers, cartridges, and screens turn into HTTP, CLI, SQL, and SMTP.

Swap the adapter you need to swap. Leave the core alone. The rest keeps working.

The rule at the heart of hexagonal architecture

Hexagonal architecture has one rule. References point inward. The outside depends on the core. The core depends on nothing.

In a typical layered app, the data layer sits at the bottom. Business logic depends on data access. Data access depends on the database driver.

Hexagonal architecture flips the shape. The domain sits at the centre. Everything else depends on it, data access included.

This is a whole-app rule, not dependency inversion applied to one layer. A domain project that references an Entity Framework type has widened the boundary. So has one that references a JSON type, or anything HTTP-shaped. The rule is broken the moment that reference lands.

In a typed language, project references make this mechanical. Domain sees nothing. Application sees Domain. Infrastructure sees both, and Web sees all three. A forbidden reference fails the build, which is a useful signal.

In a dynamic language, no compiler is watching. Module layout, convention, and code review hold the boundary. Some teams use import linters to make the rule mechanical again. Either way, the direction of dependency is the quickest way to check the pattern is in place.

Side-by-side comparison showing a classic layered stack with arrows pointing down through presentation, application, domain, and infrastructure, next to a hexagonal topology with the domain at the centre and adapters on either side

Ports are interfaces with a specific owner

Ports are often described as interfaces. That is true at the language level. One further detail matters. A port is declared by the core, in terms the core uses. The interface lives with the code that depends on it, not the code that implements it.

That ownership detail is what makes hexagonal architecture a whole-app pattern rather than a local trick.

Think of a port that saves a Room. The core wants to save one and load one. The port has Save(Room) and Load(RoomId). It sits in Application, next to the use case that calls it. An adapter in Infrastructure implements it against a real tech stack.

Consider a related interface called IDbContextRoomRepository with a method ExecuteSqlSaveAsync. Its vocabulary tells you where it was born. SQL and ExecuteSql are terms the tech stack uses, not the domain.

A core can call an interface of this shape. As it does, it picks up the tech-stack vocabulary. That is the difference between an infrastructure abstraction and a port. A port’s vocabulary belongs to the core.

An adapter lives outside the core and talks to a real tech stack. It uses Entity Framework directly, writes SQL, and handles provider errors. Its only job is to implement its port well.

You can have many adapters for one port. An in-memory one might run in tests. A PostgreSQL one might run in production. A read-replica one might run in reports.

Diagram showing a RoomRepository port interface declared inside the core next to the BookMeetingRoom use case, with a SqlRoomRepository adapter in infrastructure that implements the port, and a dependency arrow pointing from the adapter into the core

Driving adapters call into the core

Driving adapters, or primary adapters, call the core. An HTTP controller is one. A CLI command is one too. So is a test that runs a scenario.

The driving side is the active side. It picks up something from outside and pushes it in.

The driving port is what the adapter calls. In this post it is a plain class called BookMeetingRoom. It has one method, Execute. Execute takes a request object and returns a Result.

There is no dispatcher, no command bus, and no mediator. The adapter calls the class directly. Those extra layers are separate concepts. They add value in their own settings, but they sit alongside hexagonal architecture rather than inside it.

Driving adapters turn a transport vocabulary into the core’s. HTTP has verbs, status codes, and content types. The controller reads what it needs from the request. It builds an object the core knows, calls the use case, and turns the Result into a status code.

All HTTP details stay in the adapter. The use case cannot tell whether HTTP, a CLI, or a test called it.

Driven adapters are called by the core

Driven adapters, or secondary adapters, are the ones the core calls. When the use case needs to save a Room, it calls a driven port. The adapter then talks to the tech stack that stores the data.

A repository is the classic driven adapter. An email sender is another. Any wrapper around a third-party HTTP client is a third.

The driven port is declared in the core, in core vocabulary. Save(room), Load(roomId), SendWelcome(email).

It does not mention SQL, SMTP, or retries. Those belong to the tech stack.

The core calls driven ports by interface. It cannot tell the adapters apart. An in-memory one in a test looks the same as an Entity Framework one in production. Both satisfy the same port. The use case does not know which it is using.

The benefits of hexagonal architecture

Five things follow from keeping the core clear of the outside. They are not separate claims. Each is a view of the same dependency rule.

The most visible one is testability. A core that touches no infrastructure can be exercised with in-memory fakes. Unit tests run in milliseconds. Most of a real app ends up covered without a database in sight. As Netflix have written, that alone is a headline reason to adopt the pattern.

Next comes the driving side. Presentations become swappable. The same use case can sit behind an HTTP controller today and a CLI command next week. Each new UI is another driving adapter calling the same class. The core does not notice.

The driven side moves the same way. A switch from SQL Server to PostgreSQL is a new driven adapter. Swapping SendGrid for Postmark is another. Ports stay as they are, and the in-memory tests keep passing.

Over time, the pattern pays off in clear diagnostics. A broken business rule shows up in a domain test. Orchestration slips show up in a use-case test. Bad adapters show up in adapter tests. When a bug reaches production, it is usually clear which layer to look at first.

Holding all of this together is a clean separation of concerns. HTTP status codes stay with the HTTP adapter. SQL constraints stay with the SQL adapter. The domain holds its rules without caring how the message came in or where it will be stored. A small, tidy core also makes it easier to write tests that survive when AI writes more of your code.

Three common variations on hexagonal architecture

Three variations show up often enough to recognise by name. Each looks like hexagonal architecture from outside. Each sits a short step away from the full benefits.

Three small hexagon diagrams labelled port inside the adapter, domain imports SQL, and anaemic core, each with a short refactor path beneath

Repository-only hexagonal is the most visible variation. An IRoomRepository interface goes in front of Entity Framework. It is injected into a service class. Business rules live in the service class. The domain tends to be a set of data objects.

The interface helps with DI and test doubles. Both are useful on their own. To move toward the full pattern, add clear use-case classes. Shift rules from the service layer into the domain objects.

Anaemic domain with ports is a subtler one. Ports sit in the right places. Adapters are clean. The dependency direction is right. Logic tends to live in use-case classes that act as the old service layer under a new name.

Hexagonal architecture gives its strongest benefits when the core is rich. Push the rules into domain objects. The use case then becomes a thin orchestrator.

Adapter-as-service-layer is a third variation. Business logic finds its way into the adapters over time. A repository might filter deleted records by default. An email adapter might pick a template from the user’s tier.

As these rules build up, they sit further from the domain than they need to. A second adapter for the same port may behave slightly differently. Move the rules back into the core, and adapters go back to their role as mechanical translators.

The C# domain

The C# example lives in four projects. Domain holds the model. Application holds the use case and the ports.

Infrastructure holds the persistence adapter. Web holds the HTTP controller and the composition root. A forbidden reference fails the build.

The domain has a TimeSlot, an Email, a Room, and a Booking. TimeSlot and Email are value objects. Room is an aggregate root. Booking is an entity held by Room. Hexagonal architecture does not require DDD, but the terms describe each role well.

TimeSlot enforces that start is before end. Email checks its own format. Room holds the rule that two bookings cannot overlap.

// Domain/TimeSlot.cs
public sealed record TimeSlot
{
    public DateTime Start { get; }
    public DateTime End { get; }

    private TimeSlot(DateTime start, DateTime end)
    {
        Start = start;
        End = end;
    }

    public static Result Create(DateTime start, DateTime end)
    {
        if (end <= start)
            return Result.Failure("End must be after start.");
        return Result.Success(new TimeSlot(start, end));
    }

    public bool Overlaps(TimeSlot other) =>
        Start < other.End && other.Start < End;
}

Room takes a time slot and an organiser. It returns either a new Booking or a conflict. The rule is enforced inside the method. Any caller gets it for free.

// Domain/Room.cs
public sealed class Room
{
    public RoomId Id { get; }
    private readonly List _bookings;
    public IReadOnlyList Bookings => _bookings.AsReadOnly();

    public Room(RoomId id, IEnumerable existingBookings)
    {
        Id = id;
        _bookings = existingBookings.ToList();
    }

    public Result Book(TimeSlot slot, Email organiser)
    {
        if (_bookings.Any(b => b.Slot.Overlaps(slot)))
            return Result.Failure("Room is already booked for that time.");

        var booking = new Booking(BookingId.New(), slot, organiser);
        _bookings.Add(booking);
        return Result.Success(booking);
    }
}

The C# use case and its port

Application holds the use case and its driven port. The use case loads the Room, calls Book, saves the Room, and returns a Result. The port, IRoomRepository, sits in Application. It uses core vocabulary only.

// Application/IRoomRepository.cs
public interface IRoomRepository
{
    Task Load(RoomId id, CancellationToken cancellationToken);
    Task Save(Room room, CancellationToken cancellationToken);
}

// Application/BookMeetingRoom.cs
public sealed class BookMeetingRoom
{
    private readonly IRoomRepository _rooms;

    public BookMeetingRoom(IRoomRepository rooms) => _rooms = rooms;

    public async Task<Result> Execute(
        BookMeetingRoomRequest request,
        CancellationToken cancellationToken)
    {
        var slot = TimeSlot.Create(request.Start, request.End);
        if (slot.IsFailure) return Result.Failure(slot.Error);

        var organiser = Email.Create(request.OrganiserEmail);
        if (organiser.IsFailure) return Result.Failure(organiser.Error);

        var room = await _rooms.Load(request.RoomId, cancellationToken);
        if (room is null) return Result.Failure("Room not found.");

        var booking = room.Book(slot.Value, organiser.Value);
        if (booking.IsFailure) return Result.Failure(booking.Error);

        await _rooms.Save(room, cancellationToken);
        return Result.Success(booking.Value.Id);
    }
}

BookMeetingRoom is the driving port for this use case. Execute is its one method. A controller, a CLI, or a test each calls it directly. No class hierarchy sits between the adapter and the use case. Any rule that applies to every request lives in the method or in the domain objects it reaches.

The C# adapters and HTTP wiring

Infrastructure has an in-memory adapter for the driven port. A production adapter backed by Entity Framework is a parallel class. It talks to a DbContext. The shape of the interface is identical.

// Infrastructure/InMemoryRoomRepository.cs
public sealed class InMemoryRoomRepository : IRoomRepository
{
    private readonly Dictionary _rooms = new();

    public Task Load(RoomId id, CancellationToken _)
        => Task.FromResult(_rooms.TryGetValue(id, out var room) ? room : null);

    public Task Save(Room room, CancellationToken _)
    {
        _rooms[room.Id] = room;
        return Task.CompletedTask;
    }
}

Web has a minimal API endpoint. It reads the HTTP request, invokes the use case, and maps the Result to a status code. The adapter builds a BookMeetingRoomRequest and calls Execute. It returns 201 Created or 409 Conflict.

// Web/Program.cs (excerpt)
app.MapPost("/rooms/{roomId:guid}/bookings", async (
    Guid roomId,
    BookMeetingRoomApiRequest body,
    BookMeetingRoom useCase,
    CancellationToken cancellationToken) =>
{
    var result = await useCase.Execute(new BookMeetingRoomRequest(
        RoomId: new RoomId(roomId),
        Start: body.Start,
        End: body.End,
        OrganiserEmail: body.OrganiserEmail), cancellationToken);

    return result.IsSuccess
        ? Results.Created($"/bookings/{result.Value}", result.Value)
        : Results.Conflict(result.Error);
});

The C# tests

The tests look like the tests of any well-structured domain. A domain test confirms the rule by building a Room directly. A use-case test uses an in-memory repository as the driven adapter. Both use xUnit. Test classes follow the Should suffix convention.

// Tests/Domain/RoomShould.cs
public class RoomShould
{
    [Fact]
    public void reject_a_booking_that_overlaps_an_existing_one()
    {
        var existing = new Booking(
            BookingId.New(),
            TimeSlot.Create(At("09:00"), At("10:00")).Value,
            Email.Create("alex@example.com").Value);
        var room = new Room(RoomId.New(), new[] { existing });
        var newSlot = TimeSlot.Create(At("09:30"), At("10:30")).Value;

        var result = room.Book(newSlot, Email.Create("kate@example.com").Value);

        Assert.True(result.IsFailure);
        Assert.Equal("Room is already booked for that time.", result.Error);
    }
}

// Tests/Application/BookMeetingRoomShould.cs
public class BookMeetingRoomShould
{
    [Fact]
    public async Task succeed_when_the_slot_is_free()
    {
        var rooms = new InMemoryRoomRepository();
        var roomId = RoomId.New();
        await rooms.Save(new Room(roomId, Array.Empty()), default);
        var useCase = new BookMeetingRoom(rooms);

        var result = await useCase.Execute(new BookMeetingRoomRequest(
            roomId, At("09:00"), At("10:00"), "kate@example.com"), default);

        Assert.True(result.IsSuccess);
    }
}

Neither test touches HTTP or SQL. The domain test builds a Room and asserts on the Result. Its use-case counterpart wires the real use case to an in-memory adapter. A failure in either one points to a specific layer.

Request flow diagram showing a booking request in hexagonal architecture passing through the HTTP controller, use case, domain method, and repository, with the Result returning back along the same path

The same use case in Python

The Python version uses four modules. They match the C# projects by role. The names are domain, application, infrastructure, and web.

No compiler enforces the direction. Module layout and discipline hold the boundary. An import linter can make this mechanical. The structure tends to read clearly even without one.

The domain has the same shape as the C# version. TimeSlot validates start and end at construction. Room is the aggregate and owns the overlap rule. A Result class in the domain module carries success or failure.

# domain/time_slot.py
from dataclasses import dataclass
from datetime import datetime
from domain.result import Result


@dataclass(frozen=True)
class TimeSlot:
    start: datetime
    end: datetime

    @staticmethod
    def create(start: datetime, end: datetime) -> Result:
        if end  bool:
        return self.start < other.end and other.start  Result:
        if any(b.slot.overlaps(slot) for b in self.bookings):
            return Result.failure("Room is already booked for that time.")

        booking = Booking(id=BookingId.new(), slot=slot, organiser=organiser)
        self.bookings.append(booking)
        return Result.success(booking)

Application holds the use case and its port. The port is an abstract base class. Its concrete adapter lives in infrastructure. A composition root injects it at construction time.

# application/ports.py
from abc import ABC, abstractmethod
from typing import Optional
from domain.ids import RoomId
from domain.room import Room


class RoomRepository(ABC):
    @abstractmethod
    def load(self, room_id: RoomId) -> Optional[Room]: ...

    @abstractmethod
    def save(self, room: Room) -> None: ...


# application/book_meeting_room.py
from dataclasses import dataclass
from datetime import datetime
from application.ports import RoomRepository
from domain.email import Email
from domain.ids import RoomId
from domain.result import Result
from domain.time_slot import TimeSlot


@dataclass(frozen=True)
class BookMeetingRoomRequest:
    room_id: RoomId
    start: datetime
    end: datetime
    organiser_email: str


class BookMeetingRoom:
    def __init__(self, rooms: RoomRepository) -> None:
        self._rooms = rooms

    def execute(self, request: BookMeetingRoomRequest) -> Result:
        slot = TimeSlot.create(request.start, request.end)
        if slot.is_failure:
            return slot

        organiser = Email.create(request.organiser_email)
        if organiser.is_failure:
            return organiser

        room = self._rooms.load(request.room_id)
        if room is None:
            return Result.failure("Room not found.")

        booking = room.book(slot.value, organiser.value)
        if booking.is_failure:
            return booking

        self._rooms.save(room)
        return Result.success(booking.value.id)

The Python adapters and HTTP wiring

Infrastructure has an in-memory adapter that satisfies the RoomRepository port. A production version can talk to SQLAlchemy or raw psycopg. It has the same shape. The core does not care which is wired up.

# infrastructure/in_memory_room_repository.py
from typing import Dict, Optional
from application.ports import RoomRepository
from domain.ids import RoomId
from domain.room import Room


class InMemoryRoomRepository(RoomRepository):
    def __init__(self) -> None:
        self._rooms: Dict[RoomId, Room] = {}

    def load(self, room_id: RoomId) -> Optional[Room]:
        return self._rooms.get(room_id)

    def save(self, room: Room) -> None:
        self._rooms[room.id] = room

Web has a Flask route. It parses the request, calls the use case, and maps the Result to a status code. The route is a thin driving adapter. It plays the same role as the ASP.NET endpoint in the C# example.

# web/routes.py
from datetime import datetime
from flask import Blueprint, jsonify, request
from application.book_meeting_room import (
    BookMeetingRoom,
    BookMeetingRoomRequest,
)
from domain.ids import RoomId


def build_blueprint(use_case: BookMeetingRoom) -> Blueprint:
    bp = Blueprint("bookings", __name__)

    @bp.post("/rooms//bookings")
    def book_room(room_id: str):
        body = request.get_json()
        result = use_case.execute(BookMeetingRoomRequest(
            room_id=RoomId(room_id),
            start=datetime.fromisoformat(body["start"]),
            end=datetime.fromisoformat(body["end"]),
            organiser_email=body["organiser_email"],
        ))

        if result.is_success:
            return jsonify({"booking_id": str(result.value)}), 201
        return jsonify({"error": result.error}), 409

    return bp

The Python tests

Python tests mirror the C# tests. A domain test builds a Room and confirms the overlap rule. An application test wires the use case to an in-memory repo. It runs the whole call path. Neither touches HTTP or SQLAlchemy.

# tests/domain/test_room.py
from datetime import datetime
from domain.booking import Booking
from domain.email import Email
from domain.ids import BookingId, RoomId
from domain.room import Room
from domain.time_slot import TimeSlot


def at(hm: str) -> datetime:
    return datetime.fromisoformat(f"2026-06-01T{hm}:00")


def test_room_rejects_a_booking_that_overlaps_an_existing_one():
    existing = Booking(
        id=BookingId.new(),
        slot=TimeSlot.create(at("09:00"), at("10:00")).value,
        organiser=Email.create("alex@example.com").value,
    )
    room = Room(id=RoomId.new(), bookings=[existing])

    result = room.book(
        TimeSlot.create(at("09:30"), at("10:30")).value,
        Email.create("kate@example.com").value,
    )

    assert result.is_failure
    assert result.error == "Room is already booked for that time."


# tests/application/test_book_meeting_room.py
from application.book_meeting_room import (
    BookMeetingRoom,
    BookMeetingRoomRequest,
)
from domain.ids import RoomId
from domain.room import Room
from infrastructure.in_memory_room_repository import InMemoryRoomRepository


def test_booking_a_free_slot_succeeds():
    rooms = InMemoryRoomRepository()
    room_id = RoomId.new()
    rooms.save(Room(id=room_id))
    use_case = BookMeetingRoom(rooms)

    result = use_case.execute(BookMeetingRoomRequest(
        room_id=room_id,
        start=at("09:00"),
        end=at("10:00"),
        organiser_email="kate@example.com",
    ))

    assert result.is_success

Hexagonal architecture travels across languages because it is about where concepts live. C# has the compiler refuse a forbidden reference. Python relies on module layout and discipline.

Both produce code where the core can be reasoned about on its own, with no database, no HTTP, and no fakes pretending to be either.

When hexagonal architecture is less useful

A CRUD admin screen for twelve records does not need four projects and a Result type. If the domain has no rules to protect, hexagonal architecture adds ceremony without a matching payoff. The scaffolding costs more than it pays back.

Prototypes built to learn something quickly sit in a similar place. A short-lived spike can stay simple and mostly tactical. The useful thing is to notice when a spike has turned into a product. Refactor at that moment, not later.

Hexagonal architecture is also easier to hold onto when a team shares a picture of the boundary. Read the diagram together at the start of a project. Everyone leaves with the same idea of where rules live. Drift becomes easier to spot. A hexagon diagram can do for architecture what an impact map does for product decisions, one page everyone can point at.

Where hexagonal architecture ends up

Hexagonal architecture is less about hexagons and more about direction. The inside of an app holds its own terms. The outside adapts to them. HTTP, SQL, and the CLI all sit on the outside.

When a change to the outside forces a change inside, the pattern points straight at where to look. That signal alone earns the pattern its keep.

Get the dependency direction right and the rest follows. Tests become fast because the core has nothing slow to call. Presentations become swappable. So does the infrastructure.

None of this requires a command bus, a mediator, or a framework. A class with a method, an interface, and a DI container are enough. That is hexagonal architecture in practice.


Posted

in

by