Skip to main content
    Courses/C#/EF Core Mastery

    Advanced Track

    EF Core Relationships & Migrations

    By the end of this lesson you'll be able to model the three core relationship types — one-to-many, many-to-many, and one-to-one — plus owned entities, choose the right loading strategy with Include, and evolve your schema safely with migrations. These are the skills that turn a toy app into a real database-backed system.

    What You'll Learn

    • Model one-to-many, many-to-many and one-to-one with navigation properties
    • Configure relationships and owned entities with the Fluent API
    • Load related data eagerly with Include and ThenInclude
    • Compare eager, lazy and explicit loading — and their tradeoffs
    • Evolve the schema safely with Add-Migration and Update-Database
    • Spot and fix cartesian explosion, N+1 queries and migration drift

    💡 Real-World Analogy

    Think of your database as a library. An author has many books — that's one-to-many. A book wears many tags ("Fiction", "Classic") and a tag labels many books — that's many-to-many, and you need an index card (a join table) listing which book has which tag. An author has exactly one membership card — that's one-to-one. An author's address isn't a thing the library catalogues on its own; it's just extra lines printed on the author's record — that's an owned entity. And migrations are the library's renovation log: every time you add a shelf or move a room, you write down the change so you can rebuild — or undo — the layout later.

    📊 The Three Relationship Types

    RelationshipExampleNavigation shapeFluent API
    One-to-ManyAuthor → BooksList<Book> + AuthorIdHasOne/WithMany
    Many-to-ManyBook ↔ TagsList on both sidesHasMany/WithMany
    One-to-OneAuthor → Profilesingle ref + unique FKHasOne/WithOne
    Owned entityAuthor owns Addressno key of its ownOwnsOne

    A navigation property is just a property that points to the related entity (or list of them). A foreign key (FK) is the plain column — like AuthorId — that physically links the rows.

    1. One-to-Many — the Workhorse

    One-to-many is by far the most common relationship: one Author writes many Books. You model it with two navigation properties — a collection (List<Book> Books) on the "one" side, and a reference plus a foreign key (Author and int AuthorId) on the "many" side. EF can often infer the relationship from those names, but spelling it out with the Fluent API lets you set the delete behaviour. Read this worked example first.

    Worked example: one-to-many with Fluent API

    Read-only — this is the EF entity config and what it maps to (won't run in-browser).

    Try it Yourself »
    C#
    using System.Collections.Generic;
    using Microsoft.EntityFrameworkCore;
    
    // === ONE-TO-MANY: one Author has many Books ===
    
    // The "one" side. It carries a COLLECTION navigation: List<Book>.
    public class Author
    {
        public int Id { get; set; }                       // primary key (by convention)
        public string Name { get; set; } = "";
        public List<Book> Books { get; set; } = new();    // navigation: the many side
    }
    
    // The "many" side. It carries the FOREIGN KEY + a reference navigation.
    p
    ...

    Now you try — but in plain, runnable C#. The program below models the same one-to-many shape (an Author with a List<Book>) as in-memory objects, so it executes here. Fill in the three ___ blanks, then run it.

    🎯 Your turn: model a one-to-many & count

    Fill in the blanks so the author ends up with two books, then check the count.

    Try it Yourself »
    C#
    using System;
    using System.Collections.Generic;
    
    // 🎯 YOUR TURN — model a one-to-many the same shape EF uses,
    //                but as plain in-memory objects so it RUNS here.
    
    class Book
    {
        public string Title { get; set; } = "";
    }
    
    class Author
    {
        public string Name { get; set; } = "";
    
        // 1) An Author has MANY Books. Give it a collection navigation.
        public List<Book> Books { get; set; } = ___;   // 👉 a new empty list: new()
    }
    
    class Program
    {
        static void Main()
        {
         
    ...

    2. Many-to-Many — Skip Navigations

    In a many-to-many, both sides carry a collection: a Book has many Tags and a Tag labels many Books. Since EF Core 5 you no longer write a join class by hand — you put a List on each side and EF generates the hidden join table for you (this is called a skip navigation because you "skip over" the join table when you query). Physically there are still three tables; you just never have to mention the middle one.

    Worked example: many-to-many (auto join table)

    Read-only — EF builds the join table from collections on both sides.

    Try it Yourself »
    C#
    using System.Collections.Generic;
    using Microsoft.EntityFrameworkCore;
    
    // === MANY-TO-MANY: a Book has many Tags, a Tag labels many Books ===
    // EF Core 5+ creates the join table for you (a "skip navigation").
    
    public class Book
    {
        public int Id { get; set; }
        public string Title { get; set; } = "";
        public List<Tag> Tags { get; set; } = new();   // collection on BOTH sides
    }
    
    public class Tag
    {
        public int Id { get; set; }
        public string Name { get; set; } = "";
        public List<
    ...

    3. One-to-One & Owned Entities

    A one-to-one ties two entities so each maps to at most one of the other — an Author and their Profile. You configure it with HasOne/WithOne, and the foreign key lives on the dependent side (Profile.AuthorId) and is made unique. An owned entity is different: it has no identity of its own and isn't a row in its own table — it's a value object (like an Address or a money amount) whose columns are folded into the owner's table with OwnsOne. Reach for owned types when a thing only makes sense as part of its parent.

    Worked example: one-to-one + owned Address

    Read-only — unique FK for one-to-one; OwnsOne folds Address into the parent table.

    Try it Yourself »
    C#
    using Microsoft.EntityFrameworkCore;
    
    // === ONE-TO-ONE: each Author has exactly one Profile ===
    public class Author
    {
        public int Id { get; set; }
        public string Name { get; set; } = "";
        public Profile Profile { get; set; } = null!;   // reference navigation, ONE
    }
    
    public class Profile
    {
        public int Id { get; set; }
        public string Bio { get; set; } = "";
        public int AuthorId { get; set; }               // FK that is also UNIQUE
        public Author Author { get; set; } = null!;
    ...

    4. Loading Related Data — Eager, Lazy & Explicit

    By default EF loads an entity without its relationships — db.Authors.ToList() gives you authors with empty Books lists. You then choose how related data arrives. Eager loading with Include pulls it in the same query (one round-trip). Explicit loading fetches it later, by hand, only when you decide you need it. Lazy loading fetches it automatically the first time you touch a navigation — convenient, but the classic source of accidental extra queries. Use ThenInclude to reach a second level (an author's books' tags).

    Worked example: Include, explicit & lazy loading

    Read-only — three loading strategies and when each hits the database.

    Try it Yourself »
    C#
    using System.Linq;
    using Microsoft.EntityFrameworkCore;
    
    // Three ways to pull related data. Pick deliberately — it decides
    // how many round-trips hit the database.
    
    // 1) EAGER loading with Include — fetch related data in the SAME query.
    var authors = db.Authors
        .Include(a => a.Books)            // bring each author's Books along
            .ThenInclude(b => b.Tags)    // ...and each book's Tags too (multi-level)
        .ToList();
    // ✅ One query (a JOIN) returns authors + books + tags together.
    
    ...

    🔎 Deep Dive: which loading strategy, and when?

    Eager (Include) is the default choice when you know you'll need the related data — it's one round-trip and predictable. The catch: chaining several Includes onto collection navigations multiplies rows (a "cartesian explosion"). If you load an author's 10 books and 5 tags, a single joined query returns 50 rows of duplicated author data. AsSplitQuery() fixes this by issuing one query per collection instead.

    Explicit loading shines when related data is only sometimes needed — you load the parent cheaply, then call .Load() on the rare path that needs the children.

    Lazy loading is the most convenient and the most dangerous: a navigation touched inside a loop fires one query per iteration (the N+1 problem). It's off by default for a reason — prefer Include unless you have a specific reason not to.

    // Cartesian-explosion fix when you must Include several collections:
    db.Authors
      .Include(a => a.Books)
      .Include(a => a.Awards)
      .AsSplitQuery()      // one query per collection, no row multiplication
      .ToList();

    Time to query related data yourself. Once an Include has loaded everything, you work with it using ordinary LINQ. The exercise below uses in-memory lists in place of the loaded tables, so it runs — join books to authors and group to count. Fill in the two ___ blanks.

    🎯 Your turn: query related data with LINQ

    Group books by their author foreign key and count — then run it.

    Try it Yourself »
    C#
    using System;
    using System.Collections.Generic;
    using System.Linq;
    
    // 🎯 YOUR TURN — this is the LINQ you'd run AFTER an Include loads
    //                the data. Here the "related data" is plain in-memory
    //                lists, so it RUNS and prints.
    
    class Book   { public string Title = ""; public int AuthorId; }
    class Author { public int Id; public string Name = ""; }
    
    class Program
    {
        static void Main()
        {
            var authors = new List<Author>
            {
                new Author { Id 
    ...

    5. Migrations — Versioning Your Schema

    Your entity classes are the source of truth for your schema, but the database doesn't update itself. Migrations bridge that gap: each one is a generated class with an Up() (apply the change) and a Down() (reverse it), like a commit you can roll forward or back. You run Add-Migration (or dotnet ef migrations add) after changing your model, then Update-Database to apply it. EF tracks which migrations have run in a __EFMigrationsHistory table, so it only ever applies the new ones.

    Worked example: the migrations workflow

    Read-only — the CLI/PMC commands and the generated Up/Down methods.

    Try it Yourself »
    C#
    // === EF Core MIGRATIONS: version control for your database schema ===
    // Each migration is like a git commit: it can be applied (Up) or
    // reverted (Down). You run these in the terminal or the Package
    // Manager Console — NOT in C# code.
    
    // ---- Package Manager Console (Visual Studio) ----
    // Add-Migration InitialCreate     // generate a migration from model changes
    // Update-Database                 // apply pending migrations to the DB
    
    // ---- dotnet CLI (cross-platform) ----
    // dotnet ef 
    ...

    Putting It Together: an Order Total

    Here's a small but real relational graph — an Order with many OrderItems, each pointing at a Product by id. It's modelled exactly like EF entities (one-to-many + a foreign-key lookup) but as plain in-memory objects, so it runs. You join each item to its product and total the order — the same query you'd run after an Include.

    Worked example: order items joined to products

    Runs in-browser — join order items to products and total the order.

    Try it Yourself »
    C#
    using System;
    using System.Collections.Generic;
    using System.Linq;
    
    // === An order system modelled the way EF entities relate, but as plain
    //     objects so it runs. Order 1---* OrderItem, each item -> a Product. ===
    
    class Product   { public int Id; public string Name = ""; public decimal Price; }
    class OrderItem { public int ProductId; public int Quantity; }
    class Order     { public string Customer = ""; public List<OrderItem> Items = new(); }
    
    class Program
    {
        static void Main()
        {
      
    ...

    With real EF you'd write db.Orders.Include(o => o.Items).ThenInclude(i => i.Product) and the join would happen in SQL — the LINQ over the loaded objects would be identical.

    Pro Tips

    • 💡 Prefer eager Include over lazy loading in web apps — it makes the database round-trips visible and predictable instead of hidden inside a loop.
    • 💡 Reach for AsSplitQuery() the moment you Include two or more collections, to avoid cartesian row multiplication.
    • 💡 Add AsNoTracking() to read-only queries — skipping change tracking is a free speed and memory win for data you won't save.
    • 💡 Never run EnsureCreated() alongside migrations — they're mutually exclusive. Use db.Database.Migrate() to apply pending migrations at startup instead.
    • 💡 Always review the generated migration before applying it; for production, generate a SQL script with dotnet ef migrations script and run it through CI/CD.
    • 💡 Set OnDelete deliberatelyCascade, Restrict, or SetNull — so deleting a parent does exactly what you intend.

    Common Errors (and the fix)

    • Cartesian explosion (slow query, duplicated rows): chaining several Includes on collections makes EF join everything into one giant result. Add .AsSplitQuery() so each collection is fetched in its own query.
    • N+1 from lazy loading: reading a navigation inside a foreach fires one query per item — 100 authors becomes 101 queries. Load the data up front with .Include(a => a.Books) instead of relying on lazy proxies.
    • "Unable to determine the relationship represented by navigation property": EF can't work out the foreign key. Add the FK property (int AuthorId) or configure it explicitly with HasForeignKey(...) in OnModelCreating.
    • Migration drift ("model differs from the database"): the model changed but no migration was added, or someone hand-edited the DB. Run Add-Migration to capture the diff, then Update-Database — never edit the schema by hand.
    • "The entity type requires a primary key to be defined": your class has no Id (or <Class>Id) property and you didn't call HasKey(...). Add a key, or mark it owned with OwnsOne if it has no identity of its own.

    📋 Quick Reference

    TaskCodeNotes
    One-to-manyHasOne(...).WithMany(...)FK on the "many" side
    Many-to-manyHasMany(...).WithMany(...)Auto join table
    One-to-oneHasOne(...).WithOne(...)Unique FK on dependent
    Owned entityOwnsOne(...)Columns in parent table
    Eager load.Include(a => a.Books)Same query (JOIN)
    Multi-level load.ThenInclude(b => b.Tags)Reach the next level
    Split query.AsSplitQuery()Avoid cartesian blow-up
    Explicit load.Entry(a).Collection(...).Load()Load on demand
    Add migrationAdd-Migration Namedotnet ef migrations add
    Apply migrationUpdate-Databasedotnet ef database update

    Frequently Asked Questions

    Q: Do I always need a foreign-key property like AuthorId?

    No — EF can create a shadow FK behind the scenes from the navigation alone. But adding the explicit AuthorId property is usually better: you can set and query it directly without loading the whole related entity, and it makes the relationship obvious in your code.

    Q: When should I use a many-to-many vs a join entity?

    If the link itself carries no data, use the automatic many-to-many (a list on each side). If the link needs its own fields — say a BookTag with an AddedDate — model it as a real entity with two one-to-many relationships instead.

    Q: Eager, lazy or explicit — which should I default to?

    Default to eager (Include). It makes every database round-trip explicit in your code, which is exactly what you want in a web app. Reach for explicit loading when related data is only occasionally needed, and avoid lazy loading unless you fully understand the N+1 risk.

    Q: What's the difference between EnsureCreated() and migrations?

    EnsureCreated() builds the whole schema once from the current model and can't evolve it — fine for quick demos and tests. Migrations track each change over time and can upgrade an existing database without losing data. Use migrations for anything real, and never mix the two.

    Q: My query got slow after adding an Include — why?

    You probably hit a cartesian explosion: including multiple collections joins them all into one result, duplicating parent rows. Add .AsSplitQuery() so EF runs a separate query per collection, or only Include what you actually use.

    Mini-Challenge: Total an Order

    No blanks this time — just a brief and a starter with the data set up for you. Model and query a small relational graph in memory: a list of OrderItems (each with a ProductId and Quantity) joined to Products. Total the units ordered and find the most expensive line. Run it and check your output against the expected lines in the comments.

    🎯 Mini-Challenge: sum units & find the top line

    Use Sum and Max over the items, looking up each product's price by id.

    Try it Yourself »
    C#
    using System;
    using System.Collections.Generic;
    using System.Linq;
    
    // 🎯 MINI-CHALLENGE: model & query a small relational graph (in-memory)
    //
    // 1. You're given Products, and an Order with a list of OrderItems
    //    (each item has a ProductId + Quantity). This is a one-to-many
    //    (Order ---* OrderItem) joined to Products on ProductId.
    // 2. Work out the TOTAL number of UNITS ordered (sum of all Quantity).
    // 3. Work out the most EXPENSIVE single line (Price * Quantity).
    // 4. Print:  "Units
    ...

    🎉 Lesson Complete

    • One-to-many: collection on the "one" side, reference + FK on the "many" side
    • Many-to-many: a list on both sides; EF builds the join table for you
    • One-to-one: unique FK on the dependent; owned entities fold into the parent table
    • Loading: eager (Include/ThenInclude), explicit (.Load()), lazy (auto, risky)
    • Tradeoffs: cartesian explosion → AsSplitQuery(); N+1 → eager load up front
    • Migrations: Add-Migration then Update-Database; Up()/Down() roll forward and back
    • Next lesson: Repository Pattern — wrapping the DbContext behind a clean, testable abstraction

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service