.NET & C# Development · Lesson 22 of 92
Relationships Done Right: 1-to-1, 1-to-Many, Many-to-Many
One-to-Many — Order → OrderItems
The most common relationship. One order has many line items; each line item belongs to exactly one order.
Entities
public class Order
{
public int Id { get; set; }
public string CustomerId { get; set; } = default!;
public DateTime CreatedAt { get; set; }
// Navigation property — EF uses this to build the JOIN
public List<OrderItem> Items { get; set; } = [];
}
public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; } // Foreign key
public int ProductId { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public Order Order { get; set; } = default!; // Inverse nav property
}Fluent API Configuration
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.HasMany(o => o.Items)
.WithOne(i => i.Order)
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade); // delete items when order deleted
}
}Eager Loading
// Load order + items in one query
var order = await _db.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
// Nested: items + their product
var order = await _db.Orders
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.FirstOrDefaultAsync(o => o.Id == id, ct);One-to-One — Order → ShippingAddress
Each order has at most one shipping address; each shipping address belongs to exactly one order.
Entities
public class Order
{
public int Id { get; set; }
public ShippingAddress? ShippingAddress { get; set; }
}
public class ShippingAddress
{
public int Id { get; set; }
public int OrderId { get; set; } // FK + unique index enforces 1-to-1
public string Street { get; set; } = default!;
public string City { get; set; } = default!;
public string PostalCode { get; set; } = default!;
public Order Order { get; set; } = default!;
}Fluent API Configuration
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasOne(o => o.ShippingAddress)
.WithOne(a => a.Order)
.HasForeignKey<ShippingAddress>(a => a.OrderId)
.OnDelete(DeleteBehavior.Cascade);
}
}EF Core requires you to tell it which side holds the FK via HasForeignKey<TDependentEntity>. The dependent (child) side holds the FK.
Loading
var order = await _db.Orders
.Include(o => o.ShippingAddress)
.FirstOrDefaultAsync(o => o.Id == id, ct);Many-to-Many — Products ↔ Categories
A product can belong to many categories; a category can contain many products.
Implicit Join Table (EF Core 5+)
EF Core can manage the join table automatically with no extra entity:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = default!;
public List<Category> Categories { get; set; } = [];
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = default!;
public List<Product> Products { get; set; } = [];
}public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasMany(p => p.Categories)
.WithMany(c => c.Products)
.UsingEntity(j => j.ToTable("ProductCategories"));
}
}EF creates a ProductCategories table with ProductsId and CategoriesId columns. You never interact with it directly.
Explicit Join Entity — When You Need Payload
If the join table needs extra columns (e.g., AssignedAt, AssignedBy), use an explicit entity:
public class ProductCategory
{
public int ProductId { get; set; }
public int CategoryId { get; set; }
public DateTime AssignedAt { get; set; }
public string AssignedBy { get; set; } = default!;
public Product Product { get; set; } = default!;
public Category Category { get; set; } = default!;
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = default!;
public List<ProductCategory> ProductCategories { get; set; } = [];
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = default!;
public List<ProductCategory> ProductCategories { get; set; } = [];
}public class ProductCategoryConfiguration
: IEntityTypeConfiguration<ProductCategory>
{
public void Configure(EntityTypeBuilder<ProductCategory> builder)
{
builder.HasKey(pc => new { pc.ProductId, pc.CategoryId });
builder.HasOne(pc => pc.Product)
.WithMany(p => p.ProductCategories)
.HasForeignKey(pc => pc.ProductId);
builder.HasOne(pc => pc.Category)
.WithMany(c => c.ProductCategories)
.HasForeignKey(pc => pc.CategoryId);
}
}Loading products with their categories through the explicit join:
var products = await _db.Products
.Include(p => p.ProductCategories)
.ThenInclude(pc => pc.Category)
.ToListAsync(ct);Cascade Delete Behavior
.OnDelete(DeleteBehavior.Cascade) // delete children when parent deleted
.OnDelete(DeleteBehavior.Restrict) // prevent deletion if children exist (throws)
.OnDelete(DeleteBehavior.SetNull) // set FK to null on children
.OnDelete(DeleteBehavior.NoAction) // do nothing in DB (you handle it)Cascade is the default for required relationships. For optional relationships (nullable FK), SetNull is the default. Always be explicit — relying on defaults makes migrations harder to reason about.
Common Mistakes
Loading without Include. Navigation properties are null unless you explicitly load them. If you access order.Items without Include, you get an empty collection (lazy loading is off by default).
// Wrong — Items will be empty
var order = await _db.Orders.FindAsync(id);
var count = order.Items.Count; // 0
// Right
var order = await _db.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);Cartesian explosion. Including multiple collection navigations in one query generates a Cartesian product. Use AsSplitQuery():
var order = await _db.Orders
.Include(o => o.Items)
.Include(o => o.Tags)
.AsSplitQuery()
.FirstOrDefaultAsync(o => o.Id == id, ct);Split query issues two SQL queries instead of a JOIN, avoiding the explosion.