Skip to content

.NET Core · Entity Framework Core

Configuring Relationships in EF Core

7 min read Updated 2026-06-23 Share:

Practice Relationships interview questions

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 are how EF resolves the object graph. Three types:

TypeExamplePurpose
Reference navigationCustomer CustomerPoints to one related entity
Collection navigationICollection<Order> OrdersPoints to many related entities
Inverse navigationOrder Order on OrderItemPoints 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:

OptionWhat happens to dependents
CascadeDeleted automatically with the principal
RestrictError thrown if any dependents exist
SetNullFK set to NULL (FK must be nullable)
NoActionApplication 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:

  1. Declare both FK property and navigation on the dependent entity.
  2. Use explicit Fluent API — don't rely on conventions for anything non-trivial.
  3. Use implicit many-to-many (EF 5+) when no payload; explicit join entity when extra columns needed.
  4. Always add a unique index for one-to-one FK columns.
  5. Use Restrict for business-critical relationships; Cascade for true parent-child.
  6. Use OwnsOne / OwnsMany for DDD value objects.
  7. Start with TPH inheritance; switch to TPT when null columns are a concern.
  8. Add [Timestamp] for automatic optimistic concurrency on SQL Server.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel