What are shadow properties in EF Core?
Quick Answer
Shadow properties exist in the EF Core model and change tracker but not on the .NET entity class — they're persisted to the database without polluting your domain model. Common uses include foreign keys you don't want as CLR properties and audit fields (CreatedAt/UpdatedAt). They're configured in `OnModelCreating` and accessed via `EF.Property<T>()` or the change tracker.
Detailed Answer
Shadow Properties are properties that exist in the EF Core model but not in the .NET entity class. They only exist in the database and change tracker.
Why Use Shadow Properties?
- Foreign keys you don't want in your domain model
- Audit fields (CreatedDate, ModifiedDate)
- Soft delete flags
- Database-specific metadata
- Keep domain model clean
1. Basic Shadow Property Configuration:
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
// No CreatedDate or ModifiedDate properties in class!
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Add shadow properties
modelBuilder.Entity()
.Property("CreatedDate")
.HasDefaultValueSql("GETDATE()");
modelBuilder.Entity()
.Property("ModifiedDate");
modelBuilder.Entity()
.Property("CreatedBy")
.HasMaxLength(100);
}
2. Accessing Shadow Properties:
// Setting shadow property values
var product = new Product { Name = "Laptop", Price = 999.99m };
context.Products.Add(product);
// Access through Entry API
context.Entry(product).Property("CreatedBy").CurrentValue = "john.doe";
context.Entry(product).Property("ModifiedDate").CurrentValue = DateTime.UtcNow;
await context.SaveChangesAsync();
// Reading shadow property values
var createdDate = context.Entry(product).Property("CreatedDate").CurrentValue;
Console.WriteLine($"Created: {createdDate}");
3. Querying Shadow Properties:
// Use EF.Property in LINQ queries
var recentProducts = context.Products
.Where(p => EF.Property(p, "CreatedDate") > DateTime.UtcNow.AddDays(-7))
.ToList();
// Order by shadow property
var products = context.Products
.OrderByDescending(p => EF.Property(p, "ModifiedDate"))
.ToList();
// Select shadow properties
var productInfo = context.Products
.Select(p => new
{
p.Name,
Created = EF.Property(p, "CreatedDate"),
Modified = EF.Property(p, "ModifiedDate")
})
.ToList();
4. Shadow Foreign Keys:
public class Order
{
public int Id { get; set; }
public Customer Customer { get; set; }
// No CustomerId property!
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.HasOne(o => o.Customer)
.WithMany()
.HasForeignKey("CustomerId"); // Shadow FK
}
// Usage
var order = new Order { Customer = customer };
context.Orders.Add(order);
await context.SaveChangesAsync();
// Access shadow FK
var customerId = context.Entry(order).Property("CustomerId").CurrentValue;
5. Audit Trail with Shadow Properties:
public abstract class AuditableEntity
{
public int Id { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(AuditableEntity).IsAssignableFrom(entityType.ClrType))
{
modelBuilder.Entity(entityType.ClrType)
.Property("CreatedDate")
.HasDefaultValueSql("GETDATE()");
modelBuilder.Entity(entityType.ClrType)
.Property("ModifiedDate");
modelBuilder.Entity(entityType.ClrType)
.Property("CreatedBy")
.HasMaxLength(256);
modelBuilder.Entity(entityType.ClrType)
.Property("ModifiedBy")
.HasMaxLength(256);
}
}
}
// Auto-populate on SaveChanges
public override int SaveChanges()
{
var entries = ChangeTracker.Entries()
.Where(e => e.Entity is AuditableEntity &&
(e.State == EntityState.Added || e.State == EntityState.Modified));
foreach (var entry in entries)
{
var currentUser = GetCurrentUser(); // Your user service
if (entry.State == EntityState.Added)
{
entry.Property("CreatedDate").CurrentValue = DateTime.UtcNow;
entry.Property("CreatedBy").CurrentValue = currentUser;
}
entry.Property("ModifiedDate").CurrentValue = DateTime.UtcNow;
entry.Property("ModifiedBy").CurrentValue = currentUser;
}
return base.SaveChanges();
}
6. Soft Delete with Shadow Property:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Add IsDeleted shadow property to all entities
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
modelBuilder.Entity(entityType.ClrType)
.Property("IsDeleted")
.HasDefaultValue(false);
// Global query filter
var parameter = Expression.Parameter(entityType.ClrType, "e");
var property = Expression.Property(parameter, "IsDeleted");
var filter = Expression.Lambda(
Expression.Equal(property, Expression.Constant(false)),
parameter);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(filter);
}
}
// Soft delete implementation
public void SoftDelete(T entity) where T : class
{
context.Entry(entity).Property("IsDeleted").CurrentValue = true;
}
// Usage
var product = context.Products.Find(1);
SoftDelete(product);
context.SaveChanges();
// Query automatically filters soft-deleted records
var products = context.Products.ToList(); // Only non-deleted
// Include soft-deleted records
var allProducts = context.Products
.IgnoreQueryFilters()
.ToList();
7. Indexing Shadow Properties:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.Property("CreatedDate");
modelBuilder.Entity()
.HasIndex("CreatedDate")
.HasDatabaseName("IX_Product_CreatedDate");
}
8. Shadow Properties vs Regular Properties:
| Aspect | Shadow Properties | Regular Properties |
|---|---|---|
| In Entity Class | No | Yes |
| Type Safety | Runtime only | Compile-time |
| IntelliSense | No | Yes |
| Access | EF.Property() | Direct access |
| Refactoring | Manual | Automatic |
| Use Case | Infrastructure concerns | Domain model |
Advantages:
- Keep domain model clean
- Separate infrastructure concerns
- Database-specific features
- Flexible schema management
- Audit without polluting model
Disadvantages:
- No compile-time safety
- No IntelliSense support
- String-based access (typo-prone)
- Less discoverable
- More complex queries
Best Practices:
- Use for infrastructure concerns only
- Document shadow properties clearly
- Create helper methods for common access patterns
- Consider using interfaces for better discoverability
- Use constants for property names to avoid typos
// Good practice: Use constants
public static class ShadowProperties
{
public const string CreatedDate = nameof(CreatedDate);
public const string ModifiedDate = nameof(ModifiedDate);
public const string IsDeleted = nameof(IsDeleted);
}
// Usage
var createdDate = context.Entry(product)
.Property(ShadowProperties.CreatedDate)
.CurrentValue;
Summary
Entity Framework Core provides a powerful and flexible ORM solution for .NET applications. Key takeaways:
- EF Core vs EF6: Cross-platform, better performance, modern features
- Code First vs Database First: Choose based on project requirements
- Loading Strategies: Eager, lazy, and explicit loading - each with specific use cases
- Migrations: Track and manage database schema changes
- Patterns: Repository and Unit of Work for better architecture
- N+1 Problem: Use eager loading and projections to avoid performance issues
- Query Optimization: AsNoTracking, projections, compiled queries, and proper indexing
- Tracking: Understand when to use tracking vs no-tracking queries
- Owned Entities: Model value objects effectively
- Concurrency: Use optimistic concurrency control with RowVersion
- Async Operations: Always use async in web applications for scalability
- Shadow Properties: Keep infrastructure concerns separate from domain model
Master these concepts to build efficient, maintainable, and scalable applications with Entity Framework Core!