Viklund Random thoughts


Ports and Adapters Without Exceptions

Someone needs to load a ticket. They already have its id as a Guid, so they add an overload to the port, just this once:

public interface IForLoadingTickets
{
    Task<Ticket?> LoadAsync(TicketId id, CancellationToken ct);

    // just this once…
    Task<Ticket?> LoadAsync(Guid id, CancellationToken ct);
}

It compiles, and every unit test still passes. The build goes red anyway:

$ dotnet test tests/Architecture

failed DrivenPortsArchitectureTests.Driven_ports_should_never_accept_a_guid_parameter
  Driven ports should never accept a guid as a parameter. Offending types:
  Tickets.Application.Ports.Driven.IForLoadingTickets

The backend from the previous post is built this way. Its ports and adapters are rules the build enforces, with no exceptions, not even for plain CRUD. This post covers the layout, how it got there, and what it costs.

The pattern

Ports and adapters, or hexagonal architecture, is Alistair Cockburn’s idea from 2005: keep the application in the middle and push technology to the edges, so business rules don’t end up tangled with SQL and HTTP. A port is an interface the application owns. Driving adapters, like endpoints, background jobs and tests, call into the application through driving ports. Driven ports describe what the application needs, and driven adapters implement them with a database, a scheduler or another module.

   driving adapters                               driven adapters
   (they call us)                                 (we call them)

   HTTP endpoint ──┐    ┌───────────────────┐    ┌──▶ SQL database
   Background job ─┼──▶ │    Application    │ ───┼──▶ Job scheduler
   Test ───────────┘    │ use cases, domain │    └──▶ Another module
                        └───────────────────┘

Ports live in the application and adapters depend on them, so the compile-time dependency always points inward, even when the call at runtime goes out to the database.

Why we chose it

We had three reasons. Use cases can be tested with test doubles for their driven ports, and the domain needs none at all. One use case can be called from an endpoint, a background job and an event handler without any of them knowing about the others. And a module can change its insides, or be rewritten, without the modules around it noticing.

The common alternative in .NET is vertical slice architecture, where each feature has its own handler and decides for itself how to reach the data. This codebase is sliced by use case too, with one folder, one port and one use case per feature. The difference is where the technology goes. A vertical slice can talk to the database directly, but here technology stays behind driven ports. That suits this domain, where the rules live in a shared model, the same use case has several ways in, and module boundaries have to survive a rewrite. If most features were simple and independent, vertical slices would be the better choice: less boilerplate, everything for a feature in one place, and integration tests against a real database are cheap these days.

Four projects per module

Every module has the same four projects:

The database, ORM and scheduler packages only appear in Infrastructure, so the compiler stops Core and Application from reaching them.

Each use case implements exactly one driving port and asks for exactly the driven ports it needs. It took me a while to see what that means:

Every use case is its own small hexagon, and the module just groups use cases that share a domain model.

Tickets.Application/
  Ports/
    Driving/SubmitTicket/
      ISubmitTicketPort.cs
      SubmitTicketCommand.cs
    Driven/
      IForStoringTickets.cs
      IForFetchingTopicDetails.cs
  UseCases/
    SubmitTicketUseCase.cs

Tickets.Infrastructure/
  Endpoints/   BackgroundJobs/   ContractAdapters/     driving
  Persistence/   Scheduling/   Bridges/                driven

Driven ports are named IFor…ing, after Cockburn. IForStoringTickets says what the application needs, without saying whether it’s SQL or a repository.

The driving side

The port, its command and its response sit in one folder, and together they’re the use case’s whole public contract:

public interface ISubmitTicketPort
{
    Task<SubmitTicketResponse> HandleAsync(SubmitTicketCommand cmd, CancellationToken ct);
}

public sealed record SubmitTicketCommand(Guid TopicId, Guid CustomerId, string Content);

public sealed record SubmitTicketResponse(Guid TicketId);

The command uses Guids and strings on purpose, because they’re the outside world’s language. Turning them into domain types is the use case’s job, which the last post covers.

The endpoint maps HTTP to a command and calls the port:

app.MapPost("/tickets", async (
    SubmitTicketRequest request,
    ClaimsPrincipal user,
    ISubmitTicketPort port,
    CancellationToken ct) =>
{
    var command = new SubmitTicketCommand(request.TopicId, user.CustomerId(), request.Content);

    var response = await port.HandleAsync(command, ct);

    return TypedResults.Ok(new SubmitTicketEndpointResponse(response.TicketId));
});

It never sees the use case class, and it couldn’t: use cases are internal, so outside Application only the port exists. A port can also have more than one driver. An agent closes a ticket over HTTP, a scheduled background job can close one later, and both call ICloseTicketPort without knowing about each other. The scheduler sits on both sides of the hexagon here: a use case schedules the closing through a driven port, and the job it later runs drives the close port.

The driven side

A use case asks for what it needs in its constructor:

internal sealed class SubmitTicketUseCase(
    IForStoringTickets tickets,
    IForCommittingChanges changes,
    ITopicServiceWindowResolver serviceWindows,
    TimeProvider time
) : ISubmitTicketPort
{
    public async Task<SubmitTicketResponse> HandleAsync(SubmitTicketCommand cmd, CancellationToken ct)
    {
        var topicId = TopicId.From(cmd.TopicId);
        var now = time.GetUtcNow();
        var serviceWindow = await serviceWindows.ResolveAsync(topicId, now, ct);

        var ticket = Ticket.Submit(
            topicId,
            CustomerId.From(cmd.CustomerId),
            MessageContent.From(cmd.Content),
            serviceWindow,
            now);

        tickets.Add(ticket);
        await changes.CommitAsync(ct);

        return new SubmitTicketResponse(ticket.Id.ToPrimitive());
    }
}

There are no database, HTTP or scheduler types, and even time comes in through TimeProvider. ITopicServiceWindowResolver is the one interface without an IFor…ing name, because it isn’t a port. It’s an application service shared by several use cases, and it uses a driven port of its own to fetch the topic.

The adapters are often tiny. This is the whole database adapter behind IForStoringTickets:

internal sealed class TicketStoringAdapter(TicketsDatabase db) : IForStoringTickets
{
    public void Add(Ticket ticket) => db.Add(ticket);
}

The ORM mapping lives in Infrastructure as well, and value objects cross the boundary through ToPrimitive() and From(). Core makes one concession: the ORM needs a parameterless constructor, so each aggregate has a private one in a clearly named region.

Because use cases and adapters are internal, each project registers its own in the dependency injection container, and a small class per module calls both registrations and maps the endpoints. Tests skip all of that and pass test doubles straight to the constructor.

Rules the build enforces

The cost of ports is discipline, and I didn’t want to rely on code review for it. The rules are architecture tests, so breaking one fails the build:

The Guid rule is the one from the start of this post, and the last post in the series explains why it exists.

The test behind it is small. It’s written with NetArchTest, which inspects the compiled assemblies, plus a custom rule that checks each method’s parameters:

using System.Reflection;
using Mono.Cecil;
using NetArchTest.Rules;

public class DrivenPortsArchitectureTests
{
    private const string DrivenPortsNamespace = "Ports.Driven";
    private static readonly Assembly[] ApplicationAssemblies = ModuleDiscovery.ApplicationAssemblies.ToArray();

    [Fact]
    public void Driven_ports_should_never_accept_a_guid_parameter()
    {
        var result = Types
            .InAssemblies(ApplicationAssemblies)
            .That()
            .ResideInNamespaceContaining(DrivenPortsNamespace)
            .Should()
            .MeetCustomRule(new DoesNotHaveMethodsWithGuidParametersRule())
            .GetResult();

        result.IsSuccessful.ShouldBeTrue(
            "Driven ports should never accept a guid as a parameter. Offending types:\n"
                + string.Join('\n', result.FailingTypeNames ?? [])
        );
    }
}

public class DoesNotHaveMethodsWithGuidParametersRule : ICustomRule
{
    public bool MeetsRule(TypeDefinition type) =>
        !type.Methods.Any(m => m.Parameters.Any(p => MentionsGuid(p.ParameterType)));

    // Guid, Guid?, Guid[], ref Guid, List<Guid>, Task<Guid?>…
    internal static bool MentionsGuid(TypeReference type) => type switch
    {
        { FullName: "System.Guid" } => true,
        GenericInstanceType generic => generic.GenericArguments.Any(MentionsGuid),
        TypeSpecification spec => MentionsGuid(spec.ElementType),
        _ => false,
    };
}

ModuleDiscovery finds the Application assembly of every module, so a new module is covered without touching the test. MentionsGuid looks inside nullables, arrays, ref parameters and generic arguments, so a list of Guids can’t slip through either. A second rule uses it for return types.

Adding a feature follows the same inside-out recipe every time. It’s on the series page, along with a cheat sheet of where everything goes.

What I learned

None of these conventions were there on day one.

The names took three rounds. The first driven ports had ordinary .NET names like ITopicsRepository and ITopicLookup. About six weeks in we renamed them to the IFor…ing style, and then overdid it: an application service became IForFactoringTicketAssignment, even though no adapter implemented it. We moved those back out. If an adapter implements it, it’s a port, and if the application implements it, it’s a service.

Use cases were called handlers until September, and their commands lived in UseCases/, so every endpoint and job had to import the implementation’s namespace. Since the commands and responses moved into the port’s folder, an adapter needs a single using.

Most rules came after the code had already drifted. The first architecture tests arrived about six weeks in, and adding the Guid rule meant changing eight existing ports.

What has worked is writing the rule for the target, skipping it with a reason, migrating, and then turning it on.

One rule is still skipped, waiting for the last few use cases to get tests.

Configuration started as options classes in Application. Now it comes through driven ports that return value objects, and a missing setting fails loudly instead of falling back to a default.

Is it worth it?

The cost is frustration. For a use case that’s just CRUD, writing the port, the command, the response, the use case and the adapter feels like ceremony, because it is.

Reads are no exception either. Getting a ticket goes through a driving port and a use case like any command. The use case loads the aggregate through the same driven port the commands use and maps it to a response. There’s no separate read model, so the price is that a query loads whole aggregates even when the response needs only a few fields. If that becomes a problem, a read model behind its own driven port would fit the same rules.

I counted. About a third of the backend’s files exist only because of the ports: the port interfaces, their commands and responses, and the endpoint types that map those responses again. Most of them are a few lines long, though, so together they’re only about an eighth of the code. A typical CRUD feature is seven small files and about 80 lines, and the business rule itself is under ten of those lines. The ceremony shows up in the file list far more than in the line count.

We decided not to skip it for the simple cases. The code stays uniform, with no exceptions to the rule, because once there’s one exception it’s easy to make the next one where it really shouldn’t be made. It’s the broken windows theory applied to code, and the architecture tests are what hold the line. We’ve seen what happens without them: by the time the Guid rule arrived, eight driven ports already took Guids.

The payoff shows up when a use case gets more complex: when it needs facts from another module, has more than one way in, or has rules worth testing without a database. Replying to a ticket, in the last post, is that kind of use case.

In six months, we’ve seen that payoff. Several use cases run behind both a REST endpoint and a scheduled job, and several have been reused as they were, with only the endpoint changing what it maps into its response. That mapping is the largest single part of the ceremony, and it’s also what makes the reuse cheap.

The architecture tests have helped most in local development. A wrong reference or a Guid on a driven port fails the build on your own machine, so you can’t make those mistakes, and nobody has to catch them in code review. A developer who joined after the project had started picked up the patterns easily, too. I think the uniformity is why: once you’ve seen how one feature is built, you’ve seen them all.

For a backend like this one, I think it’s worth it. The next post looks at one more kind of adapter, other modules, and at what happened when two of them were merged into one.