Updated on

Hexagonal architecture, also known as the ports and adapters pattern, keeps the application core independent of external components, such as storage, UI, and communication protocols.

We will explain its structure, where it came from, and its advantages and disadvantages. Then, we will demonstrate its implementation in C#, with a core that we can test with no database behind it.

To download the source code for this article, you can visit our GitHub repository.

Let’s dive in!

What Is the Hexagonal Architectural Pattern?

The hexagonal architectural pattern divides an application into two parts: the application core and everything outside it. Alistair Cockburn described it in 2005 under the name Ports and Adapters.

The application core holds the entities and the business rules. It doesn’t know which database stores its data, or whether a web API or a test is calling it.

The core talks to the outside world only through ports, which are interfaces that the core defines itself. Driving ports describe what the outside world can ask the core to do. Driven ports describe what the core needs from the outside world, such as storage or messaging.

Adapters connect a particular technology to a port. A Web API endpoint is a driving adapter, and it calls a driving port. A repository that uses Entity Framework Core is a driven adapter, and it implements a driven port.

Since the core depends only on its own ports, we can replace any adapter without changing the core.

The General Idea Behind the Hexagonal Architectural Pattern

The main idea of the hexagonal architectural pattern is to divide the system into loosely coupled components. The components are loosely coupled when there is a weak association between them. That means that changes in one of the components affect other components as little as possible, and ideally, not at all. Generally, we achieve that when one component has little knowledge or use of other components.

The hexagonal pattern gets there with one rule: all the dependencies point toward the application core, and the core doesn’t depend on any technology. In his original article, Cockburn asks for an application that can “equally be driven by users, programs, automated test or batch scripts”. We should also be able to test it without the databases it will eventually run with.

Let’s look at how the hexagonal architectural pattern proposes achieving that.

The Graphical Presentation of the Pattern

We will start with a graphical presentation of the hexagonal architectural pattern, drawn for the small application we build later:

Hexagonal architecture diagram of the newsroom sample. In the middle, a hexagon labeled Newsroom.Core holds ArticleService and the Article entity with its publish-once rule. On its top edge sits the driving port IArticleService. Above it, two driving adapters call down into it: the HTTP endpoints in ArticleEndpoints and the unit tests in ArticleServiceTests. On its bottom edge sit two driven ports, and below them the adapters that implement them, with arrows pointing up into each port. ArticleRepository, using EF Core and SQLite, implements IArticleRepository in the app, and FakeArticleRepository implements it in the tests. LoggingSubscriberNotifier implements ISubscriberNotifier in the app, and FakeSubscriberNotifier implements it in the tests. A note says every adapter project references Newsroom.Core.

As we can see, the hexagonal pattern deals with three main terms: application core, ports, and adapters.

But why a hexagon? The number six doesn’t mean anything. Cockburn wanted a shape with room on every side for as many ports as the application needs, instead of a layered drawing. The applications he had seen needed only two to four ports.

With this in mind, let’s describe each of the three terms.

The Terminology

The application core is a central part of our application. The core consists of application logic and entities. Thus, it implements the main functionality and rules of the application. Overall, it should be completely independent of external components, such as storage, UI, communication protocols, etc.

The ports are the interfaces that the application core defines for external components. They describe how the external world can communicate with the core. In general, we can divide them into two categories:

  • primary or driving ports: operations that external systems or users can initiate (API interface, user interface, tests, etc.)
  • secondary or driven ports: operations that the core needs from the external systems to perform its function (database, messaging systems, etc.)

We define both kinds in the application core, usually as interfaces. The core implements the driving ports, while the driven ports define the external functionality we need to enable the core to work. That way, we decouple the external dependencies from the core logic.

The driven ports use the Inversion of Control (IoC) principle and Dependency Injection. In other words, we inject the adapters through the ports from outside the core application. To learn more about IoC and DI be sure to read Dependency Injection in ASP.NET Core. If you know the dependency inversion principle, the driven ports are that principle at the level of an application.

