Chapter 05 · Relationships, Navigations, Cascade Behavior, and Many-to-Many Modeling

Many-to-Many Relationships: Skip Navigations, Join Entities, Payload Columns, and Ordering

Model many-to-many associations with skip navigations or explicit join entities, then promote the join when payload, audit, ordering, or lifecycle makes the association domain data.

Intermediate100–125 minutesmany-to-many join + payload labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

Learning outcomes

Dispatchers want flexible labels such as safety, electrical, and customer-waiting. A work order can have many tags, and each tag can classify many work orders. No single FK column can represent that cardinality; the relational model needs a join row containing both foreign keys. EF Core can hide that join CLR type with skip navigations, or make it explicit when the association carries payload and lifecycle.

01

Explain why every many-to-many relationship still has a join entity/table at the relational level.

02

Configure a simple skip-navigation many-to-many with an intentional join-table name/key.

03

Promote the association to an explicit WorkOrderTag entity when payload columns matter.

04

Add payload such as AppliedUtc, AppliedBy, and DisplayOrder without losing skip-navigation convenience.

05

Recognize when shared-type join entities are appropriate and avoid depending on undocumented join CLR implementation details.

06

Inspect join ChangeTracker entries, generated DDL, and association inserts/deletes.

1. Many-to-many is two one-to-many relationships through a join row

The join table has one FK to work_orders and one FK to tags. A composite key on those two values normally prevents duplicate associations. EF's skip navigations let application code write workOrder.Tags.Add(tag), but EF still tracks and persists a join entity internally.

csharp · pure tag navigations
public sealed partial class WorkOrder{    public ICollection<Tag> Tags { get; } = new List<Tag>();}public sealed class Tag{    public int Id { get; private set; }    public string Name { get; private set; } = string.Empty;    public ICollection<WorkOrder> WorkOrders { get; } = new List<WorkOrder>();}

2. Configure a named skip-navigation join table

csharp · simple many-to-many mapping
modelBuilder.Entity<WorkOrder>()    .HasMany(x => x.Tags)    .WithMany(x => x.WorkOrders)    .UsingEntity(        "work_order_tags",        right => right.HasOne(typeof(Tag))            .WithMany()            .HasForeignKey("tag_id"),        left => left.HasOne(typeof(WorkOrder))            .WithMany()            .HasForeignKey("work_order_id"),        join =>        {            join.HasKey("work_order_id", "tag_id");        });

String-based shared join configuration is compact, but it is less discoverable and more fragile if the association grows. Also, EF currently uses a dictionary-backed internal representation for convention-based shared joins; Microsoft explicitly warns not to depend on that implementation unless you configured it yourself.

sql · conceptual SQLite join DDL
CREATE TABLE work_order_tags (    work_order_id INTEGER NOT NULL,    tag_id INTEGER NOT NULL,    PRIMARY KEY (work_order_id, tag_id),    FOREIGN KEY (work_order_id) REFERENCES work_orders(work_order_id) ON DELETE CASCADE,    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE);

Exact constraint names and quoting are provider/version details. Review the generated migration instead of copying conceptual DDL into production.

3. Make the association an entity when it has payload

ServiceHub must know who applied a tag, when it was applied, and the order in which badges should appear. Those values belong to the relationship itself, not to WorkOrder or Tag. That is the signal to introduce an explicit join entity.

csharp · explicit WorkOrderTag association
public sealed class WorkOrderTag{    public int WorkOrderId { get; private set; }    public WorkOrder WorkOrder { get; private set; } = null!;    public int TagId { get; private set; }    public Tag Tag { get; private set; } = null!;    public DateTime AppliedUtc { get; private set; }    public string AppliedBy { get; private set; } = string.Empty;    public int DisplayOrder { get; private set; }}

The composite key expresses one association per work-order/tag pair. If the domain needs tag history (same tag applied, removed, then reapplied), this key would be too restrictive; an association ID or temporal history entity would be more appropriate. Keys should reflect lifecycle.

4. Keep skip navigations and join navigations together

csharp · explicit join plus convenient skip navigation
public sealed partial class WorkOrder{    public ICollection<Tag> Tags { get; } = new List<Tag>();    public ICollection<WorkOrderTag> WorkOrderTags { get; } = new List<WorkOrderTag>();}public sealed partial class Tag{    public ICollection<WorkOrder> WorkOrders { get; } = new List<WorkOrder>();    public ICollection<WorkOrderTag> WorkOrderTags { get; } = new List<WorkOrderTag>();}modelBuilder.Entity<WorkOrder>()    .HasMany(x => x.Tags)    .WithMany(x => x.WorkOrders)    .UsingEntity<WorkOrderTag>(        right => right.HasOne(x => x.Tag)            .WithMany(x => x.WorkOrderTags)            .HasForeignKey(x => x.TagId),        left => left.HasOne(x => x.WorkOrder)            .WithMany(x => x.WorkOrderTags)            .HasForeignKey(x => x.WorkOrderId),        join =>        {            join.ToTable("work_order_tags");            join.HasKey(x => new { x.WorkOrderId, x.TagId });            join.Property(x => x.AppliedUtc).HasColumnName("applied_utc");            join.Property(x => x.AppliedBy).HasColumnName("applied_by").HasMaxLength(80);            join.Property(x => x.DisplayOrder).HasColumnName("display_order");        });

