AI WisdomArchitecture & guides β†—
HT
How Things Work

Relationships & Navigation Properties

How EF Core maps one-to-many, many-to-many, and owned types β€” and the shadow properties you didn't know you had.

How It Works

EF Core builds a model of your entity relationships from C# class structure, data annotations, and fluent API configuration. For every relationship, EF needs to know: the principal entity (the '1' side), the dependent entity (the FK side), and the delete behavior. When you omit explicit FK properties, EF silently creates shadow properties β€” FK columns with no C# counterpart. Understanding how EF discovers and configures these relationships is critical to avoiding cascade-delete disasters, silent null navigation properties, and unexpected schema diffs in migrations.

1
EF Convention-based FK Discovery

EF Core 9 scans your entity classes for navigation properties and infers foreign keys by convention: if Order has a CustomerId int property and a Customer navigation, EF links them. If you only declare the navigation (no FK property), EF creates a shadow property β€” a FK column that exists in the database but has no corresponding C# property on your entity.

2
Required vs Optional Relationships

A required relationship generates a NOT NULL FK column. EF infers required from whether the FK property is nullable (int vs int?) or whether the navigation's reference type is nullable (Customer? vs Customer). EF Core 8+ uses C# nullable reference types to drive this β€” if you have <Nullable>enable</Nullable>, non-nullable navs become required relationships automatically.

3
Shadow Properties β€” the Hidden FK

When you declare only a navigation property without an FK property, EF creates a shadow property in the model. It's named by convention (e.g., CategoryId). It exists in the DB schema but is invisible in your C# class. You can read/write it via dbContext.Entry(entity).Property<int>("CategoryId"). It's a common source of confusion when debugging why a column appears in your migration.

4
Many-to-Many: Implicit vs Explicit Join

EF Core 5+ supports implicit many-to-many (just two ICollection navs, no join class). EF generates the join table automatically with shadow FK columns. The moment you need extra columns on the relationship (e.g., TaggedAt, CreatedBy), you must introduce an explicit join entity β€” you cannot add columns to the implicit join table via fluent API.

5
Owned Types and Table Splitting

Marking a type with [Owned] or .OwnsOne() in fluent API tells EF the owned type's properties should be inlined into the owner's table as columns. No JOIN required. If you use .OwnsMany() or ToTable() on the owned type to put it in a separate table, EF requires explicit .Include() to load it β€” owned-in-separate-table does NOT load automatically, which is a sharp footgun.

6
Cascade Delete Propagation

DeleteBehavior.Cascade is EF's default for required relationships. Deleting a parent deletes all children β€” recursively. This is translated to ON DELETE CASCADE at the DB schema level (in migrations). Dangerous: if your Customer has Orders which have OrderItems, deleting a Customer can cascade-delete hundreds of rows across multiple tables if all relationships are Cascade.

Key Concepts

πŸ‘»Shadow Property

A property in the EF model that has no corresponding C# property on the entity class. Commonly created for FK columns when you omit the explicit FK property. Accessible via Entry().Property<T>("name"). Appears in migrations as a real DB column.

🧭Navigation Property

A C# property that represents a relationship to another entity β€” either a reference (Customer) or collection (ICollection<Order>). EF uses navs to determine how to generate JOINs and how to wire up FK constraints in migrations.

πŸ—‘DeleteBehavior

Controls what EF/DB does when the principal (parent) entity is deleted. Cascade = delete dependents. Restrict = throw if dependents exist. SetNull = set FK to NULL. ClientSetNull = EF sets FK to null in tracked entities, relies on DB to throw for untracked ones.

πŸ“¦[Owned]

Marks a type as an owned entity β€” it has no independent identity and belongs to exactly one owner. By default maps to the owner's table as columns. Owned types can themselves own other types. An owned type cannot be shared between owners.

πŸ”‘[ForeignKey]

Data annotation to explicitly declare which property is the FK for a navigation. Use when the convention-based name doesn't match (e.g., FK is 'BillingCustomerId' but nav is 'Customer'). Without it, EF may create a shadow property instead of linking to your existing property.

↔[InverseProperty]

