Skip to content

Domain-Driven Design With Entity Framework Core 8

Spider-Man reading "Domain-Driven Design: Tackling Complexity in Heart of Software" by Eric Evans.

Are you curious about the book that Spider-Man is reading? It's "Domain-Driven Design: Tackling Complexity in Heart of Software" by Eric Evans. This book coined the term "Domain-Driven Design". It's also known as "the DDD book" or "the blue book". It's a challenging read, but trust me when I say it's worth it.

Domain-Driven Design (DDD) is a collection of principles and patterns that helps developers build software by modelling business domains. In other words, it's all about understanding your business and transforming it into the code. Writing code is, dare I say it, the easiest part of DDD. Yet, storing your sophisticated domain models in a database can sometimes get tricky. In this article, I will show you how to use various DDD patterns together with Entity Framework (EF) Core 8. I will be using Microsoft SQL Server as a database. However, the code examples (with minor adjustments) should also work with other relational databases such as MySQL or PostgreSQL.

Creating Entities

Entities in Domain-Driven Design are domain objects defined by their identities and lifecycles. In simpler terms, they represent unique domain objects that can change over time. Let's define two of them—a Product and a Seller that sells products:

public sealed class Product
{
    public Guid Id { get; }
    public Guid SellerId { get; }
    public string Name { get; }
    public string Currency { get; }
    public decimal Price { get; }

    private Product() { }

    public Product(
        Guid id,
        Seller seller,
        string name,
        string currency,
        decimal price) : this()
    {
        Id = id;
        SellerId = seller.Id;
        Name = name;
        Currency = currency;
        Price = price;
    }
}

public sealed class Seller
{
    public Guid Id { get; }
    public string Name { get; }

    public Seller(
        Guid id,
        string name)
    {
        Id = id;
        Name = name;
    }
}

Notice the empty private constructor in the Product entity. EF can use either a constructor with properties matching arguments (i.e. Seller) or an empty constructor and then resolve all entity properties using .NET reflection. It doesn't matter whether constructors are public, private, or internal. That means we can create many different constructors with all kinds of arguments. I usually go with an empty private constructor for EF and a public one for the rest of the application. It might not be super clean, as I'm leaking infrastructure concerns into my domain model. However, it's a pragmatic approach as it reduces boilerplate code while not impacting the domain model in a significant way.

Let's add Microsoft.EntityFrameworkCore.SqlServer library and define EF database context together with our entity configurations:

using Microsoft.EntityFrameworkCore;

public sealed class MyDbContext : DbContext
{
    private readonly string _connectionString;

    public MyDbContext(string connectionString)
    {
        _connectionString = connectionString;
    }

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Seller> Sellers => Set<Seller>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer(_connectionString);
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(b => 
        {
            b.HasKey(x => x.Id);

            b.Property(x => x.Name)
                .HasMaxLength(100);

            b.Property(x => x.Currency)
                .HasMaxLength(3);

            b.Property(x => x.Price);

            b.HasOne<Seller>()
                .WithMany()
                .HasForeignKey(x => x.SellerId);
        });

        modelBuilder.Entity<Seller>(b => 
        {
            b.HasKey(x => x.Id);

            b.Property(x => x.Name)
                .HasMaxLength(100);
        });
    }
}

After generating migrations and updating our database, we should now be able to work with Product and Seller entities:

using Microsoft.EntityFrameworkCore;

const string connectionString = "Server=localhost;Database=MyDatabase;Trusted_Connection=True;";

var sellerId = Guid.NewGuid();

await using (var ctx = new MyDbContext(connectionString))
{
    var seller = new Seller(sellerId, "Seller");
    ctx.Sellers.Add(seller);

    var product = new Product(Guid.NewGuid(), seller, "Product", "EUR", 100);
    ctx.Products.Add(product);

    await ctx.SaveChangesAsync();
}

await using (var ctx = new MyDbContext(connectionString))
{
    var sellers = await ctx.Sellers.ToListAsync();
    var products = await ctx.Products.ToListAsync();
}

Easy, right? Not so fast! Let's go one step further and introduce value objects.

Mapping Value Objects

Value objects in Domain-Driven Design are immutable stateless domain objects defined only by their property values. Let's define a couple of them:

public sealed class ProductId
{
    public Guid Value { get; }

    public ProductId(Guid value)
    {
        Value = value;
    }
}

public sealed class ProductName
{
    public const int MaxLength = 100;

    public string Value { get; }

    public ProductName(string value)
    {
        if (value.Length is 0 or > MaxLength)
            throw new ArgumentException($"Length must be between 1 and {MaxLength}.", nameof(value));

        Value = value;
    }
}

