Why relationships matter in interviews
EF Core relationship configuration is one of the most frequently tested topics because getting it wrong — wrong cascade behaviour, missing unique indexes, incorrect FK placement — causes data integrity bugs that are hard to detect without integration tests.
One-to-many — the most common relationship
A Customer has many Orders; each Order belongs to one Customer.
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public ICollection<Order> Orders { get; set; } = new List<Order>();
}
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; } // FK property
public Customer Customer { get; set; } = null!; // reference navigation
public decimal Total { get; set; }
}
EF Core detects CustomerId by convention (entity name + Id). Use Fluent API for
non-conventional names or to control cascade behaviour:
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> b)
{
b.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.IsRequired()
.OnDelete(DeleteBehavior.Restrict); // error if customer deleted while orders exist
}
}
Always declare both the FK property (CustomerId) and the navigation (Customer) on the
dependent entity. EF Core generates a better schema and produces clearer error messages.
Many-to-many — implicit vs explicit join entity
EF Core 5+ supports implicit many-to-many with no join class required:
public class Post
{
public int Id { get; set; }
public ICollection<Tag> Tags { get; set; } = new List<Tag>();
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = "";
public ICollection<Post> Posts { get; set; } = new List<Post>();
}
// EF infers a join table "PostTag" — no Fluent API needed for simple cases.
When the join has extra columns (a timestamp, ordering flag, etc.), use an explicit join entity:
public class PostTag
{
public int PostId { get; set; }
public int TagId { get; set; }
public DateTime TaggedAt { get; set; }
public Post Post { get; set; } = null!;
public Tag Tag { get; set; } = null!;
}
modelBuilder.Entity<PostTag>()
.HasKey(pt => new { pt.PostId, pt.TagId });
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);
Use implicit many-to-many when the join has no payload. Add an explicit join entity as soon as you need any extra column.
One-to-one
One-to-one links exactly one principal to at most one dependent. Always specify which end holds the FK (EF Core can't infer it when both navigations are present):
public class User { public int Id { get; set; } public UserProfile? Profile { get; set; } }
public class UserProfile
{
public int Id { get; set; }
public int UserId { get; set; } // FK lives on the dependent
public User User { get; set; } = null!;
}
modelBuilder.Entity<UserProfile>()
.HasOne(p => p.User)
.WithOne(u => u.Profile)
.HasForeignKey<UserProfile>(p => p.UserId)
.OnDelete(DeleteBehavior.Cascade);
// EF does NOT add a unique index automatically for one-to-one:
modelBuilder.Entity<UserProfile>()
.HasIndex(p => p.UserId).IsUnique();
Always add a unique index on the FK column — without it the database doesn't enforce the one-to-one constraint.
Navigation properties
Navigation properties are how EF resolves the object graph. Three types:
| Type | Example | Purpose |
|---|---|---|
| Reference navigation | Customer Customer | Points to one related entity |
| Collection navigation | ICollection<Order> Orders | Points to many related entities |
| Inverse navigation | Order Order on OrderItem | Points back to the principal |
// Initialise collections to avoid NullReferenceException:
public ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
// Make reference navigations nullable — they may not be loaded:
public Customer? Customer { get; set; }
// Check and load navigations on demand:
if (!_db.Entry(order).Reference(o => o.Customer).IsLoaded)
await _db.Entry(order).Reference(o => o.Customer).LoadAsync();
Cascade delete
EF Core supports four delete behaviours:
| Option | What happens to dependents |
|---|---|
Cascade | Deleted automatically with the principal |
Restrict | Error thrown if any dependents exist |
SetNull | FK set to NULL (FK must be nullable) |
NoAction | Application must handle orphans manually |
// Cascade — deleting Order also deletes its OrderItems:
b.HasOne(i => i.Order)
.WithMany(o => o.Items)
.HasForeignKey(i => i.OrderId)
.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 Category sets Product.CategoryId to NULL:
b.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId) // int? (nullable)
.OnDelete(DeleteBehavior.SetNull);
Use Restrict for business-critical data (orders, invoices). Use Cascade for true
parent-child where the child has no independent existence (order items, line items).
Owned entities
Owned entities are value objects that share the owner's table and have no independent lifecycle:
public class Address { public string Street { get; set; } = ""; public string City { get; set; } = ""; }
public class Customer { public int Id { get; set; } public Address ShippingAddress { get; set; } = new(); }
// Stored as columns in the Customers table:
modelBuilder.Entity<Customer>()
.OwnsOne(c => c.ShippingAddress, a =>
{
a.Property(x => x.Street).HasColumnName("Ship_Street").HasMaxLength(100);
a.Property(x => x.City).HasColumnName("Ship_City");
});
// Or as a JSON column (EF Core 7+):
modelBuilder.Entity<Customer>()
.OwnsOne(c => c.ShippingAddress, a => a.ToJson());
Use OwnsOne / OwnsMany for DDD value objects — keeps the schema tidy and the domain
model free of Id properties that don't carry business meaning.
Self-referencing relationships (hierarchies)
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int? ParentId { get; set; }
public Category? Parent { get; set; }
public ICollection<Category> Children { get; set; } = new List<Category>();
}
modelBuilder.Entity<Category>()
.HasOne(c => c.Parent)
.WithMany(c => c.Children)
.HasForeignKey(c => c.ParentId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Restrict);
// Load shallow trees with Include:
var categories = await _db.Categories
.Include(c => c.Children)
.ThenInclude(c => c.Children)
.Where(c => c.ParentId == null)
.ToListAsync();
// For deep trees, load all and build hierarchy in memory:
var all = await _db.Categories.AsNoTracking().ToListAsync();
var roots = all.Where(c => c.ParentId == null).ToList();
Inheritance mapping strategies
EF Core supports three strategies for mapping class hierarchies:
Table-Per-Hierarchy (TPH) — default
One table with a Discriminator column. No joins required:
modelBuilder.Entity<Payment>()
.HasDiscriminator<string>("Discriminator")
.HasValue<CardPayment>("Card")
.HasValue<BankPayment>("Bank");
// Schema: Payments(Id, Amount, Discriminator, Last4, IBAN) — sparse nulls
Table-Per-Type (TPT) — normalised
Each type gets its own table, joined on PK:
modelBuilder.Entity<CardPayment>().ToTable("CardPayments");
modelBuilder.Entity<BankPayment>().ToTable("BankPayments");
// Schema: Payments(Id, Amount) + CardPayments(Id, Last4) + BankPayments(Id, IBAN)
// Pro: no null columns. Con: JOIN on every query.
Table-Per-Concrete-Type (TPC) — EF Core 7+
Each concrete type gets a full table with all columns:
modelBuilder.Entity<CardPayment>().UseTpcMappingStrategy();
modelBuilder.Entity<BankPayment>().UseTpcMappingStrategy();
// Pro: no joins. Con: UNION ALL for base-type queries.
Start with TPH (the default). Move to TPT when null columns are a schema concern.
Shadow properties for auditing
Shadow properties exist in the EF model and database but not in the entity class:
modelBuilder.Entity<Order>()
.Property<DateTime>("CreatedAt").HasDefaultValueSql("GETUTCDATE()");
modelBuilder.Entity<Order>()
.Property<string>("CreatedBy").HasMaxLength(256);
// Write in SaveChangesInterceptor:
entry.Property("CreatedBy").CurrentValue = "alice@example.com";
// Query:
var orders = await _db.Orders
.Where(o => EF.Property<string>(o, "CreatedBy") == "alice@example.com")
.ToListAsync();
Use shadow properties for cross-cutting metadata (audit timestamps, tenant ID) that shouldn't appear in the domain model.
Optimistic concurrency with RowVersion
EF Core adds the concurrency token to WHERE clauses on UPDATE. If zero rows are affected,
another writer modified the row — DbUpdateConcurrencyException is thrown:
public class Product
{
public int Id { get; set; }
public int Stock { get; set; }
[Timestamp] // SQL Server rowversion — auto-incremented by DB
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
// Generated: UPDATE Products SET Stock=@s WHERE Id=@id AND RowVersion=@v
// 0 rows updated → DbUpdateConcurrencyException
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var dbValues = await entry.GetDatabaseValuesAsync();
entry.OriginalValues.SetValues(dbValues!); // refresh original values
await _db.SaveChangesAsync(); // retry
}
Use [Timestamp] on SQL Server for automatic, zero-application-code concurrency detection.
Alternate keys
Alternate keys are unique columns referenced by foreign keys from other tables:
modelBuilder.Entity<Product>().HasAlternateKey(p => p.Sku);
modelBuilder.Entity<OrderItem>()
.HasOne(i => i.Product)
.WithMany()
.HasForeignKey(i => i.ProductSku)
.HasPrincipalKey(p => p.Sku); // FK targets alternate key, not PK
Use alternate keys when external systems reference your entity by a natural key.
Use HasIndex(...).IsUnique() for uniqueness constraints that don't need to be FK targets.
Recap
EF Core relationship configuration rules for interviews:
- Declare both FK property and navigation on the dependent entity.
- Use explicit Fluent API — don't rely on conventions for anything non-trivial.
- Use implicit many-to-many (EF 5+) when no payload; explicit join entity when extra columns needed.
- Always add a unique index for one-to-one FK columns.
- Use
Restrictfor business-critical relationships;Cascadefor true parent-child. - Use
OwnsOne/OwnsManyfor DDD value objects. - Start with TPH inheritance; switch to TPT when null columns are a concern.
- Add
[Timestamp]for automatic optimistic concurrency on SQL Server.