The adapters are the components that connect the external world to the ports. They implement the communication and exchange with the application core. There could be one or more adapters for each port. Similarly to the ports, we can have primary or driving adapters, which call the driving ports, and secondary or driven adapters, which implement the driven ports. The adapters implement the logic of external dependencies (databases, UI, message bus, etc). This logic is unknown to the core application and completely decoupled from it.

Cockburn calls ports and adapters a particular use of the Adapter design pattern, because each adapter converts one interface into another.

Similar Architectural Patterns and Their Influence

The onion architectural pattern is very similar to the hexagonal pattern. It further decomposes the application core into several concentric rings. The onion architecture was proposed by Jeffrey Palermo in 2008, and his first post about it names the hexagonal architecture as sharing the same premise. The application core is divided into the domain model, domain services, and application services. The external dependencies are pushed further into the outer ring. To learn more about the onion architectural pattern, please read our article about the Onion Architecture in ASP.NET Core.

Robert C. Martin proposed the clean architectural pattern in his 2012 post on Clean Architecture. It combines and further refines the principles from the hexagonal and the onion architectural patterns. It isolates the external dependencies into additional components in the outer layers. The inner circles are reserved for domain entities and use cases, which can be roughly approximated with application services. This approach also prescribes that dependencies only point from an outer layer to an inner layer.

Microsoft’s architecture guide even treats all three as one architecture with several names. We compare the onion and the clean architectural patterns in our Onion vs Clean Architecture article.

The Hexagonal Architectural Pattern in C#

Let’s create a simple ASP.NET Core application that implements the described architectural pattern for demonstration.

The Demo Project

Our demo project deals with publishing articles. We will implement features for adding, reading, and publishing articles, with one business rule: an article is published only once. Publishing notifies the subscribers, and our readers don’t want the same article in their inbox twice.

Let’s briefly describe each of the projects, and following that, we will examine each of them more closely.

Newsroom.Core is the application core. Here, we will implement application logic and domain entities, and define our ports.

After that, we have Newsroom.Persistence and Newsroom.Notifications, which are the projects where we will implement driven adapters for storing our articles in a SQLite database and notifying the subscribers.

On the other hand, the Newsroom.Api project is the driving adapter. Here, we expose our endpoints so consumers of our service can call them. It is also where everything is wired together and run.

And finally, Newsroom.Tests is our second driving adapter, with fake driven adapters instead of a real database.

Everything here runs on the .NET 10 SDK, whose CLI spells commands noun first, as in dotnet package add.

First, we need a solution with the four application projects:

dotnet new sln -n Newsroom
dotnet new classlib -n Newsroom.Core
dotnet new classlib -n Newsroom.Persistence
dotnet new classlib -n Newsroom.Notifications
dotnet new web -n Newsroom.Api
dotnet solution add Newsroom.Core Newsroom.Persistence Newsroom.Notifications Newsroom.Api

We don’t need the Class1.cs file in the class libraries, so let’s delete all three. The dotnet new sln command gives us Newsroom.slnx, which is the default solution format on .NET 10.

Now, the project references. Every adapter project references the core:

dotnet reference add Newsroom.Core --project Newsroom.Persistence
dotnet reference add Newsroom.Core --project Newsroom.Notifications
dotnet reference add Newsroom.Core Newsroom.Persistence Newsroom.Notifications --project Newsroom.Api

Newsroom.Core doesn’t reference anything. That’s what keeps the core independent, because the compiler won’t let it use a DbContext or an ASP.NET Core attribute.

Finally, each driven adapter gets the one package its technology needs:

dotnet package add Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12 --project Newsroom.Persistence
dotnet package add Microsoft.Extensions.Logging.Abstractions --version 10.0.12 --project Newsroom.Notifications

The Application Core

In the application core, we define our entities. Based on the project description, we need an Article class, so let’s create it in Newsroom.Core/Article.cs:

namespace Newsroom.Core;

public enum ArticleStatus
{
    Draft,
    Published
}

public sealed class Article
{
    private Article(string title) => Title = title;

    public int Id { get; private set; }
    public string Title { get; private set; }
    public ArticleStatus Status { get; private set; }

    public static Article CreateDraft(string title)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(title);

