Introduction
EF Core shadow properties are properties that exist in the entity type model but not in the .NET CLR class. They are configured through the Fluent API and stored in the database like regular columns. When shadow properties are not correctly configured, EF Core generates SQL that references non-existent columns, migrations do not include the shadow columns, or the property values are not tracked correctly.
Symptoms
SqlException: Invalid column name 'Discriminator'- Shadow property value not saved when entity is updated
- Migration does not generate column for shadow property
- Query filters using shadow property return wrong results
EF.Property<T>(entity, "PropertyName")throws at runtime
Error output:
``
Microsoft.Data.SqlClient.SqlException (0x80131904):
Invalid column name 'IsDeleted'.
Invalid column name 'DeletedAt'.
Common Causes
- Shadow property not defined in
OnModelCreating - Property name typo between Fluent API configuration and query filter
- Shadow property configured but migration not generated
- Using shadow property in LINQ query without proper configuration
- Column type or default value not specified for shadow property
Step-by-Step Fix
- 1.**Configure shadow property in OnModelCreating":
- 2.```csharp
- 3.public class ApplicationDbContext : DbContext
- 4.{
- 5.protected override void OnModelCreating(ModelBuilder modelBuilder)
- 6.{
- 7.// Configure shadow property for all entities
- 8.foreach (var entityType in modelBuilder.Model.GetEntityTypes())
- 9.{
- 10.// Add IsDeleted shadow property
- 11.var isDeletedProperty = entityType.FindProperty("IsDeleted");
- 12.if (isDeletedProperty == null)
- 13.{
- 14.modelBuilder.Entity(entityType.ClrType)
- 15..Property<bool>("IsDeleted")
- 16..HasDefaultValue(false)
- 17..IsRequired();
- 18.}
// Add audit shadow properties modelBuilder.Entity(entityType.ClrType) .Property<DateTime>("CreatedAt") .HasDefaultValueSql("GETUTCDATE()");
modelBuilder.Entity(entityType.ClrType) .Property<DateTime?>("UpdatedAt");
modelBuilder.Entity(entityType.ClrType) .Property<string>("UpdatedBy") .HasMaxLength(100); }
// Configure global query filter using shadow property foreach (var entityType in modelBuilder.Model.GetEntityTypes() .Where(e => !e.IsOwned())) { modelBuilder.Entity(entityType.ClrType) .HasQueryFilter(BuildSoftDeleteFilter(entityType.ClrType)); } }
private LambdaExpression BuildSoftDeleteFilter(Type entityType) { var parameter = Expression.Parameter(entityType, "e"); var property = Expression.Call( typeof(EF), nameof(EF.Property), new[] { typeof(bool) }, parameter, Expression.Constant("IsDeleted")); var filter = Expression.Lambda( Expression.Equal(property, Expression.Constant(false)), parameter); return filter; } } ```
- 1.**Set shadow property values in SaveChanges":
- 2.```csharp
- 3.public override int SaveChanges()
- 4.{
- 5.var entries = ChangeTracker.Entries()
- 6..Where(e => e.State != EntityState.Detached && e.Entity != null);
foreach (var entry in entries) { var now = DateTime.UtcNow;
switch (entry.State) { case EntityState.Added: entry.Property("CreatedAt").CurrentValue = now; entry.Property("IsDeleted").CurrentValue = false; break;
case EntityState.Modified: entry.Property("UpdatedAt").CurrentValue = now; entry.Property("UpdatedBy").CurrentValue = _httpContextAccessor?.HttpContext?.User?.Identity?.Name; break;
case EntityState.Deleted: // Convert delete to soft delete entry.State = EntityState.Modified; entry.Property("IsDeleted").CurrentValue = true; entry.Property("UpdatedAt").CurrentValue = now; break; } }
return base.SaveChanges(); } ```
- 1.**Access shadow property values in queries":
- 2.```csharp
- 3.// Query using shadow property
- 4.var activeUsers = context.Users
- 5..Where(u => EF.Property<bool>(u, "IsDeleted") == false)
- 6..ToList();
// Include shadow property in projection var userDtos = context.Users .Select(u => new UserDto { Id = u.Id, Name = u.Name, IsDeleted = EF.Property<bool>(u, "IsDeleted"), UpdatedAt = EF.Property<DateTime?>(u, "UpdatedAt") }) .ToList();
// Include deleted items (ignore query filter) var allUsers = context.Users .IgnoreQueryFilters() .Where(u => EF.Property<bool>(u, "IsDeleted") == true) .ToList(); ```
Prevention
- Configure shadow properties in a centralized location (base DbContext class)
- Always call
HasDefaultValueorHasDefaultValueSqlfor new shadow properties - Generate and review migrations after adding shadow properties
- Test soft delete scenarios in integration tests
- Use
IgnoreQueryFilters()when you need to access soft-deleted records - Document which entities use shadow properties and their naming convention