EF Core's relationship API has enough surface area that it is easy to configure something that compiles cleanly and then behaves wrong at runtime: a cascade delete you did not intend, a join table with the wrong primary key, or an owned entity serialized into a separate table when you expected column-inlining. Getting the configuration right the first time saves painful migrations later.
This post covers the three relationship shapes you will use in almost every application: one-to-many, many-to-many (with and without a payload), and owned entities. For each shape you will see the entity classes, the fluent configuration, and the key behavioral detail that trips developers up.
Before starting, make sure you have:
- .NET 8 SDK
- EF Core 8 (
Microsoft.EntityFrameworkCoreplus a provider) - A working
DbContextwith migrations enabled - Familiarity with
OnModelCreatingand theModelBuilderAPI
One-to-many is the most common relationship shape: a Blog has many Post records; each Post belongs to exactly one Blog. The principal entity (Blog) holds the collection; the dependent entity (Post) holds the foreign key.
Entity classes
public class Blog
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
public ICollection<Post> Posts { get; set; } = [];
}
public class Post
{
public int Id { get; set; }
public int BlogId { get; set; }
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public DateTimeOffset PublishedAt { get; set; }
public Blog Blog { get; set; } = null!;
}
Fluent configuration
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.HasOne(p => p.Blog)
.WithMany(b => b.Posts)
.HasForeignKey(p => p.BlogId)
.IsRequired()
.OnDelete(DeleteBehavior.Cascade);
}
Always specify OnDelete explicitly. EF Core defaults to Cascade for required relationships and ClientSetNull for optional ones, but the correct behavior depends on your domain and leaving it implicit is a migration-time surprise waiting to happen.
Querying with Include
// Load a blog with all its posts
var blog = await _context.Blogs
.Include(b => b.Posts)
.FirstOrDefaultAsync(b => b.Id == blogId, ct);
// Load posts with their parent blog (useful for display-only reads)
var posts = await _context.Posts
.AsNoTracking()
.Include(p => p.Blog)
.Where(p => p.PublishedAt >= cutoff)
.OrderByDescending(p => p.PublishedAt)
.ToListAsync(ct);
.CountAsync(p => p.BlogId == blogId) directly; EF Core translates it to a COUNT(*) without loading any rows.
Many-to-many maps each record in one table to zero or more records in another table, and vice versa. EF Core 5 and later can create the join table implicitly, but as soon as the join needs extra data, such as a timestamp or an ordering column, you must define an explicit join entity.
Implicit join table (no payload)
When the join carries no extra information, the shortest configuration is:
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public ICollection<Tag> Tags { get; set; } = [];
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public ICollection<Post> Posts { get; set; } = [];
}
modelBuilder.Entity<Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity(j => j.ToTable("PostTags"));
EF Core creates the PostTags join table with a composite primary key of (PostsId, TagsId). UsingEntity lets you rename the table and columns without introducing a full entity class.
Explicit join entity (with payload)
When you need to store when a tag was applied, or who applied it, you need an entity class for the join row:
public class PostTag
{
public int PostId { get; set; }
public int TagId { get; set; }
public DateTimeOffset TaggedAt { get; set; }
public string TaggedBy { get; set; } = string.Empty;
public Post Post { get; set; } = null!;
public Tag Tag { get; set; } = null!;
}
modelBuilder.Entity<PostTag>()
.HasKey(pt => new { pt.PostId, pt.TagId });
modelBuilder.Entity<Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity<PostTag>(
right => right
.HasOne(pt => pt.Tag)
.WithMany()
.HasForeignKey(pt => pt.TagId),
left => left
.HasOne(pt => pt.Post)
.WithMany()
.HasForeignKey(pt => pt.PostId));
// Update the navigation collections to use PostTag
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public ICollection<PostTag> PostTags { get; set; } = [];
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public ICollection<PostTag> PostTags { get; set; } = [];
}
Once you add payload, replace the ICollection<Tag> navigation with ICollection<PostTag>. You gain access to the extra columns; you lose the convenience of direct cross-navigation. That is the intentional trade-off.
Adding a tag to a post
var post = await _context.Posts.FindAsync(postId);
var tag = await _context.Tags.FindAsync(tagId);
_context.Set<PostTag>().Add(new PostTag
{
PostId = post!.Id,
TagId = tag!.Id,
TaggedAt = DateTimeOffset.UtcNow,
TaggedBy = currentUser
});
await _context.SaveChangesAsync(ct);
An owned entity is a class that has no identity of its own outside its owner. It maps to columns in the owner's table by default and is deleted automatically when the owner is deleted. Use owned entities for value objects: addresses, money, coordinates, contact details.
Defining owner and owned type
public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public DateTimeOffset PlacedAt { get; set; }
public Address ShippingAddress { get; set; } = null!;
public Address BillingAddress { get; set; } = null!;
}
public class Address
{
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
}
Fluent configuration with column name overrides
modelBuilder.Entity<Order>(order =>
{
order.OwnsOne(o => o.ShippingAddress, addr =>
{
addr.Property(a => a.Street).HasColumnName("ShipStreet").IsRequired();
addr.Property(a => a.City).HasColumnName("ShipCity").IsRequired();
addr.Property(a => a.PostalCode).HasColumnName("ShipPostalCode").IsRequired();
addr.Property(a => a.Country).HasColumnName("ShipCountry").IsRequired();
});
order.OwnsOne(o => o.BillingAddress, addr =>
{
addr.Property(a => a.Street).HasColumnName("BillStreet").IsRequired();
addr.Property(a => a.City).HasColumnName("BillCity").IsRequired();
addr.Property(a => a.PostalCode).HasColumnName("BillPostalCode").IsRequired();
addr.Property(a => a.Country).HasColumnName("BillCountry").IsRequired();
});
});
Without explicit column name overrides, EF Core generates names in the form ShippingAddress_Street, which is fine but can be verbose in a schema you share with other teams.
OwnsMany for collections of value objects
When an order can have multiple line items that are fully owned (not referenced from anywhere else), use OwnsMany. EF Core maps the collection to a separate table with a foreign key back to the owner:
public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; } = string.Empty;
public IList<OrderNote> Notes { get; set; } = [];
}
public class OrderNote
{
public string Text { get; set; } = string.Empty;
public DateTimeOffset AddedAt { get; set; }
}
// Configuration
modelBuilder.Entity<Order>()
.OwnsMany(o => o.Notes, note =>
{
note.ToTable("OrderNotes");
note.WithOwner().HasForeignKey("OrderId");
note.Property(n => n.Text).IsRequired();
});
DbSet. You always access them through the owner. If you need to query the child type independently, it should be a regular entity with its own primary key, not an owned type.
After writing your configuration, run a dry-run migration to verify EF Core generated the schema you intended before touching any database:
// Add a migration to inspect the generated SQL
// dotnet ef migrations add InitialRelationships --project YourProject
// Preview the SQL without applying it
// dotnet ef migrations script --idempotent
Look for three things: the foreign key columns with the correct names, the composite primary key on any join table, and the owned-entity columns inlined into the owner table (or separated into the expected table for OwnsMany). If anything looks wrong, it is far cheaper to fix the configuration and drop the migration now than after it has been applied to a shared database.
One-to-many is your default; configure the foreign key and delete behavior explicitly. Many-to-many with an implicit join table is convenient for pure link tables, but move to an explicit entity the moment the join needs any extra columns. Owned entities are the right tool for value objects: they keep the class model clean while mapping to flat table columns.
Next in this series: Tracking vs. No-Tracking Queries in EF Core, covering where the change tracker hurts performance, when you genuinely need it, and how to update entities safely in a no-tracking world.
No comments: