Beginner Guide · 8 Sections · EF Core + DDD

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.

Aggregates Value Objects Fluent API Repositories

Before you start: know basic C# (C# for Dummies). Know what EF Core is (EF Core In Depth, sections 2–4). DDD means putting business rules inside your C# classes instead of scattering them everywhere — see Clean Architecture & CQRS section 3 for a gentle intro.

01

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(), and 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.

1.1

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 */ }
}
1.2

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 between them — and it lives in Infrastructure, not in your business code.

02

Where Does the Code Go?

Split your solution into folders (projects). Business rules go in Domain. Database code goes in Infrastructure. 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.

2.1

Folder layout — who lives where

Domain = your business classes. Application = "do this action" handlers. Infrastructure = EF Core and mapping files. Don't mix them up.

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/         # "Place order", "Cancel order" — NO EF Core
│
└── Billing.Infrastructure/      # EF Core lives HERE
    └── Persistence/
        ├── ApplicationDbContext.cs
        └── Configurations/
            └── OrderConfiguration.cs   # "How to save Order to SQL"
2.2

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);
    }
}
03

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.

3.1

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));
    }
}
3.2

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. Your business rules are more important than saving a few lines of config.

04

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.

4.1

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();
    }
}
4.2

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);
});

// Got more value objects on the same class? Add another OwnsOne block.
4.3

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
4.4

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.

// Order has a list of discount codes (no ID on each code — value objects)

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));
});
05

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) and only let people read it. 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.

5.1

Private list, public read-only view

Outsiders see Lines but can't change it. Only AddLine() can add items — and it checks the rules first.

// 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);

// Other way (same result):
builder.HasMany(o => o.Lines).WithOne().HasForeignKey(l => l.OrderId);
builder.Navigation(o => o.Lines)
    .UsePropertyAccessMode(PropertyAccessMode.Field)
    .HasField("_lines");
5.2

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> and fetch lines separately. Always load the Order first. Need a list screen? Build a separate read-only query (CQRS) — see CQRS section.

06

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.

6.1

Wrap your Guids

Each ID gets its own type. They all hold a Guid inside, but C# treats them as different things.

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!
6.2

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>();
07

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.

7.1

Full OrderConfiguration — copy this pattern

Every section from this guide in one file. Use it as a template for your own entities.

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);
    }
}
7.2

What goes in the mapping file vs what stays in Domain

Simple rule: if it's about the database, it goes in Infrastructure. If it's about business rules, it stays in Domain.

// ✅ 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, bool properties
//   • 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;
08

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.

8.1

How to load, change, and save

Load the order (with its lines). Call a domain method. Save. EF Core tracks what changed — you don't need an Update() method.

// 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
8.2

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);

// To actually send events (email, message queue, etc.)
// use an interceptor — see Clean Architecture tutorial section 8
8.3

Quick lookup — "I have X, how do I map it?"

// 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
8.4

5 mistakes beginners make

  1. Making everything public set — fix it in the mapping file, not the class.
  2. DbSet for line items — only the boss object gets a DbSet.
  3. Putting [Table] on domain classes — mapping files go in Infrastructure.
  4. Customer property on Order — store CustomerId only, not the whole Customer object.
  5. Forgetting .Include(o => o.Lines) — line items won't load and you'll wonder why the list is empty.
What's next?

1. Clean Architecture & CQRS — how the whole app fits together.

2. EF Core In Depth — migrations, speed tips, and production setup.

3. Testing .NET — test your save/load code with a real database.

4. How-To Cookbook — quick copy-paste EF Core answers.