👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Ports and Adapters Pattern in DDD - Implementing Hexagonal Architecture in .NET

Ports and Adapters Pattern in DDD - Implementing Hexagonal Architecture in .NET

Author - Abdul Rahman (Bhai)

DDD

22 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

Onion Architecture tells you which layer owns what. But how exactly does the domain talk to infrastructure without importing any of it? How do you swap a real database for an in-memory store in tests without touching a single line of business logic?

The answer is the Ports and Adapters pattern — also known as Hexagonal Architecture. It is the mechanism that makes Onion Architecture work in practice.

A port is an interface defined in the domain or application layer using domain language. An adapter is a concrete class in the infrastructure layer that implements the port using real technology. The domain only ever sees the port. It never touches the adapter.

In this article you will follow a complete, five-step walkthrough: define the port, create an in-memory adapter for tests, create an EF Core adapter for production, swap between them, and protect the contract with shared tests.

Why we gonna do?

Why Swappable Adapters Matter

Before looking at the implementation, consider what the absence of this pattern looks like. Domain objects call EF Core directly:


// BAD: Application service coupled to EF Core
public sealed class LicenseApplicationService
{
    private readonly LicenseDbContext _db;   // EF Core leaking into application layer

    public async Task ActivateLicenseAsync(Guid id, string fingerprint)
    {
        var entity = await _db.Licenses.FindAsync(id);   // EF directly
        if (entity is null) throw new NotFoundException();

        entity.ActivationCount++;
        entity.Hardware = fingerprint;
        await _db.SaveChangesAsync();
    }
}

Testing this service now requires a real database, a migration, test data seeding, and transaction cleanup. Every test run adds seconds. Add a hundred tests and the feedback loop breaks.


With direct EF coupling          | With Ports & Adapters
─────────────────────────────────────────────────────────────────────────
Tests need a real database       | Tests use in-memory adapter
100 tests take ~30 seconds       | 100 tests take ~0.1 seconds
Changing DB = refactoring domain | Changing DB = new adapter, 1 DI line
Hard to test error paths         | Easy: in-memory adapter fakes any error
─────────────────────────────────────────────────────────────────────────

How we gonna do?

Step 1: Define the Port in the Domain

The port is a plain C# interface. It uses only domain types — no EF entities, no database IDs, no connection strings:


// File: LicenseContext/Domain/Ports/ILicenseRepository.cs
namespace LicenseContext.Domain.Ports;

/// <summary>
/// Port: the domain's view of persistence.
/// No annotations, no EF, no SQL. Pure domain language.
/// </summary>
public interface ILicenseRepository
{
    /// <summary>Returns a domain object, not a persistence entity.</summary>
    Task<License?> FindByIdAsync(LicenseId id,
                                  CancellationToken ct = default);

    Task SaveAsync(License license, CancellationToken ct = default);

    Task DeleteAsync(LicenseId id, CancellationToken ct = default);

    Task<IReadOnlyList<License>> FindByCustomerAsync(
        CustomerId customerId,
        CancellationToken ct = default);

    Task<IReadOnlyList<License>> FindActiveAsync(
        CancellationToken ct = default);
}

Step 2: In-Memory Adapter for Tests

The in-memory adapter is a simple Dictionary-backed implementation of the port. It lives in a test project or a testing utilities assembly. Zero database, zero EF:


// File: LicenseContext.Tests/Fakes/InMemoryLicenseRepository.cs
using LicenseContext.Domain;
using LicenseContext.Domain.Ports;

namespace LicenseContext.Tests.Fakes;

public sealed class InMemoryLicenseRepository : ILicenseRepository
{
    private readonly Dictionary<LicenseId, License> _store = [];

    public Task<License?> FindByIdAsync(LicenseId id,
                                         CancellationToken ct = default)
        => Task.FromResult(_store.TryGetValue(id, out var l) ? l : null);

    public Task SaveAsync(License license, CancellationToken ct = default)
    {
        _store[license.Id] = license;
        return Task.CompletedTask;
    }

    public Task DeleteAsync(LicenseId id, CancellationToken ct = default)
    {
        _store.Remove(id);
        return Task.CompletedTask;
    }

    public Task<IReadOnlyList<License>> FindByCustomerAsync(
        CustomerId customerId,
        CancellationToken ct = default)
    {
        IReadOnlyList<License> result = _store.Values
            .Where(l => l.CustomerId == customerId)
            .ToList();
        return Task.FromResult(result);
    }

