Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Associations and Self-References

"A many-to-many relationship without payload is rare in real systems. The moment you add 'display order' or 'is featured', the join table becomes an entity."


The M:N Evolution in EF Core

Many-to-many relationships have evolved significantly across EF Core versions:

  • EF Core 3.x: No implicit M:N support. You had to create an explicit join entity with two one-to-many relationships.
  • EF Core 5.0: Implicit M:N via UsingEntity — the join table is transparent. Clean API, but the join table has no payload.
  • EF Core 7.0: Implicit M:N with payload via UsingEntity<TJoinEntity> — you can add columns to the join entity.

In practice, most many-to-many relationships carry payload. Products and Categories have a display order. Students and Courses have a grade. Articles and Tags have a creation date. The "simple" transparent join table is the exception, not the rule.

Entity.Dsl treats both cases as first-class concepts:

  • [ManyToMany] for simple, transparent M:N (no payload)
  • [AssociationClass] for M:N with payload (the join entity is a full DSL concept)

Simple Many-to-Many: [ManyToMany]

For cases where the join table has no payload — just two foreign keys:

[AggregateRoot("Article")]
[Table("Articles")]
public partial class Article
{
    [PrimaryKey]
    public int Id { get; set; }

    [Required]
    [MaxLength(200)]
    public string Title { get; set; } = "";

    [ManyToMany(JoinTable = "ArticleTags")]
    public List<Tag> Tags { get; set; } = new();
}

[Entity("Tag")]
[Table("Tags")]
public partial class Tag
{
    [PrimaryKey]
    public int Id { get; set; }

    [Required]
    [MaxLength(50)]
    public string Name { get; set; } = "";
}

Generated Output

protected virtual void ConfigureTags(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Article> builder)
{
    builder.HasMany(e => e.Tags)
        .WithMany()
        .UsingEntity(j => j.ToTable("ArticleTags"));
}

The ArticleTags table is transparent — EF Core manages it automatically. No entity class, no repository, no configuration. This is the simplest case.

But our marketplace needs something more powerful for Product-Category.


Association Classes: The Real Deal

In our marketplace, the relationship between Product and Category carries payload:

  • DisplayOrder: The position of the product within the category listing
  • IsFeatured: Whether the product is highlighted in the category page
  • AddedAt: When the product was added to the category

This makes the join table an association class — a first-class entity with its own attributes and behavior.

The Domain Classes

[AssociationClass("ProductCategory", typeof(Product), typeof(Category),
    OnDeleteLeft = "Cascade", OnDeleteRight = "Cascade")]
[Table("ProductCategories")]
public partial class ProductCategory
{
    // FK properties (convention: {EndpointName}Id)
    public Guid ProductId { get; set; }
    public int CategoryId { get; set; }

    // Navigations
    public Product Product { get; set; } = null!;
    public Category Category { get; set; } = null!;

    // Payload — this is what makes it an association CLASS
    public int DisplayOrder { get; set; }

    [DefaultValue(Value = "false")]
    public bool IsFeatured { get; set; }

    [DefaultValue(Sql = "GETUTCDATE()")]
    public DateTimeOffset AddedAt { get; set; }
}
Diagram

What [AssociationClass] Auto-Generates

The [AssociationClass] attribute triggers a comprehensive set of generated artifacts:

1. Composite Primary Key

The generator creates a composite PK from {Left}Id + {Right}Id:

// In ProductCategoryConfigurationBase.g.cs
protected virtual void ConfigurePrimaryKey(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.ProductCategory> builder)
{
    builder.HasKey(e => new { e.ProductId, e.CategoryId });
}

If the developer declares explicit [PrimaryKey] attributes, those take precedence over the auto-generated composite key.

2. FK Navigation Configuration

Both endpoints get their relationship configured:

protected virtual void ConfigureProductEndpoint(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.ProductCategory> builder)
{
    builder.HasOne(e => e.Product)
        .WithMany(e => e.ProductCategories)
        .HasForeignKey(e => e.ProductId)
        .OnDelete(DeleteBehavior.Cascade);
}

protected virtual void ConfigureCategoryEndpoint(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.ProductCategory> builder)
{
    builder.HasOne(e => e.Category)
        .WithMany(e => e.ProductCategories)
        .HasForeignKey(e => e.CategoryId)
        .OnDelete(DeleteBehavior.Cascade);
}

3. Skip Navigations

The generator injects skip navigations into both endpoint entities via partial classes:

// Generated: Product.AssociationNavigations.g.cs
public partial class Product
{
    public ICollection<global::Marketplace.Domain.Category> Categories { get; set; }
        = new List<global::Marketplace.Domain.Category>();
    public ICollection<global::Marketplace.Domain.ProductCategory> ProductCategories { get; set; }
        = new List<global::Marketplace.Domain.ProductCategory>();
}

// Generated: Category.AssociationNavigations.g.cs
public partial class Category
{
    public ICollection<global::Marketplace.Domain.Product> Products { get; set; }
        = new List<global::Marketplace.Domain.Product>();
    public ICollection<global::Marketplace.Domain.ProductCategory> ProductCategories { get; set; }
        = new List<global::Marketplace.Domain.ProductCategory>();
}

Each endpoint gets two navigations:

  • A direct navigation to the association class (ProductCategories)
  • A skip navigation to the other endpoint (Categories / Products)

4. Skip Navigation Configuration

// In ProductConfigurationBase.g.cs
protected virtual void ConfigureCategories(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
    builder.HasMany(e => e.Categories)
        .WithMany(e => e.Products)
        .UsingEntity<global::Marketplace.Domain.ProductCategory>();
}

5. IAssociationRepository

Association classes get a specialized repository interface:

// Generated: IProductCategoryRepository.g.cs
public interface IProductCategoryRepository
    : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IAssociationRepository<
        global::Marketplace.Domain.ProductCategory,
        global::Marketplace.Domain.Product,
        global::Marketplace.Domain.Category>
{
}

The IAssociationRepository<TAssoc, TLeft, TRight> extends IRepository<TAssoc> with bidirectional queries:

public interface IAssociationRepository<TAssoc, TLeft, TRight> : IRepository<TAssoc>
    where TAssoc : class
    where TLeft : class
    where TRight : class
{
    /// Find the association between two specific endpoints
    Task<TAssoc?> FindByEndpointsAsync(object leftKey, object rightKey, CancellationToken ct = default);

    /// Find all right-side entities for a given left-side key
    Task<IReadOnlyList<TRight>> FindRightsByLeftAsync(object leftKey, CancellationToken ct = default);

    /// Find all left-side entities for a given right-side key
    Task<IReadOnlyList<TLeft>> FindLeftsByRightAsync(object rightKey, CancellationToken ct = default);

    /// Check if an association exists between two endpoints
    Task<bool> ExistsByEndpointsAsync(object leftKey, object rightKey, CancellationToken ct = default);
}

Using the Association Repository

public class CatalogService
{
    private readonly IMarketplaceDbContextUnitOfWork _uow;

    public CatalogService(IMarketplaceDbContextUnitOfWork uow) => _uow = uow;

    // Add a product to a category with payload
    public async Task AddProductToCategoryAsync(
        Guid productId, int categoryId, int displayOrder, bool isFeatured)
    {
        _uow.ProductCategories.Add(new ProductCategory
        {
            ProductId = productId,
            CategoryId = categoryId,
            DisplayOrder = displayOrder,
            IsFeatured = isFeatured
        });
        await _uow.SaveChangesAsync();
    }

    // Get all categories for a product
    public async Task<IReadOnlyList<Category>> GetCategoriesForProductAsync(Guid productId)
        => await _uow.ProductCategories.FindRightsByLeftAsync(productId);

    // Get all products in a category
    public async Task<IReadOnlyList<Product>> GetProductsInCategoryAsync(int categoryId)
        => await _uow.ProductCategories.FindLeftsByRightAsync(categoryId);

    // Check if a product is in a category
    public async Task<bool> IsProductInCategoryAsync(Guid productId, int categoryId)
        => await _uow.ProductCategories.ExistsByEndpointsAsync(productId, categoryId);

    // Get the association with payload
    public async Task<ProductCategory?> GetAssociationAsync(Guid productId, int categoryId)
        => await _uow.ProductCategories.FindByEndpointsAsync(productId, categoryId);
}

The bidirectional queries (FindRightsByLeftAsync, FindLeftsByRightAsync) are generated with the correct eager loading — they include the target endpoint entity in the query.


Self-Referencing Entities: Category Tree

Categories in a marketplace form a tree: Electronics → Smartphones → Android Phones. Each category has an optional parent and zero or more children.

The Domain Class

[AggregateRoot("Category")]
[Table("Categories")]
public partial class Category
{
    [PrimaryKey]
    public int Id { get; set; }

    [Required]
    [MaxLength(200)]
    public string Name { get; set; } = "";

    [MaxLength(500)]
    public string? Description { get; set; }

    public int? ParentId { get; set; }

    [SelfReference(InverseNavigation = "Children", ForeignKey = "ParentId", OnDelete = "Restrict")]
    public Category? Parent { get; set; }