This gives two views of the same association: skip navigations for “which tags?” and explicit join entities for payload. Do not independently manipulate both representations without understanding fixup; EF keeps them synchronized when tracked.

5. Ordering belongs to the association when the order is per work order

A global Tag.SortOrder would impose one order everywhere. ServiceHub needs different badge ordering for each work order, so DisplayOrder belongs on WorkOrderTag. Query the join entity when you need payload-aware ordering:

csharp · payload-aware ordered query
var badges = await db.Set<WorkOrderTag>()    .Where(x => x.WorkOrderId == workOrderId)    .OrderBy(x => x.DisplayOrder)    .Select(x => new    {        x.Tag.Name,        x.AppliedUtc,        x.AppliedBy,        x.DisplayOrder    })    .ToListAsync(ct);

6. Adding through a skip navigation creates join state

csharp · observe join creation
var order = await db.WorkOrders    .Include(x => x.Tags)    .SingleAsync(x => x.WorkOrderNumber == "WO-2026-000201", ct);var tag = await db.Tags.SingleAsync(x => x.Name == "safety", ct);order.Tags.Add(tag);Console.WriteLine(db.ChangeTracker.DebugView.LongView);await db.SaveChangesAsync(ct);

With a pure skip-navigation mapping, EF creates the join instance internally. With an explicit payload entity, adding only the skip navigation may not provide required payload values. If AppliedBy is required, construct/configure the join entity explicitly or provide defaults through a clearly documented mechanism.

7. Deliberately wrong design: required payload with no creation policy

csharp · broken payload contract
join.Property(x => x.AppliedBy)    .IsRequired();// Later:order.Tags.Add(tag); // Who supplies AppliedBy?

The object operation expresses only association membership. It does not answer who applied it. If the join payload is required business data, expose a domain operation that creates WorkOrderTag explicitly with the actor/time/order.

csharp · safer domain operation
public void ApplyTag(Tag tag, string actor, DateTime utcNow, int displayOrder){    WorkOrderTags.Add(new WorkOrderTag(        Id,        tag.Id,        utcNow,        actor,        displayOrder));}

For generated keys, creating a join with raw numeric IDs before both principals are persisted may need navigation-based construction instead. The exact constructor design should respect entity lifecycle; the point is to make payload ownership explicit.

8. Shared-type join entities are specialized, not magic

A shared-type entity can be useful for infrastructure joins without a dedicated CLR class, but string property names reduce refactor safety. Do not assume EF's implicit join CLR type will always be Dictionary<string, object>; configure it explicitly if code depends on that type.

csharp · explicit shared-type join sketch
modelBuilder.SharedTypeEntity<Dictionary<string, object>>(    "WorkOrderSkill",    b =>    {        b.ToTable("work_order_skills");        b.IndexerProperty<int>("WorkOrderId");        b.IndexerProperty<int>("SkillId");        b.HasKey("WorkOrderId", "SkillId");    });

9. Hands-on lab: promote a pure association into payload data

  1. Add Tag and start with a pure many-to-many skip-navigation mapping.
  2. Generate/review the migration; verify the composite join key and both FKs.
  3. Seed deterministic tags.
  4. Add and remove one tag through WorkOrder.Tags; inspect DebugView and generated insert/delete SQL.
  5. Reset the lab database.
  6. Promote the join to WorkOrderTag with AppliedUtc, AppliedBy, and DisplayOrder.
  7. Generate a new migration and review how the join table changes.
  8. Create one payload-aware association explicitly and query it ordered by DisplayOrder.
  9. Attempt the broken required-payload/skip-only add and document the resulting validation/store failure if your mapping makes the payload required.
  10. Inspect SQLite schema and foreign keys for work_order_tags.

Verification checklist

  • The join row is visible in both EF tracker state and the database.
  • The composite key prevents duplicate active associations.
  • Payload fields live on the association, not duplicated on principals.
  • Required payload has an explicit creation policy.
  • No code depends on an undocumented implicit join CLR type.

Check your understanding

  1. Why is a join entity required relationally for many-to-many?
  2. What does a skip navigation skip?
  3. When should WorkOrderTag become an explicit CLR entity?
  4. Why might DisplayOrder belong on the join?
  5. What prevents duplicate work-order/tag pairs in the simple design?
  6. Why should code not depend on the implicit join being Dictionary?
Review the answers

A single FK cannot represent many values on both sides; a join row stores the pair of FKs.

It skips exposing the join entity in normal navigation code; the relational join still exists.

When the association has payload, identity/lifecycle, additional relationships, audit data, ordering, or behavior.

Because ordering is per association/work order, not globally per tag.

A composite primary/unique key on WorkOrderId and TagId.

Microsoft documents that the implementation may change; only depend on a CLR type you explicitly configure.

10. Production judgment and bridge

Use skip navigations for truly payload-free associations. The moment the join carries business meaning, promote it to a named entity and review its key, lifecycle, cascade rules, indexes, and concurrency requirements like any other table. Do not hide audit or authorization-relevant association state behind convenience APIs.

Lesson 4 now examines what happens when principals, dependents, or relationships are removed: database cascades, client cascades, nulling, restrict/no-action behavior, conceptual nulls, and orphan deletion.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n