Persistence Ignorance With EF Core: How Close Can You Get?

Persistence Ignorance With EF Core: How Close Can You Get?

By

7 min read··

clean-architecturedddef-core

Persistence ignorance keeps database mapping out of domain classes. EF Core supports this through private setters, backing fields, constructor binding, and separate Fluent API configuration. Your entities can enforce business rules without referencing EF Core, although their constructors, collections, and loading requirements still have to work with the persistence model.

The boundary matters when a persistence shortcut exposes state that should be protected. A setter made public for the ORM is a setter any handler can abuse. An entity shaped for easy storage instead of correct behavior drifts back toward the anemic model you refactored away from.

The goal is to keep business methods independent of storage, while making the remaining mapping constraints explicit.

The Target: A Domain Class With No Database Fingerprints

This example targets EF Core 8 or later with a relational provider. The domain classes themselves need only the .NET runtime:

public sealed class Order
{
    private readonly List<OrderLine> _lines = [];

    private Order(OrderId id, CustomerId customerId, Address shippingAddress)
    {
        Id = id;
        CustomerId = customerId;
        ShippingAddress = shippingAddress;
        Status = OrderStatus.Pending;
    }

    private Order() { } // Materialization does not place a new order.

    public OrderId Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public Address ShippingAddress { get; private set; } = null!;
    public OrderStatus Status { get; private set; }
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    public static Order Place(CustomerId customerId, Address shippingAddress)
    {
        if (customerId.Value == Guid.Empty)
            throw new ArgumentException("A customer ID is required.", nameof(customerId));

        ArgumentNullException.ThrowIfNull(shippingAddress);
        return new(OrderId.New(), customerId, shippingAddress);
    }

    public void AddLine(ProductId productId, Money price, int quantity)
    {
        if (Status != OrderStatus.Pending)
        {
            throw new InvalidOperationException("Only pending orders can change.");
        }

        _lines.Add(new OrderLine(productId, price, quantity));

    }
}

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);
public sealed record Address(string Street, string City, string PostalCode);
public sealed record Money(decimal Amount, string Currency);
public enum OrderStatus { Pending, Submitted }

public sealed class OrderLine
{
    private OrderLine() { }

    internal OrderLine(ProductId productId, Money price, int quantity)
    {
        if (productId.Value == Guid.Empty)
            throw new ArgumentException("A product ID is required.", nameof(productId));
        ArgumentNullException.ThrowIfNull(price);
        if (price.Amount < 0 || string.IsNullOrWhiteSpace(price.Currency))
            throw new ArgumentException("A valid price is required.", nameof(price));
        if (quantity <= 0)
            throw new ArgumentOutOfRangeException(nameof(quantity));

        Id = Guid.NewGuid();
        ProductId = productId;
        Price = price with { };
        Quantity = quantity;
    }

    public Guid Id { get; private set; }
    public ProductId ProductId { get; private set; }
    public Money Price { get; private set; } = null!;
    public int Quantity { get; private set; }
}

Order.Place creates new state; the private constructors allow existing state to be restored. OrderLine clones the immutable price because the mapping below gives each owned price its own owner. The records keep this example focused on persistence; add the address and currency validation your domain requires.

The Mapping Layer Does the Dirty Work

All persistence knowledge goes into an IEntityTypeConfiguration in the infrastructure layer:

internal 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, value => new OrderId(value))
            .ValueGeneratedNever();

        builder.Property(o => o.CustomerId)
            .HasConversion(id => id.Value, value => new CustomerId(value));

        builder.ComplexProperty(o => o.ShippingAddress, address =>
        {
            address.Property(a => a.Street).HasColumnName("shipping_street");
            address.Property(a => a.City).HasColumnName("shipping_city");
            address.Property(a => a.PostalCode).HasColumnName("shipping_postal_code");
        });

        builder.OwnsMany(o => o.Lines, lines =>
        {
            lines.ToTable("order_lines");
            lines.WithOwner().HasForeignKey("order_id");
            lines.HasKey("order_id", nameof(OrderLine.Id));
            lines.Property(l => l.Id).ValueGeneratedNever();
            lines.Property(l => l.ProductId)
                .HasConversion(id => id.Value, value => new ProductId(value));
            lines.OwnsOne(l => l.Price, price =>
            {
                price.Property(p => p.Amount).HasPrecision(18, 2);
                price.Property(p => p.Currency).HasMaxLength(3);
            });
            lines.Navigation(l => l.Price).IsRequired();
        });

        builder.Navigation(o => o.Lines)
            .UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}

Register the whole assembly of configurations once:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}

The techniques stacked here, each keeping a specific database concern out of the domain:

  • Value converters map strongly typed IDs to their primitive columns. The ID types stay plain records.
  • Complex types (EF Core 8+) map multi-property value objects with EF Core into columns of the parent table. Owned types do the same job when you need a separate table or collections.
  • Field access mode tells EF to read and write the _lines backing field directly, which is what lets the entity expose IReadOnlyCollection and keep collections encapsulated. Configure it with UsePropertyAccessMode and PropertyAccessMode.Field as shown above. EF finds _lines by convention, so often even this line is optional.
  • Private setters are supported by EF during materialization. Field or property access depends on the model's access mode.

At this point the domain project has zero references to EF Core. Delete the ORM tomorrow and the domain compiles untouched.

