👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Testing Domain Models in DDD with .NET

Testing Domain Models in DDD with .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?

Here is one of the biggest advantages of doing DDD properly that most developers only discover once they have already built their domain correctly: testing it is almost effortless. No databases, no HTTP clients, no mocks for a dozen services. Just plain C# objects and business rules.

In this article, we'll explore how to test a DDD domain model in .NET - from unit tests using the Given-When-Then pattern, to verifying domain events, to writing readable tests with test builders, handling idempotency, and setting up fixtures for integration tests. We'll use xUnit throughout.

Why we gonna do?

Why Testing Domain Logic in Isolation Matters

When your domain is pure - no infrastructure, no framework dependencies, no database calls - testing becomes a superpower. Here's what you gain:


Benefit               Detail
──────────────────────────────────────────────────────────────
Very fast feedback    Tests run in milliseconds - no I/O
No mocks needed       Domain has no infrastructure dependencies
One rule per test     Each test verifies exactly one business rule
Easy to debug         When a test fails, you know exactly which rule broke
Run while coding      Tests are fast enough to run continuously

Contrast this with services that mix business logic with infrastructure. Those tests need mock repositories, mock email services, mock databases—and when they fail, you have no idea whether the business rule or the infrastructure wiring broke.

What Goes Wrong Without Careful Domain Testing

Before looking at good tests, it is worth understanding what production bugs arise when tests are absent or poorly structured. Four things go wrong most often:


Problem                              Production Consequence
──────────────────────────────────────────────────────────────────────────────
Event never raised                   Downstream systems never react
Event raised with wrong data         Every consumer gets incorrect data
Event raised at the wrong time       Race conditions or out-of-order processing
Handler not idempotent               Duplicate processing creates wrong state

All four of these create production bugs that are very hard to trace to their source. Careful domain testing eliminates them before they reach production.

How we gonna do?

Step 1: The Given-When-Then Structure

Every domain test should follow the Given-When-Then structure. It maps directly to business language and makes tests readable to domain experts:


// File: LicenseTests.cs
public class LicenseTests
{
    [Fact]
    public void ActivatingValidLicenseIncreasesActivationCount()
    {
        // GIVEN a valid license with zero activations
        var license = License.Create(
            id: new LicenseId(Guid.NewGuid()),
            customerId: new CustomerId(Guid.NewGuid()),
            productId: new ProductId(Guid.NewGuid()),
            expiresAt: DateOnly.FromDateTime(DateTime.UtcNow.AddYears(1)),
            maxActivations: 5);

        // WHEN the license is activated
        var hardware = HardwareFingerprint.Of("DEVICE-ABC-001");
        license.Activate(hardware);

        // THEN the activation count increases
        Assert.Equal(1, license.ActivationCount);
    }
}

Note what is absent: no database, no HTTP call, no mock. Just a plain object and a business assertion. This test runs in microseconds and tells you exactly what it is testing.

Step 2: Naming Tests with Ubiquitous Language

A good test name reads like a business rule spoken in a conversation with a domain expert. Compare these:


// ❌ Weak test names — describe implementation, not business meaning
public void TestActivate() { }
public void ActivateShouldIncrementCounter() { }

// ✅ Strong test names — read like business rules
public void ActivatingValidLicenseIncreasesActivationCount() { }
public void ExpiredLicenseCannotBeActivated() { }
public void ActivationLicenseRaisesLicenseActivatedEvent() { }
public void SuspendedLicenseCannotBeActivated() { }
public void LicenseCannotExceedMaxActivations() { }

If a non-technical stakeholder can read your test names and understand what rules your system enforces, your naming is correct.

Step 3: Testing Business Rules That Throw Exceptions

Domain rules that reject invalid operations should throw domain-specific exceptions. Test them explicitly:


[Fact]
public void ExpiredLicenseCannotBeActivated()
{
    // GIVEN an expired license
    var license = License.Create(
        id: new LicenseId(Guid.NewGuid()),
        customerId: new CustomerId(Guid.NewGuid()),
        productId: new ProductId(Guid.NewGuid()),
        expiresAt: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),  // Yesterday
        maxActivations: 5);

    // WHEN activating
    var hardware = HardwareFingerprint.Of("DEVICE-ABC-001");

    // THEN a domain-specific exception is thrown
    Assert.Throws<LicenseExpiredException>(
        () => license.Activate(hardware));
}

[Fact]
public void LicenseCannotExceedMaxActivations()
{
    // GIVEN a license already at its activation limit
    var license = License.Create(
        id: new LicenseId(Guid.NewGuid()),
        customerId: new CustomerId(Guid.NewGuid()),
        productId: new ProductId(Guid.NewGuid()),
        expiresAt: DateOnly.FromDateTime(DateTime.UtcNow.AddYears(1)),
        maxActivations: 1);

    var hardware = HardwareFingerprint.Of("DEVICE-ABC-001");
    license.Activate(hardware);  // First activation — OK

    // WHEN activating again
    var anotherDevice = HardwareFingerprint.Of("DEVICE-XYZ-999");

    // THEN the limit is enforced
    Assert.Throws<ActivationLimitReachedException>(
        () => license.Activate(anotherDevice));
}

Step 4: Testing Domain Events

Domain events carry data that downstream systems rely on. Test that the right event is raised with the correct data—not just that an event was raised:


public class LicenseEventTests
{
    [Fact]
    public void ActivatingLicenseRaisesLicenseActivatedEvent()
    {
        // GIVEN a valid license
        var licenseId = new LicenseId(Guid.NewGuid());
        var customerId = new CustomerId(Guid.NewGuid());

        var license = License.Create(
            id: licenseId,
            customerId: customerId,
            productId: new ProductId(Guid.NewGuid()),
            expiresAt: DateOnly.FromDateTime(DateTime.UtcNow.AddYears(1)),
            maxActivations: 5);

        // WHEN activated
        var hardware = HardwareFingerprint.Of("DEVICE-ABC-001");
        license.Activate(hardware);

        // THEN exactly one event is recorded
        Assert.Single(license.DomainEvents);

        // AND it is the correct event type
        var domainEvent = Assert.IsType<LicenseActivated>(
            license.DomainEvents[0]);

        // AND it carries the correct data
        Assert.Equal(licenseId, domainEvent.LicenseId);
        Assert.Equal(customerId, domainEvent.CustomerId);
        Assert.Equal(hardware, domainEvent.Hardware);
    }

    [Fact]
    public void FailedActivationDoesNotRaiseEvent()
    {
        // GIVEN an expired license
        var license = License.Create(
            id: new LicenseId(Guid.NewGuid()),
            customerId: new CustomerId(Guid.NewGuid()),
            productId: new ProductId(Guid.NewGuid()),
            expiresAt: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
            maxActivations: 5);

        var hardware = HardwareFingerprint.Of("DEVICE-ABC-001");

        // WHEN activation fails
        Assert.Throws<LicenseExpiredException>(
            () => license.Activate(hardware));

        // THEN no event was recorded
        Assert.Empty(license.DomainEvents);
    }
}

We are not testing publishing here—we are testing that the aggregate recorded the correct facts. Publishing is tested at the application service layer.

Step 5: Test Builders for Readable Tests

When your tests require complex setup, the Test Builder pattern removes noise and makes tests read like specifications. Compare these two approaches:


// ❌ Without a builder — noisy, hard to see what matters
[Fact]
public void ExpiredLicenseCannotBeActivated()
{
    // 6 lines just to set up one object — most fields irrelevant to the test
    var license = License.Create(
        id: new LicenseId(Guid.NewGuid()),
        customerId: new CustomerId(Guid.NewGuid()),
        productId: new ProductId(Guid.NewGuid()),
        expiresAt: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
        maxActivations: 5);

    Assert.Throws<LicenseExpiredException>(
        () => license.Activate(HardwareFingerprint.Of("DEVICE")));
}

