Ports and Adapters Architecture (Hexagonal Architecture) in practice with .NET

Rafael CâmaraRafael Câmara
6 min read

In one of my previous blog posts, I wrote about the Ports and Adapters Architecture, also known as Hexagonal Architecture.

Now we are going to apply those theoretical concepts in practice by using .NET.

Before we go any further, here's an image to recap the structure of the Ports and Adapters Architecture:

Hexagonal Architecture diagram

The anatomy of a use case

As mentioned in the aforementioned blog post, the Ports and Adapter Architecture is use case driven. Therefore, it's critical to clarify what a use case is and how to structure one properly.

What is a use-case

Use cases are related to user intentions. They describe how users interact with your application to achieve a specific goal. As an example, a use case can be withdrawing money.

In Hexagonal Architecture, use-cases encapsulate business logic, and you should avoid having a use-case call another use-case directly, as they represent different intentions in your system and should evolve independently and for different reasons.

How to structure a use-case

First, it's important to clarify that Hexagonal Architecture is not prescriptive about how you should structure your code.

So anything I mention here about code structure it's nothing but my own opinion on the subject.

I like to think of use cases as actions that have an input and might return an output. Therefore, I like to use a variation of the Command design pattern when it comes to the use cases.

If you try to categorize use cases into groups, you will find 3 main ones:

1. "Regular" use cases. These are use cases that have an input and an output.

Converting this to C#, it would look something like:

public interface IUseCase<TInput, TOutput>
{
    Task<TOutput> ExecuteAsync(TInput input);
}

2. Unit use-cases. The characteristic of these use cases is that they only have an input. Their return is void. You could use this use-case to execute some piece of logic where you don't care about any returns from it (what we could call a command in the CQRS world).

Converting this to C#, it would look something like:

public interface IUnitUseCase<TInput>
{
    Task ExecuteAsync(TInput input);
}

3. Nullary use-cases. The characteristic of these use cases is that they only have an output. Their input is void. An example of where you might want to use this is when you want to get all the elements from a specific collection without any filters being applied (what we could call a query in the CQRS world).

Converting this to C#, it would look something like:

public interface INullaryUseCase<TOutput>
{
    Task<TOutput> ExecuteAsync();
}

Technically speaking, the ports we mentioned are inside the hexagon, so I will give no further explanation about them next.

Inside the hexagon

.NET wise, you can create a Class Library project to hold this hexagon. Let's call it Core:

Location of the Core project in our Hexagonal solution

As mentioned previously, inside the hexagon we will need to have our business logic. As suggested, this business logic can be encapsulated inside a use case.

So, one of the things you can do is to create a port that maps this use case.

Let's assume we want a port to compute a tax rate for a given continent. We can either directly use the IUseCase<TInput, TOutput> interface or create a more specific one that inherits from this one, which is what we are going to do.

This use case would look something like this:

public interface IComputeTaxRateUseCase : IUseCase<Continent, double>
{
}

Now it's time to make this use-case concrete. As an example:

public class ComputeTaxRateUseCase(IRepository<Domain.Models.TaxRate> repository, INotification notification) : IComputeTaxRateUseCase
{
    public async Task<double> ExecuteAsync(Continent continent)
    {
        var taxRateForContinent = await repository.GetSingleByAsync(x => x.Continent == continent);

        if (taxRateForContinent is null)
        {
            throw new NoTaxRateForContinentException(continent);
        }

        var currentDayOfTheWeek = (int) DateTime.UtcNow.DayOfWeek;

        var updatedTaxRate = taxRateForContinent.Rate * (1 + currentDayOfTheWeek / 7);

        await notification.NotifyAsync(new
        {
            Title = "Tax Rate Computation",
            Message = $"Computed tax rate for {continent} is {updatedTaxRate}",
            Timestamp = DateTime.UtcNow
        });

        return updatedTaxRate;
    }
}

As you can see, this use case drives (i.e. uses) two other ports: one port for persistence (IRepository) and another for notification (INotification).

The interfaces for the driven ports are defined inside the hexagon:

