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.
-
Moneyis just an amount and a currency. -
Addressis 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 theOrderfirst.
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 readonly record struct ProductId(Guid Value);
// This won't compile — you swapped the IDs:
// void Ship(OrderId orderId, CustomerId customerId) { }
// Ship(customerId, orderId); // ❌ compiler error — saved you!
Tell EF Core how to save them
Write this helper once, then use one line per ID in every mapping file.
public static class StronglyTypedIdExtensions
{
public static PropertyBuilder<TId> HasStronglyTypedIdConversion<TId>(
this PropertyBuilder<TId> propertyBuilder)
where TId : struct
{
var converter = new ValueConverter<TId, Guid>(
id => (Guid)typeof(TId).GetProperty("Value")!.GetValue(id)!,
value => (TId)Activator.CreateInstance(typeof(TId), value)!);
return propertyBuilder.HasConversion(converter);
}
}
// In OrderConfiguration — one line each:
builder.Property(o => o.Id).HasStronglyTypedIdConversion<OrderId>();
builder.Property(o => o.CustomerId).HasStronglyTypedIdConversion<CustomerId>();
7 · The Mapping File (Putting It All Together)
One mapping class per entity, in Infrastructure.
It answers: what table? what columns? how to save IDs and value objects? what to ignore?
Here's a complete example for Order — use it as a template.
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders", schema: "billing");
// ID
builder.HasKey(o => o.Id);
builder.Property(o => o.Id).HasStronglyTypedIdConversion<OrderId>();
// Link to Customer — just store the ID, no Customer object here
builder.Property(o => o.CustomerId).HasStronglyTypedIdConversion<CustomerId>();
// Status as readable text in SQL ("Draft", "Placed", ...)
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(32)
.IsRequired();
// Money value object → extra columns
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 via private _lines field
builder.HasMany<OrderLine>("_lines")
.WithOne()
.HasForeignKey(l => l.OrderId)
.OnDelete(DeleteBehavior.Cascade);
// Don't save events to the database
builder.Ignore(o => o.DomainEvents);
// Speed up common searches
builder.HasIndex(o => o.CustomerId);
builder.HasIndex(o => o.Status);
}
}
What goes where?
✅ Put in mapping file (Infrastructure):
- Table name
- How to save IDs, enums, value objects
- How to save lists (
_lines) - Ignore events and calculated properties
- Column sizes (
MaxLength,Precision)
✅ OK to leave alone (EF guesses correctly):
-
int,string,boolproperties - Column names same as property names
❌ Never put on Domain classes:
-
[Table],[Column],[ForeignKey]attributes -
public setters"because EF needs them" - Any
using Microsoft.EntityFrameworkCore;
8 · Saving, Loading & Mistakes to Avoid
A repository is a thin wrapper that loads and saves your "boss" objects (Order, Customer).
Your handler calls GetById, changes the object through its methods, then calls SaveChanges. That's it.
How to load, change, and save
// Interface — only works with Order, not OrderLine
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct);
void Add(Order order);
}
// Implementation
public sealed class OrderRepository(ApplicationDbContext db) : IOrderRepository
{
public Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct) =>
db.Orders
.Include(o => o.Lines) // don't forget this — or lines won't load!
.FirstOrDefaultAsync(o => o.Id == id, ct);
public void Add(Order order) => db.Orders.Add(order);
}
// Typical flow in a handler:
var order = await repo.GetByIdAsync(cmd.OrderId, ct)
?? throw new NotFoundException();
order.Place(); // business rules run here
await db.SaveChangesAsync(ct); // EF saves changes to SQL
Domain events — memory only, not in the database
When something important happens (order placed), the class raises an event. These live in memory until SaveChanges sends them out. Never save them as a table column.
// In mapping file — always ignore events:
builder.Ignore(o => o.DomainEvents);
Quick lookup — "I have X, how do I map it?"
| You have… | Map it with… |
|---|---|
| Main object (Order) |
DbSet + mapping file |
| Item inside Order (Line) |
HasMany("_lines"), no DbSet |
| Money, Address (no ID) |
OwnsOne or ComplexType
|
| List of small objects | OwnsMany |
| OrderId, CustomerId | HasStronglyTypedIdConversion |
private set on property |
works fine — don't change it |
private Order() { } |
EF Core needs it, your code doesn't |
| DomainEvents | builder.Ignore(...) |
| Calculated property | builder.Ignore(...) |
| Status enum | save as string |
| Link to another aggregate | store ID only, no Customer property |
5 mistakes beginners make
- Making everything public set — fix it in the mapping file, not the class.
- DbSet for line items — only the boss object gets a DbSet.
-
Putting
[Table]on domain classes — mapping files go in Infrastructure. -
Customer property on Order — store
CustomerIdonly, not the whole Customer object. -
Forgetting
.Include(o => o.Lines)— line items won't load and you'll wonder why the list is empty.
What's next?
- Clean Architecture & CQRS — how the whole app fits together
- EF Core In Depth — migrations, speed tips, production setup
- Testing .NET — test your save/load code with a real database
Written by Spyros Ponaris — .NET Tech Lead, Athens.
Top comments (0)