    public Task<IReadOnlyList<License>> FindActiveAsync(
        CancellationToken ct = default)
    {
        var today = DateOnly.FromDateTime(DateTime.UtcNow);
        IReadOnlyList<License> result = _store.Values
            .Where(l => l.ExpiresAt >= today)
            .ToList();
        return Task.FromResult(result);
    }
}

Step 3: EF Core Adapter for Production

The EF Core adapter lives in the infrastructure layer. It maps between domain objects and persistence entities. The domain never sees this class:


// File: LicenseContext/Infrastructure/Persistence/EfLicenseRepository.cs
using LicenseContext.Domain;
using LicenseContext.Domain.Ports;
using Microsoft.EntityFrameworkCore;

namespace LicenseContext.Infrastructure.Persistence;

public sealed class EfLicenseRepository : ILicenseRepository
{
    private readonly LicenseDbContext _db;
    private readonly LicenseMapper _mapper;

    public EfLicenseRepository(LicenseDbContext db, LicenseMapper mapper)
    {
        _db = db;
        _mapper = mapper;
    }

    public async Task<License?> FindByIdAsync(
        LicenseId id,
        CancellationToken ct = default)
    {
        var entity = await _db.Licenses
            .AsNoTracking()
            .FirstOrDefaultAsync(l => l.Id == id.Value, ct);

        // map from persistence entity → domain object (in infrastructure, not domain)
        return entity is null ? null : _mapper.ToDomain(entity);
    }

    public async Task SaveAsync(License license, CancellationToken ct = default)
    {
        var entity = _mapper.ToEntity(license);

        var exists = await _db.Licenses
            .AnyAsync(l => l.Id == entity.Id, ct);

        if (!exists)
            _db.Licenses.Add(entity);
        else
            _db.Licenses.Update(entity);

        await _db.SaveChangesAsync(ct);
    }

    public async Task DeleteAsync(LicenseId id, CancellationToken ct = default)
    {
        var entity = await _db.Licenses
            .FirstOrDefaultAsync(l => l.Id == id.Value, ct);

        if (entity is not null)
        {
            _db.Licenses.Remove(entity);
            await _db.SaveChangesAsync(ct);
        }
    }

    public async Task<IReadOnlyList<License>> FindByCustomerAsync(
        CustomerId customerId,
        CancellationToken ct = default)
    {
        var entities = await _db.Licenses
            .AsNoTracking()
            .Where(l => l.CustomerId == customerId.Value)
            .ToListAsync(ct);

        return entities.Select(_mapper.ToDomain).ToList();
    }

    public async Task<IReadOnlyList<License>> FindActiveAsync(
        CancellationToken ct = default)
    {
        var today = DateOnly.FromDateTime(DateTime.UtcNow);
        var entities = await _db.Licenses
            .AsNoTracking()
            .Where(l => l.ExpiresAt >= today)
            .ToListAsync(ct);

        return entities.Select(_mapper.ToDomain).ToList();
    }
}

Step 4: Swapping Adapters Without Touching the Domain

The only change when switching adapters is the DI registration. The domain, application service, and all business logic remain unchanged:


// File: Web/Program.cs

// Production: use EF Core adapter
if (builder.Environment.IsProduction() || builder.Environment.IsStaging())
{
    builder.Services.AddScoped<ILicenseRepository, EfLicenseRepository>();
}

// Development/Tests: use in-memory adapter (or configure per test)
if (builder.Environment.IsDevelopment())
{
    builder.Services.AddSingleton<ILicenseRepository,
                                   InMemoryLicenseRepository>();
}

Step 5: Contract Tests That Protect All Adapters

Write an abstract base test class that defines the contract. Each concrete adapter must pass all of these tests. This guarantees that every adapter behaves identically:


// File: LicenseContext.Tests/Contracts/LicenseRepositoryContractTests.cs
using LicenseContext.Domain;
using LicenseContext.Domain.Ports;
using Xunit;

namespace LicenseContext.Tests.Contracts;

/// <summary>
/// Abstract contract tests. Every ILicenseRepository implementation must pass these.
/// </summary>
public abstract class LicenseRepositoryContractTests
{
    /// <summary>Subclasses provide the concrete adapter under test.</summary>
    protected abstract ILicenseRepository CreateRepository();