        return new Article(title);
    }

    public Result Publish()
    {
        if (Status == ArticleStatus.Published)
            return ArticleErrors.AlreadyPublished(Id);

        Status = ArticleStatus.Published;

        return Result.Success();
    }
}

Besides the class, there is a simple ArticleStatus enumeration in the same file, for describing the stage at which the article is currently.

The setters are private, so only the Publish() method can change the status. That’s where our business rule lives. The first call publishes the article, and every later call returns an error.

Why not throw an exception instead? Because publishing an article twice isn’t a bug. It’s a request we expect and refuse, so Publish() returns a Result, which we add in Newsroom.Core/Result.cs:

namespace Newsroom.Core;

public enum ErrorType
{
    NotFound,
    Conflict
}

public sealed record Error(string Code, string Description, ErrorType Type);

public class Result
{
    protected Result(Error? error) => Error = error;

    public Error? Error { get; }
    public bool IsSuccess => Error is null;

    public static Result Success() => new(null);

    public static implicit operator Result(Error error) => new(error);
}

public sealed class Result<TValue> : Result
{
    private readonly TValue? _value;

    private Result(TValue? value, Error? error)
        : base(error) => _value = value;

    public TValue Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException("A failed result has no value.");

    public static implicit operator Result<TValue>(TValue value) =>
        new(value, null);

    public static implicit operator Result<TValue>(Error error) =>
        new(default, error);
}

The Result class holds the Error when an operation fails, and Result<TValue> also carries the value of a successful call. The ErrorType tells the HTTP adapter which status code to use, so the core knows nothing about HTTP. We cover the Result pattern in more detail in a separate article.

Our two errors live in Newsroom.Core/ArticleErrors.cs:

namespace Newsroom.Core;

public static class ArticleErrors
{
    public static Error NotFound(int articleId) =>
        new("Article.NotFound", $"Article {articleId} was not found.", ErrorType.NotFound);

    public static Error AlreadyPublished(int articleId) =>
        new("Article.AlreadyPublished", $"Article {articleId} is already published.", ErrorType.Conflict);
}

After that, in the folder Ports, we define our driving and driven ports.

On the driving side, we expose the interface of our service in Newsroom.Core/Ports/Driving/IArticleService.cs:

namespace Newsroom.Core.Ports.Driving;

public sealed record ArticleDetails(int Id, string Title, string Status);

public interface IArticleService
{
    Task<ArticleDetails> CreateDraftAsync(string title, CancellationToken cancellationToken = default);

    Task<Result<ArticleDetails>> GetAsync(int articleId, CancellationToken cancellationToken = default);

    Task<Result<ArticleDetails>> PublishAsync(int articleId, CancellationToken cancellationToken = default);
}

The port returns ArticleDetails instead of the entity, so an adapter can’t call Publish() on its own and skip the rest of the use case.

On the driven side, they are simple interfaces for our repository and our subscriber notifications. The first one goes in Newsroom.Core/Ports/Driven/IArticleRepository.cs:

namespace Newsroom.Core.Ports.Driven;

public interface IArticleRepository
{
    Task<Article?> GetByIdAsync(int articleId, CancellationToken cancellationToken = default);

