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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
1// EF Core 9 β Relationships, Shadow Properties & Owned Types23// Required relationship (non-nullable FK)4public class Order5{6 public int Id { get; set; }7 public int CustomerId { get; set; } // explicit FK property8 public Customer Customer { get; set; } = null!; // required nav910 public ICollection<OrderItem> Items { get; set; } = [];11}1213// Optional relationship β EF generates nullable FK column14public class Product15{16 public int Id { get; set; }17 public string Name { get; set; } = "";1819 // No FK property declared β EF creates shadow property "CategoryId" (int?)20 public Category? Category { get; set; }21}2223// Many-to-many WITHOUT explicit join entity β EF creates "ProductTag" join table24// with shadow FK properties ProductsId, TagsId25public class Product26{27 public ICollection<Tag> Tags { get; set; } = [];28}29public class Tag30{31 public ICollection<Product> Products { get; set; } = [];32}3334// Many-to-many WITH explicit join entity β allows payload columns35public class ProductTag36{37 public int ProductId { get; set; }38 public Product Product { get; set; } = null!;3940 public int TagId { get; set; }41 public Tag Tag { get; set; } = null!;4243 public DateTime TaggedAt { get; set; } // payload column β impossible without explicit entity44 public string TaggedBy { get; set; } = "";45}4647// Owned entity β maps to same table by default (no separate JOIN needed)48public class Customer49{50 public int Id { get; set; }51 public string Name { get; set; } = "";5253 public Address ShippingAddress { get; set; } = null!; // owned type54}5556[Owned]57public class Address58{59 public string Street { get; set; } = "";60 public string City { get; set; } = "";61 public string PostCode { get; set; } = "";62}6364// 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 Items7273 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 exist7879 // Accessing shadow property value80 var shadowFk = _dbContext.Entry(product)81 .Property<int?>("CategoryId").CurrentValue;82}
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
1Cascade Delete Wiped Our Entire Order History
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.
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.
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
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.
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.
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
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.
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.
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.