Isaac.

EF Core Shadow Properties

Use shadow properties in Entity Framework Core.

By EMEPublished: February 20, 2025
entity frameworkshadow propertiesconfigurationtracking

A Simple Analogy

Shadow properties are like secret attributes. They exist in the database but not in your C# class.


Why Shadow Properties?

  • Clean entities: Keep DTOs simple
  • Audit tracking: Add CreatedAt, ModifiedBy
  • Infrastructure: Don't pollute domain
  • Flexibility: Change without class changes
  • Relationships: Foreign keys optional

Defining Shadow Properties

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<User>()
        .Property<DateTime>("CreatedAt")
        .HasDefaultValueSql("GETUTCDATE()");
    
    modelBuilder.Entity<User>()
        .Property<DateTime?>("ModifiedAt");
    
    modelBuilder.Entity<User>()
        .Property<string>("ModifiedBy")
        .HasMaxLength(256);
}

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
    // CreatedAt, ModifiedAt, ModifiedBy are shadow properties
}

Accessing Shadow Properties

var user = new User { Name = "Alice" };
context.Users.Add(user);

// Set shadow property via Entry
context.Entry(user).Property("CreatedAt").CurrentValue = DateTime.UtcNow;
context.Entry(user).Property("ModifiedBy").CurrentValue = "admin";

await context.SaveChangesAsync();

// Read shadow property
var createdAt = context.Entry(user).Property("CreatedAt").CurrentValue;
Console.WriteLine($"User created at: {createdAt}");

Querying with Shadow Properties

// Include shadow properties in queries
var users = await context.Users
    .Select(u => new
    {
        u.Id,
        u.Name,
        CreatedAt = EF.Property<DateTime>(u, "CreatedAt"),
        ModifiedBy = EF.Property<string>(u, "ModifiedBy")
    })
    .ToListAsync();

foreach (var user in users)
{
    Console.WriteLine($"{user.Name} created at {user.CreatedAt} by {user.ModifiedBy}");
}

Interceptor Pattern

public class AuditInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, 
        InterceptionResult<int> result, 
        CancellationToken ct = default)
    {
        var context = eventData.Context;
        
        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Property("CreatedAt").CurrentValue = DateTime.UtcNow;
                entry.Property("CreatedBy").CurrentValue = GetCurrentUser();
            }
            
            if (entry.State == EntityState.Modified)
            {
                entry.Property("ModifiedAt").CurrentValue = DateTime.UtcNow;
                entry.Property("ModifiedBy").CurrentValue = GetCurrentUser();
            }
        }
        
        return base.SavingChangesAsync(eventData, result, ct);
    }
}

// Register
builder.Services.AddDbContext<AppContext>(options =>
    options.AddInterceptors(new AuditInterceptor()));

Best Practices

  1. Use for audit: CreatedAt, ModifiedAt
  2. Keep simple: Avoid complex logic
  3. Document clearly: Shadow properties are hidden
  4. Test queries: Ensure they work
  5. Consider visibility: When to use vs properties

Related Concepts

  • Entity auditing
  • Change tracking
  • Database computed columns
  • Interceptors

Summary

Shadow properties add columns to the database without adding properties to your C# classes. Useful for audit fields and infrastructure concerns.