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
_linesbacking field directly, which is what lets the entity exposeIReadOnlyCollectionand keep collections encapsulated. Configure it withUsePropertyAccessModeandPropertyAccessMode.Fieldas shown above. EF finds_linesby 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.
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
PlaceorAddLine. Avoid raising creation events while rehydrating existing entities. - Collections need a compatible backing object. A read-only view over a
Listworks; 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
SaveChangesvia 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.