public sealed class Money
{
    public Currency Currency { get; }
    public decimal Amount { get; }

    public Money(Currency currency, decimal amount)
    {
        Currency = currency;

        if (amount < 0)
            throw new ArgumentException("Amount can't be negative.", nameof(amount));

        Amount = amount;
    }
}

public sealed class Currency
{
    public const int Length = 100;

    public static readonly Currency USD = new("USD");
    public static readonly Currency EUR = new("EUR");

    public string Value { get; }

    private Currency(string value)
    {
        if (value.Length is not Length)
            throw new ArgumentException($"Length must be {Length}.", nameof(value));

        Value = value;
    }
}

Since we validate data when creating value objects, we can be sure that our database will have valid data after value objects are persisted. There's no need to repeat validations when restoring persisted value objects. It can even be problematic if we do so, especially after introducing new validation rules that have yet to be applied to the whole database. To mitigate this, we could introduce a static factory method:

public sealed class ProductName
{
    public const int MaxLength = 100;

    public string Value { get; }

    private ProductName(string value)
    {
        Value = value;
    }

    public static ProductName Create(string value)
    {
        if (value.Length is 0 or > MaxLength)
            throw new ArgumentException($"Length must be between 1 and {MaxLength}.", nameof(value));

        return new ProductName(value);
    }
}

We could then use .NET reflection to configure EF to use private value object constructors when restoring persisted value objects. However, to ensure the code examples are not overly complicated, we'll stick with one public constructor for the rest of this article.

We should also override equality operators, as two or more value objects are equal if they have the same property values (entities, on the other hand, are equal if they have identical IDs). We could use either the standard .NET method or one of my go-to open-source libraries, CSharpFunctionalExtensions. This library provides base classes for value objects and entities, significantly reducing boilerplate code.

After implementing the changes mentioned above, our value objects should look like this:

using CSharpFunctionalExtensions;

public sealed class ProductId : ValueObject
{
    public Guid Value { get; }

    public ProductId(Guid value)
    {
        Value = value;
    }

    protected override IEnumerable<IComparable> GetEqualityComponents()
    {
        yield return Value;
    }
}

public sealed class ProductName : ValueObject
{
    public const int MaxLength = 100;

    public string Value { get; }

    public ProductName(string value)
    {
        if (value.Length is 0 or > MaxLength)
            throw new ArgumentException($"Length must be between 1 and {MaxLength}.", nameof(value));

        Value = value;
    }

    protected override IEnumerable<IComparable> GetEqualityComponents()
    {
        yield return Value;
    }
}

public sealed class Money : ValueObject
{
    public Currency Currency { get; }
    public decimal Amount { get; }

    public Money(Currency currency, decimal amount)
    {
        Currency = currency;

        if (amount < 0)
            throw new ArgumentException("Amount can't be negative.", nameof(amount));

        Amount = amount;
    }

    protected override IEnumerable<IComparable> GetEqualityComponents()
    {
        yield return Currency;
        yield return Amount;
    }
}

public sealed class Currency : ValueObject
{
    public const int Length = 100;

    public static readonly Currency USD = new("USD");
    public static readonly Currency EUR = new("EUR");

    public string Value { get; }

    public Currency(string value)
    {
        if (value.Length is not Length)
            throw new ArgumentException($"Length must be {Length}.", nameof(value));

        Value = value;
    }

    protected override IEnumerable<IComparable> GetEqualityComponents()
    {
        yield return Value;
    }
}

Let's also change our entities accordingly:

using CSharpFunctionalExtensions;

public sealed class Product : Entity<ProductId>
{
    public SellerId SellerId { get; }
    public ProductName Name { get; }
    public Money Price { get; }

    private Product() { }

    public Product(
        ProductId id,
        Seller seller,
        ProductName name,
        Money price) : this()
    {
        Id = id;
        SellerId = seller.Id;
        Name = name;
        Price = price;
    }
}

public sealed class Seller : Entity<SellerId>
{
    public SellerName Name { get; }

    public Seller(
        SellerId id,
        SellerName name)
    {
        Id = id;
        Name = name;
    }
}

Let's update our database context:

modelBuilder.Entity<Product>(b =>
{
    b.HasKey(x => x.Id);

    b.Property(x => x.Id)
        .HasConversion(x => x.Value, x => new ProductId(x));

    b.Property(x => x.Name)
        .HasConversion(x => x.Value, x => new ProductName(x))
        .HasMaxLength(ProductName.MaxLength);

    b.OwnsOne(
        x => x.Price,
        b =>
        {
            b.Property(x => x.Currency)
                .HasConversion(x => x.Value, x => new Currency(x))
                .HasMaxLength(Currency.Length);

            b.Property(x => x.Amount);
        });

    b.HasOne<Seller>()
        .WithMany()
        .HasForeignKey(x => x.SellerId);
});

