Advanced EF Core mapping unlocks domain-driven design patterns in the data layer. Owned types map value objects (which have no identity of their own) to columns in the owner’s table or a related table. Value converters map C# types that have no direct SQL Server equivalent. Global query filters automatically apply WHERE conditions to all queries for a given entity — the standard mechanism for soft-delete and multi-tenancy.
Advanced Mapping Patterns
// ── Owned types — value objects in the same table ─────────────────────────
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = "";
public AuthorInfo Author { get; set; } = new(); // owned type
}
public class AuthorInfo // no Id — not an entity, a value object
{
public string DisplayName { get; set; } = "";
public string? AvatarUrl { get; set; }
public string? Bio { get; set; }
}
// In DbContext:
modelBuilder.Entity<Post>().OwnsOne(p => p.Author, a => {
a.Property(x => x.DisplayName).HasColumnName("AuthorName").HasMaxLength(100);
a.Property(x => x.AvatarUrl).HasColumnName("AuthorAvatarUrl");
});
// Columns in Posts table: AuthorName, AuthorAvatarUrl, Bio (not a separate table)
// ── Value converter — map enum to string ──────────────────────────────────
public enum PostStatus { Draft, Review, Published, Archived }
modelBuilder.Entity<Post>()
.Property(p => p.Status)
.HasConversion<string>() // store as "Draft", "Published" etc.
.HasMaxLength(20);
// Custom converter for a Money value object:
modelBuilder.Entity<Order>()
.Property(o => o.TotalAmount)
.HasConversion(
money => money.Amount, // C# Money → decimal for SQL
amount => new Money(amount)); // decimal from SQL → C# Money
// ── Global query filter — automatic soft delete ───────────────────────────
public class AppDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder mb)
{
// All queries on Posts automatically add WHERE IsDeleted = 0
mb.Entity<Post>().HasQueryFilter(p => !p.IsDeleted);
// Access deleted posts when needed (use IgnoreQueryFilters()):
// var deleted = _db.Posts.IgnoreQueryFilters().Where(p => p.IsDeleted);
}
}
// ── Shadow properties — audit fields without domain model pollution ────────
modelBuilder.Entity<Post>()
.Property<DateTime>("CreatedAt") // not in Post class — shadow property
.HasDefaultValueSql("SYSUTCDATETIME()");
// Set in SaveChangesInterceptor:
_db.Entry(post).Property("CreatedAt").CurrentValue = DateTime.UtcNow;
// ── Table-per-hierarchy (TPH) inheritance ─────────────────────────────────
// All derived types in one table with a discriminator column
public abstract class Notification { public int Id { get; set; } }
public class EmailNotification : Notification { public string Email { get; set; } = ""; }
public class PushNotification : Notification { public string DeviceToken { get; set; } = ""; }
modelBuilder.Entity<Notification>()
.HasDiscriminator<string>("Type") // Type column: "Email" or "Push"
.HasValue<EmailNotification>("Email")
.HasValue<PushNotification>("Push");
HasQueryFilter()) are the clean EF Core mechanism for soft delete. Every query on Post automatically includes WHERE IsDeleted = 0 — developers cannot accidentally query deleted posts without explicitly calling IgnoreQueryFilters(). This is safer than relying on developers to remember the filter on every query. The filter also applies to Include() navigations — a post’s comments navigation will also filter out deleted comments if a filter is applied to Comment..HasConversion<string>() stores PostStatus.Published as the string "Published" — human-readable in the database, queryable with WHERE Status = 'Published', and safe from integer-to-enum mapping errors when enum values are reordered. Integer enum storage (HasConversion<int>()) breaks when enum members are inserted or reordered in C#. Always prefer string storage for enums that appear in database queries and reports.FromSqlRaw, ExecuteSqlRaw). If you use raw SQL to query entities with global filters, you must manually add the filter condition. Also, global filters may cause surprising results with Any() and Count() on navigations — a post’s Comments.Count() in C# may return a different value than the database’s actual count because the filter excludes soft-deleted comments.Common Mistakes
Mistake 1 — Enum stored as integer (breaks when enum members are reordered)
❌ Wrong — Status stored as int; Draft=0, Review=1; team inserts Archive=0 before Draft; all statuses shift.
✅ Correct — .HasConversion<string>(); stored as “Draft”, “Review” — immune to enum reordering.
Mistake 2 — Global query filter applied but raw SQL bypasses it (inconsistent results)
❌ Wrong — HasQueryFilter for IsDeleted but FromSqlRaw queries return deleted records; inconsistent soft-delete behaviour.
✅ Correct — add filter conditions explicitly to raw SQL, or avoid raw SQL for soft-deleted entity types.