Previous Onion Architecture in .NET Micro-Frontends Next

Hexagonal Architecture

πŸ”· Hexagonal Architecture Overview

Hexagonal Architecture, coined by Alistair Cockburn, is a software design pattern that separates the core business logic from external systems like databases, UIs, or APIs. It promotes flexibility, testability, and adaptability by using ports and adapters.

πŸ“ Key Concepts

  • Core Logic: The heart of the application containing business rules
  • Ports: Interfaces that define how external systems interact with the core
  • Adapters: Implementations of ports that connect external systems

βš™οΈ The Key Components of Hexagonal Architecture

  • πŸ›οΈ Core: The central part of the application, often a class library project, that contains all the business logic and domain entities. It is technology-agnostic and has no knowledge of the external systems it interacts with.
  • πŸ”Œ Ports: Interfaces or contracts defined within the core that represent the entry and exit points for data and functionality. There are two types:
    • 🎯 Driving/Primary Ports (inbound): APIs that the core exposes for external actors (users, other applications) to drive it.
    • ⚑ Driven/Secondary Ports (outbound): Interfaces that the core uses to interact with external systems.
  • πŸ” Adapters: Implementations of the ports that handle communication with specific external systems. They act as a bridge, converting data from the external system's format into a format the core understands, and vice versa.

πŸ§ͺ Example: Banking Application

  • Core: AccountService with methods like deposit, withdraw
  • Inbound Adapter: REST API controller calling AccountService
  • Outbound Adapter: Repository implementation using SQL Server
  • Ports: IAccountRepository, INotificationService

πŸ§ͺ Example in .NET Core: A Simple Product Service

1️⃣ Project Structure

The solution is typically split into multiple projects to enforce dependency rules.

  • ProductService.Core (Class Library): Contains the domain entities, application services, and port interfaces.
  • ProductService.Infrastructure (Class Library): Contains adapters for specific external systems (e.g., a database).
  • ProductService.Api (Web API): The entry point that hosts the driving adapters (e.g., controllers).

2️⃣ Core Layer (ProductService.Core)

This project contains the Product entity, the IProductRepository port, and a CreateProductUseCase application service.

// ProductService.Core/Entities/Product.cs
public class Product
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public string Name { get; private set; }
    public decimal Price { get; private set; }

    public Product(string name, decimal price)
    {
        // Business rules and validation
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Product name cannot be empty.", nameof(name));
        if (price <= 0)
            throw new ArgumentException("Price must be greater than zero.", nameof(price));

        Name = name;
        Price = price;
    }
}

// ProductService.Core/Ports/IProductRepository.cs
// This is a driven (outbound) port.
public interface IProductRepository
{
    Task AddAsync(Product product);
    Task<Product?> GetByIdAsync(Guid id);
}

// ProductService.Core/Application/CreateProductUseCase.cs
public class CreateProductUseCase
{
    private readonly IProductRepository _productRepository;

    public CreateProductUseCase(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public async Task Execute(string name, decimal price)
    {
        var product = new Product(name, price);
        await _productRepository.AddAsync(product);
    }
}
    

3️⃣ Infrastructure Layer (ProductService.Infrastructure)

This project, which references the ProductService.Core project, provides the concrete implementation of the IProductRepository port.

// ProductService.Infrastructure/Adapters/Persistence/ProductRepository.cs
public class ProductRepository : IProductRepository
{
    // In a real application, this would interact with a database
    private readonly Dictionary<Guid, Product> _products = new();

    public Task AddAsync(Product product)
    {
        _products[product.Id] = product;
        return Task.CompletedTask;
    }

    public Task<Product?> GetByIdAsync(Guid id)
    {
        _products.TryGetValue(id, out var product);
        return Task.FromResult(product);
    }
}
    

4️⃣ API Layer (ProductService.Api)

This project, referencing both the Core and Infrastructure layers, serves as the entry point and wires everything together.

// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Register Core services
builder.Services.AddScoped<CreateProductUseCase>();

// Register Adapters
builder.Services.AddScoped<IProductRepository, ProductRepository>();

var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();

app.MapPost("/products", async (string name, decimal price, CreateProductUseCase useCase) =>
{
    await useCase.Execute(name, price);
    return Results.Ok();
});

app.Run();
    

βœ… Advantages

  • πŸ§ͺ High testability: The core business logic can be tested in isolation from external dependencies using mock or fake adapters.
  • πŸ”— Decoupling: External dependencies can be replaced without affecting core logic.
  • πŸ”„ Flexibility and adaptability: Adding new interfaces (like REST or gRPC) requires only a new adapter.
  • 🧠 Technology independence: The core is free of framework-specific code.
  • ⏳ Deferred decisions: Core business logic can be developed before choosing external technologies.
  • Facilitates easier replacement of external systems

⚠️ Disadvantages

  • πŸ“ˆ Increased complexity: May be overkill for small applications.
  • πŸŽ“ Steeper learning curve: Requires understanding ports, adapters, and dependency inversion.
  • βš™οΈ Performance overhead: Additional abstraction layers may slightly affect performance.
  • πŸ•’ Initial effort: Setup and structure increase early development time.
  • Requires discipline to maintain boundaries

πŸ• When to Use

  • 🏒 Complex, long-lived applications.
  • πŸ§ͺ High testability requirements.
  • 🌐 Multiple interfaces or integrations.
  • πŸ”„ Preparing for technology changes.
  • Applications with multiple input/output channels
  • Systems requiring high test coverage
  • Projects expected to evolve or scale

🚫 When Not to Use

  • 🧩 Simple or short-lived applications.
  • ⏰ Tight deadlines and limited resources.
  • πŸ‘Ά Inexperienced teams.
  • πŸ—‚οΈ CRUD-heavy or I/O-driven systems.

🧭 Precautions

  • 🚫 Avoid "leaky" abstractions β€” keep the core independent.
  • πŸ”’ Enforce dependency rules β€” outer layers can reference inner layers, not vice versa.
  • πŸ” Manage data mapping carefully β€” consider tools like AutoMapper.
  • βš–οΈ Guard against over-abstraction β€” only create ports where needed.

πŸ—οΈ Best Practices and Tips

  • πŸ’‰ Use dependency injection (DI) with .NET Core’s built-in container.
  • πŸ›οΈ Embrace Domain-Driven Design (DDD).
  • 🧱 Prefer constructor injection for clarity.
  • 🧩 Keep adapters focused and thin.
  • πŸš€ Use templates for consistency.
  • Define clear interfaces (ports) for all external interactions
  • Document adapter responsibilities clearly
  • Continuously refactor to maintain boundaries

πŸ’‘ Tips

  • Start with core logic and build outward
  • Use hexagonal diagrams to visualize dependencies
  • Refactor legacy systems incrementally into hexagonal structure

Sources: GeeksforGeeks, Java Code Geeks, Everestek Blog

Back to Index
Previous Onion Architecture in .NET Micro-Frontends Next
*