Skip to main content

Hexagonal Architecture

Hexagonal Architecture (also known as Ports and Adapters) is an architectural pattern that isolates the core business logic from external concerns like databases, messaging systems, and APIs. SpecKit implements this pattern across all microservices to achieve maximum testability and flexibility.

Conceptual Overview

Layer Structure

Location: services/{service}/src/{Service}.Domain/Contents:
  • Domain entities (business objects)
  • Domain ports (interfaces)
  • Business rules and validations
Dependencies: NONE (completely isolated)Example Structure:
Domain/
├── Entities/
│   ├── Reservation.cs
│   └── Seat.cs
└── Ports/
    ├── IRedisLock.cs
    ├── IKafkaProducer.cs
    └── IDbInitializer.cs
Reservation Entity:
// services/inventory/src/Inventory.Domain/Entities/Reservation.cs
namespace Inventory.Domain.Entities;

public class Reservation
{
    public Guid Id { get; set; }
    public Guid SeatId { get; set; }
    public string CustomerId { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public DateTime ExpiresAt { get; set; }
    public string Status { get; set; } = "active";

    public bool IsExpired(DateTime now)
    {
        return Status == "active" && ExpiresAt <= now;
    }
}
IRedisLock Port:
// services/inventory/src/Inventory.Domain/Ports/IRedisLock.cs
namespace Inventory.Domain.Ports;

public interface IRedisLock
{
    /// <summary>
    /// Attempts to acquire a distributed lock.
    /// </summary>
    /// <returns>Lock token if successful, null otherwise</returns>
    Task<string?> AcquireLockAsync(string key, TimeSpan ttl);

    /// <summary>
    /// Releases a previously acquired lock.
    /// </summary>
    /// <returns>True if released, false if token mismatch</returns>
    Task<bool> ReleaseLockAsync(string key, string token);
}
Key Principle: Domain layer defines WHAT it needs, not HOW it’s implemented.

Dependency Inversion Principle

Core Principle: Dependencies point INWARD toward the domainWhat This Means:
  1. Domain layer has ZERO dependencies
    • No references to EF Core, Redis, Kafka, etc.
    • Pure business logic
  2. Application layer depends only on Domain
    • Uses domain entities and ports
    • No knowledge of infrastructure implementations
  3. Infrastructure layer implements domain/application ports
    • Depends on external libraries (Confluent.Kafka, StackExchange.Redis)
    • Adapts external systems to domain interfaces
  4. API layer orchestrates everything
    • Configures dependency injection
    • Maps HTTP to application commands/queries
Dependency Injection Configuration:
// services/inventory/src/Infrastructure/ServiceCollectionExtensions.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Inventory.Domain.Ports;
using Inventory.Infrastructure.Locking;
using Inventory.Infrastructure.Messaging;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Database
        services.AddDbContext<InventoryDbContext>(options =>
            options.UseNpgsql(
                configuration.GetConnectionString("Default"),
                npgsqlOptions => npgsqlOptions.MigrationsHistoryTable(
                    "__EFMigrationsHistory",
                    "bc_inventory"
                )
            )
        );

        services.AddScoped<IDbInitializer, DbInitializer>();

        // Redis (Singleton: one connection shared across requests)
        var redisConn = configuration.GetConnectionString("Redis") 
            ?? "localhost:6379";
        var multiplexer = ConnectionMultiplexer.Connect(redisConn);
        services.AddSingleton<IConnectionMultiplexer>(multiplexer);
        
        // Register adapter implementing IRedisLock port
        services.AddScoped<IRedisLock, RedisLock>();

        // Kafka (Singleton: one producer shared)
        var kafkaBootstrap = configuration.GetConnectionString("Kafka") 
            ?? "localhost:9092";
        var kafkaConfig = new ProducerConfig
        {
            BootstrapServers = kafkaBootstrap,
            AllowAutoCreateTopics = true,
            Acks = Acks.All
        };
        var producer = new ProducerBuilder<string?, string>(kafkaConfig).Build();
        services.AddSingleton(producer);
        
        // Register adapter implementing IKafkaProducer port
        services.AddSingleton<IKafkaProducer, KafkaProducer>();

        // Background services
        services.AddHostedService<ReservationExpiryWorker>();

        return services;
    }
}
Usage in Program.cs:
// services/inventory/src/Api/Program.cs
var builder = WebApplication.CreateBuilder(args);

// Register application layer (MediatR handlers)
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(
        typeof(CreateReservationCommand).Assembly
    )
);

// Register infrastructure layer (adapters)
builder.Services.AddInfrastructure(builder.Configuration);

// Register API layer
builder.Services.AddControllers();

var app = builder.Build();

// Initialize database
using (var scope = app.Services.CreateScope())
{
    var dbInit = scope.ServiceProvider.GetRequiredService<IDbInitializer>();
    await dbInit.InitializeAsync();
}

app.MapControllers();
app.Run();

Benefits Realized in SpecKit

Testability

Domain logic tested without infrastructure:
// Unit test with mocks
[Fact]
public async Task CreateReservation_AcquiresLock_Success()
{
    // Arrange
    var mockRedisLock = new MockRedisLock(
        acquireResult: "token-123"
    );
    var mockKafka = new MockKafkaProducer();
    var context = CreateInMemoryContext();
    
    var handler = new CreateReservationCommandHandler(
        context,
        mockRedisLock,  // Mock, not real Redis
        mockKafka       // Mock, not real Kafka
    );
    
    // Act
    var response = await handler.Handle(
        new CreateReservationCommand(seatId, customerId),
        CancellationToken.None
    );
    
    // Assert
    Assert.NotEqual(Guid.Empty, response.ReservationId);
    mockRedisLock.Verify_AcquireLockCalled();
    mockKafka.Verify_ProduceAsyncCalled();
}

