← Open in the full interactive course (progress tracking, search & more)

Real data isn't a pile of isolated tables — a customer has orders, an order has lines, a line points at a product. EF Core needs to know how those tables connect, and it learns that from the shape of your classes.

So far, every entity in this module has stood alone — a Product here, a Post there, each with its own table and no idea the others exist. Real applications don't look like that. A Customer places Orders. Each Order is made up of OrderLine rows, and each of those points at a Product. Ask "show me everything Priya has ever ordered" and you're not querying one table — you're walking a graph of connected tables, and EF Core needs to understand that graph well enough to generate the right joins for you.

In this lesson, you'll learn the three relationship shapes EF Core supports — one-to-many, many-to-many, and one-to-one — how navigation properties and foreign keys express them in C#, and how to configure each with a realistic Customer / Order / OrderLine / Product example.

What Is It?

The Simple Explanation

A relationship is a connection between two entity types — a way of saying "this row is linked to that row." In a relational database, that link is a foreign key: a column on one table holding the primary key value of a row in another table. In your C# classes, that same link shows up as a navigation property — a property that lets you write order.Customer or customer.Orders instead of manually joining tables and matching IDs yourself.

The Technical Definition

EF Core recognizes three relationship cardinalities — how many rows on one side can relate to how many rows on the other:

CardinalityMeaningExample
One-to-manyOne row on the "one" side relates to many rows on the "many" sideOne Customer has many Orders
Many-to-manyMany rows on each side can relate to many rows on the otherMany Products appear in many Orders
One-to-oneOne row on each side relates to exactly one row on the otherOne Customer has one CustomerProfile

