EF Core Mapping for DDD — for Dummies

Leader 25 60 128
calendar_today agoschedule11 min read

title: "EF Core Mapping for DDD — for Dummies"
published: false
description: "Save rich domain models to SQL Server without breaking your business rules. Plain English guide with copy-paste .NET 9 code."
tags: dotnet, csharp, efcore, ddd, entityframework, tutorial

cover_image:

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:

  1. Keep a private list (_lines)
  2. Expose a read-only view (Lines)
  3. 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 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, 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;

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

  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?

  • 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

Full tutorial with more examples: [www.web-partner.gr)


Written by Spyros Ponaris — .NET Tech Lead, Athens.

1 Comment

0 votes
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

5 EF Core Features Every Enterprise Developer Should Know

Spyros - Jun 5

SQL Server and Azure SQL Updates with EF Core 10

oguzhanagir - Dec 30, 2025

🚀 EF Core Query Optimization for Beginners

Spyros - Jun 29

Optimizing the Clinical Interface: Data Management for Efficient Medical Outcomes

Huifer - Jan 26

Building a Clean Master-Detail App with Blazor Server, MudBlazor, and EF Core

Spyros - Mar 26
chevron_left
8.5k Points213 Badges
Athens Greeceweb-partner.gr
48Posts
169Comments
114Connections
Passionate about building robust and scalable software solutions with a focus on .NET technologies. ... Show more

Related Jobs

View all jobs →

Commenters (This Week)

4 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!