    [Fact]
    public async Task SaveThenFindById_ReturnsStoredLicense()
    {
        // GIVEN
        var repo = CreateRepository();
        var license = License.Create(
            new LicenseId(Guid.NewGuid()),
            new CustomerId(Guid.NewGuid()),
            maxActivations: 3,
            expiresAt: DateOnly.FromDateTime(DateTime.UtcNow.AddYears(1)));

        // WHEN
        await repo.SaveAsync(license);
        var found = await repo.FindByIdAsync(license.Id);

        // THEN
        Assert.NotNull(found);
        Assert.Equal(license.Id, found!.Id);
        Assert.Equal(license.CustomerId, found.CustomerId);
        Assert.Equal(0, found.ActivationCount);
    }

    [Fact]
    public async Task FindById_WhenNotExists_ReturnsNull()
    {
        var repo = CreateRepository();
        var found = await repo.FindByIdAsync(new LicenseId(Guid.NewGuid()));
        Assert.Null(found);
    }

    [Fact]
    public async Task SaveActivatedLicense_PersistsActivationCount()
    {
        // GIVEN
        var repo = CreateRepository();
        var license = License.Create(
            new LicenseId(Guid.NewGuid()),
            new CustomerId(Guid.NewGuid()),
            maxActivations: 5,
            expiresAt: DateOnly.FromDateTime(DateTime.UtcNow.AddYears(1)));

        license.Activate(HardwareFingerprint.Of("DEVICE-001"));

        // WHEN
        await repo.SaveAsync(license);
        var found = await repo.FindByIdAsync(license.Id);

        // THEN
        Assert.NotNull(found);
        Assert.Equal(1, found!.ActivationCount);
    }

    [Fact]
    public async Task Delete_RemovesLicense()
    {
        // GIVEN
        var repo = CreateRepository();
        var license = License.Create(
            new LicenseId(Guid.NewGuid()),
            new CustomerId(Guid.NewGuid()),
            maxActivations: 1,
            expiresAt: DateOnly.FromDateTime(DateTime.UtcNow.AddYears(1)));

        await repo.SaveAsync(license);

        // WHEN
        await repo.DeleteAsync(license.Id);
        var found = await repo.FindByIdAsync(license.Id);

        // THEN
        Assert.Null(found);
    }
}

Now each adapter just needs a tiny subclass to run the entire contract suite:


// File: LicenseContext.Tests/Adapters/InMemoryLicenseRepositoryTests.cs
namespace LicenseContext.Tests.Adapters;

public sealed class InMemoryLicenseRepositoryTests
    : LicenseRepositoryContractTests
{
    protected override ILicenseRepository CreateRepository()
        => new InMemoryLicenseRepository();   // fast, no database
}

// File: LicenseContext.IntegrationTests/Adapters/EfLicenseRepositoryTests.cs
namespace LicenseContext.IntegrationTests.Adapters;

public sealed class EfLicenseRepositoryTests
    : LicenseRepositoryContractTests, IAsyncLifetime
{
    private LicenseDbContext _db = default!;

    public async Task InitializeAsync()
    {
        var options = new DbContextOptionsBuilder<LicenseDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        _db = new LicenseDbContext(options);
        await _db.Database.EnsureCreatedAsync();
    }

    protected override ILicenseRepository CreateRepository()
        => new EfLicenseRepository(_db, new LicenseMapper());

    public async Task DisposeAsync() => await _db.DisposeAsync();
}

Both adapters pass the same contract tests. Swapping the EF adapter for MongoDB or Dapper in the future is safe: write the new adapter, make it pass the contract tests, change the DI registration.

Summary

In this article, you completed a full Ports and Adapters implementation in .NET:

  • A port is a domain interface using domain types only — no DB IDs, no ORM, no SDK.
  • An in-memory adapter implements the port with a Dictionary. Tests run in milliseconds with zero infrastructure.
  • An EF Core adapter implements the same port using Entity Framework. ORM concerns stay inside the adapter.
  • Swapping adapters requires only a DI registration change — zero domain changes.
  • Contract tests defined in an abstract base class ensure all adapters behave identically. They catch regressions instantly.
  • If domain tests need a database, the boundary is leaking. Fix it by moving the dependency behind a port.

With Bounded Contexts, Context Maps, Anti-Corruption Layers, Onion Architecture, and Ports and Adapters all in place, you have a complete strategic and tactical DDD foundation. The final checklist: keep the domain pure, keep tests fast, keep infrastructure replaceable, and keep the Context Map up to date.

👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • DDD
  • Domain-Driven Design
  • DDD
  • Ports and Adapters
  • Hexagonal Architecture
  • Repository Pattern
  • Contract Tests
  • In-Memory Adapter
  • .NET
  • C#
  • xUnit