Required when you have two navigation properties of the same type pointing to the same related entity. E.g., Employee has 'Manager' and 'DirectReports', both navigating to Employee. Without [InverseProperty], EF cannot determine which nav pairs with which and throws a model validation error.

EF Core 9 β€” Relationships, Shadow Properties & Cascade Delete
tsx
1// EF Core 9 β€” Relationships, Shadow Properties & Owned Types
2
3// Required relationship (non-nullable FK)
4public class Order
5{
6 public int Id { get; set; }
7 public int CustomerId { get; set; } // explicit FK property
8 public Customer Customer { get; set; } = null!; // required nav
9
10 public ICollection<OrderItem> Items { get; set; } = [];
11}
12
13// Optional relationship β€” EF generates nullable FK column
14public class Product
15{
16 public int Id { get; set; }
17 public string Name { get; set; } = "";
18
19 // No FK property declared β€” EF creates shadow property "CategoryId" (int?)
20 public Category? Category { get; set; }
21}
22
23// Many-to-many WITHOUT explicit join entity β€” EF creates "ProductTag" join table
24// with shadow FK properties ProductsId, TagsId
25public class Product
26{
27 public ICollection<Tag> Tags { get; set; } = [];
28}
29public class Tag
30{
31 public ICollection<Product> Products { get; set; } = [];
32}
33
34// Many-to-many WITH explicit join entity β€” allows payload columns
35public class ProductTag
36{
37 public int ProductId { get; set; }
38 public Product Product { get; set; } = null!;
39
40 public int TagId { get; set; }
41 public Tag Tag { get; set; } = null!;
42
43 public DateTime TaggedAt { get; set; } // payload column β€” impossible without explicit entity
44 public string TaggedBy { get; set; } = "";
45}
46
47// Owned entity β€” maps to same table by default (no separate JOIN needed)
48public class Customer
49{
50 public int Id { get; set; }
51 public string Name { get; set; } = "";
52
53 public Address ShippingAddress { get; set; } = null!; // owned type
54}
55
56[Owned]
57public class Address
58{
59 public string Street { get; set; } = "";
60 public string City { get; set; } = "";
61 public string PostCode { get; set; } = "";
62}
63
64// Fluent API β€” cascade delete control (CRITICAL: understand before using)
65protected override void OnModelCreating(ModelBuilder modelBuilder)
66{
67 modelBuilder.Entity<Order>()
68 .HasMany(o => o.Items)
69 .WithOne(i => i.Order)
70 .HasForeignKey(i => i.OrderId)
71 .OnDelete(DeleteBehavior.Cascade); // deleting Order deletes ALL Items
72
73 modelBuilder.Entity<Order>()
74 .HasOne(o => o.Customer)
75 .WithMany(c => c.Orders)
76 .HasForeignKey(o => o.CustomerId)
77 .OnDelete(DeleteBehavior.Restrict); // deleting Customer throws if Orders exist
78
79 // Accessing shadow property value
80 var shadowFk = _dbContext.Entry(product)
81 .Property<int?>("CategoryId").CurrentValue;
82}
πŸ’‘
Why This Matters

Relationship configuration determines your database schema (FK constraints, nullable columns, join tables), query behavior (how Include() works, what gets cascade-deleted), and migration output. Getting this wrong silently β€” by relying on conventions without understanding what EF is generating β€” is the source of some of the most damaging production incidents: accidental mass deletes from cascade, null navigation properties from missing Include, and data corruption from shadow FK collisions.

Common Pitfalls

⚠Cascade delete is EF's default for required relationships and it applies recursively. Deleting a root entity can cascade through 10 tables without any warning. Always audit your model's delete behaviors: modelBuilder.Model.GetEntityTypes().SelectMany(e => e.GetForeignKeys()).Select(fk => fk.DeleteBehavior).
⚠Owned types moved to a separate table via ToTable() no longer auto-load with the owner β€” they require explicit .Include(). This is a silent breaking change from inline owned types. EF does not warn you. Your navigation properties will just be null.
⚠Implicit many-to-many join tables cannot have payload columns. If you later need to add 'CreatedAt' or 'TaggedBy' to the join, you must introduce an explicit join entity and migrate the data β€” there is no upgrade path that preserves the implicit syntax.
⚠Shadow property names follow the [NavigationName]Id convention. If your database already has a column with that name for unrelated reasons, EF will map to it silently β€” potentially causing wrong JOIN behavior. Always declare FK properties explicitly if in doubt.
Real-World Use Cases