    public List<Category> Children { get; set; } = new();

    public int DisplayOrder { get; set; }

    public int Depth { get; set; }
}

The [SelfReference] attribute tells the generator:

  • The navigation property is a self-referencing relationship
  • The inverse navigation (the other side) is Children
  • The FK is ParentId
  • Delete behavior is Restrict (cannot delete a category that has children)

Generated Output

protected virtual void ConfigureParent(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Category> builder)
{
    builder.HasOne(e => e.Parent)
        .WithMany(e => e.Children)
        .HasForeignKey(e => e.ParentId)
        .OnDelete(DeleteBehavior.Restrict);
}

Why Restrict?

DeleteBehavior.Restrict on self-references prevents accidental deletion of an entire branch:

Electronics (id=1)
├── Smartphones (id=2, parentId=1)
│   ├── Android Phones (id=3, parentId=2)
│   └── iPhones (id=4, parentId=2)
└── Laptops (id=5, parentId=1)

If you could cascade-delete "Electronics", you would lose the entire tree. Restrict forces you to delete leaf categories first, then work your way up — a deliberate, auditable process.

Querying Hierarchies

The generated repository gives you the standard IRepository<Category> interface. For tree queries, you have several options:

Eager loading with Include:

// Load a category with its immediate children
var category = await _uow.Categories.Query
    .Include(c => c.Children)
    .FirstOrDefaultAsync(c => c.Id == categoryId);

// Load two levels deep
var category = await _uow.Categories.Query
    .Include(c => c.Children)
        .ThenInclude(c => c.Children)
    .FirstOrDefaultAsync(c => c.Id == categoryId);

Flat query with depth:

// Get all categories at depth 0 (root categories)
var roots = await _uow.Categories.FindWhereAsync(c => c.Depth == 0);

// Get all categories under a parent
var children = await _uow.Categories.FindWhereAsync(c => c.ParentId == parentId);

Recursive CTE (for full subtree):

// In a partial CategoryRepository.cs (developer-owned extension)
public partial class CategoryRepository
{
    public async Task<IReadOnlyList<Category>> GetSubtreeAsync(int rootId, CancellationToken ct = default)
    {
        return await Context.Categories
            .FromSqlRaw(@"
                WITH CategoryTree AS (
                    SELECT * FROM Categories WHERE Id = {0}
                    UNION ALL
                    SELECT c.* FROM Categories c
                    INNER JOIN CategoryTree ct ON c.ParentId = ct.Id
                )
                SELECT * FROM CategoryTree", rootId)
            .ToListAsync(ct);
    }
}

The recursive CTE approach is the most efficient for deep trees. The developer adds it as a partial class extension — the generator provides the base repository, and the developer adds domain-specific queries.


The Complete Category-Product Relationship

With both the [AssociationClass] and [SelfReference] patterns in place, the Category-Product model supports:

Diagram

A product can be in multiple categories (Galaxy S24 might be in both "Android Phones" and "Smartphones"). Each association carries its own display order and featured flag. The category tree is navigable from any node.


UnitOfWork with Association Repositories

The UnitOfWork now includes the association repository:

// Generated: IMarketplaceDbContextUnitOfWork.g.cs
public interface IMarketplaceDbContextUnitOfWork
    : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IUnitOfWork<global::Marketplace.Domain.MarketplaceDbContext>
{
    global::Marketplace.Domain.Repositories.IProductRepository Products { get; }
    global::Marketplace.Domain.Repositories.IOrderRepository Orders { get; }
    global::Marketplace.Domain.Repositories.ICustomerRepository Customers { get; }
    global::Marketplace.Domain.Repositories.IStoreRepository Stores { get; }
    global::Marketplace.Domain.Repositories.ICategoryRepository Categories { get; }
    global::Marketplace.Domain.Repositories.IProductCategoryRepository ProductCategories { get; }
}

The ProductCategories property gives access to the association repository with its bidirectional query methods. The Categories property gives access to the standard category repository for tree operations.


Many-to-Many with Join Entity (No AssociationClass)

Sometimes you want a join entity for code readability but without the full [AssociationClass] machinery. Use [ManyToMany(JoinEntity = typeof(...))]:

[AggregateRoot("Order")]
[Table("Orders")]
public partial class Order
{
    [PrimaryKey]
    public Guid Id { get; set; }

    [ManyToMany(JoinEntity = typeof(OrderTag))]
    public List<Tag> Tags { get; set; } = new();
}

[Entity("Tag")]
[Table("Tags")]
public partial class Tag
{
    [PrimaryKey]
    public int Id { get; set; }