    void Add(Article article);

    Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

The second one goes in Newsroom.Core/Ports/Driven/ISubscriberNotifier.cs:

namespace Newsroom.Core.Ports.Driven;

public sealed record ArticlePublished(int ArticleId, string Title);

public interface ISubscriberNotifier
{
    Task NotifyAsync(ArticlePublished message, CancellationToken cancellationToken = default);
}

Neither port mentions SQL, email, or a message broker. They describe what the core needs in its own words, and the adapters decide how to do it.

Finally, we need a concrete implementation of the driving port. Let’s create the ArticleService class in Newsroom.Core/ArticleService.cs:

using Newsroom.Core.Ports.Driven;
using Newsroom.Core.Ports.Driving;

namespace Newsroom.Core;

public sealed class ArticleService(IArticleRepository articles, ISubscriberNotifier notifier)
    : IArticleService
{
    public async Task<ArticleDetails> CreateDraftAsync(
        string title, CancellationToken cancellationToken = default)
    {
        var article = Article.CreateDraft(title);

        articles.Add(article);
        await articles.SaveChangesAsync(cancellationToken);

        return ToDetails(article);
    }

    public async Task<Result<ArticleDetails>> GetAsync(
        int articleId, CancellationToken cancellationToken = default)
    {
        var article = await articles.GetByIdAsync(articleId, cancellationToken);
        if (article is null)
            return ArticleErrors.NotFound(articleId);

        return ToDetails(article);
    }

    public async Task<Result<ArticleDetails>> PublishAsync(
        int articleId, CancellationToken cancellationToken = default)
    {
        var article = await articles.GetByIdAsync(articleId, cancellationToken);
        if (article is null)
            return ArticleErrors.NotFound(articleId);

        var publishing = article.Publish();
        if (!publishing.IsSuccess)
            return publishing.Error!;

        await articles.SaveChangesAsync(cancellationToken);
        await notifier.NotifyAsync(new ArticlePublished(article.Id, article.Title), cancellationToken);

        return ToDetails(article);
    }

    private static ArticleDetails ToDetails(Article article) =>
        new(article.Id, article.Title, article.Status.ToString());
}

The ArticleService class gets both driven ports through its constructor, and it never learns which adapters sit behind them. The PublishAsync() method implements some slightly more exciting logic. It loads the article, asks it to publish itself, saves it, and notifies the subscribers. If the article is already published, the method returns the error before it saves anything or notifies anyone.

Let’s check that the core builds on its own:

dotnet build Newsroom.Core

It builds without a single package or project reference, although it holds all the business logic of our application.

The Driven Adapters

As we already mentioned, there are two ports for driven adapters, so we implement them here.

Like most applications, we want to persist our entities’ state. With that in mind, we use Entity Framework Core to abstract our database. In this demo, we use SQLite, but with straightforward changes, we can use any database for which there is an Entity Framework Core provider. First, we define our DbContext in Newsroom.Persistence/NewsroomDbContext.cs:

using Microsoft.EntityFrameworkCore;
using Newsroom.Core;

namespace Newsroom.Persistence;

public sealed class NewsroomDbContext(DbContextOptions<NewsroomDbContext> options)
    : DbContext(options)
{
    public DbSet<Article> Articles => Set<Article>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Article>(builder =>
        {
            builder.Property(a => a.Title).HasMaxLength(200);
            builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20);
        });
    }
}

EF Core can create an Article through its private constructor and set its private properties, so we don’t have to open up the entity for the database.

After that, we implement the repository, which is the adapter itself, in Newsroom.Persistence/ArticleRepository.cs:

using Microsoft.EntityFrameworkCore;
using Newsroom.Core;
using Newsroom.Core.Ports.Driven;

namespace Newsroom.Persistence;

public sealed class ArticleRepository(NewsroomDbContext dbContext) : IArticleRepository
{
    public Task<Article?> GetByIdAsync(int articleId, CancellationToken cancellationToken = default) =>
        dbContext.Articles.FirstOrDefaultAsync(a => a.Id == articleId, cancellationToken);

    public void Add(Article article) => dbContext.Articles.Add(article);

    public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
        dbContext.SaveChangesAsync(cancellationToken);
}

Both classes include standard use of the Entity Framework. To learn more about Entity Framework, check out our Entity Framework Core Series.

Another driven adapter is the LoggingSubscriberNotifier class. We don’t send real emails in this demo project, so it only writes a log line as a placeholder for a true implementation. Let’s add it to Newsroom.Notifications/LoggingSubscriberNotifier.cs:

using Microsoft.Extensions.Logging;
using Newsroom.Core.Ports.Driven;

namespace Newsroom.Notifications;

public sealed class LoggingSubscriberNotifier(ILogger<LoggingSubscriberNotifier> logger)
    : ISubscriberNotifier
{
    public Task NotifyAsync(ArticlePublished message, CancellationToken cancellationToken = default)
    {
        logger.LogInformation("Telling subscribers about article {ArticleId}: {Title}",
            message.ArticleId, message.Title);

        return Task.CompletedTask;
    }
}

When we want real emails, we write a new adapter for the ISubscriberNotifier port and change one line in Program.cs. The ArticleService class stays as it is.