Every one of these is built out of the same two ingredients: a foreign key (a property, usually named <Navigation>Id, holding a related row's primary key) and one or more navigation properties (a reference to a single related entity, or a collection of them) that expose the relationship in C#.

Why Does It Exist?

The Problem — Relational Data Doesn't Fit Naturally Into Isolated Objects

A relational database is, by design, a set of separate tables connected by keys — that's what "relational" means. But object-oriented code prefers to work in terms of whole graphs of connected objects: give me this order, and let me just walk to its customer, and from there to all their other orders, without writing a single JOIN by hand. Without a defined mapping between the two, you'd be back to manually matching foreign key values and writing your own join logic every time you needed related data — exactly the tedious, error-prone work an ORM exists to remove.

The Solution — Navigation Properties Map the Graph for You

EF Core lets you describe relationships once, as ordinary C# properties, and then handles the translation in both directions: when you query with .Include(o => o.Customer), it generates the JOIN; when you set order.CustomerId = 7 and save, it writes the correct foreign key value. You think in terms of connected objects; EF Core thinks in terms of joins and foreign keys underneath — and keeps the two in sync.

Big Picture

Here's the full graph this lesson builds, all four entity types and how they connect:

THE ORDER DOMAIN — ONE GRAPH, THREE RELATIONSHIP SHAPES
Customer ── (one-to-many) ──▶ Order
Order ── (one-to-many) ──▶ OrderLine
OrderLine ── (many-to-one) ──▶ Product
Customer ── (one-to-one) ──▶ CustomerProfile

One-to-Many — Customer and Order

This is the most common relationship shape by far. One Customer has many Orders; each Order belongs to exactly one Customer.

public class Customer { public int Id { get; set; } public required string Name { get; set; } // Collection navigation — the "many" side public List<Order> Orders { get; set; } = []; } public class Order { public int Id { get; set; } public DateTime PlacedAtUtc { get; set; } // Foreign key public int CustomerId { get; set; } // Reference navigation — the "one" side public Customer Customer { get; set; } = null!; public List<OrderLine> Lines { get; set; } = []; }

Notice the pattern: Order holds the foreign key (CustomerId) because it's the "many" side — each order needs to say which one customer it belongs to. Customer holds a List<Order> because one customer can have many. By convention, a property named <NavigationName>Id (here, CustomerId matching the Customer navigation) is automatically recognized as the foreign key — no configuration needed for this shape.

Order and OrderLine — the same shape again

The Order-to-OrderLine relationship follows the identical pattern — OrderLine is the "many" side and carries the foreign key:

public class OrderLine { public int Id { get; set; } public int Quantity { get; set; } public decimal UnitPrice { get; set; } public int OrderId { get; set; } // FK to Order public Order Order { get; set; } = null!; public int ProductId { get; set; } // FK to Product public Product Product { get; set; } = null!; }

Many-to-Many — Order and Product

An order can contain many products, and a product can appear on many orders — that's many-to-many. In a relational database, this always requires a third table in between (a join table) holding pairs of foreign keys. In this domain, OrderLine already is that join table — and because it also needs to carry its own data (Quantity, UnitPrice), modeling it as an explicit entity, exactly as shown above, is the right approach.

When there's no extra data: skip navigations

Sometimes a many-to-many relationship carries no data of its own — for example, a Product can belong to many Category tags, and a Category groups many products, with nothing to say about the pairing itself. EF Core can map that directly, without you writing a join entity class at all:

public class Product { public int Id { get; set; } public required string Name { get; set; } public decimal Price { get; set; } // Skip navigation — "skips over" the hidden join table public List<Category> Categories { get; set; } = []; } public class Category { public int Id { get; set; } public required string Name { get; set; } public List<Product> Products { get; set; } = []; }

EF Core sees two collection navigations pointing at each other and infers a many-to-many relationship, silently creating a hidden join table (ProductCategory, by convention) behind the scenes during migrations. You never define a class for it, and you rarely query it directly — you just work with product.Categories and category.Products as plain lists.

Rule of thumb: If the many-to-many pairing needs to carry its own data (a quantity, a timestamp, a role), model it as an explicit join entity like OrderLine. If it's a pure association with nothing to say about the pairing itself, skip navigations are less code for the same result.

One-to-One — Customer and CustomerProfile

Sometimes you split one conceptual "thing" into two tables — often to keep a frequently-queried entity lean, moving rarely-needed data (shipping address, marketing preferences) into a separate table that's only joined in when actually needed. One Customer has exactly one CustomerProfile; each CustomerProfile belongs to exactly one Customer.

public class CustomerProfile { public int Id { get; set; } public required string ShippingAddress { get; set; } public bool ReceivesMarketingEmails { get; set; } public int CustomerId { get; set; } // FK — also the natural place for a unique constraint public Customer Customer { get; set; } = null!; }

A one-to-one relationship looks almost identical to one-to-many in C# — a foreign key plus two reference navigations — but the database needs one extra thing to actually enforce "at most one": a unique constraint on the foreign key column. Without it, nothing stops several CustomerProfile rows from pointing at the same CustomerId, which would silently make it one-to-many instead. That's configured with the Fluent API:

protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<CustomerProfile>() .HasOne(p => p.Customer) .WithOne() .HasForeignKey<CustomerProfile>(p => p.CustomerId); modelBuilder.Entity<CustomerProfile>() .HasIndex(p => p.CustomerId) .IsUnique(); }

Simple Example — Querying Across a Relationship

With the navigation properties in place, walking the graph is ordinary LINQ. To load an order along with its customer and lines, use .Include(...) — without it, related entities are simply left null or empty:

Order? order = await context.Orders .Include(o => o.Customer) .Include(o => o.Lines) .ThenInclude(line => line.Product) .FirstOrDefaultAsync(o => o.Id == orderId); Console.WriteLine($"Order for {order.Customer.Name}:"); foreach (OrderLine line in order.Lines) { Console.WriteLine($" {line.Quantity} x {line.Product.Name}"); }

.Include(o => o.Customer) tells EF Core to JOIN the Customers table and populate the navigation. .ThenInclude(...) chains one level deeper — after including Lines, also include each line's Product. Without either call, order.Customer and line.Product would be null, not because the data doesn't exist, but because you never asked EF Core to fetch it.

Real-World Example

An e-commerce order summary endpoint needs to show a customer's name, every line on their most recent order, and each product's current name — exactly the graph this lesson built:

[ApiController] [Route("api/customers/{customerId}/orders/latest")] public class OrderSummaryController(ShopDbContext context) : ControllerBase { [HttpGet] public async Task<IActionResult> GetLatestOrder(int customerId) { Order? order = await context.Orders .Where(o => o.CustomerId == customerId) .OrderByDescending(o => o.PlacedAtUtc) .Include(o => o.Lines) .ThenInclude(line => line.Product) .FirstOrDefaultAsync(); if (order is null) return NotFound(); return Ok(new { order.Id, order.PlacedAtUtc, Lines = order.Lines.Select(l => new { l.Product.Name, l.Quantity, l.UnitPrice }) }); } }

One query, one round trip to the database, and the resulting SQL is a set of JOINs that EF Core wrote for you — because the relationships between Order, OrderLine, and Product were already described once, in the entity classes.

Analogy

A Filing Cabinet With Cross-References

Picture a filing cabinet where every folder has an ID written on its tab. A Customer folder doesn't physically contain their orders — it's too thick, and orders keep arriving. Instead, each Order folder has a sticky note reading "Customer #7" (the foreign key). To find all of Customer #7's orders, you'd flip through every order folder checking that note — tedious, but doable.

A navigation property is the assistant who's already done that flipping for you and keeps an index card listing "Customer #7's orders: #101, #104, #109" taped to the front of the customer folder. You just read the card (customer.Orders) instead of searching the whole cabinet yourself — but the underlying sticky notes (foreign keys) are still what makes the connection real.

Under the Hood

HOW EF CORE DISCOVERS A RELATIONSHIP BY CONVENTION
1. It looks for matching navigation properties
2. It looks for a matching foreign key property by name
3. Two collection navigations pointing at each other become many-to-many
4. Anything ambiguous falls back to explicit configuration

Common Confusion

1. "A one-to-one relationship is just a one-to-many that happens to only have one row"

Without an explicit unique constraint on the foreign key, that's exactly what it silently becomes — a one-to-many relationship that currently, coincidentally, has one related row per parent. Nothing stops a second row from appearing later. A genuine one-to-one needs the database itself to enforce uniqueness; C# navigation properties alone describe the shape you intend, not a guarantee.

2. "Navigation properties automatically load related data whenever you access them"

Not by default. order.Customer is null unless you explicitly asked for it with .Include(...) (or assigned it yourself after loading). This is different from some other ORMs' "lazy loading" behavior, where simply touching a navigation property triggers an automatic extra query behind your back — EF Core supports that as an opt-in feature, but most modern codebases avoid it in favor of explicit, predictable loading with .Include(...).

Common Mistakes

Mistake 1 — Forgetting .Include() and then being confused by null navigations

Querying context.Orders.FirstOrDefaultAsync(...) and then trying to read order.Customer.Name, hitting a NullReferenceException because the customer was never loaded. Add .Include(o => o.Customer) to the query whenever you know you'll need that related data.

Mistake 2 — Modeling a many-to-many relationship with a plain list of IDs instead of navigation properties

Adding a List<int> ProductIds property to Order and manually managing it — EF Core has no idea this list means anything relationally, and it won't generate joins, foreign keys, or a join table for it. Use proper navigation properties (skip navigations, or an explicit join entity like OrderLine) so EF Core actually understands and maintains the relationship.

Mistake 3 — Assuming a one-to-one relationship needs no configuration at all

Defining Customer.Profile and CustomerProfile.Customer with a matching CustomerId foreign key and expecting convention to enforce "exactly one." Explicitly configure a unique index on the foreign key — one-to-one is the one shape convention cannot fully infer on its own.

When Should I Use It?

SituationUse
A parent that owns many child rows (Customer/Order, Order/OrderLine)One-to-many — the default, most common shape
Two entity types that can each relate to many of the other, with extra data on the pairing (quantity, price at time of purchase)Many-to-many via an explicit join entity (OrderLine)
Two entity types that can each relate to many of the other, with nothing extra to say about the pairingMany-to-many via skip navigations (Product.Categories)
Splitting a large or sensitive optional data set off a frequently-queried entityOne-to-one, with an explicit unique foreign key
Rule of thumb: Start by asking "how many of A relate to how many of B?" — that answers the cardinality. Then ask whether the pairing itself needs to carry data — that answers whether many-to-many needs an explicit join entity or can use skip navigations.

Mental Model

Foreign key = the column that actually stores the connection in the database.
Navigation property = the C# property that lets you walk that connection without writing a join.
One-to-many = a collection on one side, a single reference on the other.
Many-to-many = two collections pointing at each other, with or without a join entity in between.
One-to-one = one-to-many, plus a unique constraint that actually enforces "only one."

Remember: navigation properties are a convenient view onto foreign keys — not a replacement for understanding what's really stored in the database.

Key Takeaway


Check Your Understanding

You've built out a full Customer/Order/OrderLine/Product graph. Let's check the reasoning behind each relationship shape stuck.

1. In the Customer/Order relationship, why does the Order class hold the CustomerId foreign key, rather than the Customer class holding an OrderId?

Show answer

Correct: B

Why B is correct: A foreign key column can only hold one value per row. The "many" side of a one-to-many relationship (Order) can meaningfully store one CustomerId, because each order really does have exactly one customer. The "one" side (Customer) cannot store a single OrderId, because it has many orders — that's why it gets a collection navigation instead.

Why A is incorrect: File order has no bearing on relational modeling — the placement follows from the cardinality of the relationship, not source code layout.

Why C is incorrect: This isn't an arbitrary convention — it reflects a real constraint of relational databases: a single column can't hold a variable-length list of related IDs.

Why D is incorrect: This is the correct, standard way to model a one-to-many relationship — not a mistake.

Reinforcement: The foreign key always lives on the "many" side of a one-to-many relationship, because only that side has a single, well-defined related row to point at.

2. Why does the Order/Product many-to-many relationship in this lesson use an explicit OrderLine join entity instead of skip navigations?

Show answer

Correct: C

Why C is correct: A pure many-to-many pairing with nothing to say about the relationship itself (like Product/Category) fits skip navigations well. But Order and Product need to say how many and at what price for each pairing — data that belongs to the relationship, not to either entity alone — which requires an explicit join entity with its own properties.

Why A is incorrect: Skip navigations are a fully supported, current EF Core feature (shown for Product/Category in this same lesson) — not deprecated.

Why B is incorrect: Skip navigations exist specifically to handle many-to-many relationships — they're just not the right tool when the pairing needs its own data.

Why D is incorrect: Assembly location has nothing to do with this choice — it's entirely about whether the relationship needs to store its own data.

Reinforcement: Choose an explicit join entity over skip navigations whenever the many-to-many pairing itself needs to carry data.

3. A developer defines Customer.Profile and CustomerProfile.Customer navigation properties with a matching CustomerId foreign key, but adds no Fluent API configuration. What is the risk?

Show answer

Correct: B

Why B is correct: One-to-one is never fully inferred by convention. Without an explicit unique index on the foreign key, the shape you get is functionally a one-to-many relationship that currently happens to have one related row — nothing at the database level stops a second CustomerProfile from being added for the same customer later.

Why A is incorrect: EF Core doesn't throw at startup for this — it will build a model, just not the strictly-enforced one-to-one shape intended, and it will compile and run without complaint.

Why C is incorrect: This is exactly the trap the lesson warns about — navigation property shape and naming alone can't distinguish an intended one-to-one from a one-to-many that hasn't yet acquired a second row.

Why D is incorrect: Whether a related Customer must already exist is a foreign key constraint concern, unrelated to whether the relationship is uniquely one-to-one.

Reinforcement: Always pair a one-to-one relationship with an explicit unique index on its foreign key — it's the one shape where convention genuinely cannot guess your intent.

4. After running context.Orders.FirstOrDefaultAsync(o => o.Id == 5) with no .Include(...) calls, what will order.Customer be?

Show answer

Correct: B

Why B is correct: EF Core does not automatically load related entities. Without .Include(o => o.Customer), the query only loads Order columns — the Customer reference navigation stays null (or a default) unless it was explicitly requested or previously loaded and tracked in the same context.

Why A is incorrect: Eager loading is opt-in via .Include(...), not automatic — that's precisely the behavior this lesson highlights as a common source of confusion.

Why C is incorrect: There's no compile-time requirement to include navigation properties — the code compiles fine; the issue only shows up at runtime as a null reference if you assume Customer is populated.

Why D is incorrect: EF Core doesn't construct partial placeholder entities from foreign key values alone — the navigation is simply left unpopulated.

Reinforcement: Always add .Include(...) for any navigation property you plan to read after the query — EF Core loads exactly what you ask for, nothing more.

You can now model and query real, connected data with one-to-many, many-to-many, and one-to-one relationships. Next up: turning these entity classes into an actual database schema with migrations.


dotnetmadeeasy.com — Learn C# and .NET, the right way.