1Cascade Delete Wiped Our Entire Order History

Scenario

We were running EF Core 7 on a multi-tenant SaaS. A tenant deactivation flow called _dbContext.Remove(tenant) and SaveChanges(). Within 4 seconds, 6 years of order history, invoice line items, and audit logs were gone β€” 2.3 million rows deleted across 11 tables.

Problem

The Tenant entity was at the root of a required relationship chain: Tenant β†’ Customers β†’ Orders β†’ OrderItems β†’ InvoiceLines. Every relationship used the default DeleteBehavior.Cascade because no one had explicitly set it in OnModelCreating. EF emitted ON DELETE CASCADE constraints in the original migration and the DB executed them all in one atomic transaction.

Solution

Restored from backup (4-hour data loss). Added a global convention in OnModelCreating to set all FK delete behaviors to Restrict, then explicitly opted specific relationships into Cascade only where it made semantic sense (e.g., OrderItem when Order is deleted). Added an integration test that asserts no unexpected cascade-deletes exist in the model.

πŸ’‘

Takeaway: Never rely on EF's default DeleteBehavior.Cascade. In OnModelCreating, add: foreach (var fk in modelBuilder.Model.GetEntityTypes().SelectMany(e => e.GetForeignKeys())) { fk.DeleteBehavior = DeleteBehavior.Restrict; } then selectively re-enable Cascade only where you've thought it through.

2Owned Entity in Separate Table Broke Our API Silently

Scenario

We refactored Customer.ShippingAddress (an owned type) from inline columns to a separate table using .OwnsOne(c => c.ShippingAddress, sa => sa.ToTable("CustomerAddresses")). All tests passed. Deployed Friday. Monday, the support team reported that every customer's shipping address was showing as null in the checkout flow.

Problem

Moving the owned type to a separate table changed EF's loading behavior. Inline owned types are always loaded with their owner (no JOIN needed β€” same table). Owned types in a separate table must be explicitly included with .Include(c => c.ShippingAddress). Every LINQ query that fetched customers omitted the Include, so ShippingAddress was null. No exception β€” EF just didn't load it.

Solution

Added .Include(c => c.ShippingAddress) to all customer queries. Added a global query filter approach using HasQueryFilter to auto-include it wasn't viable, so instead we moved the Include into the repository base class and added a regression test that asserts ShippingAddress is never null after a load.

πŸ’‘

Takeaway: Moving an owned type from inline to ToTable() is a silent breaking change to loading behavior. Document this explicitly when doing the refactor. Add a test that asserts owned-type-in-separate-table properties are populated after load, not just non-null after construction.

3Shadow FK Collision Corrupted Navigation Wiring

Scenario

We had a Product entity with both a ManufacturerId (explicit FK, maps to Manufacturer nav) and a SupplierId (explicit FK, maps to Supplier nav). When we added a second optional Category nav without an explicit FK property, EF created a shadow property named 'CategoryId'. The migration looked fine. But at runtime, querying .Include(p => p.Category) returned wrong Category records.

Problem

Our Product table already had a CategoryId column from a previous migration (added manually as a raw SQL migration, not through EF). EF's shadow property used the same name 'CategoryId' and mapped to that existing column, but the column had been populated with data that mapped to an old category schema before we introduced EF relationships. EF silently joined on incorrect values.

Solution

Added an explicit int? CategoryId property to Product and decorated it with [ForeignKey(nameof(Category))]. This forced EF to use our explicit property and map it correctly. Lesson: never have columns in the DB schema that shadow EF's naming conventions unless you explicitly declare them.

πŸ’‘

Takeaway: Always declare FK properties explicitly when your database has existing columns. EF's shadow property convention creates properties named [NavigationName]Id β€” if a column by that name already exists for unrelated reasons, you get silent data corruption. Explicit is always safer than convention for FK mapping.