The Driving Adapters

In this demo project, the first driving adapter is a Web API with three minimal API endpoints, where we expose our features to the consumers. Within them, we are calling our application logic through the IArticleService port. In this case, the application logic is driven by these APIs.

Let’s take a look at the endpoints in Newsroom.Api/ArticleEndpoints.cs:

using System.ComponentModel.DataAnnotations;
using Newsroom.Core.Ports.Driving;

namespace Newsroom.Api;

public sealed record CreateArticleRequest([property: Required, StringLength(200)] string Title);

public static class ArticleEndpoints
{
    public static void MapArticleEndpoints(this IEndpointRouteBuilder app)
    {
        var articles = app.MapGroup("/api/articles");

        articles.MapPost("/", async (
            CreateArticleRequest request,
            IArticleService service,
            CancellationToken cancellationToken) =>
        {
            var article = await service.CreateDraftAsync(request.Title, cancellationToken);

            return Results.Created($"/api/articles/{article.Id}", article);
        });

        articles.MapGet("/{articleId:int}", async (
            int articleId,
            IArticleService service,
            CancellationToken cancellationToken) =>
        {
            var result = await service.GetAsync(articleId, cancellationToken);

            return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
        });

        articles.MapPost("/{articleId:int}/publish", async (
            int articleId,
            IArticleService service,
            CancellationToken cancellationToken) =>
        {
            var result = await service.PublishAsync(articleId, cancellationToken);

            return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
        });
    }
}

In each endpoint, we inject the driving port, execute the application logic, and turn the result into a response. None of the endpoints decides anything about articles.

The [Required] and [StringLength] attributes on CreateArticleRequest check the HTTP input, and the minimal API validation built into .NET 10 runs them before the endpoint’s code does.

The ToProblem() method translates a failed Result for the HTTP clients. Let’s create it in Newsroom.Api/ResultExtensions.cs:

using Newsroom.Core;

namespace Newsroom.Api;

public static class ResultExtensions
{
    public static IResult ToProblem(this Result result)
    {
        var error = result.Error
            ?? throw new InvalidOperationException("A successful result is not a problem.");

        var statusCode = error.Type switch
        {
            ErrorType.NotFound => StatusCodes.Status404NotFound,
            ErrorType.Conflict => StatusCodes.Status409Conflict,
            _ => StatusCodes.Status500InternalServerError
        };

        return TypedResults.Problem(statusCode: statusCode, title: error.Code, detail: error.Description);
    }
}

It maps NotFound to a 404 and Conflict to a 409. The body follows the problem details format that RFC 9457 defines, with our error code in its title field.

Why does this mapping live in the adapter? Because status codes are an HTTP detail. The core only knows that an article wasn’t found or that a request broke its rule.

The ASP.NET Core Application

And finally, to wire it all up, in the application project, we add our services and adapters to the IoC container. But first, we point the SQLite adapter at its database file, so we replace the contents of Newsroom.Api/appsettings.json with:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "Newsroom": "Data Source=newsroom.db"
  }
}

Besides the connection string, we turn down the EF Core logging, so the notifier’s log line stands out.

Then, we replace the contents of Newsroom.Api/Program.cs with:

using Microsoft.EntityFrameworkCore;
using Newsroom.Api;
using Newsroom.Core;
using Newsroom.Core.Ports.Driven;
using Newsroom.Core.Ports.Driving;
using Newsroom.Notifications;
using Newsroom.Persistence;

var builder = WebApplication.CreateBuilder(args);

// The core, behind its driving port
builder.Services.AddScoped<IArticleService, ArticleService>();

// One driven adapter per driven port
builder.Services.AddDbContext<NewsroomDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("Newsroom")));
builder.Services.AddScoped<IArticleRepository, ArticleRepository>();
builder.Services.AddScoped<ISubscriberNotifier, LoggingSubscriberNotifier>();

builder.Services.AddProblemDetails();
builder.Services.AddValidation();

var app = builder.Build();

app.UseExceptionHandler();

using (var scope = app.Services.CreateScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<NewsroomDbContext>();
    dbContext.Database.EnsureCreated();
}

app.MapArticleEndpoints();

