Chapter 06 · Complex Types, Owned Types, JSON Mapping, Conversions, Comparers, and Encapsulation

Encapsulated Models with Constructors, Private Setters, Fields, and Domain-Friendly Persistence

Combine constructors, private setters, backing fields, private collections, and invariant-preserving methods into a domain-friendly model that EF Core can still materialize and persist safely.

Intermediate105–135 minutesencapsulation + round-trip materialization labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

Learning outcomes

By this point ServiceHub has entity identity, relationships, complex values, owned structures, JSON documents, and conversions. The final design problem is keeping the domain model honest. Public setters everywhere make persistence easy but let callers bypass invariants. EF Core can materialize encapsulated entities through constructors, private setters, and backing fields—as long as the mapping respects EF's constructor-binding and access rules.

01

Design an encapsulated WorkOrder with constructor/factory invariants while preserving EF materialization.

02

Explain constructor binding by convention, including name/type matching and the fact that navigations cannot be constructor-bound.

03

Use private setters and backing fields without confusing domain access with EF property access.

04

Map private collections/navigations through fields where appropriate.

05

Explain lazy-loading proxy constructor/virtual-member constraints and why proxies are not required for encapsulation.

06

Build tests that prove invalid states are rejected while round-trip materialization still succeeds.

1. Persistence should not be the only thing allowed to create state

A domain-friendly entity exposes operations that correspond to business actions: create, revise summary, assign technician, relocate, advance concurrency revision, add a checklist item. The CLR type protects invariants before EF ever generates SQL.

csharp · encapsulated WorkOrder sketch
public sealed partial class WorkOrder{    private string _summary = string.Empty;    private readonly List<WorkOrderTag> _workOrderTags = new();    private WorkOrder() { } // materialization path if EF cannot use a bound constructor    private WorkOrder(string number, string summary, ServiceAddress address)    {        WorkOrderNumber = RequireNumber(number);        SetSummary(summary);        ServiceAddress = address ?? throw new ArgumentNullException(nameof(address));        PublicId = WorkOrderPublicId.New();        Revision = Guid.NewGuid();    }    public int Id { get; private set; }    public WorkOrderPublicId PublicId { get; private set; }    public string WorkOrderNumber { get; private set; } = string.Empty;    public string Summary => _summary;    public ServiceAddress ServiceAddress { get; private set; } = null!;    public Guid Revision { get; private set; }    public static WorkOrder Open(string number, string summary, ServiceAddress address)        => new(number, summary, address);    public void ReviseSummary(string summary)    {        SetSummary(summary);        Revision = Guid.NewGuid();    }    private void SetSummary(string value)    {        value = value?.Trim() ?? throw new ArgumentNullException(nameof(value));        if (value.Length is < 8 or > 500)            throw new ArgumentOutOfRangeException(nameof(value));        _summary = value;    }}

2. EF constructor binding is convention-based

EF can call a parameterized constructor when parameter names/types match mapped properties (camel-case parameter names can match Pascal-case properties). It can also set other mapped properties after construction. Constructor choice is still convention-based; do not design an elaborate constructor-selection protocol that EF cannot explicitly configure.

csharp · constructor that EF can bind to mapped properties
private WorkOrder(int id, string workOrderNumber, Guid revision){    Id = id;    WorkOrderNumber = workOrderNumber;    Revision = revision;}// EF may bind mapped scalar properties by convention.// Other properties can be set afterward through mapped setters/fields.

3. Navigations cannot be injected through entity constructors

EF constructor binding can bind mapped scalar properties and certain EF services, but not relationship navigations such as AssignedTechnician or Tags. Keep graph setup compatible with relationship fixup and field/property access rather than requiring a navigation constructor parameter.

csharp · deliberately unbindable navigation constructor idea
// Do not require EF to construct an entity this way.private WorkOrder(    int id,    string workOrderNumber,    Technician assignedTechnician,      // navigation    IReadOnlyCollection<Tag> tags)       // navigation collection{    ...}

The repair is to bind scalars through a suitable constructor and let EF populate navigations according to relationship mapping.

4. Private setters are mapped as writable properties

Private setters preserve a normal property shape while preventing ordinary callers from assigning arbitrary values. EF considers them read/write and can set generated keys during materialization/save.

csharp · private-setter mapping remains ordinary
builder.Property(x => x.Id)    .HasColumnName("work_order_id")    .ValueGeneratedOnAdd();builder.Property(x => x.WorkOrderNumber)    .HasColumnName("work_order_number")    .HasMaxLength(40);

5. Backing fields let domain getters stay controlled

Chapter 03 already introduced _summary. Keep the mapping explicit so EF reads/writes the field while the domain controls mutations through methods.

csharp · backing-field configuration
builder.Property(x => x.Summary)    .HasField("_summary")    .UsePropertyAccessMode(PropertyAccessMode.Field)    .HasColumnName("summary")    .HasMaxLength(500);

Choosing Field, PreferFieldDuringConstruction, or property access is not cosmetic: property setters may contain validation/events that you do or do not want invoked during materialization. Test the selected mode.

6. Private collections can preserve aggregate operations

csharp · private relationship collection
private readonly List<WorkOrderTag> _workOrderTags = new();public IReadOnlyCollection<WorkOrderTag> WorkOrderTags => _workOrderTags;public void AddTag(Tag tag, string actor, DateTime appliedUtc){    if (_workOrderTags.Any(x => x.TagId == tag.Id))        return;    _workOrderTags.Add(WorkOrderTag.Create(this, tag, actor, appliedUtc));    Revision = Guid.NewGuid();}

Map the navigation/field deliberately using the relationship APIs established in Chapter 05. The invariant belongs to the method; the database composite key remains a second line of defense against duplicate associations.

7. Deliberately wrong design: make every setter public “for EF”

csharp · anemic persistence-driven entity
public string Summary { get; set; } = "";public Guid Revision { get; set; }public int? AssignedTechnicianId { get; set; }public List<Tag> Tags { get; set; } = new();

This lets controllers, serializers, tests, and mapping libraries bypass summary validation, concurrency-token updates, assignment authorization, and relationship-diff semantics. EF does not require this openness. The safer model uses private setters/fields and explicit operations while retaining database constraints and concurrency predicates.

8. Lazy-loading proxies impose additional constraints

If a project uses proxy-based lazy loading, the generated proxy subclasses the entity. Constructors therefore need appropriate accessibility (commonly protected/public), and navigation members typically need to be virtual. That is a separate architectural choice. Do not weaken every entity solely for proxies when explicit/eager loading is a better fit. Chapter 10 evaluates lazy loading and N+1 risk directly.

9. Service injection into entities is a narrow EF mechanism

EF can bind certain services to constructors, but that does not mean application repositories, HTTP clients, tenant services, or arbitrary domain services should be injected into persistence entities. Keep business orchestration in application/domain services unless the entity truly owns the behavior and the dependency model remains testable.

10. Round-trip tests prove persistence compatibility

csharp · encapsulation round-trip test sketch
await using (var write = await factory.CreateDbContextAsync(ct)){    var order = WorkOrder.Open(        "WO-2026-000901",        "Inspect compressor vibration before restart",        new ServiceAddress("Plant 4", "Baku", "Absheron", "AZ1000", "AZ",            new GeoPoint(40.4093, 49.8671)));    write.Add(order);    await write.SaveChangesAsync(ct);}await using (var read = await factory.CreateDbContextAsync(ct)){    var reloaded = await read.WorkOrders        .SingleAsync(x => x.WorkOrderNumber == "WO-2026-000901", ct);    Console.WriteLine(reloaded.Summary);    Console.WriteLine(reloaded.ServiceAddress.City);}

A useful encapsulation test suite has two directions: invalid public operations must fail before SQL, and valid state must successfully round-trip through a new context/database read.

11. Hands-on lab: make the aggregate domain-friendly without breaking EF