// ✅ With a builder — the intent is clear in one line
[Fact]
public void ExpiredLicenseCannotBeActivated_WithBuilder()
{
    var license = LicenseBuilder.ALicense().Expired().Build();

    Assert.Throws<LicenseExpiredException>(
        () => license.Activate(HardwareFingerprint.Of("DEVICE")));
}

The builder version reads like plain English. Here's how to build it:


public class LicenseBuilder
{
    private LicenseId _id = new LicenseId(Guid.NewGuid());
    private CustomerId _customerId = new CustomerId(Guid.NewGuid());
    private ProductId _productId = new ProductId(Guid.NewGuid());
    private DateOnly _expiresAt = DateOnly.FromDateTime(DateTime.UtcNow.AddYears(1));
    private int _maxActivations = 5;

    // Factory method — fluent entry point
    public static LicenseBuilder ALicense() => new();

    // Override only what matters for the test
    public LicenseBuilder Expired()
    {
        _expiresAt = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1));
        return this;
    }

    public LicenseBuilder WithCustomer(CustomerId customerId)
    {
        _customerId = customerId;
        return this;
    }

    public LicenseBuilder WithMaxActivations(int max)
    {
        _maxActivations = max;
        return this;
    }

    public LicenseBuilder WithId(LicenseId id)
    {
        _id = id;
        return this;
    }

    public License Build() => License.Create(
        id: _id,
        customerId: _customerId,
        productId: _productId,
        expiresAt: _expiresAt,
        maxActivations: _maxActivations);
}

The builder follows three rules. It starts in a valid state with sensible defaults. Only what matters for the test is overridden. And methods name the business concept, not the field - Expired() instead of WithExpiresAt(DateTime.UtcNow.AddDays(-1)).

Step 6: Testing Idempotency

Event handlers must be idempotent—safe to run more than once. This happens because message queues use at-least-once delivery. Here is how to test for it:


public class ActivationEmailHandlerTests
{
    [Fact]
    public async Task ProcessingSameEventTwiceDoesNotSendDuplicateEmail()
    {
        // GIVEN an event and a handler with idempotency tracking
        var domainEvent = new LicenseActivated(
            LicenseId: new LicenseId(Guid.NewGuid()),
            CustomerId: new CustomerId(Guid.NewGuid()),
            Hardware: HardwareFingerprint.Of("DEVICE-ABC"),
            ActivationCount: 1);

        var emailService = new FakeEmailService();
        var processedEvents = new InMemoryProcessedEventStore();
        var handler = new SendActivationEmailOnLicenseActivated(
            emailService, processedEvents);

        // WHEN the same event is processed twice
        await handler.HandleAsync(domainEvent);
        await handler.HandleAsync(domainEvent);  // Simulating a retry

        // THEN the email was sent only once
        Assert.Equal(1, emailService.SentCount);
    }

    [Fact]
    public async Task DifferentEventsAreEachProcessedOnce()
    {
        // GIVEN two different events
        var event1 = new LicenseActivated(
            new LicenseId(Guid.NewGuid()),
            new CustomerId(Guid.NewGuid()),
            HardwareFingerprint.Of("DEVICE-A"),
            ActivationCount: 1);

        var event2 = new LicenseActivated(
            new LicenseId(Guid.NewGuid()),
            new CustomerId(Guid.NewGuid()),
            HardwareFingerprint.Of("DEVICE-B"),
            ActivationCount: 1);

        var emailService = new FakeEmailService();
        var processedEvents = new InMemoryProcessedEventStore();
        var handler = new SendActivationEmailOnLicenseActivated(
            emailService, processedEvents);

        // WHEN each event is processed once
        await handler.HandleAsync(event1);
        await handler.HandleAsync(event2);

        // THEN two emails were sent
        Assert.Equal(2, emailService.SentCount);
    }
}

// Test double for email service
public class FakeEmailService : IEmailService
{
    public int SentCount { get; private set; }