app.Run();

The three AddScoped() lines are the wiring diagram of the whole application. Each port gets its implementation, and no other file mentions an adapter by name. The container creates a new ArticleService, with a new repository and DbContext, for every request.

The Program class also configures the middleware and runs the application. UseExceptionHandler() and AddProblemDetails() turn unexpected exceptions into a 500 response.

The AddValidation() call turns on the request checks, and EnsureCreated() creates the SQLite database on the first start. In a real application, we would use migrations instead.

Running the Application

Let’s run our application from the folder with the Newsroom.slnx file:

dotnet run --project Newsroom.Api -- --urls http://localhost:5270

The first start creates the newsroom.db file in the Newsroom.Api folder.

Visual Studio and Rider run the requests in a .http file with one click, and VS Code does the same once we install the REST Client extension.

Let’s add five requests to Newsroom.Api/Newsroom.Api.http:

POST http://localhost:5270/api/articles
Content-Type: application/json

{ "title": "Hexagonal Architecture in C#" }

###

POST http://localhost:5270/api/articles/1/publish

###

POST http://localhost:5270/api/articles/1/publish

###

GET http://localhost:5270/api/articles/42

###

POST http://localhost:5270/api/articles
Content-Type: application/json

{ "title": "" }

The first request creates a draft, and we get 201 Created with the Location header pointing at /api/articles/1:

{
  "id": 1,
  "title": "Hexagonal Architecture in C#",
  "status": "Draft"
}

The second request publishes it, and we get 200 OK:

{
  "id": 1,
  "title": "Hexagonal Architecture in C#",
  "status": "Published"
}

At the same time, the notification adapter writes to the console:

info: Newsroom.Notifications.LoggingSubscriberNotifier[0]
      Telling subscribers about article 1: Hexagonal Architecture in C#

What happens if we publish the same article again? The core refuses, and the HTTP adapter turns the error into 409 Conflict:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
  "title": "Article.AlreadyPublished",
  "status": 409,
  "detail": "Article 1 is already published.",
  "traceId": "00-052862eb89fd28cd8dfd428741d20ed8-c5fcb9a6aef3fb0b-00"
}

This time, there is no log line, because the core returned before it called the notifier.

An article that doesn’t exist gets 404 Not Found:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Article.NotFound",
  "status": 404,
  "detail": "Article 42 was not found.",
  "traceId": "00-8fc7bf037d2f66d90bd27cca6c566f3e-349be0149a2c7d65-00"
}

And the empty title never reaches the core, because the validation in the HTTP adapter rejects it with 400 Bad Request:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Title": [
      "The Title field is required."
    ]
  },
  "traceId": "00-21aa23424b544112f8fc92592c471c0d-cbb1727372176822-00"
}

Testing the Core With Fake Adapters

Our second driving adapter is the test project. The tests call the core through the same driving port as the endpoints, with fakes in both driven ports.

First, we create the test project:

dotnet new classlib -n Newsroom.Tests
dotnet solution add Newsroom.Tests

We remove the template’s Class1.cs file again and replace the contents of Newsroom.Tests/Newsroom.Tests.csproj with:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
    <PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Newsroom.Core\Newsroom.Core.csproj" />
  </ItemGroup>

</Project>

The test project references only Newsroom.Core, so there’s no SQLite and no ASP.NET Core in the test run. We also add xunit.v3.mtp-off, so that dotnet test runs our xUnit v3 tests on VSTest without extra setup.

The fakes are our test adapters. Let’s create them in Newsroom.Tests/FakeAdapters.cs:

using Newsroom.Core;
using Newsroom.Core.Ports.Driven;

namespace Newsroom.Tests;

internal sealed class FakeArticleRepository(Article? stored) : IArticleRepository
{
    public int SaveCount { get; private set; }

    public Task<Article?> GetByIdAsync(int articleId, CancellationToken cancellationToken = default) =>
        Task.FromResult(stored);

    public void Add(Article article) => stored = article;

    public Task SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        SaveCount++;
        return Task.CompletedTask;
    }
}

internal sealed class FakeSubscriberNotifier : ISubscriberNotifier
{
    public List<ArticlePublished> Sent { get; } = [];

