Configuring EF Core relationships
Entity Framework Core gives you powerful tools to model your database relationships, but getting them wrong wastes CPU cycles and invites data corruption. Over five years building production platforms, I've seen the same mistakes repeat: hidden foreign keys, surprise cascades, and owned types that should have been value objects. This article walks you through one-to-many, many-to-many, and owned types with the clarity you need to ship relationships that actually work.
One-to-many — Always expose the foreign key
A one-to-many relationship is the bread and butter of most applications. An Order has many Items. A Customer has many Orders. The temptation is to let EF Core hide the foreign key and work purely through navigation properties. Don't. Always expose the FK as an explicit property on the dependent entity.
Here's why. When you load an Order and want to reassign its Items to a different Order, you have two paths: the expensive one and the cheap one. Without an explicit FK property, you navigate through the relationship:
var item = order.Items.First();
item.Order = newOrder;
dbContext.SaveChanges(); // EF has to track the entire order graph
With an explicit FK property, you set the integer directly:
var item = await dbContext.Items.FindAsync(itemId);
item.OrderId = newOrderId;
await dbContext.SaveChanges(); // EF updates one column, one row
The second query generates UPDATE Items SET OrderId = @newOrderId WHERE Id = @id. The first query forces EF to load the entire order graph, track changes, and generate a more complex update. In bulk operations across thousands of rows, that difference becomes your production incident.
Here's how to configure it in your DbContext:
modelBuilder.Entity<Order>(b =>
{
b.HasMany(o => o.Items)
.WithOne(i => i.Order)
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade);
});
public class Order
{
public int Id { get; set; }
public string OrderNumber { get; set; }
public List<OrderItem> Items { get; set; } = new();
}
public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; } // Explicit FK
public Order Order { get; set; } // Navigation property
public int ProductId { get; set; }
public decimal Quantity { get; set; }
}
The HasForeignKey call is redundant here—EF Core conventions will detect OrderId automatically—but being explicit prevents confusion. If you need a non-standard name like ParentOrderId, the fluent API forces you to say it out loud.
One more thing: if the FK is nullable, you can represent an optional relationship. OrderItem doesn't usually make sense without an Order, but if you're modeling a scenario where items can exist independently before assignment, use public int? OrderId { get; set; }. That choice directly affects your delete behavior, which we'll cover shortly.
Many-to-many — Skip the join table until you need it
A Product belongs to many Categories, and a Category has many Products. EF Core 5.0+ introduced payload-free skip navigations, which let you model many-to-many relationships without explicitly defining a join table.
modelBuilder.Entity<Product>()
.HasMany(p => p.Categories)
.WithMany(c => c.Products)
.UsingEntity(j => j.ToTable("ProductCategories"));
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public List<Category> Categories { get; set; } = new();
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
public List<Product> Products { get; set; } = new();
}
EF Core creates the join table for you. Behind the scenes, ProductCategories has foreign keys to both Product and Category, and EF handles inserts and deletes transparently. This works beautifully for simple many-to-many relationships where the join table carries no business data.
But real systems often need extra columns on the join table. Maybe you're tracking when a Product was added to a Category. Maybe you're recording the order in which Products appear in a Category for display purposes. The moment you need that metadata, you create an explicit join entity:
modelBuilder.Entity<Product>()
.HasMany(p => p.CategoryMappings)
.WithOne(pcm => pcm.Product)
.HasForeignKey(pcm => pcm.ProductId);
modelBuilder.Entity<Category>()
.HasMany(c => c.CategoryMappings)
.WithOne(pcm => pcm.Category)
.HasForeignKey(pcm => pcm.CategoryId);
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public List<ProductCategoryMapping> CategoryMappings { get; set; } = new();
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
public List<ProductCategoryMapping> CategoryMappings { get; set; } = new();
}
public class ProductCategoryMapping
{
public int ProductId { get; set; }
public Product Product { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; }
public DateTime AddedAt { get; set; } = DateTime.UtcNow;
public int DisplayOrder { get; set; }
}
Now you can query and update with business logic in mind. Want to get all categories for a product, ordered by display priority? You navigate through the explicit mapping. Want to add a product to a category with a specific display order? You create a new ProductCategoryMapping row with those values set.
The rule: use skip navigations for convenience relationships. Use explicit join entities whenever the join table might grow metadata. In my experience, the second case happens faster than you think.
Owned types — Value objects in the same table
Not every domain concept deserves its own table. An Address is a value object: it has no identity, and it's meaningless outside the context of the entity that owns it. A Customer has an Address, and that Address belongs entirely to that Customer. You could give Address its own table with a foreign key, but you'd waste a join for every customer query and complicate your model.
Owned types embed the Address columns directly into the Customer table:
modelBuilder.Entity<Customer>(b =>
{
b.OwnsOne(c => c.ShippingAddress, addr =>
{
addr.Property(a => a.Street).HasColumnName("ShippingStreet");
addr.Property(a => a.City).HasColumnName("ShippingCity");
addr.Property(a => a.PostalCode).HasColumnName("ShippingPostalCode");
addr.Property(a => a.Country).HasColumnName("ShippingCountry");
});
b.OwnsOne(c => c.BillingAddress, addr =>
{
addr.Property(a => a.Street).HasColumnName("BillingStreet");
addr.Property(a => a.City).HasColumnName("BillingCity");
addr.Property(a => a.PostalCode).HasColumnName("BillingPostalCode");
addr.Property(a => a.Country).HasColumnName("BillingCountry");
});
});
public class Customer
{
public int Id { get; set; }
public string Email { get; set; }
public Address ShippingAddress { get; set; }
public Address BillingAddress { get; set; }
}
public class Address
{
public string Street { get; set; }
public string City { get; set; }
public string PostalCode { get; set; }
public string Country { get; set; }
}
Your database ends up with a single Customers table containing ShippingStreet, ShippingCity, BillingStreet, BillingCity, etc. No join needed. When you load a Customer, its Address objects are populated automatically. When you save, the Address properties map to the customer row.
Owned types shine for embeddable, immutable-ish value objects. Use them for Address, Money, ContactInfo, Coordinates—things that don't vary in identity and don't need their own business logic layer. Don't use them for entities that might be shared across different parents (a single Address shared by multiple Customers should be its own entity with a FK). The moment an owned type needs its own lifecycle or could be referenced elsewhere, convert it to a regular entity.
Delete behavior is a business decision
When you delete a parent entity, what happens to its children? EF Core gives you three main options: DeleteBehavior.Cascade, DeleteBehavior.Restrict, and DeleteBehavior.SetNull. None of them is a default that works everywhere.
Cascade means deleting the parent deletes all children. Make sense for Order → OrderItems. Delete an order, and its items vanish. The order only exists as a collection of items.
b.HasMany(o => o.Items)
.WithOne(i => i.Order)
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade);
Restrict means deleting the parent fails if children exist. Makes sense for Category → Product. You don't want to lose product records because someone deleted a category. You force the caller to reassign products first or fail explicitly.
b.HasMany(c => c.Products)
.WithOne(p => p.Category)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
SetNull orphans the child by clearing its FK. Makes sense for Employee → Department if an employee's department closes. The employee record survives, but DepartmentId becomes null. This requires a nullable FK.
b.HasMany(d => d.Employees)
.WithOne(e => e.Department)
.HasForeignKey(e => e.DepartmentId)
.OnDelete(DeleteBehavior.SetNull);
public class Employee
{
public int Id { get; set; }
public int? DepartmentId { get; set; } // Nullable
public Department Department { get; set; }
}
Think through each relationship from a domain perspective. What does it mean to delete this parent? Should children disappear, should the delete fail, or should children become independent? Make that decision explicit in your fluent API configuration, and your future self debugging a data loss incident will thank you.
Common mistakes
- Hiding foreign keys: Exposing the FK as a simple scalar property costs nothing and saves you on every bulk update. Hidden FKs hide complexity; explicit FKs reveal it.
- Using cascade by default: Cascade is useful but dangerous. Data loss from a single delete is a real risk. Configure delete behavior per relationship based on business rules, never copy-paste the same behavior across your model.
- Over-using owned types: Owned types are for value objects that truly belong to one parent. If an Address might be shared, give it its own table. If it needs its own queries or lifecycle, it's not owned.
- Forgetting to test delete scenarios: Write unit tests that verify delete behavior. Create a parent, create children, delete the parent, and assert what happens to the children. You'll catch assumptions before they hit production.
- Mixing navigational and scalar updates: Don't load the entire relationship graph when you only need to update a FK. Use
item.OrderId = newOrderId, notitem.Order = newOrder. The first is cheaper.
Key takeaways
- Always expose foreign keys as explicit scalar properties on dependent entities. They make bulk updates and reassignments cheap and obvious.
- One-to-many relationships should declare FK, navigation property, and explicit delete behavior. No surprises.
- Many-to-many relationships can use skip navigations for simple cases, but add an explicit join entity as soon as you need metadata on the relationship itself.
- Owned types embed value objects into the parent table. Use them for Address, Money, and other immutable, non-shared concepts. Don't use them for entities that need their own identity or might be referenced elsewhere.
- Delete behavior (Cascade, Restrict, SetNull) is a business rule, not a technical default. Choose deliberately per relationship and test your assumptions.






Comments
Loading comments…