Chapter 04 · Keys, Indexes, Property Semantics, Value Generation, and Shadow State
Shadow Properties, Indexer Properties, Property Bags, and Infrastructure Metadata
Use shadow state, EF.Property, indexer properties, and property bags deliberately while making storage location, tracking lifetime, and string-name risks explicit.
Learning outcomes
Chapter 03 introduced backing fields and contrasted field-only with shadow state. In ServiceHub, Chapter 04 now uses shadow state deliberately for infrastructure metadata and shows the adjacent concepts that are often confused with it: shadow foreign keys, indexer properties, and shared-type property-bag entities. The goal is not to hide more data from the domain; it is to know exactly where each mapped value lives.
Explain where shadow-property values live and how tracked/no-tracking behavior affects access.
Configure and query shadow properties with string-based Property and EF.Property while recognizing rename/typo risks.
Distinguish shadow properties from indexer properties and property-bag entity types.
Inspect metadata flags such as IsShadowProperty/IsIndexerProperty rather than guessing from source code.
Decide when infrastructure metadata belongs in shadow state and when hiding it damages domain clarity or disconnected workflows.
1. Shadow state exists in the EF model and ChangeTracker, not the CLR object
A shadow property has a name/type in EF's model but no CLR property or field on the entity class. For a tracked entity, EF stores its current/original values in the change-tracking entry. That makes shadow state useful for some infrastructure fields, but it also means a detached WorkOrder object does not carry those values with it.
builder.Property<DateTime>("CreatedUtc") .HasColumnName("created_utc") .HasDefaultValueSql("CURRENT_TIMESTAMP") .ValueGeneratedOnAdd();builder.Property<DateTime?>("LastModifiedUtc") .HasColumnName("last_modified_utc");CreatedUtc continues Lesson 3's database-generated experiment as UTC DateTime specifically so the SQLite lab can compare/order it server-side. LastModifiedUtc is application-managed in this lesson so we can observe setting a shadow value explicitly.
2. Read/write shadow state through Entry while tracked
var workOrder = await db.WorkOrders.SingleAsync( x => x.WorkOrderNumber == "WO-2026-000201", ct);var entry = db.Entry(workOrder);Console.WriteLine(entry.Property<DateTime>("CreatedUtc").CurrentValue);entry.Property<DateTime?>("LastModifiedUtc").CurrentValue = new DateTime(2026, 8, 27, 10, 0, 0, DateTimeKind.Utc);workOrder.ReviseSummary("Inspect cavitation alarm and inlet pressure");await db.SaveChangesAsync(ct);This is powerful but string-heavy. If business code routinely needs LastModifiedUtc, hiding it as a shadow property may be worse than exposing a well-designed domain/infrastructure abstraction.
3. EF.Property puts shadow state into a translatable query
var cutoff = new DateTime(2026, 8, 27, 0, 0, 0, DateTimeKind.Utc);var recent = await db.WorkOrders .Where(x => EF.Property<DateTime>(x, "CreatedUtc") >= cutoff) .OrderByDescending(x => EF.Property<DateTime>(x, "CreatedUtc")) .Select(x => new { x.WorkOrderNumber, CreatedUtc = EF.Property<DateTime>(x, "CreatedUtc") }) .ToListAsync(ct);Console.WriteLine(db.WorkOrders .Where(x => EF.Property<DateTime>(x, "CreatedUtc") >= cutoff) .ToQueryString());EF.Property is recognized by EF's query pipeline; it is not a general-purpose reflection API. Keep the exact property name centralized where possible because the compiler cannot rename a string literal for you.
4. No-tracking materialization cannot carry shadow values inside the entity
Microsoft documents that shadow properties cannot be accessed after a no-tracking query through a detached entity because the values are maintained by the change tracker. Project the value in the query if you need it outside tracking.
var rows = await db.WorkOrders .AsNoTracking() .Select(x => new { WorkOrder = x, CreatedUtc = EF.Property<DateTime>(x, "CreatedUtc") }) .ToListAsync(ct);The projection carries the value explicitly. Do not load an untracked entity and then expect Entry(entity).Property("CreatedUtc") to magically recover the original store value without querying.
5. Shadow foreign keys can appear by convention
If a relationship has a navigation but no matching CLR foreign-key property, EF can create a shadow foreign-key property. This keeps the domain class free of a scalar FK, but it also makes serialization/disconnected updates harder because the FK value is tracker state rather than entity state.
public sealed class WorkOrderAttachment{ public long Id { get; set; } public string FileName { get; set; } = string.Empty; public WorkOrder WorkOrder { get; set; } = null!;}// With no CLR WorkOrderId property, relationship conventions/configuration// can create a shadow WorkOrderId foreign-key property.modelBuilder.Entity<WorkOrderAttachment>() .HasOne(x => x.WorkOrder) .WithMany() .HasForeignKey("WorkOrderId");Chapter 05 will decide when explicit FK properties improve aggregate/disconnected behavior. Here, inspect the metadata rather than assuming whether the FK is shadow.
6. Indexer properties are CLR-backed through an indexer
An indexer property is not shadow state. The entity has a CLR indexer—often backed by a dictionary—and EF maps named properties through that indexer.
public sealed class WorkOrderEnvelope{ private readonly Dictionary<string, object?> _values = new(); public int Id { get; set; } public object? this[string key] { get => _values.TryGetValue(key, out var value) ? value : null; set => _values[key] = value; }}modelBuilder.Entity<WorkOrderEnvelope>(b =>{ b.HasKey(x => x.Id); b.IndexerProperty<string>("RegionCode").HasMaxLength(12);});The value lives in CLR state accessible through the indexer. This can be useful for dynamic extension data, but it trades compile-time domain discoverability for flexibility.
7. Property-bag entity types use Dictionary<string, object>
An EF property-bag entity type contains only indexer properties. EF currently supports Dictionary<string, object> for this pattern and configures it as a shared-type entity with a model name.
modelBuilder.SharedTypeEntity<Dictionary<string, object>>( "WorkOrderAttribute", b => { b.ToTable("work_order_attributes"); b.IndexerProperty<int>("WorkOrderId"); b.IndexerProperty<string>("Name").HasMaxLength(80); b.IndexerProperty<string>("Value").HasMaxLength(400); b.HasKey("WorkOrderId", "Name"); });var set = db.Set<Dictionary<string, object>>("WorkOrderAttribute");Property bags can model truly dynamic data or join payloads, but they are not a free replacement for normal entity types. They have feature limitations and make refactoring/validation more string-oriented.
8. Inspect shadow/indexer metadata explicitly
foreach (var entityType in db.Model.GetEntityTypes()){ foreach (var property in entityType.GetProperties()) { Console.WriteLine( $"{entityType.DisplayName()}.{property.Name} " + $"shadow={property.IsShadowProperty()} " + $"indexer={property.IsIndexerProperty()}"); }}A field-only property from Chapter 03 is neither the same as a shadow property nor an indexer property; it has CLR field storage. Keep these categories explicit in design reviews.
9. Deliberately wrong approach: typo silently creates a second shadow property
builder.Property<DateTime>("CreatedUtc") .HasColumnName("created_utc");// Later typo in another configuration fragment:builder.Property<DateTimeOffset>("CreatedUTC") .HasColumnName("created_utc_2");Because neither name must correspond to a CLR member, both strings can become valid model properties. The bug may surface as migration drift rather than a compiler error.
Centralize infrastructure property names, assert model metadata in tests, generate/review migrations, and avoid duplicate configuration fragments. If a value is central to business logic, strongly typed CLR state may be safer than a shadow name.
10. Hands-on lab: prove where each kind of state lives
- Keep
CreatedUtcand addLastModifiedUtcas shadow properties. - Load one work order with tracking, print both values via
Entry.Property, setLastModifiedUtc, save, and inspect SQL. - Query by
CreatedUtcwithEF.Propertyand inspectToQueryString(). - Run an
AsNoTrackingprojection that carriesCreatedUtcexplicitly. - Create the temporary
WorkOrderAttachmentshadow-FK example and inspectIsShadowProperty(). - Create
WorkOrderEnvelopeand proveRegionCodeis indexer-backed, not shadow. - Create the temporary shared
WorkOrderAttributeproperty bag and inspect its model metadata. - Introduce the
CreatedUTCtypo, generate a migration/model dump, diagnose the duplicate, and remove it.
Verification checklist
- You can state where each mapped value is physically stored in CLR/tracker state.
- No-tracking code projects shadow values before detachment when needed.
- String property names are covered by model assertions.
- The property-bag example remains an explicit dynamic-data experiment, not a default modeling style.
- Relationship shadow-state examples are left for deeper treatment in Chapter 05.
Check your understanding
- Where does a shadow-property value live for a tracked entity?
- How do you reference a shadow property in LINQ?
- Why can no-tracking entities lose access to shadow values?
- How is an indexer property different from a shadow property?
- What CLR type does EF currently support for property-bag entity types?
- Why can a typo in a shadow property name be especially dangerous?
Review the answers
In the ChangeTracker entry; there is no CLR property/field storing it.
Use EF.Property
Because the values are maintained in tracking state; project them explicitly if needed outside tracking.
An indexer property is backed by a CLR indexer, while a shadow property has no CLR storage member.
Dictionary
The compiler cannot validate the string against a CLR member, so a typo may create a distinct model property and schema drift.
11. Production judgment and bridge
Shadow state is best for narrowly scoped infrastructure metadata whose absence from the CLR model is intentional. Do not hide tenant identifiers, authorization-critical state, or business decisions merely to make entities look clean. Indexer/property-bag patterns are specialized flexibility tools, not the default for strongly modeled domains.
Lesson 5 combines property semantics with writes. You will configure optimistic concurrency, inspect the generated UPDATE predicate, compare application-managed tokens on SQLite with SQL Server rowversion, and see how store-generated/save behaviors decide which values EF sends or reads back.
Authoritative references
- Shadow and Indexer Properties - EF Core — shadow properties, EF.Property, indexer properties, and property-bag entity types
- Foreign and principal keys — shadow foreign-key conventions/configuration
- EF.Property<TProperty> — query-time access to shadow/indexer/field-backed metadata
- Change Tracking - EF Core — tracking-state lifetime and values
- Microsoft.EntityFrameworkCore 10.0.11 — current EF Core baseline