    public Task NotifyAsync(ArticlePublished message, CancellationToken cancellationToken = default)
    {
        Sent.Add(message);
        return Task.CompletedTask;
    }
}

The fake repository holds one article and counts the saves, and the fake notifier keeps the messages in a list. Cockburn’s article calls a database replacement like this a mock database. When a fake grows complicated, a library such as NSubstitute can generate one for us.

Now, let’s write the tests in Newsroom.Tests/ArticleServiceTests.cs:

using Newsroom.Core;
using Newsroom.Core.Ports.Driving;

namespace Newsroom.Tests;

public class ArticleServiceTests
{
    [Fact]
    public async Task PublishAsync_WhenArticleIsADraft_PublishesAndNotifiesOnce()
    {
        var articles = new FakeArticleRepository(Article.CreateDraft("Ports and Adapters in C#"));
        var notifier = new FakeSubscriberNotifier();
        IArticleService service = new ArticleService(articles, notifier);

        var result = await service.PublishAsync(articleId: 1, TestContext.Current.CancellationToken);

        Assert.True(result.IsSuccess);
        Assert.Equal("Published", result.Value.Status);
        Assert.Equal(1, articles.SaveCount);
        Assert.Single(notifier.Sent);
    }

    [Fact]
    public async Task PublishAsync_WhenAlreadyPublished_ReturnsConflictAndNotifiesNobody()
    {
        var article = Article.CreateDraft("Ports and Adapters in C#");
        article.Publish();
        var articles = new FakeArticleRepository(article);
        var notifier = new FakeSubscriberNotifier();
        IArticleService service = new ArticleService(articles, notifier);

        var result = await service.PublishAsync(articleId: 1, TestContext.Current.CancellationToken);

        Assert.False(result.IsSuccess);
        Assert.Equal("Article.AlreadyPublished", result.Error!.Code);
        Assert.Equal(0, articles.SaveCount);
        Assert.Empty(notifier.Sent);
    }
}

The first test publishes a draft. The second one starts with a published article and expects an error, no save, and no notification, which is the same rule that returned the 409 over HTTP.

Let’s run them:

dotnet test

Both tests pass:

Test summary: total: 2, failed: 0, succeeded: 2, skipped: 0, duration: 2.9s
Build succeeded in 4.5s

Do the fakes prove everything, then? No. They prove the rules in the core, but not the adapters. For those, we need integration tests that run the real database and endpoints.

The Folder Structure of Our Solution

Every file of our solution appeared in one of the steps above, except the project files, the database file, and two files from the web template, appsettings.Development.json and Properties/launchSettings.json. Here is the general structure of our project:

Newsroom/
├── Newsroom.slnx
├── Newsroom.Core/                  the hexagon, references nothing
│   ├── Article.cs
│   ├── ArticleErrors.cs
│   ├── ArticleService.cs
│   ├── Result.cs
│   └── Ports/
│       ├── Driving/
│       │   └── IArticleService.cs
│       └── Driven/
│           ├── IArticleRepository.cs
│           └── ISubscriberNotifier.cs
├── Newsroom.Persistence/           driven adapter: SQLite through EF Core
│   ├── ArticleRepository.cs
│   └── NewsroomDbContext.cs
├── Newsroom.Notifications/         driven adapter: subscriber notifications
│   └── LoggingSubscriberNotifier.cs
├── Newsroom.Api/                   driving adapter: HTTP, plus the composition root
│   ├── ArticleEndpoints.cs
│   ├── Newsroom.Api.http
│   ├── Program.cs
│   ├── ResultExtensions.cs
│   └── appsettings.json
└── Newsroom.Tests/                 driving adapter: tests, with fake driven adapters
    ├── ArticleServiceTests.cs
    └── FakeAdapters.cs

The layout mirrors the hexagon. The core sits in the middle, and every other project references it.

Here’s each port with the adapters that serve it, in the application and in the tests:

PortSideAdapter in the appAdapter in the tests
IArticleServiceDriving (primary)ArticleEndpoints (HTTP)ArticleServiceTests
IArticleRepositoryDriven (secondary)ArticleRepository (EF Core and SQLite)FakeArticleRepository
ISubscriberNotifierDriven (secondary)LoggingSubscriberNotifierFakeSubscriberNotifier