The domain entity is plain C# with no EF references, while a mapping configuration in the infrastructure layer maps and materializes the entity and reads and writes the database

Two More Tricks Worth Knowing

Shadow properties hold columns the domain shouldn't see. Foreign keys are the classic case: order_id on the lines table exists in the mapping above (HasForeignKey("order_id")) without any OrderId property polluting OrderLine. Audit columns work the same way; pair shadow properties with a SaveChanges interceptor and CreatedAt/ModifiedAt never appear in a domain class at all.

Constructor binding can remove the parameterless constructor when parameters match mapped scalar properties. A typed ID mapped through a value converter is a scalar property and can participate. An entity constructor cannot receive its navigation or complex properties through that binding, so the Address parameter above requires another materialization path. Keep constructor side effects separate from restoring stored data.

The Honest List of Remaining Compromises

External configuration keeps dependencies out of the domain, but these details still need attention:

  • Materialization may bypass factories. Some types need a private constructor; scalar constructor binding works for others. Old rows, imports, and direct SQL may not satisfy today's validation rules, so protect durable invariants with appropriate database constraints as well.
  • Loading does not replay business operations. Constructors still run, and EF can use setters, but it does not call Place or AddLine. Avoid raising creation events while rehydrating existing entities.
  • Collections need a compatible backing object. A read-only view over a List works; EF needs an underlying collection it can populate. Custom collections must satisfy EF's collection-navigation requirements.
  • Mapping changes tracking semantics. Complex properties are tracked by their scalar values, so replacing a value with equal values does not itself require an update. Owned types have identity and ownership constraints that still matter for immutable records.
  • Loading strategy affects correctness. Proxy-based lazy loading requires overridable navigations. Explicitly load the state a business operation requires, and avoid accidentally evaluating rules against an incomplete aggregate.
  • The model is shaped by mappability at the margins. Inheritance hierarchies, generic domain types, and multi-column uniqueness all have "EF-friendly" and "EF-hostile" versions, and knowing the difference is part of the job.
  • Domain events need a delivery mechanism that touches EF: typically collecting events from tracked aggregates in SaveChanges via an interceptor. The entities stay clean, but the pattern exists because of the ORM's unit-of-work shape.

Public setters and mapping attributes in the domain are unnecessary for this model.

How Much Persistence Ignorance Is Practical?

Separate persistence models and explicit translation give you more control over the domain's shape. The cost is another model and mapping code that must evolve with every relevant change.

Use that extra layer when a concrete mapping constraint harms the model. A private constructor alone rarely justifies maintaining a second representation of every entity.

Where I hold the line:

  • The domain project never references Microsoft.EntityFrameworkCore. Mapping configuration, interceptors, and the DbContext live in infrastructure.
  • No property exists because the database wants it. Shadow properties cover those.
  • No setter becomes public for persistence. If EF can't map it privately, the mapping changes, not the domain.

This split, rich domain in the center and persistence machinery at the edge, is the Clean Architecture domain layer working as designed.

Summary

EF Core can persist this domain model without a dependency from the domain project to the ORM:

  • Private setters and backing fields keep state changes behind business methods.
  • Value converters, complex types, and owned types map strongly typed IDs and value objects without polluting them.
  • Field-access mode preserves encapsulated collections; shadow properties absorb foreign keys and audit columns.
  • Constructor binding, materialization, and loading behavior still require deliberate mapping and integration tests.

Keep persistence-specific code in infrastructure, and verify that saving and loading preserves the state your business methods rely on.

Frequently Asked Questions

What is persistence ignorance?

Persistence ignorance means domain classes contain no knowledge of how they are stored: no ORM base classes, no mapping attributes, no properties that exist only for the database. The domain is plain C#, and mapping lives in separate configuration.

Does EF Core require public setters on entity properties?

No. EF Core writes to private setters and backing fields when materializing entities, so your domain can expose read-only properties and enforce changes through methods.

Why does EF Core need a parameterless constructor?

A parameterless constructor is not always required. EF Core can bind parameters that match mapped scalar properties, including single-value wrappers mapped with converters. Navigation properties and complex properties cannot be passed to an entity constructor this way, so a private parameterless constructor is often useful.

How do you map value objects with EF Core?

Use owned types or complex types for multi-property value objects, and value converters for single-value wrappers like strongly typed IDs. All of it is configured in the mapping layer, so the value objects themselves stay plain C#.

Is full persistence ignorance possible with EF Core?

Domain classes can stay free of EF Core dependencies, attributes, and public setters. Their shape still needs to be mappable, and application code must load the state required by domain rules. Whether you need a private parameterless constructor depends on constructor binding.

  • Aggregate Design in DDD - Rules, Boundaries, and Consistency

    Aggregates are the most important tactical pattern in Domain-Driven Design. They define consistency boundaries, enforce invariants, and protect your domain model. Here are the rules and practical guidance for designing aggregates in .NET.

  • Aggregate Root in DDD: Rules and Implementation

    The Aggregate Root is the gatekeeper of consistency in Domain-Driven Design. Here are the rules for designing aggregate roots and implementing them in C#.

  • The Always-Valid Domain Model

    An always-valid domain model enforces business invariants at construction and on every state change. Private constructors, validated value objects, and guarded methods reduce repeated validation while keeping persistence and concurrency checks explicit.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.