Hexagonal Architecture with .NET: Designing for Testability and Adaptability
In an era where software must evolve rapidly, integrate seamlessly, and remain robust over time, how we architect our systems matters more than ever. Among the architectural styles that have been successful in enabling maintainability, testability, and flexibility is Hexagonal Architecture, or the Ports and Adapters architecture. In this article, we’ll talk about Hexagonal Architecture in .NET apps, explaining the thinking behind the pattern, why it matters, and how you can apply it to build systems that are easier to test, maintain, and grow. We’ll conclude with a full working example developed with C# using .NET.
What Is Hexagonal Architecture?
Hexagonal Architecture, introduced by Alistair Cockburn, is an approach to software design that aims to isolate the core business logic from external concerns like databases, web APIs, UIs and messaging systems.
Key Concepts:
- Application Core: Contains your business logic and use cases. It has no dependencies on infrastructure or external systems.
- Ports: Interfaces defined by the core to communicate with the outside world.
- Adapters: Implementations of ports that bridge external systems to the application core.
Benefits:
- Improved testability (the core logic can be unit tested in isolation).
- Enhanced adaptability (easy to swap infrastructure technologies).
- Clear separation of concerns and better dependency management.
When and Why Use Hexagonal Architecture in .NET?
Software development in the world of .NET is often quick off the blocks ASP.NET Core gives us ready to use templates, Entity Framework simplifies the data access, and integrating third-party services is just a package away. However, as expectations shift and the code grows, tightly coupled code becomes the drag rather than the benefit. This is where Hexagonal Architecture really shines. Instead of structuring your app based on frameworks and technology, you structure your app based on your domain logic the essence of your application that actually matters. By isolating the core business rules from external dependencies, Hexagonal Architecture helps .NET developers create systems that are more maintainable, testable, and adaptable over time. You’re no longer tied to a specific database, UI, or even framework those are just adapters that can be swapped out with minimal impact. Here’s why this approach can be a game-changer for .NET applications:
- Testability: You are able to test your business logic in isolation without touching web servers, databases, or file systems. This means faster, more reliable unit tests.
- Technology Independence: You are able to switch from Entity Framework to Dapper, or from REST to gRPC, without changing your business logic.
- UI Flexibility: You are able to present your application as a REST API, a Blazor frontend, or even a CLI. The underlying is the same.
- Clean Evolution: You can move legacy systems incrementally, one adapter at a time, without touching the domain.
In the real world, this structure is very powerful in:
- Domain-Driven Design (DDD) situations, where your domain logic is rich and needs to be kept apart from noisy infrastructure code.
- Microservices, where each service benefits from having a clear boundary between its core behavior and the way it communicates.
- Legacy modernization efforts, where shrouding and substituting infrastructure components piecemeal is paramount.
Overall, Hexagonal Architecture does not slow your development process down it just ensures the speed you’re gaining today won’t come at the expense of maintainability tomorrow.
Project Structure Overview
Let’s implement a simple Webinar Management system following the hexagonal approach.
Domain: WebinarControl.Core
- Domain Models
- Interfaces (Ports)
- Use Cases
Application Layer: WebinarControl.Application
- Services that coordinate use cases
Adapters:
WebinarControl.Infrastructure
: Database Adapter (EF Core)WebinarControl.WebApi
: REST API Adapter (ASP.NET Core)
Step-by-Step Implementation
- Define the Domain (Core)
Project:
WebinarControl.Core
// Models/Webinar.cs
namespace WebinarControl.Core.Models
{
public class Webinar
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = string.Empty;
public string Speaker { get; set; } = string.Empty;
public DateTime ScheduledAt { get; set; }
}
}
// Ports/IWebinarRepository.cs
namespace WebinarControl.Core.Ports
{
public interface IWebinarRepository
{
Task<Webinar> GetByIdAsync(Guid id);
Task<IEnumerable<Webinar>> GetAllAsync();
Task AddAsync(Webinar webinar);
}
}
// Ports/IScheduleWebinarUseCase.cs
namespace WebinarControl.Core.Ports
{
public interface IScheduleWebinarUseCase
{
Task<Webinar> ScheduleAsync(string title, string speaker, DateTime scheduledAt);
}
}
- Implement Application Logic
Project:
WebinarControl.Application
// Services/ScheduleWebinarService.cs
using WebinarControl.Core.Models;
using WebinarControl.Core.Ports;
namespace WebinarControl.Application.Services
{
public class ScheduleWebinarService : IScheduleWebinarUseCase
{
private readonly IWebinarRepository _repository;
public ScheduleWebinarService(IWebinarRepository repository)
{
_repository = repository;
}
public async Task<Webinar> ScheduleAsync(string title, string speaker, DateTime scheduledAt)
{
var webinar = new Webinar { Title = title, Speaker = speaker, ScheduledAt = scheduledAt };
await _repository.AddAsync(webinar);
return webinar;
}
}
}
- Implement Infrastructure Adapter (EF Core)
Project:
WebinarControl.Infrastructure
// EF/WebinarDbContext.cs
using Microsoft.EntityFrameworkCore;
using WebinarControl.Core.Models;
namespace WebinarControl.Infrastructure.EF
{
public class WebinarDbContext : DbContext
{
public DbSet<Webinar> Webinars => Set<Webinar>();
public WebinarDbContext(DbContextOptions<WebinarDbContext> options)
: base(options) { }
}
}
// Repositories/WebinarRepository.cs
using Microsoft.EntityFrameworkCore;
using WebinarControl.Core.Models;
using WebinarControl.Core.Ports;
namespace WebinarControl.Infrastructure.Repositories
{
public class WebinarRepository : IWebinarRepository
{
private readonly WebinarDbContext _context;
public WebinarRepository(WebinarDbContext context)
{
_context = context;
}
public async Task AddAsync(Webinar webinar)
{
_context.Webinars.Add(webinar);
await _context.SaveChangesAsync();
}
public async Task<IEnumerable<Webinar>> GetAllAsync()
{
return await _context.Webinars.ToListAsync();
}
public async Task<Webinar> GetByIdAsync(Guid id)
{
return await _context.Webinars.FindAsync(id);
}
}
}
- Implement Web API Adapter
Project:
WebinarControl.WebApi
// Program.cs
using Microsoft.EntityFrameworkCore;
using WebinarControl.Core.Ports;
using WebinarControl.Application.Services;
using WebinarControl.Infrastructure.EF;
using WebinarControl.Infrastructure.Repositories;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<WebinarDbContext>(opt =>
opt.UseInMemoryDatabase("WebinarDb"));
builder.Services.AddScoped<IWebinarRepository, WebinarRepository>();
builder.Services.AddScoped<IScheduleWebinarUseCase, ScheduleWebinarService>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapPost("/webinars", async (string title, string speaker, DateTime scheduledAt, IScheduleWebinarUseCase useCase) =>
{
var webinar = await useCase.ScheduleAsync(title, speaker, scheduledAt);
return Results.Created($"/webinars/{webinar.Id}", webinar);
});
app.Run();
- Testing the Core
// UnitTests/ScheduleWebinarServiceTests.cs
using Moq;
using WebinarControl.Core.Ports;
using WebinarControl.Application.Services;
using Xunit;
public class ScheduleWebinarServiceTests
{
[Fact]
public async Task ScheduleAsync_ShouldCreateWebinarWithGivenDetails()
{
var repoMock = new Mock<IWebinarRepository>();
var service = new ScheduleWebinarService(repoMock.Object);
var scheduledAt = DateTime.UtcNow.AddDays(1);
var webinar = await service.ScheduleAsync(".NET Hexagonal", "F. Del Re", scheduledAt);
Assert.Equal(".NET Hexagonal", webinar.Title);
Assert.Equal("F. Del Re", webinar.Speaker);
Assert.Equal(scheduledAt, webinar.ScheduledAt);
repoMock.Verify(r => r.AddAsync(It.IsAny<Webinar>()), Times.Once);
}
}
Potential Limitations and Challenges
While Hexagonal Architecture offers numerous benefits, especially around testability, separation of concerns, and adaptability, it’s important to understand that it comes with trade-offs.
-
Initial Complexity and Overhead: When starting a new project, Hexagonal Architecture introduces abstractions and layers that may feel premature or heavy-weight for small or prototype applications. You might be crafting multiple projects, interfaces, and dependency injections before you see a single feature working.
-
Over-Abstraction: Too much abstraction can lead to boilerplate code and cognitive overhead, especially when interfaces are created for every single service, even when there’s only a single implementation. For small teams or codebases, this slows down development rather than speeding it up.
-
Learning Curve for Developers Teams: New developers to the pattern may struggle to find their way around or contribute to the code base. Terms like ports, adapters, and the separation of inbound and outbound interfaces require a shift in thinking from traditional layered architectures.
Conclusion
Hexagonal Architecture is a strong paradigm for .NET developers wishing to write maintainable, testable, and dynamic systems. By keeping your domain logic and infrastructure concerns well separated, you not only decouple, but you allow your application to scale in a manner independent of tech choices. With .NET and C#’s evolving features, it’s easier than ever now to write well-architected systems that stand the test of time.