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

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:
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
:
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:
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).
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.
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.
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