Flexibility

Easy to swap implementations:
// Production: Real Redis
services.AddScoped<IRedisLock, RedisLock>();

// Testing: In-memory mock
services.AddScoped<IRedisLock, MockRedisLock>();

// Alternative: Distributed lock using PostgreSQL
services.AddScoped<IRedisLock, PostgresLock>();
No changes to domain or application code required!

Domain Purity

Business logic free from technical concerns:
// Domain entity - no infrastructure references
public class Reservation
{
    public bool IsExpired(DateTime now)
    {
        return Status == "active" && ExpiresAt <= now;
    }
}
No:
  • [Table("reservations")] attributes
  • INotifyPropertyChanged interfaces
  • ORM-specific code
Just pure business logic.

Independent Evolution

Change infrastructure without touching domain:
  • Migrate from Redis to Memcached
  • Switch from Kafka to RabbitMQ
  • Replace PostgreSQL with MongoDB
  • Add caching layer
Only adapters change, core logic remains stable.

Real-World Example: Complete Flow

1. HTTP Request arrives at API layer:
POST /api/reservations
Content-Type: application/json

{
  "seatId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "customerId": "customer-123"
}
2. Endpoint creates command and sends to MediatR:
// Api/Endpoints/ReservationEndpoints.cs
var command = new CreateReservationCommand(
    request.SeatId,
    request.CustomerId
);
var response = await mediator.Send(command);
3. MediatR dispatches to handler (Application layer):
// Application/UseCases/CreateReservation/CreateReservationCommandHandler.cs
public async Task<CreateReservationResponse> Handle(...)
{
    // Uses IRedisLock port (defined in Domain)
    var lockToken = await _redisLock.AcquireLockAsync(lockKey, ttl);
    
    try
    {
        // Business logic
        var seat = await _context.Seats.FindAsync(seatId);
        if (seat.Reserved) throw new InvalidOperationException();
        
        var reservation = new Reservation { ... };
        seat.Reserved = true;
        
        _context.Add(reservation);
        await _context.SaveChangesAsync();
        
        // Uses IKafkaProducer port (defined in Domain)
        await _kafkaProducer.ProduceAsync("reservation-created", json);
        
        return new CreateReservationResponse(...);
    }
    finally
    {
        await _redisLock.ReleaseLockAsync(lockKey, lockToken);
    }
}
4. Infrastructure adapters execute:
// Infrastructure/Locking/RedisLock.cs (implements IRedisLock)
public async Task<string?> AcquireLockAsync(string key, TimeSpan ttl)
{
    var token = Guid.NewGuid().ToString("N");
    bool acquired = await _db.StringSetAsync(
        key, token, ttl, when: When.NotExists
    );
    return acquired ? token : null;
}
// Infrastructure/Messaging/KafkaProducer.cs (implements IKafkaProducer)
public async Task ProduceAsync(string topic, string message, string? key)
{
    await _producer.ProduceAsync(topic, new Message<string?, string>
    {
        Key = key,
        Value = message
    });
}
5. Response flows back to client:
{
  "reservationId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "seatId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "customerId": "customer-123",
  "expiresAt": "2026-03-04T15:45:00Z",
  "status": "active"
}
Key Observation:
  • Domain defines IRedisLock and IKafkaProducer interfaces
  • Application uses these interfaces without knowing implementation
  • Infrastructure provides concrete implementations
  • Dependency injection wires everything together at runtime

Ports vs Adapters Summary

Definition: Interfaces defined by the core (domain/application)SpecKit Ports:
PortDefined InPurpose
IRedisLockDomainDistributed locking abstraction
IKafkaProducerDomainEvent publishing abstraction
IDbInitializerDomainDatabase initialization
IOrderRepositoryApplicationOrder persistence abstraction
IReservationValidationServiceApplicationReservation validation
Characteristics:
  • Defined by what the core needs
  • No implementation details
  • Stable (change infrequently)

Common Pitfalls & Solutions

Pitfall #1: Leaking Infrastructure into Domain
// ❌ BAD: Domain entity with EF Core attributes
[Table("reservations", Schema = "bc_inventory")]
public class Reservation
{
    [Key]
    public Guid Id { get; set; }
}
Solution:
// ✅ GOOD: Pure domain entity
public class Reservation
{
    public Guid Id { get; set; }
}

// Configure in DbContext (Infrastructure layer)
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Reservation>().ToTable("reservations", "bc_inventory");
    modelBuilder.Entity<Reservation>().HasKey(r => r.Id);
}
Pitfall #2: Handler Depends on Concrete Implementation
// ❌ BAD: Handler uses concrete RedisLock class
public class CreateReservationCommandHandler
{
    private readonly RedisLock _redisLock;  // Concrete class!
}
Solution:
// ✅ GOOD: Handler depends on interface
public class CreateReservationCommandHandler
{
    private readonly IRedisLock _redisLock;  // Interface
}

CQRS Pattern

See how commands and queries fit within hexagonal architecture

Microservices Design

Learn how each service implements hexagonal architecture

Event-Driven Architecture

Understand Kafka integration via ports and adapters

System Architecture

View complete architecture overview

Build docs developers (and LLMs) love