Katabench
Start free
← All posts

Hexagonal architecture in .NET without the dogma

Hexagonal architecture has a branding problem. The name suggests geometry, the diagrams have six sides for no functional reason, and the canonical explanation buries a genuinely simple idea under a pile of arrows. People walk away thinking it is a heavyweight pattern for enterprise systems, then either over-apply it to a to-do app or dismiss it entirely.

The idea underneath is small and worth keeping. Alistair Cockburn called it "ports and adapters," which is the better name because it tells you what the parts are. Strip away the hexagon and you are left with one rule: your domain talks to the outside world only through interfaces it defines, and the outside world plugs into those interfaces. That is the whole thing.

Domain in the center, ports on the edge

Picture three concentric ideas, not three folders necessarily, but three responsibilities.

In the center is the domain: your business logic, entities, the rules that would still be true if you swapped every database and message bus tomorrow. The domain depends on nothing external.

Around it are ports: interfaces, owned by the domain, describing what it needs from the world. "I need to load an order." "I need to publish that an order shipped." Phrased in the domain's language, not the technology's.

On the outside are adapters: the concrete implementations that satisfy those ports. The EF Core class that actually loads the order. The controller that drives the domain in response to an HTTP request. Adapters depend inward on the ports. The domain never depends outward on the adapters.

Ports and adapters

Domain depends on nothing DispatchShipment use case IShipmentRepository IEventPublisher HTTP controller drives the domain EF Core adapter persistence Message bus adapter publishes events every arrow points inward ✓ domain -> EF Core ✗
Adapters plug into ports the domain owns. The one outward arrow is the whole failure mode.

The reason this beats the layered architecture most of us learned first is direction. In classic layering, the data layer sits at the bottom and everything piles on top, so the dependency arrow runs straight down into the database. Business logic ends up importing Microsoft.EntityFrameworkCore and the layers congeal into the thing everyone calls "the spaghetti": you cannot touch the service without dragging persistence along, and you cannot test it without a database. Ports and adapters flips the bottom of that stack. Persistence becomes a detail that plugs into the domain, instead of a foundation the domain rests on.

A port and an adapter, in C#

Concretely. The port is an interface in the domain project, written in domain terms:

namespace Shipping.Domain;

public interface IShipmentRepository
{
    Shipment? GetById(ShipmentId id);
    void Save(Shipment shipment);
}

A use case in the domain depends only on that port. It has no idea EF Core exists:

namespace Shipping.Domain;

public sealed class DispatchShipment(IShipmentRepository repository)
{
    public void Handle(ShipmentId id, Carrier carrier)
    {
        var shipment = repository.GetById(id)
            ?? throw new ShipmentNotFoundException(id);

        shipment.Dispatch(carrier);   // business rule lives on the entity
        repository.Save(shipment);
    }
}

The adapter lives in the infrastructure project. It references the domain to implement the port, and it is where EF Core is allowed to appear:

namespace Shipping.Infrastructure;

using Shipping.Domain;

public sealed class EfShipmentRepository(ShippingDbContext db) : IShipmentRepository
{
    public Shipment? GetById(ShipmentId id) =>
        db.Shipments.SingleOrDefault(s => s.Id == id);

    public void Save(Shipment shipment)
    {
        db.Shipments.Update(shipment);
        db.SaveChanges();
    }
}

Read the using directives like an architect. Shipping.Domain imports nothing from Shipping.Infrastructure. Shipping.Infrastructure imports Shipping.Domain. The dependency points inward, always. Swap to Dapper, Postgres, an in-memory fake for tests, and the domain file does not change a character. That immovability is the payoff.

The trap: hexagonalizing a CRUD app

Here is the part the tutorials skip. If your application is mostly create-read-update-delete over a handful of tables, ports and adapters can cost you more than it returns.

When DispatchShipment would only ever be "load row, set a column, save row," the port adds a layer of interface and a layer of mapping to protect a domain that has no behavior worth protecting. You get four files where one minimal API endpoint calling DbContext directly would have been honest and readable. Abstraction is not free; every port is a contract you have to maintain and a hop a new reader has to follow.

A port earns its keep when there is real domain behavior behind it, or a genuine prospect of swapping the adapter. A port that wraps a single table you will never reorganize is ceremony.

The judgment call is about where the complexity actually lives. Rich domains, where order rules, pricing, and state machines carry weight, are exactly where you want infrastructure held at arm's length. Thin CRUD is where you should let the framework be the architecture and move on. Knowing which one you are looking at is the senior skill. Reaching for the hexagon reflexively is how a two-week feature becomes a two-month one.

Layered "spaghetti" Ports and adapters
Dependency direction Domain to database Infrastructure to domain
Test the domain Needs a database Plain unit test with a fake
Swap persistence Edit the domain Write a new adapter
Cost on a thin CRUD app Low Often too high

Getting the direction into your fingers

Hexagonal architecture is easy to nod along to and hard to keep honest under deadline pressure. The boundary erodes one shortcut at a time: a DbContext referenced from a handler "just for now," a domain entity decorated with an EF attribute, an adapter leaking into the center. Each one compiles. None of them announces that the dependency arrow just bent the wrong way.

The way the rule becomes a reflex is reps with feedback that checks the boundary, not just the behavior. Katabench's architecture katas include Ports and Adapters as a real kata type. You build the domain, the ports, and the adapters, and your submission is graded on two axes: does it behave correctly, and does it respect the layering and dependency direction the kata specifies. The structural part is enforced by fitness functions on the static assembly, so a domain that reaches out to infrastructure fails the grade, with the violation named.

That feedback loop is the difference between having read about ports and adapters and being able to apply it under pressure without overshooting. If you want to see the pipeline that compiles, runs, and grades your code, we wrote up how it works, and the rest is reps.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.