modelBuilder.Entity<Seller>(b =>
{
    b.HasKey(x => x.Id);

    b.Property(x => x.Id)
        .HasConversion(x => x.Value, x => new SellerId(x));

    b.Property(x => x.Name)
        .HasConversion(x => x.Value, x => new ProductName(x))
        .HasMaxLength(SellerName.MaxLength);
});

Alternatively, for value objects, we could use complex properties introduced in EF Core 8:

b.ComplexProperty(
    x => x.Price,
    b =>
    {
        b.ComplexProperty(
            x => x.Currency,
            b =>
            {
                b.Property(x => x.Value)
                    .HasColumnName($"{nameof(Product.Price)}_{nameof(Money.Currency)}")
                    .HasMaxLength(Currency.Length);
            });

        b.Property(x => x.Amount);
    });

In theory, it's better to use EF complex properties—configuring value objects as owned entities will prevent us from reusing those value objects in different entities. In practice, however, EF complex properties still have many limitations, one of which is not supporting null values. I guess most of the missing features will be implemented in EF Core 9, as many limitations were not deliberate design decisions.

EF Core library maintainer discussing optional complex types in EF Core 8.
EF Core library maintainer discussing optional complex types in EF Core 8.

Storing Value Object Collections

Let's extend our domain model further by adding a collection of SocialMediaLink value objects to the Seller entity:

using CSharpFunctionalExtensions;

public sealed class Seller : Entity<SellerId>
{
    public SellerName Name { get; }

    private readonly List<SocialMediaLink> _socialMediaLinks;
    public IReadOnlyList<SocialMediaLink> SocialMediaLinks => _socialMediaLinks;

    private Seller() { }

    public Seller(
        SellerId id,
        SellerName name) : this()
    {
        Id = id;
        Name = name;
        _socialMediaLinks = [];
    }

    public void AddSocialMediaLink(SocialMediaLink link)
    {
        if (_socialMediaLinks.Contains(link))
            return;

        _socialMediaLinks.Add(link);
    }
}

public sealed class SocialMediaLink : ValueObject
{
    public const int MaxSocialNetworkLength = 20;
    public const int MaxHandleLength = 20;

    public static SocialMediaLink Facebook(string handle) => new("Facebook", handle);
    public static SocialMediaLink Instagram(string handle) => new("Instagram", handle);

    public string SocialNetwork { get; }
    public string Handle { get; }

    public SocialMediaLink(string socialNetwork, string handle)
    {
        if (socialNetwork.Length is 0 or > MaxSocialNetworkLength)
            throw new ArgumentException($"Length must be between 1 and {MaxSocialNetworkLength}.", nameof(socialNetwork));

        SocialNetwork = socialNetwork;

        if (handle.Length is 0 or > MaxHandleLength)
            throw new ArgumentException($"Length must be between 1 and {MaxSocialNetworkLength}.", nameof(handle));

        Handle = handle;
    }

    protected override IEnumerable<IComparable> GetEqualityComponents()
    {
        yield return SocialNetwork;
        yield return Handle;
    }
}

Before EF Core 8, we could use custom EF value converters serializing value object collections to a text value (i.e. JSON) or map them to separate database tables. Fortunately, EF Core 8 supports mapping value object collections to JSON without having custom value converters. Moreover, it allows data to be filtered by serialized value object properties. Let's update our database context:

modelBuilder.Entity<Seller>(b =>
{
    // ...

    b.OwnsMany(
        x => x.SocialMediaLinks,
        b =>
        {
            b.Property(x => x.SocialNetwork);

            b.Property(x => x.Handle);

            b.ToJson();
        });
});

Introducing Aggregates

An aggregate in Domain-Driven Design is a cluster of entities treated as a single unit. One of those entities is called an aggregate root. An aggregate root ensures the integrity of the whole aggregate, as all of the modifications to the aggregate entities should only go through the aggregate root. Let's create a Store entity and add it to the Seller aggregate. Entity Seller will become an aggregate root:

public sealed class Seller : Entity<SellerId>
{
    public SellerName Name { get; }

    private readonly List<SocialMediaLink> _socialMediaLinks;
    public IReadOnlyList<SocialMediaLink> SocialMediaLinks => _socialMediaLinks;

