All articles

How to Configure Entity Relationships using Fluent API in Entity Framework Core

Configure one-to-one, one-to-many, and many-to-many relationships with the EF Core Fluent API — HasOne/WithOne, HasForeignKey, OnDelete behaviors, and EF Core 5+ skip navigations without a join entity

0 · log in to like, save & follow Share on LinkedIn Share on X

EF Core discovers most relationships from your navigation properties by convention. The Fluent API is for everything convention can't see — unconventional foreign key names, delete behavior, join table naming — and it is the only way to configure some relationship shapes. This tutorial configures one-to-one, one-to-many, and many-to-many relationships in OnModelCreating, verifies each in the generated SQL Server schema, and covers cascade delete behavior.

The entities here are hand-written for a clean example. If you already have a database, generate the model with the database-first approach instead; the general Fluent API basics are covered in using Fluent API in EF Core code first.

Entity relationships with EF Core Fluent API

The entities

public class Employee
{
    public int EmployeeId { get; set; }
    public string Name { get; set; } = string.Empty;

    public EmployeeAddress? Address { get; set; }           // one-to-one
    public List<Department> Departments { get; set; } = []; // many-to-many
}

public class EmployeeAddress
{
    public int EmployeeAddressId { get; set; }
    public string Street { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;

    public int EmpId { get; set; }                          // unconventional FK name
    public Employee Employee { get; set; } = null!;
}

public class Department
{
    public int DepartmentId { get; set; }
    public string DeptName { get; set; } = string.Empty;

    public int? ManagerId { get; set; }                     // one-to-many FK
    public Employee? Manager { get; set; }

    public List<Employee> Employees { get; set; } = [];
}

One-to-one relationship

By convention

A reference navigation on both sides makes a one-to-one: Employee.Address on the principal, EmployeeAddress.Employee plus a foreign key on the dependent. If the FK were named EmployeeId, convention would wire everything up with no configuration at all.

With the Fluent API

Our FK is named EmpId, which convention cannot match to Employee — so we state the relationship in OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Employee>()
        .HasOne(e => e.Address)
        .WithOne(a => a.Employee)
        .HasForeignKey<EmployeeAddress>(a => a.EmpId);
}

Reading the chain:

  • HasOne(e => e.Address) — an Employee has one EmployeeAddress.
  • WithOne(a => a.Employee) — and the address points back at exactly one employee.
  • HasForeignKey<EmployeeAddress>(a => a.EmpId) — the FK lives on EmployeeAddress, in the EmpId column. The generic argument is required for one-to-one because either side could hold the key.

What makes it truly one-to-one in the database is the unique index EF Core puts on the FK. After dotnet ef migrations add + database update:

sys.indexes on Addresses:
IX_Addresses_EmpId   (unique)

One employee cannot have two address rows — the index enforces it.

One-to-many relationship

By convention

A collection navigation on one side (Department.Employees) or a reference navigation on the other is enough — EF Core creates the FK and a non-unique index on it.

With the Fluent API — and delete behavior

Fluent configuration earns its keep when you want control over the FK and what happens on delete. A department has one manager; a manager can run many departments:

modelBuilder.Entity<Department>()
    .HasOne(d => d.Manager)
    .WithMany()
    .HasForeignKey(d => d.ManagerId)
    .OnDelete(DeleteBehavior.Restrict);

WithMany() with no argument means the other side has no navigation property for this relationship — perfectly valid.

OnDelete and DeleteBehavior

Cascade delete flows one way, from principal to dependent. OnDelete takes a DeleteBehavior:

  • Cascade — deleting the principal deletes the dependents. Default for required (non-nullable FK) relationships. Right when children cannot exist alone: Order and OrderDetails.
  • ClientSetNull — default for optional (nullable FK) relationships; EF Core sets tracked dependents' FK to null before saving, the database itself is not told to do anything.
  • SetNull — the database sets dependent FKs to NULL on delete: Department deleted, its employees stay but lose the department reference.
  • Restrict — the delete is refused while dependents exist. Right when losing the principal must never silently touch dependents — you delete or reassign them explicitly first.

With Restrict above, deleting an employee who manages a department fails with a foreign-key error instead of cascading — deliberately.

Many-to-many relationship

One employee works in many departments; one department has many employees.

The modern way (EF Core 5 and later)

Skip navigations on both sides are all you need — no join entity class:

public class Employee
{
    public List<Department> Departments { get; set; } = [];
}

public class Department
{
    public List<Employee> Employees { get; set; } = [];
}

EF Core creates the join table automatically. The Fluent API comes in if you want to name it:

modelBuilder.Entity<Employee>()
    .HasMany(e => e.Departments)
    .WithMany(d => d.Employees)
    .UsingEntity("DeptEmployees");

The migration produces:

TABLE_NAME       COLUMNS
DeptEmployees    DepartmentsDepartmentId, EmployeesEmployeeId

with a composite primary key over both columns and cascading FKs to each side. Querying is natural: context.Employees.Include(e => e.Departments) — no join entity in sight.

The explicit join entity (when you need payload)

The pre-EF-Core-5 pattern — a DeptEmployee class with two one-to-many relationships and HasKey(de => new { de.EmployeeId, de.DepartmentId }) — is still the right choice when the relationship itself carries data (assignment date, role in the department). You can even combine both: keep the skip navigations for easy querying and point UsingEntity<DeptEmployee>(...) at your payload class.

Wrap-up

  • Conventions handle relationships whose navigations and FK names follow the patterns; the Fluent API takes over for everything else.
  • One-to-one needs HasForeignKey<TDependent> and is enforced by a unique index.
  • Choose OnDelete behavior deliberately — the default differs between required and optional relationships.
  • Many-to-many needs no join class since EF Core 5 unless the relationship carries payload data.
Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.