Lesson 4 Guide — Nested & Related Data¶
Goal: by the end of this lesson your MenuItemsController returns each
menu item with its full Category object embedded, not just a bare
categoryId number. You'll learn navigation properties, Include(), why you
must re-fetch related data after a write, and how to stop EF Core from
serializing objects in an infinite loop.
The general pattern you're learning: a foreign-key column (CategoryId) tells
you which related row exists; a navigation property (Category) lets you
pull that whole related row into the response. Include() is the switch that
turns the navigation property on for a given query.
1. The problem with a bare foreign key¶
In the previous lesson your GET api/menuitems returned this:
The front end knows the category is 1 — but not that 1 means "Appetizers." It
would have to make a second request to api/categories/1 just to show the name.
That's wasteful. What we want is the category name to ride along:
{
"id": 1,
"name": "Mozzarella Sticks",
"price": 9.99,
"categoryId": 1,
"category": { "id": 1, "name": "Appetizers", "sortOrder": 1 }
}
2. Navigation properties¶
A navigation property is a property whose type is another entity (or a
collection of them). Add one to MenuItem — a Category property beside the
existing CategoryId:
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TableServe.Api.Models;
public class MenuItem
{
public int Id { get; set; }
[StringLength(30)]
public string Name { get; set; } = string.Empty;
[Column(TypeName = "decimal(11,2)")]
public decimal Price { get; set; }
public int CategoryId { get; set; }
public Category? Category { get; set; } // ← navigation property
}
Things to notice:
CategoryIdandCategorywork as a pair.CategoryIdis the actual FK column in the database;Categoryis the C# object EF Core can populate from it.- The navigation property is nullable (
Category?). It's only populated when you explicitly ask for it — otherwise it's null. Novirtualkeyword: this course does not use lazy loading, so a navigation property is never filled in automatically. - No new migration needed for this — adding a navigation property that maps to
an FK column you already have doesn't change the schema. (If your
CategoryIdcolumn already exists from the previous lesson, you're set.)
3. Include() — pulling in related data¶
By default EF Core does not load navigation properties — you'd get null
back. Include() tells EF Core to JOIN the related table and populate the
property. Update GetAll and GetById:
// GET: api/MenuItems
[HttpGet]
public async Task<ActionResult<IEnumerable<MenuItem>>> GetAll()
{
return await _db.MenuItems
.Include(menuItem => menuItem.Category)
.ToListAsync();
}
// GET: api/MenuItems/5
[HttpGet("{id}")]
public async Task<ActionResult<MenuItem>> GetById(int id)
{
var menuItem = await _db.MenuItems
.Include(menuItem => menuItem.Category)
.SingleOrDefaultAsync(menuItem => menuItem.Id == id);
if (menuItem == null) return NotFound();
return menuItem;
}
Things to notice:
.Include(menuItem => menuItem.Category)— the lambda names the navigation property to load. That's what makes thecategoryobject appear in the JSON.SingleOrDefaultAsync(...)replacesFindAsyncon GetById.FindAsynccan't be combined withInclude, so once you need related data, switch toSingleOrDefaultAsyncwith a predicate. It returns the matching entity ornull.- Navigation properties are always explicitly
Included — never assumed. If you forget theInclude, theCategorycomes back null. This is a very common "why is it null?" bug.
4. Re-fetching after a write¶
Here's the subtle part. When you Create a menu item, the incoming object only
has a categoryId — EF Core hasn't loaded the Category object. If you return it
directly, the category field is null even though the FK is set. The fix: after
saving, re-fetch the row with Include before returning it.
// POST: api/MenuItems
[HttpPost]
public async Task<ActionResult<MenuItem>> Create(MenuItem newMenuItem)
{
_db.MenuItems.Add(newMenuItem);
await _db.SaveChangesAsync();
var menuItemWithCategory = await _db.MenuItems
.Include(menuItem => menuItem.Category)
.SingleOrDefaultAsync(menuItem => menuItem.Id == newMenuItem.Id);
return CreatedAtAction(nameof(GetById), new { id = newMenuItem.Id }, menuItemWithCategory);
}
// PUT: api/MenuItems/5
[HttpPut("{id}")]
public async Task<ActionResult<MenuItem>> Update(int id, MenuItem updatedMenuItem)
{
if (id != updatedMenuItem.Id) return BadRequest();
var currentMenuItem = await _db.MenuItems.FindAsync(id);
if (currentMenuItem == null) return NotFound();
_db.Entry(currentMenuItem).CurrentValues.SetValues(updatedMenuItem);
await _db.SaveChangesAsync();
var menuItemWithCategory = await _db.MenuItems
.Include(menuItem => menuItem.Category)
.SingleOrDefaultAsync(menuItem => menuItem.Id == id);
return Ok(menuItemWithCategory);
}
Things to notice:
- The re-fetch variable is named
menuItemWithCategory— a{entity}With{nav}name that makes it obvious why this second query exists. - The
Updatestill usesFindAsyncfor the fetch-then-set step (no Include needed to copy values), then re-fetches with Include only for the response. Deletedoesn't change from the previous lesson — there's nothing to return, so no Include.
5. Avoiding circular JSON references¶
When two entities point at each other, serializing one can loop forever:
Order → OrderItems → Order → OrderItems → …. This throws a runtime error the
moment you GET an order with its items.
The fix used in this course is [JsonIgnore] on the child's back-reference to
the parent. On OrderItem, the Order navigation property is marked so it's
never serialized downward:
using System.Text.Json.Serialization;
public class OrderItem
{
public int Id { get; set; }
public int Quantity { get; set; } = 1;
public string? Notes { get; set; }
public int OrderId { get; set; }
[JsonIgnore]
public Order? Order { get; set; } // ← never serialize parent back down
public int MenuItemId { get; set; }
public MenuItem? MenuItem { get; set; }
}
The rule of thumb: serialize parent → child, never child → parent. An Order
includes its OrderItems; each OrderItem includes its MenuItem; but an
OrderItem does not re-serialize its Order. You'll rely on this in this
lesson's lab when you build OrdersController.
6. Nested Include with ThenInclude¶
A single Include goes one level deep. To go deeper — an order, its items, and
each item's menu item — chain ThenInclude:
var order = await _db.Orders
.Include(order => order.Staff)
.Include(order => order.OrderItems)
.ThenInclude(orderItem => orderItem.MenuItem)
.SingleOrDefaultAsync(order => order.Id == id);
Read it as: include the Staff; include the OrderItems collection; and for each
of those items, then include its MenuItem. This is the shape you'll build in
this lesson's lab.
7. Route parameters vs query parameters¶
So far every value in a URL has been a route parameter — the {id} in
[HttpGet("{id}")] that picks one specific row. There's a second way to pass data to
an endpoint: the query string, the ?key=value part after the path. In this
lesson's lab you'll give OrdersController.GetAll an optional ?status= filter with
[FromQuery], so it's worth seeing where each kind of value sits and how they differ:
Protocol & Domain Route (Path) Parameters Query Parameters
┌───────────────────┐ ┌───────────────────────────┐ ┌─────────────────┐
│ │ │ │ │ │
https://example.com / api / users / 123 / collections / 5 ? sort=desc & page=2
▲ ▲ ▲ ▲
│ │ │ │
Specific User ID │ Key=Value │
Separator Separator
(Starts Query) (Adds More)
Route (path) parameters — bound with [FromRoute] (the default for a value that
appears in the route template, like {id}). They identify which specific resource
you want and are required — the route doesn't even match if the segment is
missing. In GET /api/menuitems/5, the 5 is required.
Query parameters — bound with [FromQuery] (everything after the ?). They are
optional and usually multi-variate: several key=value pairs joined with &
(?status=PLACED&sort=desc&page=2), used for filtering, sorting, and paging. You
declare one as an optional method parameter with a default —
GetAll([FromQuery] string? status = null) — so it's null when absent and you only
filter when a value is supplied. GET /api/orders returns everything;
GET /api/orders?status=PLACED narrows it.
In a controller the two are bound with different attributes:
// Route parameter — GET /api/menuitems/5
[HttpGet("{id}")] // {id} is a token in the route template
public async Task<ActionResult<MenuItem>> GetById([FromRoute] int id)
{
// id comes from the /5 segment — required
}
// Query parameter — GET /api/orders?status=PLACED
[HttpGet] // no token in the route
public async Task<ActionResult<IEnumerable<Order>>> GetAll([FromQuery] string? status = null)
{
// status comes from ?status= — optional, null when omitted
}
Because of [ApiController], both attributes are actually optional: a parameter
whose name matches a route token binds from the route, and a plain simple-type
parameter that isn't in the route binds from the query string. That's why
GetById(int id) back in section 3 works with no attribute at all — writing
[FromRoute] / [FromQuery] just makes the binding source explicit.
Rule of thumb: route params say which resource (required, one at a time); query params say how to shape the response (optional, often several at once).
8. Verifying with Insomnia¶
With the project running (no login required — the endpoints are open), expand the MenuItems folder:
- Get All Menu Items → Send → expect 200 OK; each item now has a nested
categoryobject, not just acategoryId. - Get Menu Item by Id → Send → confirm the
categoryobject is present. - Create Menu Item → Send → expect 201 Created; the response body
includes the full
category(thanks to the re-fetch). - Update Menu Item → Send → expect 200 OK with the
categorypopulated.
Check the Tests tab on each — green means the status code and structure are correct.
Troubleshooting¶
categoryisnullin the response — you forgot the.Include()on that query, or you're still usingFindAsync(which ignores Include).JsonException/ "possible object cycle detected" — a circular reference. Make sure the child's back-reference navigation property has[JsonIgnore].
The General Pattern (what to take away)¶
Returning related data is always the same four moves:
- Navigation property on the model (
Category,Staff,OrderItems). Include()(andThenInclude()for deeper levels) on every read query that should carry the related data.- Re-fetch with
Includeafter Create/Update so the write response matches the read shape. [JsonIgnore]on the child→parent back-reference to prevent cycles.
On PRS in the capstone, ProductsController includes its Vendor exactly the way
MenuItemsController includes its Category, and RequestsController nests
RequestLines the way OrdersController nests OrderItems.
Build Steps¶
- Add the
Categorynavigation property toModels/MenuItem.cs(besideCategoryId) - Add
.Include(menuItem => menuItem.Category)toGetAllandGetById, and switchGetByIdfromFindAsynctoSingleOrDefaultAsync - In
CreateandUpdate, re-fetch the saved item withIncludeinto amenuItemWithCategoryvariable and return that - Add
[JsonIgnore](fromSystem.Text.Json.Serialization) to theOrdernavigation property onModels/OrderItem.cs— you'll need it for the lab - Run the project and verify in the MenuItems folder that each item now
carries a nested
categoryobject — see section 8