100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
C#

Many-to-Many Relationships

Learn how EF Core models many-to-many relationships with skip navigations, implicit join tables, and explicit join entities that carry payload data.

ModelingIntermediate9 min readJul 10, 2026
Analogies

Modeling Many-to-Many Relationships

A many-to-many relationship - many Students enrolled in many Courses - is modeled with a collection navigation property on each side (ICollection<Course> Courses on Student, ICollection<Student> Students on Course) and nothing else. Since EF Core 5.0, this pattern of two skip navigations is enough for EF Core to automatically create and manage a hidden join table behind the scenes, without you needing to define a join entity class.

🏏

Cricket analogy: Many-to-many is like players belonging to multiple IPL franchises across different seasons while each franchise fields multiple players - a Student-Courses relationship works the same way, with EF Core managing the many-to-many link like a transfer ledger behind the scenes.

Implicit Join Table vs Explicit Join Entity

By default, EF Core creates an implicit join entity type (e.g., CourseStudent) with just two FK columns, invisible in your C# code but visible as a table in migrations. When you need more than just the two foreign keys - say, an EnrollmentDate or Grade on the join itself - you introduce an explicit join entity class (Enrollment) and model the relationship as two one-to-many relationships instead, giving you full control over the extra columns.

🏏

Cricket analogy: An implicit join table is like an informal tally of which players faced which bowlers, invisible in the scorebook's main pages; an explicit join entity is like a dedicated ball-by-ball log that also records the outcome of each specific delivery, similar to adding a Grade column.

Adding Payload Data to the Join Entity

To add payload data via an explicit join entity, you configure it with modelBuilder.Entity<Student>().HasMany(s => s.Courses).WithMany(c => c.Students).UsingEntity<Enrollment>(j => j.HasOne(e => e.Course).WithMany(), j => j.HasOne(e => e.Student).WithMany()). Once configured this way, Enrollment becomes a first-class entity with its own DbSet<Enrollment>, so you can query enrollment records directly, filter by Grade, or add validation logic that a hidden implicit join table could never support.

🏏

Cricket analogy: Configuring UsingEntity<Enrollment> is like a cricket board formally registering a player-contract document (not just a name on a squad list) so admins can query contract details like match fee directly, rather than only seeing 'player X plays for team Y'.

csharp
public class Student
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public ICollection<Course> Courses { get; set; } = new List<Course>();
}

public class Course
{
    public int Id { get; set; }
    public string Title { get; set; } = null!;
    public ICollection<Student> Students { get; set; } = new List<Student>();
}

// Explicit join entity with payload data
public class Enrollment
{
    public int StudentId { get; set; }
    public Student Student { get; set; } = null!;
    public int CourseId { get; set; }
    public Course Course { get; set; } = null!;
    public DateTime EnrollmentDate { get; set; }
    public string? Grade { get; set; }
}

modelBuilder.Entity<Student>()
    .HasMany(s => s.Courses)
    .WithMany(c => c.Students)
    .UsingEntity<Enrollment>(
        j => j.HasOne(e => e.Course).WithMany(),
        j => j.HasOne(e => e.Student).WithMany());

Since EF Core 5.0, plain skip navigations (ICollection on both sides, no explicit join class) are enough to get a working many-to-many relationship - you only need to introduce an explicit join entity like Enrollment once you need extra columns on the relationship itself.

Skip Navigations Under the Hood

Both the implicit and explicit forms rely on EF Core's 'skip navigation' concept - a navigation property that skips over the join entity to expose the related entity directly (student.Courses instead of student.Enrollments.Select(e => e.Course)). You can still query through the join entity explicitly when you need the payload data, or use the skip navigation when you just need the related entities themselves, and EF Core keeps both views consistent automatically.

🏏

Cricket analogy: A skip navigation is like a fan checking a player's 'teams played for' list directly instead of manually cross-referencing every individual contract record - EF Core exposes student.Courses the same convenient way, skipping past the Enrollment join entity.

Once you introduce an explicit join entity, you must also add its composite key configuration (HasKey(e => new { e.StudentId, e.CourseId })) if you want to prevent duplicate enrollments - EF Core won't infer a composite key for a join entity automatically the way it does for the implicit form.

  • Many-to-many is modeled with a collection navigation (skip navigation) on both participating entities.
  • EF Core 5+ auto-creates a hidden implicit join table when no explicit join entity is declared.
  • Use an explicit join entity when you need payload data like EnrollmentDate or Grade.
  • UsingEntity<TJoinEntity>() configures the explicit join entity's two one-to-many relationships.
  • An explicit join entity gets its own DbSet, so it can be queried directly.
  • A composite key on the join entity must be configured explicitly to prevent duplicates.

Practice what you learned

Was this page helpful?

Topics covered

#EntityFrameworkCoreStudyNotes#MicrosoftTechnologies#ManyToManyRelationships#Many#Relationships#Modeling#Implicit#StudyNotes#SkillVeris#ExamPrep