  1. Start from the Chapter 05 WorkOrder plus Chapter 06 complex/owned mappings.
  2. Replace public mutation with factory/domain methods while retaining private EF-compatible setters/fields.
  3. Keep _summary mapped with explicit field access.
  4. Encapsulate the join collection and add one domain method that preserves uniqueness/revision rules.
  5. Run a valid insert/read/update round trip in two fresh contexts.
  6. Attempt invalid summary/address/tag operations and prove no SQL is emitted before the domain exception.
  7. Inspect context.Model and ChangeTracker after materialization to confirm fields/private setters were populated correctly.
  8. Document whether the application uses proxies; if not, do not add virtual members merely “in case.”

Verification checklist

  • Generated keys still populate private-setter properties.
  • EF materializes without bypassing required domain initialization assumptions.
  • Navigation construction does not depend on constructor binding.
  • Domain methods update the Chapter 04 revision token when appropriate.
  • Database constraints remain in place even though CLR invariants improve.

Check your understanding

  1. Can EF use a private constructor?
  2. What must constructor-bound scalar parameters match?
  3. Can EF bind relationship navigations through entity constructors?
  4. Why are private setters convenient for generated keys?
  5. What does PropertyAccessMode control?
  6. Why must database constraints remain even with strong domain invariants?
Review the answers

Yes; constructor accessibility can be private, though lazy-loading proxies need an accessible base constructor.

Mapped property names/types by convention, with camel-case parameters matching Pascal-case properties.

No. Navigations are populated through relationship materialization/fixup, not constructor-bound like scalar properties.

EF still sees the property as writable and can set store-generated values while ordinary application code cannot mutate it directly.

Whether EF reads/writes through fields or properties, including during construction/materialization.

Other writers, migrations, bulk SQL, bugs, and concurrency can bypass CLR methods; relational integrity must still be enforced by the database.

12. Chapter 06 production judgment and bridge

Use complex types for values, owned entities for true ownership identity, JSON for justified document storage, converters for representation changes, comparers for correct snapshot/equality semantics, and encapsulation to keep invalid state out of the aggregate. None of these APIs removes the need to inspect migrations, generated SQL, provider behavior, concurrency predicates, and database constraints.

Chapter 07 moves from rich values to advanced relational mapping: inheritance strategies, table splitting, entity splitting, and evidence-based mapping choices.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n