    private readonly List<Store> _stores;
    public IReadOnlyList<Store> Stores => _stores;

    private Seller() { }

    public Seller(
        SellerId id,
        SellerName name) : this()
    {
        Id = id;
        Name = name;
        _socialMediaLinks = [];
        _stores = [];
    }

    public void AddSocialMediaLink(SocialMediaLink link)
    {
        if (_socialMediaLinks.Contains(link))
            return;

        _socialMediaLinks.Add(link);
    }

    public void AddStore(StoreId id, Hour openFrom, Hour openTo)
    {
        var store = new Store(id, Id, openFrom, openTo);

        if (_stores.Contains(store))
            return;

        _stores.Add(store);
    }
}

public sealed class Store : Entity<StoreId>
{
    public SellerId SellerId { get; }
    public Hour OpenFrom { get; }
    public Hour OpenTo { get; }

    internal Store(
        StoreId id,
        SellerId sellerId,
        Hour openFrom,
        Hour openTo)
    {
        Id = id;
        SellerId = sellerId;
        OpenFrom = openFrom;
        OpenTo = openTo;
    }
}

public sealed class Hour : ValueObject
{
    public int Value { get; }

    public Hour(int value)
    {
        if (value is < 0 or > 24)
            throw new ArgumentException("Value must be between 0 and 24", nameof(value));

        Value = value;
    }

    protected override IEnumerable<IComparable> GetEqualityComponents()
    {
        yield return Value;
    }
}

We could use EF OwnsMany configuration, which would automatically load Stores when querying the Seller:

public sealed class MyDbContext : DbContext
{
    // ...

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Seller> Sellers => Set<Seller>();

    // ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // ...

        modelBuilder.Entity<Seller>(b =>
        {
            // ...

            b.OwnsMany(
                x => x.Stores,
                b =>
                {
                    b.HasKey(x => x.Id);

                    b.Property(x => x.Id)
                        .HasConversion(x => x.Value, x => new StoreId(x));

                    b.Property(x => x.OpenFrom)
                        .HasConversion<HourConverter>();

                    b.Property(x => x.OpenTo)
                        .HasConversion<HourConverter>();

                    b.WithOwner()
                        .HasForeignKey(x => x.SellerId);
                });
        });
    }
}

Notice the HourConverter—instead of repeating the same conversion expression twice, we could define a separate reusable value converter:

public sealed class HourConverter : ValueConverter<Hour, int>
{
    public HourConverter()
        : base(x => x.Value, x => new Hour(x)) { }
}

As an alternative to OwnsMany, we could use EF Core HasMany configuration. The downside of this approach is that we would need to include stores explicitly when querying sellers. However, this approach is more flexible, as we can query stores individually without querying sellers or query sellers without querying stores. This flexibility is valuable when working with large aggregates that take significant time to query. I usually prefer this approach, especially when using the repository pattern, as I can put all of the extra included statements inside of the repository:

public sealed class MyDbContext : DbContext
{
    // ...

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Seller> Sellers => Set<Seller>();
    public DbSet<Store> Stores => Set<Store>();

    // ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // ...

        modelBuilder.Entity<Seller>(b =>
        {
            // ...

            b.HasMany(x => x.Stores)
                .WithOne()
                .HasForeignKey(x => x.SellerId);
        });

        modelBuilder.Entity<Store>(b =>
        {
            b.HasKey(x => x.Id);

            b.Property(x => x.Id)
                .HasConversion(x => x.Value, x => new StoreId(x));

            b.Property(x => x.OpenFrom)
                .HasConversion<HourConverter>();

            b.Property(x => x.OpenTo)
                .HasConversion<HourConverter>();
        });
    }
}

public sealed class SellersRepository
{
    private readonly MyDbContext _ctx;

    public SellersRepository(MyDbContext ctx)
    {
        _ctx = ctx;
    }

    public Task<Seller?> GetById(SellerId id, CancellationToken token = default)
    {
        return _ctx.Sellers
            .Include(x => x.Stores)
            .FirstOrDefaultAsync(x => x.Id == id, token);
    }
}

To map one-to-one relations instead of one-to-many (i.e. Seller with only one Store), you could use almost identical HasOne and OwnsOne EF configurations.

Summary

Domain-driven design patterns and Entity Framework Core 8 are a very powerful combination. It only takes a couple of lines of code to map entities, value objects, and even aggregate roots to database tables. What is more, it gets better and better every release. I can only wonder what Entity Framework Core 9 will bring!

Thank you for reading. Is there something else that this post needs to include? I'd love to hear about it in the comments.