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; } = "";
}[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"));
}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; }
}[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; }
}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 });
}// 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);
}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>();
}// 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>();
}// 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>
{
}// 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);
}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);
}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; }
}[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);
}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)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);// 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);// 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);
}
}// 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:
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; }
}// 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; }
}[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>();
}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
IAssociationRepositoryis 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);
}// 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 PhonesElectronics > Smartphones > Android PhonesThe 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();
}
}
}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.