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.

Intermediate90–115 minutesshadow/indexer/property-bag labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

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.

01

Explain where shadow-property values live and how tracked/no-tracking behavior affects access.

02

Configure and query shadow properties with string-based Property and EF.Property while recognizing rename/typo risks.

03

Distinguish shadow properties from indexer properties and property-bag entity types.

04

Inspect metadata flags such as IsShadowProperty/IsIndexerProperty rather than guessing from source code.

05

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.

csharp · shadow audit properties
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

csharp · tracked shadow-state access
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

csharp · query by shadow CreatedUtc
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.

csharp · safe no-tracking projection
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.

csharp · illustrative shadow foreign key
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.

csharp · indexer-backed flexible record
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.

csharp · shared property-bag entity
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

csharp · property storage-kind probe
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

csharp · string-name drift
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.

Repair

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

  1. Keep CreatedUtc and add LastModifiedUtc as shadow properties.
  2. Load one work order with tracking, print both values via Entry.Property, set LastModifiedUtc, save, and inspect SQL.
  3. Query by CreatedUtc with EF.Property and inspect ToQueryString().
  4. Run an AsNoTracking projection that carries CreatedUtc explicitly.
  5. Create the temporary WorkOrderAttachment shadow-FK example and inspect IsShadowProperty().
  6. Create WorkOrderEnvelope and prove RegionCode is indexer-backed, not shadow.
  7. Create the temporary shared WorkOrderAttribute property bag and inspect its model metadata.
  8. Introduce the CreatedUTC typo, 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

  1. Where does a shadow-property value live for a tracked entity?
  2. How do you reference a shadow property in LINQ?
  3. Why can no-tracking entities lose access to shadow values?
  4. How is an indexer property different from a shadow property?
  5. What CLR type does EF currently support for property-bag entity types?
  6. 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(entity, "PropertyName") so the provider can translate it.

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

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