Ports location inside the Core project

Example port for IRepository:

public interface IRepository<T>
{
    Task<T?> GetSingleByAsync(Func<T, bool> predicate);
}

The implementations are defined outside of it. We will have a look at them next.

Driven Adapters (Right side of the hexagon)

To host the driven adapters, I suggest creating a folder called adapters and, inside it, create another folder that will host the driven adapters.

In our case, we need to implement driven ports for two purposes: ForPersistence and ForNotification. We will create, again, a Class Library for each one, and fake a MongoDB and RabbitMQ adapters (we will not provide exact implementations of them).

Adapters location

As previously mentioned, the actual implementations of these adapters are simple and don't use the underlying technologies we mention. As an example, this is our fake MongoDB implementation:

namespace MongoDbAdapter
{
    public class MongoRepositoryAdapter<T> : IRepository<T>
    {
        //inject MongoDB context or client here

        public Task<T?> GetSingleByAsync(Func<T, bool> predicate)
        {
            Console.WriteLine("*** Mongo Adapter - Database query");
            return Task.FromResult(Collections.GetCollectionOfType<T>().FirstOrDefault(predicate));
        }
    }

    public static class Collections
    {
        private static List<TaxRate> _collection = [
            new TaxRate { Id = Guid.NewGuid(), Continent = Continent.Europe, Rate = 0.20 },
            new TaxRate { Id = Guid.NewGuid(), Continent = Continent.Africa, Rate = 0.15 },
            new TaxRate { Id = Guid.NewGuid(), Continent = Continent.Asia, Rate = 0.18 },
            new TaxRate { Id = Guid.NewGuid(), Continent = Continent.NorthAmerica, Rate = 0.22 },
            new TaxRate { Id = Guid.NewGuid(), Continent = Continent.SouthAmerica, Rate = 0.19 },
            new TaxRate { Id = Guid.NewGuid(), Continent = Continent.Oceania, Rate = 0.25 },
            new TaxRate { Id = Guid.NewGuid(), Continent = Continent.Antarctica, Rate = 0.10 }
        ];

        public static List<T> GetCollectionOfType<T>()
        {
            if (typeof(T) == typeof(TaxRate))
            {
                return _collection as List<T>;
            }
            throw new InvalidOperationException($"No collection found for type {typeof(T).Name}");
        }

    }

}

As you can see, these adapters here have to implement the port specification that was specified inside the hexagon.

Driver Adapters (Left side of the hexagon)

Inside the adapters folder create a folder called driver. This folder will container all of our driver adapters.

Inside of it, we can create a WebApi project that will host our API adapter.

Driver adapters project location

Here we will manage the endpoints that we are exposing to the outside world and adapt their language to the language that our use cases speak.

For simplicity, we will create only one endpoint that will call our IComputeTaxRateUseCase.

This endpoint could look something like:

app.MapGet("/api/tax-rate", async (IComputeTaxRateUseCase computeTaxRate, string continent) =>
{
    var cont = (Continent)Enum.Parse(typeof(Continent), continent, true);

    return Results.Ok(await computeTaxRate.ExecuteAsync(cont));
});

You can see here a bit of the adaptation I was previously speaking about. Our use case accepts an Enum as input, but we get a string from the outside world (from the actors). So our adapter does just that: adapts. It converts what we get from the actors to a language that our use-case understands.

Tests as a driver/driven mechanism

As you might have noticed in the previous screenshots, we had one folder called test (even though there are no actual tests for simplicity's sake).

Generally speaking, tests are important in software engineering, but they have a key role in hexagonal architecture as they have a highlighted role.

Tests will provide with you an easy way to test how flexible your ports are, meaning if you can plug and play with anything. With tests, you can both simulate actions from the left and right side of the hexagon.

Final

Would you rather see this in video? Please check it here. In the video, I speak about other things, such as improving the isolation between projects even further with the Composite Root design pattern.

Check this repository for the source code.

0
Subscribe to my newsletter

Read articles from Rafael Câmara directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Rafael Câmara
Rafael Câmara