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.IForLoadingTicketsThe 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:
- Core: the domain model from the previous post. References only a small shared kernel.
- Application: ports and use cases. References Core.
- Infrastructure: every adapter, from endpoints and database access to background jobs and bridges to other modules. References Application, Core and other modules’ Contracts.
- Contracts: the module’s public surface for other modules. References only the shared kernel.
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/ drivenDriven 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:
- Core doesn’t depend on Application or Infrastructure.
- Application doesn’t depend on Infrastructure.
- There are no database or scheduler packages in Core or Application.
- Modules only meet through Contracts.
- Driven ports never take or return a Guid.
- Value objects keep their state private.
- Use cases and adapters are internal and sealed.
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.