    public Task SendActivationConfirmationAsync(
        string email, LicenseId licenseId, HardwareFingerprint hardware)
    {
        SentCount++;
        return Task.CompletedTask;
    }
}

Step 7: Fixtures for Integration Tests

While domain unit tests use builders and in-memory objects, integration tests need real database state. Test Fixtures handle this by setting up and tearing down known data for integration scenarios:


// Fixture class — sets up known database state for integration tests
public class LicenseFixtures
{
    private readonly ILicenseRepository _licenseRepository;
    private readonly ICustomerRepository _customerRepository;

    public LicenseFixtures(
        ILicenseRepository licenseRepository,
        ICustomerRepository customerRepository)
    {
        _licenseRepository = licenseRepository;
        _customerRepository = customerRepository;
    }

    // Creates a known, consistent data set in the database
    public async Task<LicenseTestData> CreateStandardDataAsync()
    {
        // Create and save a customer
        var customerId = new CustomerId(Guid.NewGuid());
        var customer = Customer.Create(
            customerId,
            Email.Of("test.customer@example.com"));
        await _customerRepository.AddAsync(customer);

        // Create a valid license
        var validLicenseId = new LicenseId(Guid.NewGuid());
        var validLicense = License.Create(
            id: validLicenseId,
            customerId: customerId,
            productId: new ProductId(Guid.NewGuid()),
            expiresAt: DateOnly.FromDateTime(DateTime.UtcNow.AddYears(1)),
            maxActivations: 5);
        await _licenseRepository.AddAsync(validLicense);

        // Create an expired license
        var expiredLicenseId = new LicenseId(Guid.NewGuid());
        var expiredLicense = License.Create(
            id: expiredLicenseId,
            customerId: customerId,
            productId: new ProductId(Guid.NewGuid()),
            expiresAt: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
            maxActivations: 5);
        await _licenseRepository.AddAsync(expiredLicense);

        // Return only IDs — let tests fetch the full objects if needed
        return new LicenseTestData(customerId, validLicenseId, expiredLicenseId);
    }
}

public sealed record LicenseTestData(
    CustomerId CustomerId,
    LicenseId ValidLicenseId,
    LicenseId ExpiredLicenseId);

The key distinction between builders and fixtures: builders are for unit tests (in-memory, fast, isolated), fixtures are for integration tests (persistent, shared, require database setup and teardown).


Strategy     When To Use            Speed     Database    Setup/Teardown
─────────────────────────────────────────────────────────────────────────
Builders     Unit tests             Fast      No          None
Fixtures     Integration tests      Slower    Yes         Required

Summary

In this article, we explored how to test a DDD domain model in .NET using practical strategies that result in fast, readable, and reliable tests. Here's what matters:

  • Pure domains are trivially testable. When your domain has no infrastructure dependencies, tests are simple, fast, and focused. This is one of the greatest payoffs of DDD done correctly.
  • Given-When-Then structures tests around business scenarios. Set up the scenario, call exactly one domain method, assert the outcome. One rule per test.
  • Test names should read like business rules. ExpiredLicenseCannotBeActivated is a business rule expressed in code. TestActivate is not.
  • Test domain events, not just state. Verify the right event was raised, at the right time, with the correct data. Downstream systems depend on event data being correct.
  • Test builders eliminate noise. LicenseBuilder.ALicense().Expired().Build() expresses intent in one line. Six lines of constructor calls do not.
  • Handlers must be idempotent. Test that processing the same event twice produces the same result as processing it once. At-least-once delivery guarantees make this critical.
  • Use fixtures for integration tests, builders for unit tests. Each tool has its place — choose based on whether the test touches a real database.
👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • DDD
  • Domain-Driven Design
  • DDD
  • Testing
  • Given-When-Then
  • Test Builders
  • Domain Events
  • Idempotency
  • Test Fixtures
  • .NET
  • C#
  • xUnit
  • Unit Tests