Chapter 03 · Model Building: Conventions, Data Annotations, Fluent API, and Metadata
Ignore Types and Properties, Configure Backing Fields, and Control Property Access Modes
Persist encapsulated domain state with ignored members, backing fields, field-only properties, and explicit property access modes while preserving materialization correctness.
Learning outcomes
ServiceHub's domain model is about to gain behavior. A useful domain object often has computed properties, private setters, backing fields, and invariants that should not all become columns. EF Core can persist encapsulated state without turning every field into a public mutable property—but only if the mapping is explicit and materialization/change-tracking behavior is understood.
Exclude types/properties with Ignore and NotMapped without confusing “not persisted” with “not part of business logic.”
Use convention-discovered or explicit backing fields with HasField.
Configure PropertyAccessMode deliberately and explain when EF reads/writes fields versus property accessors.
Create and query a field-only property with EF.Property where appropriate.
Diagnose broken backing-field mappings and encapsulation designs that EF cannot materialize safely.
1. Not every public member is persistent state
Suppose dispatch wants a display label composed from existing values. Persisting it would duplicate data and introduce synchronization risk.
public sealed class WorkOrder{ public int Id { get; set; } public string CustomerName { get; set; } = string.Empty; public string Summary { get; set; } = string.Empty; public string DisplayLabel => $"#{Id} - {CustomerName}: {Summary}";}
If EF considers a member mappable and it should not persist, exclude it explicitly.
builder.Ignore(x => x.DisplayLabel);
using System.ComponentModel.DataAnnotations.Schema;[NotMapped]public string DisplayLabel => $"#{Id} - {CustomerName}: {Summary}";
For the course policy established in Lesson 3, prefer Fluent
Ignore so persistence configuration remains in the
Data layer.
2. Entire CLR types can be ignored
public sealed record DispatchRecommendation(string Team, string Reason);protected override void OnModelCreating(ModelBuilder modelBuilder){ modelBuilder.Ignore<DispatchRecommendation>();}
Ignoring a type is useful when it is reachable from other domain
objects but is not an entity/value object intended for EF
mapping. Do not use Ignore<T> to hide a
relationship or persistence requirement you have not modeled
correctly. Exclusion should reflect architecture, not silence
model validation.
3. Backing fields preserve encapsulation
Imagine that arbitrary code must not replace a work-order summary with whitespace. The domain can own mutation through a method while EF persists the field.
public sealed class WorkOrder{ private string _summary = string.Empty; private WorkOrder() { } // EF materialization public WorkOrder( string customerName, string summary, WorkOrderPriority priority, DateTimeOffset openedUtc) { CustomerName = string.IsNullOrWhiteSpace(customerName) ? throw new ArgumentException("Customer name is required.", nameof(customerName)) : customerName.Trim(); Priority = priority; OpenedUtc = openedUtc; ReviseSummary(summary); } public int Id { get; private set; } public string CustomerName { get; private set; } = string.Empty; public string Summary => _summary; public WorkOrderPriority Priority { get; private set; } public DateTimeOffset OpenedUtc { get; private set; } public string DisplayLabel => $"#{Id} - {CustomerName}: {Summary}"; public void ReviseSummary(string value) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Summary cannot be blank.", nameof(value)); _summary = value.Trim(); }}
EF has backing-field naming conventions such as
_summary for a Summary property.
Explicit mapping is clearer when encapsulation is intentional.
Because Chapter 01 seeded WorkOrder with object
initializers, this domain refactor also requires the
deterministic seed code to use the new public constructor;
otherwise the course lab would stop compiling even though the
database mapping is valid.
new WorkOrder( "Northwind Workshop", "Replace vibration sensor on compressor C-14", WorkOrderPriority.High, DateTimeOffset.Parse("2026-08-20T08:00:00Z"))
builder.Property(x => x.Summary) .HasField("_summary") .HasColumnName("summary") .HasMaxLength(400) .IsRequired();
4. PropertyAccessMode controls how EF reaches the value
EF can access a field or property when reading/writing tracked values and materializing objects. The exact choice matters if property setters execute validation, publish events, normalize values, or have other behavior.
builder.Property(x => x.Summary) .HasField("_summary") .UsePropertyAccessMode(PropertyAccessMode.Field);
Field requires field access. Other modes include
Property, PreferField,
PreferProperty, and construction-sensitive
variants. Do not switch modes as a performance
micro-optimization without understanding domain semantics. A
property setter that raises a domain event during
materialization can be a correctness problem; bypassing a setter
that establishes a crucial invariant can also be a problem.
| Mode idea | Why choose it | Risk to inspect |
|---|---|---|
| Field | Persistence should bypass public setter logic | Can bypass validation/event logic that application code relies on |
| Property | Accessor logic must always run | Materialization may invoke side effects or reject database values |
| PreferField | Use backing field when available | Behavior may differ between members with/without fields |
| FieldDuringConstruction / PreferFieldDuringConstruction | Use fields while materializing, properties later | Requires careful understanding of when domain behavior should run |
5. Field-only properties are persisted without a CLR property
Sometimes infrastructure state belongs in the database but should not appear in the public domain API. EF can map a field directly.
public sealed class WorkOrder{ private DateTimeOffset _lastTouchedUtc; // Other domain members...}builder.Property<DateTimeOffset>("_lastTouchedUtc") .HasColumnName("last_touched_utc");
Because there is no CLR property in a lambda, queries access it
with EF.Property.
var recent = await db.WorkOrders .Where(w => EF.Property<DateTimeOffset>(w, "_lastTouchedUtc") >= cutoff) .OrderBy(w => w.Id) .ToListAsync(ct);
Use this sparingly. If application behavior needs the value, a private/public domain abstraction may be clearer. Field-only state is not a way to make important business data invisible to maintainers.
6. Shadow property and field-only property are different
A field-only property has CLR storage in a
field. A shadow property has no CLR property or
field; its value lives in EF's change tracker for tracked
entities. Both can be accessed in queries through
EF.Property, but their runtime storage and
detached-entity behavior differ.
| Kind | CLR storage? | Tracked value location | Common use |
|---|---|---|---|
| Normal property | Yes, property | Entity + tracker metadata | Domain state |
| Backing-field property | Yes, field + optional property | Entity field | Encapsulation |
| Field-only property | Yes, field only | Entity field | Infrastructure/internal persisted state |
| Shadow property | No | ChangeTracker entry | Foreign keys/audit/infrastructure metadata when CLR member is undesirable |
7. Deliberately broken mapping: HasField points at a field that does not exist
builder.Property(x => x.Summary) .HasField("_summmary"); // typo: three m characters
Model building should fail rather than silently use an imaginary field. The exact exception wording can change, but the diagnosis is straightforward: inspect the CLR type and finalized model configuration, correct the field name, and add a model-contract test so refactoring does not silently break the mapping.
String field names are vulnerable to rename drift. Keep configuration close to the entity configuration, cover it with a context-model test, and use code review/refactoring tooling carefully. Do not suppress model-validation errors merely to get startup past the failure.
8. Materialization is not normal application mutation
When EF constructs an entity from database rows, it must populate mapped state. That process is not equivalent to a user invoking domain methods. If setters enforce commands such as “only a dispatcher may revise summary,” those authorization rules should not run while reconstructing already-persisted state. Conversely, if the database contains invalid historical values, bypassing setters does not magically make the domain valid.
Define invariants at appropriate boundaries: database constraints for storage invariants, constructors/factories/domain methods for application state transitions, and materialization-compatible mapping for persistence. Later chapters cover constructors, complex/owned types, converters, and more domain-friendly persistence in depth.
9. Hands-on lab: encapsulate Summary without changing stored data
-
Convert
Summaryto a getter backed by_summaryand add the constructor shown above. -
Update
ServiceHubSeedto construct deterministic work orders through that constructor rather than object-initializing private-set members. -
Add
ReviseSummarywith a non-blank invariant. -
Map
Summaryto_summaryexplicitly and choosePropertyAccessMode.Field. - Reset/apply migrations only if the relational schema changed; a pure access-strategy change should not require destructive DDL.
- Load seeded work orders and prove EF materializes the persisted summaries.
-
Revise one summary through the domain method, call
SaveChangesAsync, and inspect generatedUPDATE. -
Add
DisplayLabeland ignore it; prove it does not appear in model properties. -
Optionally add
_lastTouchedUtcas a field-only property and query it withEF.Property. -
Introduce a deliberate
HasFieldtypo, capture the model-building failure, then repair it.
Check your understanding
- What is the purpose of a backing field mapping?
- Does [NotMapped] remove a property from the CLR type?
- How is a field-only property different from a shadow property?
- Why can using property setters during materialization be dangerous?
- What should you do after renaming a private backing field?
Review the answers
It lets EF persist/read field storage while the public property API can remain encapsulated or read-only.
No. It only tells EF not to map the member; application code can still use it.
A field-only property has CLR field storage; a shadow property has no CLR member and its tracked value lives in EF state.
Setters may run validation, authorization, events, or other side effects that are appropriate for commands but not object reconstruction.
Update/verify mapping, run model-contract tests, and exercise materialization/update paths so a string-based HasField mapping cannot drift unnoticed.
10. Production judgment and bridge
Encapsulation is valuable when it expresses domain rules, but persistence tricks should not make a model impossible to understand. Use backing fields deliberately, document access-mode choices, and test both materialization and updates. Avoid hiding ordinary business data in shadow/field-only state solely to make the CLR type look “clean.”
Lesson 5 addresses the next scaling problem: as ServiceHub gains
many entity mappings, OnModelCreating must not
become an unreviewable thousand-line method. You will split
mapping into
IEntityTypeConfiguration<T> classes, use
assembly scanning carefully, and validate the resulting model.
Authoritative references
- Backing Fields — backing-field discovery, HasField, property access modes, and field-only properties
- Entity Properties — included/excluded properties and mapping behavior
- Entity Types — ignoring entity types and model inclusion
- PropertyAccessMode enum — available access strategies in EF Core 10
- EF.Property<TProperty> — query access for shadow/field-only properties