👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Onion Architecture in DDD - Keeping Your Domain Pure and Testable in .NET

Onion Architecture in DDD - Keeping Your Domain Pure and Testable 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?

Anti-Corruption Layers protect individual external integrations. Bounded Contexts define team boundaries. But there is still a bigger question: how should you structure the entire application so that the domain stays pure, tests remain fast, and infrastructure can be swapped freely?

The answer is Onion Architecture — also known as Clean Architecture or Hexagonal Architecture. It organises an application into concentric rings where the innermost ring is the domain, and every dependency arrow points inward. The domain depends on nothing. Everything else depends on the domain.

In this article you will learn what Onion Architecture is, the problems it solves, exactly what belongs in each layer, how a real request flows through those layers, and the most common mistakes teams make when implementing it.

Why we gonna do?

Traditional Layering and Its Problems

The traditional N-tier approach puts the database at the bottom and has every layer depend on the layer below. Over time this creates three predictable problems:


Problem                              | Consequence
──────────────────────────────────────────────────────────────────────────────────
Business logic depends on database   | Rules cannot exist without a running database
Framework types leak into domain     | Annotations, HTTP types, and EF attributes appear
                                     | inside business classes
Everything couples to everything     | A minor schema change ripples through the whole app
──────────────────────────────────────────────────────────────────────────────────

Here is what polluted domain code looks like in practice:


// BAD: Domain aggregate with infrastructure leaking in
[Table("licenses")]          // EF Core annotation
[JsonObject]                  // JSON serialiser attribute
public class License
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }   // int surrogate key, not a domain concept

    [Column("hardware")]
    public string? Hardware { get; set; }  // nullable string, not a domain value object

    // Business rule buried with framework noise
    public bool Activate(string hardware)
    {
        if (ActivationCount >= MaxActivations) return false;
        Hardware = hardware;
        ActivationCount++;
        return true;
    }
}

Testing this class requires a running database, an EF Core context, and a migration. A fast unit test is impossible. And when you want to switch from SQL Server to Postgres, you touch the domain.

How we gonna do?

The Three Layers of Onion Architecture

Onion Architecture solves this by reversing the dependencies. Instead of infrastructure owning the rules, the domain owns the rules and infrastructure implements the domain's interfaces:


┌────────────────────────────────────────────────────────────────┐
│  Infrastructure Layer                                          │
│  (Controllers, Repositories, ACL, Database, External APIs)     │
│                                                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │  Application Layer                                       │  │
│  │  (Use Cases, Application Services, DTOs, Orchestration)  │  │
│  │                                                          │  │
│  │  ┌────────────────────────────────────────────────────┐  │  │
│  │  │  Domain Layer                         ← core       │  │  │
│  │  │  (Entities, Value Objects, Aggregates,             │  │  │
│  │  │   Domain Events, Domain Services, Interfaces)      │  │  │
│  │  └────────────────────────────────────────────────────┘  │  │
│  └──────────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────────┘

Key rule: dependencies always point INWARD.
Domain depends on nothing. Infrastructure depends on everything.

Layer 1: The Domain Layer

This is the innermost ring. It contains pure business concepts — no frameworks, no annotations, no infrastructure. It defines interfaces that it needs, but never implements them:


// File: LicenseContext/Domain/License.cs
namespace LicenseContext.Domain;

public sealed class License
{
    public LicenseId Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public int ActivationCount { get; private set; }
    public int MaxActivations { get; private set; }
    public DateOnly ExpiresAt { get; private set; }

    private readonly List<IDomainEvent> _events = [];
    public IReadOnlyList<IDomainEvent> DomainEvents => _events.AsReadOnly();

    private License() { }

    public static License Create(
        LicenseId id,
        CustomerId customerId,
        int maxActivations,
        DateOnly expiresAt)
    {
        var license = new License
        {
            Id = id,
            CustomerId = customerId,
            MaxActivations = maxActivations,
            ExpiresAt = expiresAt,
            ActivationCount = 0
        };
        return license;
    }

    public void Activate(HardwareFingerprint fingerprint)
    {
        if (ExpiresAt < DateOnly.FromDateTime(DateTime.UtcNow))
            throw new DomainException("Cannot activate an expired license.");

        if (ActivationCount >= MaxActivations)
            throw new DomainException(
                $"Activation limit of {MaxActivations} reached.");

        ActivationCount++;
        _events.Add(new LicenseActivated(Id, CustomerId, fingerprint,
                                         DateTimeOffset.UtcNow));
    }
}

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

// Interface lives in the domain — implementation lives in infrastructure
public interface ILicenseRepository
{
    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);
}

Layer 2: The Application Layer

The application layer coordinates use cases. It calls domain methods, uses the repository interface, and publishes events. It does not contain business rules:


// File: LicenseContext/Application/LicenseApplicationService.cs
namespace LicenseContext.Application;

public sealed class LicenseApplicationService
{
    private readonly ILicenseRepository _licenses;
    private readonly IDomainEventPublisher _publisher;

    public LicenseApplicationService(
        ILicenseRepository licenses,
        IDomainEventPublisher publisher)
    {
        _licenses = licenses;
        _publisher = publisher;
    }

    // Use case: activate a license
    public async Task ActivateLicenseAsync(
        Guid licenseId,
        string hardwareFingerprint,
        CancellationToken ct = default)
    {
        // 1. Load the aggregate
        var id = new LicenseId(licenseId);
        var license = await _licenses.FindByIdAsync(id, ct)
                   ?? throw new NotFoundException($"License {licenseId} not found.");

        // 2. Let the domain handle the business rule
        var fingerprint = HardwareFingerprint.Of(hardwareFingerprint);
        license.Activate(fingerprint);   // throws DomainException on rule violations

        // 3. Save the updated aggregate
        await _licenses.SaveAsync(license, ct);

        // 4. Publish domain events
        foreach (var domainEvent in license.DomainEvents)
            await _publisher.PublishAsync(domainEvent, ct);
    }
}

Layer 3: The Infrastructure Layer

Infrastructure implements the domain interfaces. Mapping logic lives here. The domain never sees persistence entities, connection strings, or ORM attributes:


// 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);

        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();
    }
}

How a Real Request Flows Through the Layers


Request: POST /licenses/{id}/activate
│
▼
Controller (Infrastructure)
  - Parses JSON body
  - Extracts licenseId and hardwareFingerprint
  - Calls LicenseApplicationService.ActivateLicenseAsync(...)
│
▼
Application Service (Application)
  - Loads License from ILicenseRepository
  - Calls License.Activate(fingerprint)   ← domain handles the rule
  - Saves updated License via ILicenseRepository
  - Publishes LicenseActivated event
│
▼
Domain Aggregate (Domain)
  - Checks expiry date
  - Checks activation limit
  - Increments ActivationCount
  - Adds LicenseActivated to DomainEvents
│
▼
Repository Implementation (Infrastructure)
  - Maps domain License to LicenseEntity
  - Calls EF Core SaveChangesAsync

Benefits of This Approach


Concern        | Benefit
──────────────────────────────────────────────────────────────────────────
Domain         | Pure business code. No framework noise. Easy to read and share.
Tests          | Domain tests need no database. Fast feedback. Clear test pyramid.
Evolution      | Swap SQL Server for Postgres by replacing one infrastructure class.
               | Add GraphQL alongside REST with zero domain changes.
               | Integrate new APIs without rewriting any core logic.
──────────────────────────────────────────────────────────────────────────

Most Common Architecture Mistakes


Mistake                                     | Fix
──────────────────────────────────────────────────────────────────────────────────
Domain classes importing EF / HttpClient    | Domain can only import its own types
Business rules inside application services  | Rules belong in aggregates
Repository interface in infrastructure      | Interface belongs in domain layer
Controllers calling repositories directly   | Always go through the application service
──────────────────────────────────────────────────────────────────────────────────

Summary

In this article, you learned how Onion Architecture keeps your domain clean, testable, and infrastructure-independent:

  • Onion Architecture organises code into Domain, Application, and Infrastructure rings. Dependencies always point inward.
  • The domain layer contains pure business concepts — no annotations, no ORM, no framework.
  • The application layer coordinates use cases. It orchestrates. It never decides.
  • The infrastructure layer implements domain interfaces, holds persistence entities, and maps between them.
  • Repository interfaces belong in the domain. Implementations belong in infrastructure.
  • Domain tests do not require a database. If they do, a boundary is leaking.
  • Switching persistence technology requires changing only the infrastructure layer.

Onion Architecture tells you which layer owns what. The Ports and Adapters pattern tells you exactly how to implement the connection between those layers — which is the topic of the next article.

👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • DDD
  • Domain-Driven Design
  • DDD
  • Onion Architecture
  • Clean Architecture
  • Domain Layer
  • Application Layer
  • Infrastructure Layer
  • .NET
  • C#
  • EF Core