A one-to-many relationship has one principal entity linked to many dependent entities. EF Core can infer it from navigation properties and FK naming conventions, or you can configure it explicitly with Fluent API.
// Domain model:
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public ICollection<Order> Orders { get; set; } = new List<Order>(); // navigation
}
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; } // FK property (conventional name → inferred)
public Customer Customer { get; set; } = null!; // reference navigation
public decimal Total { get; set; }
}
// EF Core convention: Finds CustomerId FK automatically — no Fluent API needed.
// Explicit Fluent API (preferred for clarity and complex cases):
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b)
{
b.HasOne(o => o.Customer) // Order has one Customer
.WithMany(c => c.Orders) // Customer has many Orders
.HasForeignKey(o => o.CustomerId) // FK column
.IsRequired() // NOT NULL in DB
.OnDelete(DeleteBehavior.Restrict); // don't cascade
}
}
// Querying:
var customerWithOrders = await _db.Customers
.Include(c => c.Orders)
.FirstOrDefaultAsync(c => c.Id == 1);
Rule of thumb: Declare both the FK property (CustomerId) and the navigation
(Customer) on the dependent. Explicit Fluent API beats convention for any non-trivial
relationship — clarity is worth the extra lines.
EF Core 5+ supports implicit many-to-many — no join entity class required. EF Core 3 required an explicit join table entity with two one-to-many relationships.
// EF Core 5+ — implicit many-to-many (no join entity):
public class Post
{
public int Id { get; set; }
public ICollection<Tag> Tags { get; set; } = new List<Tag>(); // navigation
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = "";
public ICollection<Post> Posts { get; set; } = new List<Post>(); // navigation
}
// EF Core infers a join table "PostTag" (PostsId, TagsId) automatically.
// Fluent API to customise the join table name and columns:
modelBuilder.Entity<Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity(j => j.ToTable("PostTags")); // rename join table
// Explicit join entity (required when join has extra payload columns):
public class PostTag
{
public int PostId { get; set; }
public int TagId { get; set; }
public DateTime TaggedAt { get; set; } // extra column on the join
public Post Post { get; set; } = null!;
public Tag Tag { get; set; } = null!;
}
modelBuilder.Entity<PostTag>()
.HasKey(pt => new { pt.PostId, pt.TagId }); // composite PK
modelBuilder.Entity<PostTag>()
.HasOne(pt => pt.Post).WithMany(p => p.PostTags).HasForeignKey(pt => pt.PostId);
modelBuilder.Entity<PostTag>()
.HasOne(pt => pt.Tag).WithMany(t => t.PostTags).HasForeignKey(pt => pt.TagId);
Rule of thumb: Use implicit many-to-many (EF Core 5+) when the join has no extra columns. Use an explicit join entity as soon as you need additional payload on the join (timestamps, flags, ordering).
A one-to-one relationship links exactly one principal to at most one dependent. EF Core requires you to specify which end holds the FK because it can't infer it when both navigations are present.
// Domain model:
public class User
{
public int Id { get; set; }
public string Email { get; set; } = "";
public UserProfile? Profile { get; set; } // optional navigation to dependent
}
public class UserProfile
{
public int Id { get; set; }
public int UserId { get; set; } // FK lives on the dependent
public User User { get; set; } = null!;
public string Bio { get; set; } = "";
}
// Fluent API — must declare which side has the FK:
public class UserProfileConfiguration : IEntityTypeConfiguration<UserProfile>
{
public void Configure(EntityTypeBuilder<UserProfile> b)
{
b.HasOne(p => p.User) // UserProfile has one User
.WithOne(u => u.Profile) // User has one Profile
.HasForeignKey<UserProfile>(p => p.UserId) // FK on dependent side
.IsRequired()
.OnDelete(DeleteBehavior.Cascade); // delete profile when user deleted
b.HasIndex(p => p.UserId).IsUnique(); // enforce one-to-one at DB level
}
}
// Querying:
var user = await _db.Users
.Include(u => u.Profile)
.FirstOrDefaultAsync(u => u.Id == userId);
Rule of thumb: Always place the FK on the dependent (the entity that can't exist without the other). Add a unique index on the FK column in the database — EF doesn't add it automatically for one-to-one.
Cascade delete automatically deletes dependent rows when the principal is deleted.
EF Core exposes four DeleteBehavior options with different safety profiles.
// DeleteBehavior options:
// Cascade — DB deletes dependents automatically (or EF tracks and deletes them)
// Restrict — throws if dependents exist — safest for accidental deletes
// SetNull — sets the FK to NULL on dependents (FK must be nullable)
// NoAction — no action on DB delete; the application must manage orphans
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b)
{
// Cascade — deleting Customer deletes all their Orders:
b.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
// Restrict — can't delete a Customer with existing Orders:
b.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
// SetNull — deleting Supplier sets Order.SupplierId to NULL:
b.HasOne(o => o.Supplier)
.WithMany(s => s.Orders)
.HasForeignKey(o => o.SupplierId) // SupplierId is int? (nullable)
.OnDelete(DeleteBehavior.SetNull);
}
}
// EF Core's default: Cascade for required relationships, SetNull for optional.
// SQL Server default: NO ACTION (needs EF to cascade explicitly).
// Soft delete avoids all cascade concerns:
order.IsDeleted = true;
await _db.SaveChangesAsync(); // no FK violations
Rule of thumb: Prefer Restrict for business-critical data (orders, invoices)
to prevent accidental mass deletes. Use Cascade only for true parent-child where
the child has no meaning without the parent (e.g., order → order items).
Owned entities are value-object-like types that belong exclusively to one owner
entity. They share the owner's table by default (no separate Id column) and have
no independent lifecycle.
// Domain model — Address is a value object:
public class Address
{
public string Street { get; set; } = "";
public string City { get; set; } = "";
public string Zip { get; set; } = "";
// No Id — Address is owned by Customer
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public Address ShippingAddress { get; set; } = new();
public Address BillingAddress { get; set; } = new();
}
// Configuration:
modelBuilder.Entity<Customer>()
.OwnsOne(c => c.ShippingAddress, addr =>
{
addr.Property(a => a.Street).HasColumnName("Ship_Street").HasMaxLength(100);
addr.Property(a => a.City).HasColumnName("Ship_City");
addr.Property(a => a.Zip).HasColumnName("Ship_Zip");
});
modelBuilder.Entity<Customer>()
.OwnsOne(c => c.BillingAddress, addr =>
{
addr.Property(a => a.Street).HasColumnName("Bill_Street");
// ...
});
// Schema: Customers table contains Ship_Street, Ship_City, ... Bill_Street, etc.
// Store as JSON column (EF Core 7+):
modelBuilder.Entity<Customer>()
.OwnsOne(c => c.ShippingAddress, addr => addr.ToJson());
// Customers.ShippingAddress JSON column: {"Street":"...","City":"...","Zip":"..."}
Rule of thumb: Use OwnsOne / OwnsMany for value objects that belong to one
entity and have no independent identity. This maps cleanly to DDD value objects and
keeps the schema tidy.
A self-referencing relationship points from an entity back to itself — used for trees and hierarchies (categories, organisational charts, comments with replies).
// Category tree — each category optionally has a parent:
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int? ParentId { get; set; } // nullable FK — root has no parent
public Category? Parent { get; set; } // reference navigation (up)
public ICollection<Category> Children { get; set; } = new List<Category>(); // down
}
// Configuration — works by convention, but explicit is clearer:
modelBuilder.Entity<Category>()
.HasOne(c => c.Parent)
.WithMany(c => c.Children)
.HasForeignKey(c => c.ParentId)
.IsRequired(false) // nullable FK — root nodes have null ParentId
.OnDelete(DeleteBehavior.Restrict); // prevent accidental tree deletion
// Load the full tree with recursive includes (small trees only):
var root = await _db.Categories
.Include(c => c.Children)
.ThenInclude(c => c.Children) // 3 levels deep
.Where(c => c.ParentId == null) // root nodes
.ToListAsync();
// For deep trees, load all and build the hierarchy in memory:
var all = await _db.Categories.AsNoTracking().ToListAsync();
var roots = all.Where(c => c.ParentId == null).ToList();
var byParent = all.Where(c => c.ParentId != null)
.GroupBy(c => c.ParentId!.Value)
.ToDictionary(g => g.Key, g => g.ToList());
Rule of thumb: Keep self-referencing relationships in relational databases for shallow trees (≤ 3–4 levels). For very deep or often-queried trees, consider a closure table or path enumeration strategy to avoid recursive queries.
A primary key (HasKey) is the entity's identity — one per entity. An
alternate key (HasAlternateKey) is an additional unique column that other
entities can reference as a foreign key.
public class Product
{
public int Id { get; set; } // PK
public string Sku { get; set; } = ""; // alternate key — unique, referenced externally
public string Name { get; set; } = "";
}
public class OrderItem
{
public int Id { get; set; }
public string ProductSku { get; set; } = ""; // FK pointing to Product.Sku (not PK)
public Product Product { get; set; } = null!;
}
// Configuration:
modelBuilder.Entity<Product>()
.HasAlternateKey(p => p.Sku); // creates UNIQUE constraint on Sku
modelBuilder.Entity<OrderItem>()
.HasOne(i => i.Product)
.WithMany()
.HasForeignKey(i => i.ProductSku)
.HasPrincipalKey(p => p.Sku); // FK targets the alternate key, not PK
// Composite alternate key:
modelBuilder.Entity<Product>()
.HasAlternateKey(p => new { p.Brand, p.ModelNumber });
Alternate keys differ from HasIndex(...).IsUnique():
- Alternate keys can be referenced by FKs; unique indexes cannot.
- Both add a UNIQUE constraint to the schema.
Rule of thumb: Use alternate keys when an external system references your entity by a natural key (SKU, ISBN, slug). Use unique indexes for uniqueness constraints that don't need to be referenced by FKs.
EF Core supports three strategies for mapping class hierarchies to relational tables. The strategy affects schema shape, query performance, and null handling.
// Base entity:
public abstract class Payment
{
public int Id { get; set; }
public decimal Amount { get; set; }
}
public class CardPayment : Payment { public string Last4 { get; set; } = ""; }
public class BankPayment : Payment { public string IBAN { get; set; } = ""; }
// === 1. Table-Per-Hierarchy (TPH) — EF Core default ===
// One table, Discriminator column per row:
// Payments: Id | Amount | Discriminator | Last4 | IBAN
modelBuilder.Entity<Payment>()
.HasDiscriminator<string>("Discriminator")
.HasValue<CardPayment>("Card")
.HasValue<BankPayment>("Bank");
// Pro: no joins; Con: sparse nullable columns
// === 2. Table-Per-Type (TPT) — each type has its own table ===
modelBuilder.Entity<CardPayment>().ToTable("CardPayments"); // separate table
modelBuilder.Entity<BankPayment>().ToTable("BankPayments");
// Pro: normalised, no nulls; Con: JOIN on every query
// === 3. Table-Per-Concrete-Type (TPC) — EF Core 7+ ===
modelBuilder.Entity<CardPayment>().UseTpcMappingStrategy();
modelBuilder.Entity<BankPayment>().UseTpcMappingStrategy();
// Each concrete type gets a full table with all columns (no FK to base table)
// Pro: no joins, normalised; Con: queries over base type need UNION ALL
// Querying polymorphically works the same regardless of strategy:
var allPayments = await _db.Set<Payment>().ToListAsync();
var cardPayments = await _db.Set<CardPayment>().ToListAsync();
Rule of thumb: Start with TPH — it's the default, simplest, and fastest for small hierarchies. Use TPT when nullable columns become a schema problem. Use TPC (EF Core 7+) for separate tables with no JOINs on concrete-type queries.
Shadow properties exist in the EF Core model and database schema but are NOT present on the entity class. They're useful for auditing, tenancy, and soft-delete fields you don't want to expose in the domain model.
// Define a shadow property in OnModelCreating:
modelBuilder.Entity<Order>()
.Property<DateTime>("CreatedAt") // no matching property on Order class
.HasDefaultValueSql("GETUTCDATE()");
modelBuilder.Entity<Order>()
.Property<string>("CreatedBy")
.HasMaxLength(256);
// Read and write shadow properties via Entry:
var order = new Order { Total = 99.99m };
_db.Entry(order).Property("CreatedBy").CurrentValue = "alice@example.com";
_db.Orders.Add(order);
await _db.SaveChangesAsync();
// Read shadow property from a tracked entity:
var createdAt = (DateTime)_db.Entry(order).Property("CreatedAt").CurrentValue!;
// Filter by a shadow property in a query:
var aliceOrders = await _db.Orders
.Where(o => EF.Property<string>(o, "CreatedBy") == "alice@example.com")
.ToListAsync();
// Use in SaveChangesInterceptor for automatic auditing:
foreach (var entry in db.ChangeTracker.Entries<Order>())
{
if (entry.State == EntityState.Added)
entry.Property("CreatedAt").CurrentValue = DateTime.UtcNow;
}
Rule of thumb: Use shadow properties for cross-cutting metadata (audit timestamps,
tenant ID, row version) that shouldn't pollute the domain model. For shared auditing
across many entities, combine shadow properties with a SaveChangesInterceptor.
Table splitting maps multiple entity types to a single database table. It's the inverse of owned entities — useful when you want to load a "lightweight" view of a table by default and load the heavy columns separately on demand.
// Split a table into a lightweight head and a heavy detail entity:
public class OrderSummary
{
public int Id { get; set; }
public string Status { get; set; } = "";
public decimal Total { get; set; }
public OrderDetail Detail { get; set; } = null!; // navigation to same table
}
public class OrderDetail
{
public int Id { get; set; } // same PK — shared with OrderSummary
public string Notes { get; set; } = "";
public byte[]? AttachmentData { get; set; } // large column — only loaded when needed
public OrderSummary Summary { get; set; } = null!;
}
// Map both entities to the same table:
modelBuilder.Entity<OrderSummary>().ToTable("Orders");
modelBuilder.Entity<OrderDetail>().ToTable("Orders");
// Configure the 1:1 relationship between the two entity "halves":
modelBuilder.Entity<OrderSummary>()
.HasOne(s => s.Detail)
.WithOne(d => d.Summary)
.HasForeignKey<OrderDetail>(d => d.Id); // same PK — required for table splitting
// Query the lightweight summary — no blob loaded:
var summaries = await _db.Set<OrderSummary>()
.AsNoTracking()
.Where(s => s.Status == "Pending")
.ToListAsync(); // SELECT Id, Status, Total FROM Orders WHERE Status='Pending'
// Load the heavy detail only when needed:
var detail = await _db.Set<OrderDetail>()
.AsNoTracking()
.FirstOrDefaultAsync(d => d.Id == orderId); // includes Notes and AttachmentData
When to use table splitting:
- A table has large or rarely-needed columns (BLOBs, long text) you want to defer.
- You can't or don't want to physically split the table in the schema.
- You want a strongly-typed "projection" at the entity level.
Rule of thumb: Use table splitting when a table has a natural head/detail split
and the detail columns are large and infrequently needed. For most cases, projection
via Select into a DTO is simpler and doesn't require a second entity class.
Optimistic concurrency assumes conflicts are rare and detects them at save time
rather than locking rows. EF Core supports it via concurrency tokens (a column
included in WHERE clauses on UPDATE).
// Option 1 — RowVersion (SQL Server rowversion / timestamp):
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int Stock { get; set; }
[Timestamp] // maps to SQL Server rowversion column
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
// EF generates: UPDATE Products SET Stock=@p0 WHERE Id=@id AND RowVersion=@version
// If RowVersion changed (another writer), 0 rows affected → EF throws DbUpdateConcurrencyException
// Option 2 — Concurrency token (works with any column):
public class Product
{
[ConcurrencyCheck]
public int Stock { get; set; }
}
// EF adds: AND Stock=@originalStock to the WHERE clause
// Handle the exception:
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
var proposed = entry.CurrentValues; // what we tried to write
var dbValues = await entry.GetDatabaseValuesAsync(); // what's in DB now
// Option A — keep proposed values (last writer wins):
entry.OriginalValues.SetValues(dbValues!);
// Option B — reload DB values (first writer wins):
await entry.ReloadAsync();
// Option C — merge/alert user
}
await _db.SaveChangesAsync(); // retry after resolving
}
Rule of thumb: Use [Timestamp] / rowversion on SQL Server for row-level
optimistic concurrency — it's automatic and requires no application logic to update.
Handle DbUpdateConcurrencyException explicitly in high-contention paths.
A required relationship means the dependent entity must have a principal —
the foreign key is NOT NULL. An optional relationship allows the FK to be
NULL, meaning the dependent can exist without a principal.
// Required relationship — FK is NOT NULL:
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; } // non-nullable int → required
public Customer Customer { get; set; } = null!;
}
// Fluent API — explicit required:
modelBuilder.Entity<Order>()
.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.IsRequired(); // NOT NULL in schema
// EF default for non-nullable FK types: already required by convention.
// Optional relationship — FK is nullable:
public class Order
{
public int Id { get; set; }
public int? SupplierId { get; set; } // nullable int → optional
public Supplier? Supplier { get; set; } // nullable reference navigation
}
// Fluent API — explicit optional:
modelBuilder.Entity<Order>()
.HasOne(o => o.Supplier)
.WithMany(s => s.Orders)
.HasForeignKey(o => o.SupplierId)
.IsRequired(false); // NULL allowed in schema
// Cascade delete default differs:
// Required → EF default is Cascade (delete dependents when principal deleted)
// Optional → EF default is SetNull (null out the FK when principal deleted)
// Practical consequence for loading:
var order = await _db.Orders.Include(o => o.Supplier).FirstAsync(o => o.Id == 1);
if (order.Supplier is not null) // must null-check optional navigation
Console.WriteLine(order.Supplier.Name);
Rule of thumb: Make a relationship required when the dependent has no business meaning without the principal (order must have a customer). Make it optional when the principal can legitimately be absent (order might not have a supplier yet). Reflect this in the FK nullability and reference navigation nullability in C#.
More Entity Framework Core interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.