← Back to writing

Building DddSeedwork: Extracting DDD Building Blocks into a NuGet Package

ddddotnetnugetopen-source

Every DDD project I’ve worked on starts the same way. Copy the Entity<TId> base class. Copy ValueObject. Copy AggregateRoot. Copy the guard clauses. Tweak them slightly. Move on.

After doing this across enough projects, the pattern becomes obvious. These building blocks should live in one place, tested once, improved once, and pulled in as a dependency. So I finally did it.

DddSeedwork is the result. It’s the set of DDD base classes I’ve been refining across projects, extracted into two NuGet packages with 99 tests, multi-framework support, and an EF Core integration layer.

What’s in the box

Core package: DddSeedwork

Zero external dependencies. Works on .NET 8, 9, and 10.

Domain types. The foundations you build aggregates on:

// Strongly-typed identifiers
public record OrderId(Guid Value) : Id<OrderId>(Value);

// Entities with identity-based equality, operator overloads, ORM support
public class OrderItem : Entity<Guid> { ... }

// Value objects with structural equality
public class Money : ValueObject { ... }

// Aggregate roots with domain event support
public class Order : AggregateRoot<OrderId> { ... }

// Smart enums that replace magic strings and ints
public class OrderStatus : Enumeration<OrderStatus> { ... }

Guard clauses. 12 fluent validation methods that return the validated value:

public Order(OrderId id, string customerName, decimal total) : base(id)
{
    CustomerName = DomainGuard.AgainstNullOrWhiteSpace(customerName, nameof(customerName));
    Total = DomainGuard.AgainstNegativeOrZero(total, nameof(total));
}

Every guard throws DomainValidationException with a ParameterName property, so your API layer can return structured error responses without parsing exception messages.

Exception hierarchy. Domain errors that carry metadata:

throw EntityNotFoundException.For<Order>(orderId);
// Message: "Order with id '3f47...' was not found."
// Properties: EntityType = typeof(Order), EntityId = orderId

EF Core package: DddSeedwork.EntityFrameworkCore

The part that always takes the most time to get right. Value converters, comparers, and configuration extensions so your domain types just work with EF Core.

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.ConfigureIdConventions();
    configurationBuilder.ConfigureEnumerationConventions<OrderStatus>();
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.IgnoreDomainEvents();
}

That’s it. All your Id<T> properties automatically convert to Guid columns. All your Enumeration<T> properties store as int. Domain events are ignored by the model builder.

It also includes a DomainEventDispatchInterceptor that collects and dispatches domain events after SaveChangesAsync succeeds:

var interceptor = new DomainEventDispatchInterceptor(async (evt, ct) =>
{
    await mediator.Publish(evt, ct);
});

What I improved

These classes didn’t start from scratch. They evolved from a production codebase. Here’s what changed and why.

Entity equality is stricter. The original Equals only compared Id values. Two entities of different types with the same ID would be considered equal. That’s wrong. A User and an Order should never be equal just because they share an ID. Added a GetType() check.

Guard clauses return values. The originals were void methods. You’d call the guard, then assign the value on the next line. Now they return the validated value, so Name = DomainGuard.AgainstNullOrWhiteSpace(name, nameof(name)) works in a single expression. They also throw DomainValidationException (with a ParameterName) instead of DomainException.

Id<T> got implicit Guid conversion. You can pass an OrderId anywhere a Guid is expected without calling .Value. Plus Parse(string) for deserialization scenarios.

AggregateRoot validates its inputs. RaiseDomainEvent(null) used to silently add null to the events list. Now it throws immediately.

IRepository is interface-constrained. Instead of requiring AggregateRoot<TId> as a concrete base class, the constraint is now IAggregateRoot, an interface. This means you can write custom aggregate implementations without inheriting from the base class.

Enumeration<T> is new. A smart enum implementation that replaces the pattern of public static readonly fields scattered across the codebase with something that has GetAll(), FromValue(), FromName(), TryFromValue(), and proper equality.

Spec-driven development

I wrote a spec file for each module before writing any code. They live in docs/specs/ in the repo and define the API surface, behavior, and edge cases. The tests verify every behavior from the spec. The specs stay in the repo as living documentation. If you want to know what Entity<TId>.Equals does in edge cases, the spec tells you before you read the code.

Multi-framework support

Both packages target net8.0, net9.0, and net10.0. The core package has identical code across all three. It only uses .NET 8 APIs. The EF Core package resolves the matching EF Core version per framework:

Your projectGets EF Core
.NET 88.0.13
.NET 99.0.6
.NET 1010.0.3

NuGet handles this automatically through per-TFM dependency groups in the package manifest.

Try it

dotnet add package DddSeedwork
dotnet add package DddSeedwork.EntityFrameworkCore  # if using EF Core

The source is at github.com/thurley1/Seedwork. The repo includes a sample project with a complete Order aggregate, EF Core configuration, and domain event dispatch.

If you’ve been copying these same base classes between projects, stop copying. Install the package.

Found this useful?

If this post helped you, consider buying me a coffee.

Buy me a coffee

Comments