Skip to main content
ANVISoftware Solutions
Lesson 6 of 17Intermediate22 min

Relationships

By the end of this lesson

Model one-to-many, many-to-many and one-to-one relationships correctly.

A relational database connects rows with foreign keys: a column in one table holding the key of a row in another. C# connects objects with references. A relationship in EF Core is the mapping between those two ideas.

Three terms are used constantly from here on. The principal is the entity that exists independently — a Department. The dependent is the entity that holds the foreign key and cannot stand alone — an Employee. A navigation property is a reference or collection that lets you move from one to the other in code.

Three shapes cover almost everything: one-to-many, many-to-many and one-to-one. One-to-many is by far the most common, and the other two are easier once you have seen it.

One-to-many — a department has many employees
C#
public class Department
{
    public int Id { get; set; }
    public string Name { get; set; } = "";

    // Collection navigation: the many side
    public List<Employee> Employees { get; set; } = new();
}

public class Employee
{
    public int Id { get; set; }
    public string Name { get; set; } = "";

    // Foreign key, and the reference navigation it backs
    public int DepartmentId { get; set; }
    public Department Department { get; set; } = null!;
}

// Configuration — EF Core would infer this, but stating it is clearer
builder.HasOne(e => e.Department)
    .WithMany(d => d.Employees)
    .HasForeignKey(e => e.DepartmentId)
    .OnDelete(DeleteBehavior.Restrict);
  • Employee holds DepartmentId, so Employee is the dependent. Only one table gains a column, which is what makes this shape cheap.
  • Exposing the foreign key as a property is worth doing. You can set an employee's department with an integer you already have, with no need to load the Department first — one fewer round trip.
  • The two navigations are optional in principle: EF Core needs only one side to understand the relationship. Include the side you will actually navigate, and leave out a collection you never read, because every navigation is another route by which code can accidentally load data.
  • null! on Department tells the compiler the reference will be set, without claiming it is set at construction. EF Core assigns it when the relationship is loaded; until then it is genuinely null, which the exclamation mark hides. It is a compiler concession, not a guarantee.
  • OnDelete is spelled out here to make the delete rule a decision rather than a default. The section below explains the options.
Many-to-many — an order contains many products, and a product appears on many orders
C#
public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public DateTime PlacedOn { get; set; }

    public List<OrderItem> Items { get; set; } = new();
}

public class OrderItem
{
    public int Id { get; set; }

    public int OrderId { get; set; }
    public Order Order { get; set; } = null!;

    public int ProductId { get; set; }
    public Product Product { get; set; } = null!;

    // The reason this join is an entity of its own
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}
  • This is a many-to-many relationship expressed as two one-to-many relationships meeting at OrderItem. Order has many items; Product has many items; each item points at one of each.
  • OrderItem carries Quantity and UnitPrice, so it has to be a class you can see and query. Data that belongs to the pairing rather than to either side is what makes a join entity necessary.
  • UnitPrice is deliberately duplicated from Product. An order must record the price charged on the day. If you read the price through Product at display time, last year's invoices change whenever someone updates a price.
  • When a join genuinely carries nothing extra, EF Core can create and manage the join table for you: give each side a collection navigation to the other and configure it with HasMany and WithMany. You gain simpler code and lose the ability to query the join directly or add a column to it later.
  • Start with the explicit join entity when there is any chance the pairing will need its own data. Adding a payload to an EF-managed join table later means introducing the entity anyway, plus a migration.
One-to-one — an employee has at most one contract record
C#
public class EmployeeContract
{
    // The primary key is also the foreign key
    public int EmployeeId { get; set; }
    public Employee Employee { get; set; } = null!;

    public DateOnly StartDate { get; set; }
    public int NoticePeriodDays { get; set; }
    public decimal AnnualSalary { get; set; }
}

// On Employee
public EmployeeContract? Contract { get; set; }

// In EmployeeContractConfiguration.Configure
builder.HasKey(c => c.EmployeeId);

