EF Core Mapping for DDD - for Dummies
EF Core Mapping for DDD - for Dummies
You built a nice domain model with rules and private properties - but EF Core wants simple classes with public get; set;. Don't worry. This guide shows you how to save DDD models to the database without messing up your code. Short sentences. Real examples. No jargon overload.
Before you start: know basic C# and what EF Core is. DDD just means putting business rules inside your C# classes instead of scattering them everywhere.
1 ยท What's the Problem?
DDD (Domain-Driven Design) is a fancy name for a simple idea: put your business rules inside your C# classes. For example, "you can't ship an empty order" should live on the Order class - not in some random service file.
EF Core is the tool that saves your C# objects to SQL Server. It works great with simple classes that have public { get; set; } on everything. But DDD classes are different - they use:
- private set
- factory methods like
Order.Create() - hidden lists
That's where mapping comes in: a separate config file that tells EF Core "here's how to read and write my class" - without changing the class itself.
Bad model vs good model
The bad one is easy for EF Core but anyone can break the rules. The good one protects itself - EF Core just needs a little help to save it.
// โ BAD - no rules, anyone can change anything
public class Order
{
public Guid Id { get; set; }
public string Status { get; set; } = "";
public List<OrderLine> Lines { get; set; } = [];
// Someone can write: order.Status = "Shipped" on an empty order. Oops.
}
// โ
GOOD - rules live here, properties are protected
public sealed class Order
{
public OrderId Id { get; private set; }
public OrderStatus Status { get; private set; }
private readonly List<OrderLine> _lines = [];
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
private Order() { } // EF Core needs this empty constructor - you never call it
public static Order Create(CustomerId customerId) { /* ... */ }
public void AddLine(ProductId productId, int qty, Money price) { /* checks rules */ }
public void Place() { /* checks rules before shipping */ }
}
Keep database stuff out of your business code
Your Domain project (business rules) should never mention EF Core or SQL Server. All the "how to save to the database" code goes in a separate Infrastructure project. Think of it like this: your business code shouldn't know a database exists.
// Domain/Billing.Domain.csproj
// โ NO EntityFrameworkCore package. Just plain C#.
// Infrastructure/Billing.Infrastructure.csproj
// โ Has EF Core. References Domain. Contains mapping files.
// Example mapping file:
// Infrastructure/Persistence/Configurations/OrderConfiguration.cs
๐ก Think of it like a translator. Your C# class speaks "business rules." The database speaks "tables and columns." The mapping file is the translator - and it lives in Infrastructure, not in your business code.
2 ยท Where Does the Code Go?
Split your solution into folders (projects):
| Project | What goes here |
|---|---|
| Domain | Business classes - NO EF Core |
| Application | "Place order", "Cancel order" - NO EF Core |
| Infrastructure | EF Core and mapping files |
Only the "main" object of each group gets a DbSet in EF Core. We'll explain that group (called an aggregate) in the next section.
Folder layout
src/
โโโ Billing.Domain/ # Business rules - NO EF Core here
โ โโโ Orders/
โ โ โโโ Order.cs # The "boss" object (aggregate root)
โ โ โโโ OrderLine.cs # A line item inside an order
โ โ โโโ OrderId.cs # A safe ID type (section 6)
โ โโโ Common/
โ โโโ Money.cs # A small object with no ID (section 4)
โ
โโโ Billing.Application/ # Handlers - NO EF Core
โ
โโโ Billing.Infrastructure/ # EF Core lives HERE
โโโ Persistence/
โโโ ApplicationDbContext.cs
โโโ Configurations/
โโโ OrderConfiguration.cs # "How to save Order to SQL"
DbContext - only register the "boss" objects
DbSet means "EF Core can load and save this type directly." Only the main object (like Order) gets one. Line items (OrderLine) are saved automatically when you save the order.
public sealed class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: DbContext(options)
{
// โ
YES - Order is the "boss" of its group
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
// โ NO - OrderLine is saved through Order, not on its own
// public DbSet<OrderLine> OrderLines => ...DON'T DO THIS
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Finds all mapping files automatically
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(ApplicationDbContext).Assembly);
}
}
3 ยท The Main Object (Aggregate Root)
An aggregate is a group of objects that belong together - like an Order and its OrderLine items. You always load and save the whole group through one "boss" object called the aggregate root. In our example, Order is the boss.
EF Core needs a private empty constructor (private Order() { }) to rebuild objects from the database. Your app code should never call it - always use Order.Create() instead.
The Order class - business rules inside
Notice: you can't change status directly. You call Place() and the class checks the rules for you.
// Domain/Orders/Order.cs
public sealed class Order : BaseEntity
{
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money Total { get; private set; } = Money.Zero;
private readonly List<OrderLine> _lines = [];
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
private Order() { } // EF Core only - your code never calls this
public static Order Create(CustomerId customerId)
{
var order = new Order
{
Id = OrderId.New(),
CustomerId = customerId,
Status = OrderStatus.Draft
};
order.Raise(new OrderCreatedEvent(order.Id));
return order;
}
public void AddLine(ProductId productId, int qty, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify a placed order.");
if (qty <= 0)
throw new DomainException("Quantity must be positive.");
_lines.Add(new OrderLine(productId, qty, unitPrice));
Total = Money.Sum(_lines.Select(l => l.LineTotal));
}
public void Place()
{
if (_lines.Count == 0)
throw new DomainException("Empty order.");
Status = OrderStatus.Placed;
Raise(new OrderPlacedEvent(Id, CustomerId, Total));
}
}
The mapping file - tells EF Core how to save Order
This file lives in Infrastructure. It says: table name, how to save IDs, how to save Money, how to save line items, and what to ignore.
// Infrastructure/Persistence/Configurations/OrderConfiguration.cs
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
builder.Property(o => o.Id)
.HasConversion(id => id.Value, v => new OrderId(v));
builder.Property(o => o.CustomerId)
.HasConversion(id => id.Value, v => new CustomerId(v));
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(32);
// Money is a small object - save it as extra columns (section 4)
builder.OwnsOne(o => o.Total, money =>
{
money.Property(m => m.Amount)
.HasColumnName("TotalAmount")
.HasPrecision(18, 2);
money.Property(m => m.Currency)
.HasColumnName("TotalCurrency")
.HasMaxLength(3);
});
// Line items - saved in a separate table, linked to Order
builder.HasMany<OrderLine>("_lines")
.WithOne()
.HasForeignKey(l => l.OrderId)
.OnDelete(DeleteBehavior.Cascade);
// Events are in-memory only - don't save to database
builder.Ignore(o => o.DomainEvents);
}
}
โ ๏ธ Watch out: don't make setters public just to fix an EF error. If EF Core complains about a private set, fix it in the mapping file - don't weaken your class.
4 ยท Small Objects With No ID (Value Objects)
Some objects don't need their own ID. Money is just an amount and a currency. Address is just street, city, zip. Two Money(10, "EUR") objects are the same thing - that's a value object. EF Core saves them as extra columns on the parent table (not a separate table). You use OwnsOne or, in .NET 8+, the simpler ComplexType.
What a value object looks like
No Id property. Just data. You can add validation in the constructor - like "street can't be empty."
// Money - just amount + currency, nothing else
public sealed record Money(decimal Amount, string Currency)
{
public static Money Zero => new(0m, "EUR");
public static Money Sum(IEnumerable<Money> items)
{
var list = items.ToList();
if (list.Count == 0) return Zero;
return new Money(list.Sum(m => m.Amount), list[0].Currency);
}
}
// Address - another common value object
public sealed record Address(string Street, string City, string PostalCode, string Country)
{
public Address(string street, string city, string postalCode, string country)
{
if (string.IsNullOrWhiteSpace(street))
throw new DomainException("Street required.");
Street = street.Trim();
City = city.Trim();
PostalCode = postalCode.Trim();
Country = country.Trim();
}
}
OwnsOne - save it as extra columns
The address columns go on the Customers table - no new table needed. You pick the column names.
// Creates columns on Customers table:
// ShipStreet, ShipCity, ShipPostalCode, ShipCountry
builder.OwnsOne(c => c.ShippingAddress, addr =>
{
addr.Property(a => a.Street)
.HasColumnName("ShipStreet")
.HasMaxLength(200);
addr.Property(a => a.City)
.HasColumnName("ShipCity")
.HasMaxLength(100);
addr.Property(a => a.PostalCode)
.HasColumnName("ShipPostalCode")
.HasMaxLength(20);
addr.Property(a => a.Country)
.HasColumnName("ShipCountry")
.HasMaxLength(60);
});
ComplexType - even easier (.NET 8+)
If you're on .NET 8 or later, slap [ComplexType] on the record and use ComplexProperty in the mapping. Less typing, same result.
[ComplexType]
public sealed record Money(decimal Amount, string Currency) { /* ... */ }
builder.ComplexProperty(o => o.Total, money =>
{
money.Property(m => m.Amount)
.HasColumnName("TotalAmount")
.HasPrecision(18, 2);
money.Property(m => m.Currency)
.HasColumnName("TotalCurrency")
.HasMaxLength(3);
});
// Which one to pick?
// OwnsOne โ works on older EF Core, more options
// ComplexType โ .NET 8+, simpler, always extra columns
OwnsMany - a list of small objects needs its own table
Usually one value object = extra columns. But if you have a list of them (like discount codes), EF Core creates a small separate table.
public sealed class Order : BaseEntity
{
private readonly List<DiscountCode> _discounts = [];
public IReadOnlyCollection<DiscountCode> Discounts => _discounts.AsReadOnly();
}
builder.OwnsMany(o => o.Discounts, d =>
{
d.ToTable("OrderDiscounts");
d.WithOwner().HasForeignKey("OrderId");
d.Property(x => x.Code).HasMaxLength(20);
d.Property(x => x.Percentage).HasPrecision(5, 2);
d.HasKey("OrderId", nameof(DiscountCode.Code));
});
5 ยท Lists Inside an Object (Backing Fields)
Don't expose a public List<OrderLine> that anyone can add to or clear. Instead:
- Keep a private list (
_lines) - Expose a read-only view (
Lines) - Changes go through methods like
AddLine()
EF Core can still read and write your private list - you just tell the mapping file where it is.
Private list, public read-only view
// In your Order class:
private readonly List<OrderLine> _lines = [];
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public void AddLine(ProductId productId, int qty, Money unitPrice)
{
_lines.Add(new OrderLine(productId, qty, unitPrice));
}
// In your mapping file - point EF Core at "_lines"
builder.HasMany<OrderLine>("_lines")
.WithOne()
.HasForeignKey(l => l.OrderId)
.OnDelete(DeleteBehavior.Cascade);
OrderLine - belongs inside Order, not on its own
OrderLine is not the "boss." You never load it directly from the database - you load the Order and its lines come with it.
public sealed class OrderLine
{
public OrderLineId Id { get; private set; }
public OrderId OrderId { get; private set; }
public ProductId ProductId { get; private set; }
public int Quantity { get; private set; }
public Money UnitPrice { get; private set; }
public Money LineTotal => new(Quantity * UnitPrice.Amount, UnitPrice.Currency);
private OrderLine() { } // EF Core only
internal OrderLine(ProductId productId, int qty, Money unitPrice)
{
Id = OrderLineId.New();
ProductId = productId;
Quantity = qty;
UnitPrice = unitPrice;
}
}
// Mapping file for OrderLine
public sealed class OrderLineConfiguration : IEntityTypeConfiguration<OrderLine>
{
public void Configure(EntityTypeBuilder<OrderLine> builder)
{
builder.ToTable("OrderLines");
builder.HasKey(l => l.Id);
builder.Property(l => l.Id)
.HasConversion(id => id.Value, v => new OrderLineId(v));
builder.OwnsOne(l => l.UnitPrice, m => { /* column names */ });
builder.Ignore(l => l.LineTotal); // calculated - don't save to DB
}
}
โ ๏ธ Watch out: don't query line items on their own. Don't add DbSet<OrderLine>. Always load the Order first.
6 ยท Safe IDs (No More Guid Mix-ups)
If everything is a Guid, you can accidentally pass a customer ID where an order ID is expected - and C# won't warn you. The fix: wrap each ID in its own small type (OrderId, CustomerId). Swap them by mistake and the compiler catches it. EF Core still saves them as plain Guid in SQL - you just add a one-line conversion in the mapping file.
Wrap your Guids
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
}
Comments
No comments yet. Start the discussion.