Introduction: The Pain of Coupling

If you’ve been developing with .NET for a while, you’ve probably seen or written a Controller like this: it receives an HTTP request, validates the data, handles business logic, talks directly to Entity Framework, maybe calls another API with HttpClient, and finally, returns an ActionResult. At first, it seems productive. But over time, this controller becomes a monster: fragile, difficult to test, and terribly coupled to technology.

Switching from ASP.NET Core to a worker service? A nightmare. Replacing Entity Framework with Dapper? A massive rewrite. Testing the business logic without spinning up a web server and a database? Nearly impossible.

This pain has a root cause: we mix the “what” (the business rules) with the “how” (the infrastructure details). Hexagonal Architecture, also known as Ports & Adapters, emerges as an elegant solution to this problem, proposing a radical separation between your application and the outside world.

In this article, we will unravel this architecture, not just in theory, but in practice, by analyzing the source code of a real project: FluxoSeguro, an authentication and authorization microservice in .NET.

What is Hexagonal Architecture (Ports & Adapters)?

Created by Alistair Cockburn, the core idea is simple: protect the heart of your application (the domain and application logic) from being contaminated by technological details.

Imagine your application as a core, the “hexagon.” This core doesn’t know and doesn’t care if it’s being used by a REST API, a console application, a gRPC service, or a unit test. It also doesn’t care if the data comes from PostgreSQL, SQL Server, or an in-memory file.

This communication between the core (“inside”) and the outside world (“outside”) is done through Ports and Adapters.

Hexagonal Architecture

Ports: The Entry and Exit Gates

A Port is simply an interface, a contract. It defines a method of interaction but does not implement it. There are two types of ports:

  1. Driving Ports (Primary Ports): They define how the outside world can “drive” your application. Think of them as the API for your business core. They are usually service interfaces, like IUserRegistrationService.
  2. Driven Ports (Secondary Ports): They define what your application needs from the outside world. They are dependencies that the core requires to be fulfilled, such as IUserRepository or IEmailNotificationService.

Adapters: The Technology Translators

An Adapter is the concrete implementation of a Port. It “adapts” a specific technology so it can communicate with the application through a port.

  1. Driving Adapters (Primary Adapters): They initiate communication with the application. An API Controller is a perfect example: it receives an HTTP request (technology), translates it into objects the core understands, and calls a Driving Port. Other examples include queue handlers or a command-line client.
  2. Driven Adapters (Secondary Adapters): These are the implementations of the application’s dependencies. A repository using Entity Framework is a classic example: it implements the IUserRepository interface (Driven Port) using EF Core technology to talk to the database. A service that sends emails via SendGrid would be another example.

Why Use Hexagonal Architecture? The Key Benefits

Adopting this architecture brings game-changing advantages for long-term software development:

  • Domain Isolation: Your business logic becomes a pure asset, free from any framework or infrastructure dependencies. It can be reused in different contexts much more easily.
  • Increased Testability: You can test your entire business core in complete isolation. Since Driven Ports are interfaces, you can easily replace them with mocks or stubs in your unit tests, without needing a database, web server, or any other external dependency.
  • Technological Flexibility: The architecture shines when technology needs to change. Want to expose your logic via gRPC instead of REST? Create a new Driving Adapter. Want to migrate from RabbitMQ to Kafka? Create a new Driven Adapter for your messaging interface. The application core remains untouched.
  • Adherence to SOLID Principles: The architecture is a practical manifestation of the Dependency Inversion Principle (DIP). The core does not depend on the infrastructure; both depend on abstractions (the Ports).

Practical Analysis: Traditional Controller vs. The Hexagonal Approach

Let’s compare the two approaches using a common use case: registering a new user.

Scenario 1: The All-in-One Controller (The Anti-Pattern)

Tightly coupled code might look something like this:

// Hypothetical Example of a Coupled Controller
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly UserManager<IdentityUser> _userManager;
    private readonly AppDbContext _context;
    private readonly SmtpClient _smtpClient;

    public UsersController(UserManager<IdentityUser> userManager, AppDbContext context, SmtpClient smtpClient)
    {
        _userManager = userManager;
        _context = context;
        _smtpClient = smtpClient;
    }

    [HttpPost("register")]
    public async Task<IActionResult> Register(RegisterModel model)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        var user = new IdentityUser { UserName = model.Email, Email = model.Email };
        
        // Coupled to ASP.NET Identity
        var result = await _userManager.CreateAsync(user, model.Password);

        if (!result.Succeeded)
        {
            return BadRequest(result.Errors);
        }

        // Business logic mixed with data access
        // Coupled to Entity Framework Core
        var userProfile = new UserProfile { UserId = user.Id, FullName = model.FullName };
        _context.UserProfiles.Add(userProfile);
        await _context.SaveChangesAsync();
        
        // Coupled to the email implementation
        await _smtpClient.SendMailAsync("from@example.com", model.Email, "Welcome!", "...");

        return Ok();
    }
}