Do we need a separate project for every adapter? Not really. In a small application, folders in one project work too, as long as nothing in the core’s folder uses an adapter. Separate projects let the compiler enforce that rule for us.

Mistakes That Break the Hexagon

The earlier version of this article had a core method with the signature below. It is wrong, so don’t copy it:

public async Task AddAsync([FromBody] Article article)

The [FromBody] attribute comes from ASP.NET Core, so a core with this method depends on the web framework. It compiles when the core project references a package that brings ASP.NET Core along, like Microsoft.AspNetCore.OpenApi.

Our core refuses it. If we add a using directive for Microsoft.AspNetCore.Mvc at the top of ArticleService.cs and put [FromBody] in front of the title parameter of CreateDraftAsync(), the build fails:

ArticleService.cs(1,17): error CS0234: The type or namespace name 'AspNetCore' does not exist in the namespace 'Microsoft' (are you missing an assembly reference?)
ArticleService.cs(11,10): error CS0246: The type or namespace name 'FromBodyAttribute' could not be found (are you missing a using directive or an assembly reference?)
ArticleService.cs(11,10): error CS0246: The type or namespace name 'FromBody' could not be found (are you missing a using directive or an assembly reference?)

When we undo both changes, the build is clean again. To catch framework types in the core with a test, have a look at our article about architecture tests with NetArchTest. Those tests check the types a project uses, so an unused package reference still needs a look in code review.

The earlier version also had a repository port with GetAllAsync(), UpdateAsync(), and DeleteAsync() methods. That’s a database table with an interface in front of it. Our port has only the three methods that our use cases call.

The Advantages, the Disadvantages, and Possible Use Cases

As with every predefined pattern, the hexagonal architectural pattern brings some advantages but isn’t a magic bullet for all scenarios. Let’s explore some of the pros and cons.

The Advantages of the Hexagonal Architectural Pattern

In the center of this pattern is extracting the external dependencies from the core application logic. This brings some benefits:

  • decoupling: separation of concerns between the core and the technologies around it
  • testability: clear boundaries make testing each component simple, as our two tests with fakes show
  • adaptability: an adapter for a particular port can be exchanged with one line in Program.cs
  • maintainability: changes in one component have minimal (and ideally no) impact on other components

The Disadvantages of the Hexagonal Architectural Pattern

The introduction of new terms and the different approaches to software development also bring some drawbacks:

  • increased complexity: the code base introduces several concepts that don’t exist in a traditional approach
  • harder debugging: having parts of the logic in different components makes the application more complex to debug
  • entity translations: entities that cross the boundary must be adapted, which requires some translations, like our Article to ArticleDetails mapping
  • steeper learning curve: additional ramp-up may be required to grasp the new architectural concepts

Potential Use Cases

Considering the above advantages and disadvantages, here are some sample project types for which the described pattern may be a good fit:

  • complex applications with a lot of interaction with external components
  • projects needing high testability, where we test the core logic in isolation
  • projects with frequent changes in external components
  • legacy project refactoring, where we gradually decouple business logic from external dependencies

On the other hand, the pattern is probably overkill for a mostly CRUD application, or for a prototype that won’t live long enough to swap an adapter. If most of your features are thin, Vertical Slice Architecture may fit better.

Conclusion

The hexagonal architectural pattern was the first of the three similar patterns in this article to address component dependencies in an application. The onion and clean architectural patterns build on the same idea of making software easier to understand, extend, and maintain.

When designing software, we should consider the benefits of using a given pattern versus the problems the pattern brings to our solution with its specific requirements. Also, since it is a pattern, we are free to adapt it to our specific needs and requirements.

For a whole app, the course Clean Architecture in .NET untangles a ticketing API with a welded-in payment gateway into a tested .NET 10 solution.

We hope this introduction to the Hexagonal Architectural pattern motivates you to try this approach in your next .NET project!

Tested with .NET 10.0.10, EF Core 10.0.12 and xUnit v3 4.0.1.