    [Required]
    [MaxLength(50)]
    public string Name { get; set; } = "";
}

// The join entity exists but has no payload
public class OrderTag
{
    public Guid OrderId { get; set; }
    public int TagId { get; set; }
}

Generated Output

protected virtual void ConfigureTags(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Order> builder)
{
    builder.HasMany(e => e.Tags)
        .WithMany()
        .UsingEntity<global::Marketplace.Domain.OrderTag>();
}

The difference from [AssociationClass]:

  • No IAssociationRepository is generated
  • No skip navigations are injected into the endpoint entities
  • No composite PK is auto-configured (you manage the join entity manually or let EF Core infer it)
  • Use this when the join entity is truly just a pair of FKs with no domain meaning

When To Use Each

Pattern Use When
[ManyToMany(JoinTable = "...")] No payload, no join entity class needed
[ManyToMany(JoinEntity = typeof(...))] No payload, but you want the join class for migrations or code clarity
[AssociationClass] Payload exists (DisplayOrder, IsFeatured, timestamps, etc.)

Building a Breadcrumb from Self-Reference

A practical pattern with self-referencing categories — building a breadcrumb trail:

// In CategoryRepository.cs (developer-owned)
public partial class CategoryRepository
{
    public async Task<IReadOnlyList<Category>> GetBreadcrumbAsync(
        int categoryId, CancellationToken ct = default)
    {
        var breadcrumb = new List<Category>();
        var current = await Query
            .Include(c => c.Parent)
            .FirstOrDefaultAsync(c => c.Id == categoryId, ct);

        while (current != null)
        {
            breadcrumb.Insert(0, current);
            if (current.ParentId.HasValue)
            {
                current = await Query
                    .Include(c => c.Parent)
                    .FirstOrDefaultAsync(c => c.Id == current.ParentId, ct);
            }
            else
            {
                current = null;
            }
        }

        return breadcrumb;
    }

    public async Task<int> GetMaxDepthAsync(CancellationToken ct = default)
        => await Query.MaxAsync(c => c.Depth, ct);

    public async Task<IReadOnlyList<Category>> GetLeafCategoriesAsync(
        CancellationToken ct = default)
        => await Query
            .Where(c => !c.Children.Any())
            .OrderBy(c => c.Name)
            .ToListAsync(ct);
}

This produces:

Electronics > Smartphones > Android Phones

The breadcrumb query walks up the tree from leaf to root. For deep trees (5+ levels), consider the recursive CTE approach shown earlier — it fetches the entire path in one query instead of N+1 queries.


Reordering Association Payload

A common operation with ProductCategory — changing the display order of products within a category:

public class CatalogService
{
    private readonly IMarketplaceDbContextUnitOfWork _uow;

    public CatalogService(IMarketplaceDbContextUnitOfWork uow) => _uow = uow;

    public async Task ReorderProductsInCategoryAsync(
        int categoryId, List<Guid> productIdsInOrder)
    {
        var associations = await _uow.ProductCategories.Query
            .Where(pc => pc.CategoryId == categoryId)
            .ToListAsync();

        for (int i = 0; i < productIdsInOrder.Count; i++)
        {
            var assoc = associations.FirstOrDefault(a => a.ProductId == productIdsInOrder[i]);
            if (assoc != null)
                assoc.DisplayOrder = i;
        }

        await _uow.SaveChangesAsync();
    }

    public async Task ToggleFeaturedAsync(Guid productId, int categoryId)
    {
        var assoc = await _uow.ProductCategories
            .FindByEndpointsAsync(productId, categoryId);

        if (assoc != null)
        {
            assoc.IsFeatured = !assoc.IsFeatured;
            await _uow.SaveChangesAsync();
        }
    }
}

This is the power of association classes: the payload is a first-class entity with its own state, queryable and modifiable through the UnitOfWork.


Summary

Concept Attribute Generated Artifacts
Simple M:N [ManyToMany] UsingEntity with transparent join table
M:N with payload [AssociationClass] Composite PK, FK configurations, skip navigations, IAssociationRepository
Self-reference [SelfReference] HasOne().WithMany() with self-referencing FK, default Restrict

Association classes are the DSL's answer to the most common EF Core modeling challenge: many-to-many relationships that carry data. Instead of manually creating join entities, configuring composite keys, wiring FK navigations, and building bidirectional queries — the developer writes one attribute and the generator handles the rest.

In the next part, we add cross-cutting behaviors: timestamps, soft delete, audit trails, slugs, and concurrency tokens.