builder.HasOne(c => c.Employee)
    .WithOne(e => e.Contract)
    .HasForeignKey<EmployeeContract>(c => c.EmployeeId)
    .OnDelete(DeleteBehavior.Cascade);
  • One-to-one is the shape EF Core cannot infer on its own. Both sides have a single reference, so nothing in the classes says which one holds the foreign key — HasForeignKey with an explicit type argument tells it.
  • Using EmployeeId as both primary key and foreign key is what makes the relationship one-to-one at the database level. A unique key cannot repeat, so no employee can have two contracts.
  • The nullable Contract on Employee makes the relationship optional: an employee may exist without a contract record. Remove the question mark and EF Core still cannot require the row, because there is nothing to enforce it on the Employee side. Required one-to-one is genuinely awkward, which is one reason this shape is less common than it first appears.
  • Cascade is the right delete behaviour here, unlike the earlier example. A contract has no meaning without its employee, so it should not outlive one.

Required or optional, and what happens on delete:

Required relationship
The foreign key is not nullable: int DepartmentId. Every employee must belong to a department, and the database rejects a row that does not. Choose this when the dependent is meaningless without the principal.
Optional relationship
The foreign key is nullable: int? DepartmentId. An employee may sit outside any department. Choose this when absence is a real state rather than missing data, and be ready to handle the null everywhere you navigate.
DeleteBehavior.Cascade
Deleting the principal deletes its dependents. Correct for data that is part of the principal, such as the order items of an order. It is the default for required relationships, which is the part that catches people out.
DeleteBehavior.Restrict
Deleting the principal fails while dependents exist. The caller has to deal with them first. Verbose, and the safest option for records that have independent value, such as employees in a department.
DeleteBehavior.SetNull
Deleting the principal sets the dependents' foreign key to null. Only available on an optional relationship, because the column has to accept a null. Useful when a dependent should survive without its parent.

Summary

  • The dependent holds the foreign key; navigation properties are how you move between the two in code
  • One-to-many needs one foreign key on the dependent, and exposing it saves a round trip
  • Many-to-many needs an explicit join entity as soon as the pairing carries its own data
  • One-to-one requires you to say which side holds the foreign key, usually as its primary key too
  • A nullable foreign key makes a relationship optional; set OnDelete explicitly, because cascades chain and deleted rows do not come back

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Model orders for a customer

Add a Customer entity and relate it to Order so that one customer has many orders, an order cannot exist without a customer, and deleting a customer with orders is rejected rather than cascading.

Write both the entity properties and the Fluent API configuration.

Show solution

Order holds a non-nullable CustomerId, which makes the relationship required. Orders cannot float free of a customer, which matches how an order actually works.

Restrict is the interesting part. Because the relationship is required, EF Core would have chosen Cascade, and deleting one customer would take their entire order history with it. For records you may need for accounting, reporting or a legal retention period, that is the wrong outcome.

With Restrict, an attempt to delete a customer who has orders fails, and the application has to decide: refuse, archive, or mark the customer inactive. Marking inactive is usually the real requirement, and the constraint is what forced the question into the open.

C#
public class Customer
{
    public int Id { get; set; }
    public string CompanyName { get; set; } = "";
    public List<Order> Orders { get; set; } = new();
}

// In OrderConfiguration.Configure
builder.HasOne(o => o.Customer)
    .WithMany(c => c.Orders)
    .HasForeignKey(o => o.CustomerId)
    .OnDelete(DeleteBehavior.Restrict);

Think about it

Trace the cascade

Customer cascades to Order. Order cascades to OrderItem. A customer has 40 orders, averaging 6 items each.

How many rows does deleting that one customer remove, and which of those deletions would you actually want?

Show solution

Around 281 rows: the customer, 40 orders and roughly 240 order items. One delete statement, three tables, no confirmation step.

The cascade from Order to OrderItem is defensible. An order item has no meaning without its order — it is part of the order rather than a record in its own right.

The cascade from Customer to Order is almost certainly wrong. Orders are financial records. You may be required to keep them, your revenue reports depend on them, and nobody deleting a duplicate customer account expects to lose forty invoices.

The useful general test: does the dependent exist only as part of the principal, or does it have value of its own? Part of the principal can cascade. Value of its own should restrict, and the deletion of the principal probably needs to become an archive or a status change instead.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

When does a many-to-many relationship need an explicit join entity in your model?
A required one-to-many relationship is configured with no explicit OnDelete call. What happens when the principal is deleted?

Saved in this browser only.