This code is a nightmare to test and maintain.

Scenario 2: The Hexagonal Approach in FluxoSeguro

Now, let’s see how the FluxoSeguro project handles the same task. The flow is decoupled and follows the principles of Hexagonal Architecture, using MediatR to orchestrate actions.

1. The Driving Adapter: UsersController

The Controller is lean. Its sole responsibility is to receive the HTTP request, translate it into a command, and dispatch it. It doesn’t know how the user will be created.

Location: FluxoSeguro.Services.Auth/API/Controllers/UsersController.cs

[ApiController]
[Route("api/v1/users")]
public class UsersController : ControllerBase
{
    private readonly IMediator _mediator;

    public UsersController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost("register")]
    public async Task<IActionResult> CreateUser([FromBody] CreateUserCommand command)
    {
        var result = await _mediator.Send(command);

        // ... result handling ...

        return CreatedAtAction(nameof(GetUserById), new { id = result.Data.Id }, result.Data);
    }
}

The Controller depends on MediatR and has no idea about the business logic. This is a classic Driving Adapter.

2. The Application Core: CreateUserCommandHandler

This is the heart of our feature. It belongs to the Application layer and orchestrates the user creation process. Note that it depends on an abstraction (IUserService), which is our Port.

Location: FluxoSeguro.Services.Auth/Application/Features/Users/Commands/CreateUser/CreateUserCommandHandler.cs

public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, Result<UserResponse>>
{
    private readonly IUserService _userService;
    private readonly IUnitOfWork _unitOfWork;

    public CreateUserCommandHandler(IUserService userService, IUnitOfWork unitOfWork)
    {
        _userService = userService;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<UserResponse>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
    {
        // ... validations ...

        var user = new User
        {
            // ... mapping from request to entity ...
        };

        // Uses the Port to execute the action
        var result = await _userService.CreateUserAsync(user, request.Password);
        
        if (!result.Succeeded)
        {
            return Result<UserResponse>.Fail(result.Errors.FirstOrDefault()?.Description);
        }

        await _unitOfWork.CommitAsync();

        var response = user.ToResponse();
        return Result<UserResponse>.Success(response);
    }
}

This handler is fully testable in isolation. We can mock IUserService and IUnitOfWork to verify its logic without touching any real infrastructure.

3. The Ports (Interfaces)

The IUserService and IUnitOfWork interfaces are the contracts that the application core requires.

  • IUserService (Driven Port): Defines the operation of creating a user, abstracting away the details of the identity provider.
  • IUnitOfWork (Driven Port): Defines the operation of persisting changes, abstracting away the database.

4. The Driven Adapter: UserService

Finally, in the Infrastructure layer, we find the concrete implementation of the IUserService port. This adapter “translates” our port’s call into the world of ASP.NET Core Identity.

Location: FluxoSeguro.Services.Auth/Infrastructure/Services/UserService.cs

public class UserService : IUserService
{
    private readonly UserManager<User> _userManager;

    public UserService(UserManager<User> userManager)
    {
        _userManager = userManager;
    }

    public async Task<(bool Succeeded, IEnumerable<IdentityError> Errors)> CreateUserAsync(User user, string password)
    {
        var result = await _userManager.CreateAsync(user, password);
        
        if (!result.Succeeded)
        {
            return (false, result.Errors);
        }

        // Could add to a role here, etc.
        // await _userManager.AddToRoleAsync(user, "Default");

        return (true, null);
    }

    // ... other methods ...
}

If we decide to replace ASP.NET Core Identity with another identity system tomorrow, only this class (the adapter) would need to be replaced. The CreateUserCommandHandler would remain untouched.

When to Use (and When Not to Use) Hexagonal Architecture?

Although powerful, it’s not a silver bullet.

Use it when:

  • The project has complex business rules that are the true asset of the software.
  • The system is designed for a long lifespan, where changing technologies is a real possibility.
  • You are building microservices that need clear boundaries and high cohesion.
  • Testability is a top priority for the team.

It might be overkill when:

  • You are building a simple CRUD application with little business logic.
  • The project is a quick prototype or a proof of concept.
  • The team is not familiar with concepts like domain-driven design and dependency inversion.

Conclusion: Building Resilient Software

Hexagonal Architecture forces us to think about what is truly central to our software: the business logic. By treating it as the core and isolating it from implementation details through Ports & Adapters, we build systems that are more robust, flexible, and, above all, easier to maintain and test in the long run.

It’s not just an architectural pattern; it’s a mindset shift that leads us to write better, cleaner, and future-proof code.

I invite you to explore the FluxoSeguro repository on GitHub, analyze the project structure, and see how these concepts were applied in a microservices scenario.