Customization and Escape Hatches
"A good generator generates 95% of what you need. A great generator gives you clean hooks for the other 5%."
The Generation Gap Hooks
Part I introduced the Generation Gap pattern — three layers where the developer can override generated behavior. Let us see this in practice.
PreConfigure and PostConfigure
These hooks run before and after all property configurations:
// ProductConfiguration.cs (developer-owned, never overwritten)
public partial class ProductConfiguration
{
protected override void PreConfigure(
EntityTypeBuilder<Product> builder)
{
// Runs BEFORE any generated Configure* method.
// Good for: global entity settings, table comments, row-level security
builder.HasComment("Products in the marketplace catalog");
}
protected override void PostConfigure(
EntityTypeBuilder<Product> builder)
{
// Runs AFTER all generated Configure* methods.
// Good for: composite indexes, complex constraints, raw SQL
builder.HasIndex(e => new { e.StoreId, e.Sku }).IsUnique();
builder.HasIndex(e => e.Name);
}
}// ProductConfiguration.cs (developer-owned, never overwritten)
public partial class ProductConfiguration
{
protected override void PreConfigure(
EntityTypeBuilder<Product> builder)
{
// Runs BEFORE any generated Configure* method.
// Good for: global entity settings, table comments, row-level security
builder.HasComment("Products in the marketplace catalog");
}
protected override void PostConfigure(
EntityTypeBuilder<Product> builder)
{
// Runs AFTER all generated Configure* methods.
// Good for: composite indexes, complex constraints, raw SQL
builder.HasIndex(e => new { e.StoreId, e.Sku }).IsUnique();
builder.HasIndex(e => e.Name);
}
}Per-Property Override
Every property has its own virtual Configure{PropertyName} method. Override it to change or replace the generated configuration:
public partial class ProductConfiguration
{
// Replace the generated configuration entirely
protected override void ConfigureName(
EntityTypeBuilder<Product> builder)
{
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(200)
.UseCollation("SQL_Latin1_General_CP1_CS_AS"); // case-sensitive
}
// Augment the generated configuration
protected override void ConfigurePrice(
EntityTypeBuilder<Product> builder)
{
base.ConfigurePrice(builder); // Keep generated config
builder.Property(e => e.Price)
.HasComment("Price in the store's default currency");
}
}public partial class ProductConfiguration
{
// Replace the generated configuration entirely
protected override void ConfigureName(
EntityTypeBuilder<Product> builder)
{
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(200)
.UseCollation("SQL_Latin1_General_CP1_CS_AS"); // case-sensitive
}
// Augment the generated configuration
protected override void ConfigurePrice(
EntityTypeBuilder<Product> builder)
{
base.ConfigurePrice(builder); // Keep generated config
builder.Property(e => e.Price)
.HasComment("Price in the store's default currency");
}
}The base.ConfigurePrice(builder) call executes the generated configuration first, then the override adds to it. If you omit the base call, the generated configuration is replaced entirely.
When To Use Each Hook
| Hook | Use Case |
|---|---|
PreConfigure |
Table-level settings, comments, row-level security filters |
PostConfigure |
Composite indexes, unique constraints, seed data, raw SQL |
Configure{Property} (replace) |
Full control over a specific property's mapping |
Configure{Property} (augment) |
Add collation, comment, or conversion to a generated property |
Extending Partial Repositories
Generated repositories are partial classes. The developer adds domain-specific query methods:
// OrderRepository.cs (developer-owned)
public partial class OrderRepository
{
public async Task<Order?> FindByOrderNumberAsync(
string orderNumber, CancellationToken ct = default)
=> await Query
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.OrderNumber == orderNumber, ct);
public async Task<IReadOnlyList<Order>> FindByCustomerAsync(
Guid customerId, CancellationToken ct = default)
=> await Query
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.OrderDate)
.ToListAsync(ct);
public async Task<IReadOnlyList<Order>> FindRecentAsync(
int count, CancellationToken ct = default)
=> await Query
.OrderByDescending(o => o.OrderDate)
.Take(count)
.ToListAsync(ct);
public async Task<decimal> GetTotalRevenueAsync(
Guid storeId, CancellationToken ct = default)
=> await Query
.Where(o => o.Items.Any(i => i.Product.StoreId == storeId))
.SumAsync(o => o.Total, ct);
}// OrderRepository.cs (developer-owned)
public partial class OrderRepository
{
public async Task<Order?> FindByOrderNumberAsync(
string orderNumber, CancellationToken ct = default)
=> await Query
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.OrderNumber == orderNumber, ct);
public async Task<IReadOnlyList<Order>> FindByCustomerAsync(
Guid customerId, CancellationToken ct = default)
=> await Query
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.OrderDate)
.ToListAsync(ct);
public async Task<IReadOnlyList<Order>> FindRecentAsync(
int count, CancellationToken ct = default)
=> await Query
.OrderByDescending(o => o.OrderDate)
.Take(count)
.ToListAsync(ct);
public async Task<decimal> GetTotalRevenueAsync(
Guid storeId, CancellationToken ct = default)
=> await Query
.Where(o => o.Items.Any(i => i.Product.StoreId == storeId))
.SumAsync(o => o.Total, ct);
}To expose these methods through the interface, extend the generated interface in a partial file:
// IOrderRepository.cs (developer-owned)
public partial interface IOrderRepository
{
Task<Order?> FindByOrderNumberAsync(string orderNumber, CancellationToken ct = default);
Task<IReadOnlyList<Order>> FindByCustomerAsync(Guid customerId, CancellationToken ct = default);
Task<IReadOnlyList<Order>> FindRecentAsync(int count, CancellationToken ct = default);
Task<decimal> GetTotalRevenueAsync(Guid storeId, CancellationToken ct = default);
}// IOrderRepository.cs (developer-owned)
public partial interface IOrderRepository
{
Task<Order?> FindByOrderNumberAsync(string orderNumber, CancellationToken ct = default);
Task<IReadOnlyList<Order>> FindByCustomerAsync(Guid customerId, CancellationToken ct = default);
Task<IReadOnlyList<Order>> FindRecentAsync(int count, CancellationToken ct = default);
Task<decimal> GetTotalRevenueAsync(Guid storeId, CancellationToken ct = default);
}The generated interface and repository are both partial — the developer's extensions merge seamlessly.
IEntityListener: Pre/Post Save Hooks
Entity listeners are invoked by the DbContext before or after SaveChanges, scoped to a specific entity type. They are useful for cross-cutting logic that does not belong in the entity itself.
The Interface
// From Entity.Dsl.Abstractions
public interface IEntityListener<T> where T : class
{
Task OnBeforeInsertAsync(T entity, CancellationToken ct = default);
Task OnBeforeUpdateAsync(T entity, CancellationToken ct = default);
Task OnBeforeDeleteAsync(T entity, CancellationToken ct = default);
Task OnAfterInsertAsync(T entity, CancellationToken ct = default);
Task OnAfterUpdateAsync(T entity, CancellationToken ct = default);
Task OnAfterDeleteAsync(T entity, CancellationToken ct = default);
}// From Entity.Dsl.Abstractions
public interface IEntityListener<T> where T : class
{
Task OnBeforeInsertAsync(T entity, CancellationToken ct = default);
Task OnBeforeUpdateAsync(T entity, CancellationToken ct = default);
Task OnBeforeDeleteAsync(T entity, CancellationToken ct = default);
Task OnAfterInsertAsync(T entity, CancellationToken ct = default);
Task OnAfterUpdateAsync(T entity, CancellationToken ct = default);
Task OnAfterDeleteAsync(T entity, CancellationToken ct = default);
}Example: Order Audit Logging
public class OrderAuditListener : IEntityListener<Order>
{
private readonly ILogger<OrderAuditListener> _logger;
private readonly ICurrentUserProvider _userProvider;
public OrderAuditListener(
ILogger<OrderAuditListener> logger,
ICurrentUserProvider userProvider)
{
_logger = logger;
_userProvider = userProvider;
}
public Task OnBeforeInsertAsync(Order entity, CancellationToken ct)
{
_logger.LogInformation(
"Order {OrderNumber} created by {User}",
entity.OrderNumber,
_userProvider.CurrentUserId);
return Task.CompletedTask;
}
public Task OnBeforeUpdateAsync(Order entity, CancellationToken ct)
{
_logger.LogInformation(
"Order {OrderNumber} updated by {User}, new status: {Status}",
entity.OrderNumber,
_userProvider.CurrentUserId,
entity.Status);
return Task.CompletedTask;
}
public Task OnBeforeDeleteAsync(Order entity, CancellationToken ct) => Task.CompletedTask;
public Task OnAfterInsertAsync(Order entity, CancellationToken ct) => Task.CompletedTask;
public Task OnAfterUpdateAsync(Order entity, CancellationToken ct) => Task.CompletedTask;
public Task OnAfterDeleteAsync(Order entity, CancellationToken ct) => Task.CompletedTask;
}public class OrderAuditListener : IEntityListener<Order>
{
private readonly ILogger<OrderAuditListener> _logger;
private readonly ICurrentUserProvider _userProvider;
public OrderAuditListener(
ILogger<OrderAuditListener> logger,
ICurrentUserProvider userProvider)
{
_logger = logger;
_userProvider = userProvider;
}
public Task OnBeforeInsertAsync(Order entity, CancellationToken ct)
{
_logger.LogInformation(
"Order {OrderNumber} created by {User}",
entity.OrderNumber,
_userProvider.CurrentUserId);
return Task.CompletedTask;
}
public Task OnBeforeUpdateAsync(Order entity, CancellationToken ct)
{
_logger.LogInformation(
"Order {OrderNumber} updated by {User}, new status: {Status}",
entity.OrderNumber,
_userProvider.CurrentUserId,
entity.Status);
return Task.CompletedTask;
}
public Task OnBeforeDeleteAsync(Order entity, CancellationToken ct) => Task.CompletedTask;
public Task OnAfterInsertAsync(Order entity, CancellationToken ct) => Task.CompletedTask;
public Task OnAfterUpdateAsync(Order entity, CancellationToken ct) => Task.CompletedTask;
public Task OnAfterDeleteAsync(Order entity, CancellationToken ct) => Task.CompletedTask;
}Register in DI:
services.AddScoped<IEntityListener<Order>, OrderAuditListener>();services.AddScoped<IEntityListener<Order>, OrderAuditListener>();The generated DbContext's OnBeforeSaveChanges method discovers and invokes all registered listeners via the IServiceProvider.
Example: Domain Event Dispatch
public class OrderEventListener : IEntityListener<Order>
{
private readonly IMediator _mediator;
public OrderEventListener(IMediator mediator) => _mediator = mediator;
public async Task OnAfterInsertAsync(Order entity, CancellationToken ct)
{
await _mediator.Publish(new OrderCreatedEvent(entity.Id, entity.OrderNumber), ct);
}
public async Task OnAfterUpdateAsync(Order entity, CancellationToken ct)
{
if (entity.Status == OrderStatus.Shipped)
await _mediator.Publish(new OrderShippedEvent(entity.Id), ct);
}
// ... other methods return Task.CompletedTask
}public class OrderEventListener : IEntityListener<Order>
{
private readonly IMediator _mediator;
public OrderEventListener(IMediator mediator) => _mediator = mediator;
public async Task OnAfterInsertAsync(Order entity, CancellationToken ct)
{
await _mediator.Publish(new OrderCreatedEvent(entity.Id, entity.OrderNumber), ct);
}
public async Task OnAfterUpdateAsync(Order entity, CancellationToken ct)
{
if (entity.Status == OrderStatus.Shipped)
await _mediator.Publish(new OrderShippedEvent(entity.Id), ct);
}
// ... other methods return Task.CompletedTask
}Multiple listeners can be registered for the same entity. They execute in registration order.
Custom RepositoryBase Hierarchy
By default, generated repositories inherit from RepositoryBase<T> (the framework's generic implementation). For project-wide repository behavior — tenant filtering, audit logging, soft-delete awareness — the developer creates an intermediate base:
// MarketplaceRepository.cs (developer-owned)
public class MarketplaceRepository<T> : RepositoryBase<T> where T : class
{
private readonly ITenantProvider _tenantProvider;
public MarketplaceRepository(DbContext context, ITenantProvider tenantProvider)
: base(context)
{
_tenantProvider = tenantProvider;
}
// Override query to add tenant filtering
public override IQueryable<T> Query
{
get
{
var query = base.Query;
if (typeof(T).GetProperty("TenantId") != null)
{
// Dynamic tenant filter for multi-tenant entities
var tenantId = _tenantProvider.CurrentTenantId;
query = query.Where(e =>
EF.Property<Guid>(e, "TenantId") == tenantId);
}
return query;
}
}
}// MarketplaceRepository.cs (developer-owned)
public class MarketplaceRepository<T> : RepositoryBase<T> where T : class
{
private readonly ITenantProvider _tenantProvider;
public MarketplaceRepository(DbContext context, ITenantProvider tenantProvider)
: base(context)
{
_tenantProvider = tenantProvider;
}
// Override query to add tenant filtering
public override IQueryable<T> Query
{
get
{
var query = base.Query;
if (typeof(T).GetProperty("TenantId") != null)
{
// Dynamic tenant filter for multi-tenant entities
var tenantId = _tenantProvider.CurrentTenantId;
query = query.Where(e =>
EF.Property<Guid>(e, "TenantId") == tenantId);
}
return query;
}
}
}Declare it on the DbContext:
[DbContext(RepositoryBase = typeof(MarketplaceRepository<>))]
public partial class MarketplaceDbContext : DbContext { }[DbContext(RepositoryBase = typeof(MarketplaceRepository<>))]
public partial class MarketplaceDbContext : DbContext { }Now every generated repository inherits from MarketplaceRepository<T> instead of the framework's RepositoryBase<T>:
// Generated: ProductRepository.g.cs
public partial class ProductRepository
: MarketplaceRepository<global::Marketplace.Domain.Product>, IProductRepository
{
public ProductRepository(global::Marketplace.Domain.MarketplaceDbContext context)
: base(context) { }
}// Generated: ProductRepository.g.cs
public partial class ProductRepository
: MarketplaceRepository<global::Marketplace.Domain.Product>, IProductRepository
{
public ProductRepository(global::Marketplace.Domain.MarketplaceDbContext context)
: base(context) { }
}The hierarchy becomes:
RepositoryBase<T> (framework — hand-written in Abstractions)
↑
MarketplaceRepository<T> (developer-written, project-wide)
↑
ProductRepository (SG-generated partial + developer partial)RepositoryBase<T> (framework — hand-written in Abstractions)
↑
MarketplaceRepository<T> (developer-written, project-wide)
↑
ProductRepository (SG-generated partial + developer partial)DbContext Configuration Options
The [DbContext] attribute exposes EF Core's configuration options:
[DbContext(
LazyLoading = false, // default: false
QueryTracking = "NoTracking", // "NoTracking", "TrackAll"
QuerySplitting = "Single", // "Single", "Split"
ChangeTracking = "Snapshot", // "Snapshot", "ChangingAndChangedNotifications"
EnableRetryOnFailure = true, // default: false
MaxRetryCount = 3 // default: 6
)]
public partial class MarketplaceDbContext : DbContext { }[DbContext(
LazyLoading = false, // default: false
QueryTracking = "NoTracking", // "NoTracking", "TrackAll"
QuerySplitting = "Single", // "Single", "Split"
ChangeTracking = "Snapshot", // "Snapshot", "ChangingAndChangedNotifications"
EnableRetryOnFailure = true, // default: false
MaxRetryCount = 3 // default: 6
)]
public partial class MarketplaceDbContext : DbContext { }These are emitted in the AddMarketplaceDbContext extension method:
public static IServiceCollection AddMarketplaceDbContext(
this IServiceCollection services,
global::System.Action<Microsoft.EntityFrameworkCore.DbContextOptionsBuilder> configureDbContext)
{
services.AddDbContext<Marketplace.Domain.MarketplaceDbContext>((sp, options) =>
{
configureDbContext(options);
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
options.EnableRetryOnFailure(maxRetryCount: 3);
});
return services;
}public static IServiceCollection AddMarketplaceDbContext(
this IServiceCollection services,
global::System.Action<Microsoft.EntityFrameworkCore.DbContextOptionsBuilder> configureDbContext)
{
services.AddDbContext<Marketplace.Domain.MarketplaceDbContext>((sp, options) =>
{
configureDbContext(options);
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
options.EnableRetryOnFailure(maxRetryCount: 3);
});
return services;
}Common Configurations
| Option | Recommended For |
|---|---|
QueryTracking = "NoTracking" |
Read-heavy APIs, CQRS read side |
QuerySplitting = "Split" |
Queries with multiple Includes (avoids cartesian explosion) |
EnableRetryOnFailure = true |
Cloud databases (Azure SQL, AWS RDS) |
LazyLoading = true |
Rapid prototyping (avoid in production) |
Multi-DbContext
Large domains can be split across multiple DbContexts, each representing a bounded context:
[DbContext(BoundedContext = "Catalog")]
public partial class CatalogDbContext : DbContext { }
[DbContext(BoundedContext = "Orders")]
public partial class OrdersDbContext : DbContext { }[DbContext(BoundedContext = "Catalog")]
public partial class CatalogDbContext : DbContext { }
[DbContext(BoundedContext = "Orders")]
public partial class OrdersDbContext : DbContext { }Entities are scoped to a context via the BoundedContext property on [AggregateRoot]:
[AggregateRoot("Product", BoundedContext = "Catalog")]
[Table("Products")]
public partial class Product { /* ... */ }
[AggregateRoot("Order", BoundedContext = "Orders")]
[Table("Orders")]
public partial class Order { /* ... */ }[AggregateRoot("Product", BoundedContext = "Catalog")]
[Table("Products")]
public partial class Product { /* ... */ }
[AggregateRoot("Order", BoundedContext = "Orders")]
[Table("Orders")]
public partial class Order { /* ... */ }The generator creates separate UnitOfWork interfaces for each context:
// ICatalogDbContextUnitOfWork — only has Products, Categories, etc.
// IOrdersDbContextUnitOfWork — only has Orders, OrderItems, Payments, etc.// ICatalogDbContextUnitOfWork — only has Products, Categories, etc.
// IOrdersDbContextUnitOfWork — only has Orders, OrderItems, Payments, etc.Each context gets its own DI registration:
services.AddCatalogDbContext(o => o.UseSqlServer(catalogConnectionString));
services.AddOrdersDbContext(o => o.UseSqlServer(ordersConnectionString));services.AddCatalogDbContext(o => o.UseSqlServer(catalogConnectionString));
services.AddOrdersDbContext(o => o.UseSqlServer(ordersConnectionString));This is useful for:
- Microservice preparation: Each bounded context can eventually become its own service with its own database
- Performance isolation: Read-heavy contexts use NoTracking, write-heavy contexts use full tracking
- Team ownership: Different teams own different bounded contexts
The Specification Pattern
Entity.Dsl.Abstractions includes ISpecification<T> for reusable, composable query criteria:
public interface ISpecification<T> where T : class
{
Expression<Func<T, bool>> Criteria { get; }
List<Expression<Func<T, object>>> Includes { get; }
Expression<Func<T, object>>? OrderBy { get; }
Expression<Func<T, object>>? OrderByDescending { get; }
int? Take { get; }
int? Skip { get; }
}public interface ISpecification<T> where T : class
{
Expression<Func<T, bool>> Criteria { get; }
List<Expression<Func<T, object>>> Includes { get; }
Expression<Func<T, object>>? OrderBy { get; }
Expression<Func<T, object>>? OrderByDescending { get; }
int? Take { get; }
int? Skip { get; }
}Building Specifications
public class ActiveProductsInStoreSpec : BaseSpecification<Product>
{
public ActiveProductsInStoreSpec(Guid storeId, int page, int pageSize)
: base(p => p.StoreId == storeId && p.IsActive)
{
AddInclude(p => p.Variants);
ApplyOrderBy(p => p.Name);
ApplyPaging(page, pageSize);
}
}
public class FeaturedProductsSpec : BaseSpecification<Product>
{
public FeaturedProductsSpec()
: base(p => p.IsActive && p.ProductCategories.Any(pc => pc.IsFeatured))
{
AddInclude(p => p.ProductCategories);
ApplyOrderByDescending(p => p.Price);
ApplyPaging(1, 10);
}
}public class ActiveProductsInStoreSpec : BaseSpecification<Product>
{
public ActiveProductsInStoreSpec(Guid storeId, int page, int pageSize)
: base(p => p.StoreId == storeId && p.IsActive)
{
AddInclude(p => p.Variants);
ApplyOrderBy(p => p.Name);
ApplyPaging(page, pageSize);
}
}
public class FeaturedProductsSpec : BaseSpecification<Product>
{
public FeaturedProductsSpec()
: base(p => p.IsActive && p.ProductCategories.Any(pc => pc.IsFeatured))
{
AddInclude(p => p.ProductCategories);
ApplyOrderByDescending(p => p.Price);
ApplyPaging(1, 10);
}
}Using Specifications
public async Task<IReadOnlyList<Product>> GetCatalogPageAsync(
Guid storeId, int page, int pageSize)
{
var spec = new ActiveProductsInStoreSpec(storeId, page, pageSize);
return await _uow.Products.FindBySpecAsync(spec);
}public async Task<IReadOnlyList<Product>> GetCatalogPageAsync(
Guid storeId, int page, int pageSize)
{
var spec = new ActiveProductsInStoreSpec(storeId, page, pageSize);
return await _uow.Products.FindBySpecAsync(spec);
}The generated repository's FindBySpecAsync applies the specification to the IQueryable<T>:
// In RepositoryBase<T> (from Abstractions)
public async Task<IReadOnlyList<T>> FindBySpecAsync(
ISpecification<T> spec, CancellationToken ct = default)
{
var query = Query.Where(spec.Criteria);
foreach (var include in spec.Includes)
query = query.Include(include);
if (spec.OrderBy != null)
query = query.OrderBy(spec.OrderBy);
else if (spec.OrderByDescending != null)
query = query.OrderByDescending(spec.OrderByDescending);
if (spec.Skip.HasValue)
query = query.Skip(spec.Skip.Value);
if (spec.Take.HasValue)
query = query.Take(spec.Take.Value);
return await query.ToListAsync(ct);
}// In RepositoryBase<T> (from Abstractions)
public async Task<IReadOnlyList<T>> FindBySpecAsync(
ISpecification<T> spec, CancellationToken ct = default)
{
var query = Query.Where(spec.Criteria);
foreach (var include in spec.Includes)
query = query.Include(include);
if (spec.OrderBy != null)
query = query.OrderBy(spec.OrderBy);
else if (spec.OrderByDescending != null)
query = query.OrderByDescending(spec.OrderByDescending);
if (spec.Skip.HasValue)
query = query.Skip(spec.Skip.Value);
if (spec.Take.HasValue)
query = query.Take(spec.Take.Value);
return await query.ToListAsync(ct);
}Why Specifications?
Specifications encapsulate query logic in a testable, reusable object:
- Reusable: The same spec works in API controllers, background jobs, and tests
- Testable: Test the spec's criteria independently from EF Core
- Composable: Combine specs with
&&or create derived specs - Discoverable: All query patterns are in one folder, not scattered across services
When To Escape: Raw Fluent API
Sometimes the generator cannot express what you need. The partial Configuration class is always available for raw EF Core Fluent API:
public partial class OrderConfiguration
{
protected override void PostConfigure(EntityTypeBuilder<Order> builder)
{
// Complex composite index with filter
builder.HasIndex(e => new { e.CustomerId, e.OrderDate })
.HasFilter("[Status] <> 'Cancelled'")
.HasDatabaseName("IX_Orders_Customer_Date_Active");
// Full-text search index (SQL Server specific)
builder.HasIndex(e => e.OrderNumber)
.HasDatabaseName("IX_Orders_OrderNumber_FT")
.IsClustered(false);
// Seed data
builder.HasData(new Order
{
Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),
OrderNumber = "SEED-001",
Status = OrderStatus.Pending,
Total = 0m,
CustomerId = Guid.Parse("00000000-0000-0000-0000-000000000001")
});
// Check constraint
builder.ToTable(t =>
t.HasCheckConstraint("CK_Orders_Total_NonNegative", "[Total] >= 0"));
}
}public partial class OrderConfiguration
{
protected override void PostConfigure(EntityTypeBuilder<Order> builder)
{
// Complex composite index with filter
builder.HasIndex(e => new { e.CustomerId, e.OrderDate })
.HasFilter("[Status] <> 'Cancelled'")
.HasDatabaseName("IX_Orders_Customer_Date_Active");
// Full-text search index (SQL Server specific)
builder.HasIndex(e => e.OrderNumber)
.HasDatabaseName("IX_Orders_OrderNumber_FT")
.IsClustered(false);
// Seed data
builder.HasData(new Order
{
Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),
OrderNumber = "SEED-001",
Status = OrderStatus.Pending,
Total = 0m,
CustomerId = Guid.Parse("00000000-0000-0000-0000-000000000001")
});
// Check constraint
builder.ToTable(t =>
t.HasCheckConstraint("CK_Orders_Total_NonNegative", "[Total] >= 0"));
}
}When To Use Raw Fluent API
| Scenario | Why the Generator Cannot Help |
|---|---|
| Filtered indexes | Database-specific SQL expressions |
| Check constraints | Arbitrary SQL conditions |
| Seed data | Static data, not derivable from attributes |
| Full-text indexes | Provider-specific feature |
| Raw SQL views | ToView() with custom SQL |
| Alternate keys | HasAlternateKey() — rarely used, not worth an attribute |
| Temporal tables | IsTemporal() — SQL Server specific |
The escape hatch is always the same: override a Configure* method or use PostConfigure. The generated code handles the common case. The developer handles the exceptions.
Views and Keyless Entities
Not everything maps to a table. Some queries are best expressed as database views or keyless projections.
Mapping a View
[Entity("OrderSummary")]
[Keyless]
public partial class OrderSummary
{
public Guid OrderId { get; set; }
public string OrderNumber { get; set; } = "";
public string CustomerName { get; set; } = "";
public int ItemCount { get; set; }
public decimal Total { get; set; }
public string Status { get; set; } = "";
}[Entity("OrderSummary")]
[Keyless]
public partial class OrderSummary
{
public Guid OrderId { get; set; }
public string OrderNumber { get; set; } = "";
public string CustomerName { get; set; } = "";
public int ItemCount { get; set; }
public decimal Total { get; set; }
public string Status { get; set; } = "";
}The developer creates the view mapping in the partial configuration:
public partial class OrderSummaryConfiguration
{
protected override void ConfigureTable(EntityTypeBuilder<OrderSummary> builder)
{
builder.ToView("vw_OrderSummaries");
}
}public partial class OrderSummaryConfiguration
{
protected override void ConfigureTable(EntityTypeBuilder<OrderSummary> builder)
{
builder.ToView("vw_OrderSummaries");
}
}Keyless entities appear in the DbContext as queryable DbSets but cannot be inserted, updated, or deleted:
// Generated in MarketplaceDbContextBase.g.cs
// (IsKeyless flag prevents repository generation)
public Microsoft.EntityFrameworkCore.DbSet<global::Marketplace.Domain.OrderSummary> OrderSummaries { get; set; } = null!;// Generated in MarketplaceDbContextBase.g.cs
// (IsKeyless flag prevents repository generation)
public Microsoft.EntityFrameworkCore.DbSet<global::Marketplace.Domain.OrderSummary> OrderSummaries { get; set; } = null!;No repository is generated for keyless entities — they are query-only.
Raw SQL Projections
For ad-hoc projections without a database view:
// Developer adds to the partial DbContext
public partial class MarketplaceDbContext
{
public async Task<IReadOnlyList<OrderSummary>> GetOrderSummariesAsync(
Guid? customerId = null, CancellationToken ct = default)
{
var query = OrderSummaries.FromSqlRaw(@"
SELECT
o.Id AS OrderId,
o.OrderNumber,
c.Name AS CustomerName,
COUNT(oi.Id) AS ItemCount,
o.Total,
o.Status
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id
LEFT JOIN OrderItems oi ON oi.OrderId = o.Id
GROUP BY o.Id, o.OrderNumber, c.Name, o.Total, o.Status");
if (customerId.HasValue)
query = query.Where(s => s.OrderId == customerId.Value);
return await query.ToListAsync(ct);
}
}// Developer adds to the partial DbContext
public partial class MarketplaceDbContext
{
public async Task<IReadOnlyList<OrderSummary>> GetOrderSummariesAsync(
Guid? customerId = null, CancellationToken ct = default)
{
var query = OrderSummaries.FromSqlRaw(@"
SELECT
o.Id AS OrderId,
o.OrderNumber,
c.Name AS CustomerName,
COUNT(oi.Id) AS ItemCount,
o.Total,
o.Status
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id
LEFT JOIN OrderItems oi ON oi.OrderId = o.Id
GROUP BY o.Id, o.OrderNumber, c.Name, o.Total, o.Status");
if (customerId.HasValue)
query = query.Where(s => s.OrderId == customerId.Value);
return await query.ToListAsync(ct);
}
}Combining Multiple Customization Layers
A real-world entity often uses several customization mechanisms together. Here is Order with everything applied:
// 1. Entity definition (developer-written, attributed)
[AggregateRoot("Order")]
[Table("Orders")]
[Timestampable]
[Blameable]
public partial class Order
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(50)]
public string OrderNumber { get; set; } = "";
// ... other properties
}
// 2. Configuration override (developer-written, partial)
public partial class OrderConfiguration
{
protected override void PostConfigure(EntityTypeBuilder<Order> builder)
{
builder.HasIndex(e => e.OrderNumber).IsUnique();
builder.HasIndex(e => new { e.CustomerId, e.OrderDate });
builder.ToTable(t =>
t.HasCheckConstraint("CK_Orders_Total_NonNegative", "[Total] >= 0"));
}
}
// 3. Repository extension (developer-written, partial)
public partial class OrderRepository
{
public async Task<Order?> FindByOrderNumberAsync(string orderNumber, CancellationToken ct = default)
=> await Query.Include(o => o.Items).FirstOrDefaultAsync(o => o.OrderNumber == orderNumber, ct);
}
// 4. Interface extension (developer-written, partial)
public partial interface IOrderRepository
{
Task<Order?> FindByOrderNumberAsync(string orderNumber, CancellationToken ct = default);
}
// 5. Entity listener (developer-written, registered in DI)
public class OrderAuditListener : IEntityListener<Order>
{
// ... audit logging, domain events
}// 1. Entity definition (developer-written, attributed)
[AggregateRoot("Order")]
[Table("Orders")]
[Timestampable]
[Blameable]
public partial class Order
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(50)]
public string OrderNumber { get; set; } = "";
// ... other properties
}
// 2. Configuration override (developer-written, partial)
public partial class OrderConfiguration
{
protected override void PostConfigure(EntityTypeBuilder<Order> builder)
{
builder.HasIndex(e => e.OrderNumber).IsUnique();
builder.HasIndex(e => new { e.CustomerId, e.OrderDate });
builder.ToTable(t =>
t.HasCheckConstraint("CK_Orders_Total_NonNegative", "[Total] >= 0"));
}
}
// 3. Repository extension (developer-written, partial)
public partial class OrderRepository
{
public async Task<Order?> FindByOrderNumberAsync(string orderNumber, CancellationToken ct = default)
=> await Query.Include(o => o.Items).FirstOrDefaultAsync(o => o.OrderNumber == orderNumber, ct);
}
// 4. Interface extension (developer-written, partial)
public partial interface IOrderRepository
{
Task<Order?> FindByOrderNumberAsync(string orderNumber, CancellationToken ct = default);
}
// 5. Entity listener (developer-written, registered in DI)
public class OrderAuditListener : IEntityListener<Order>
{
// ... audit logging, domain events
}Five layers of customization, each in its own file, each with a clear responsibility:
| Layer | File | Responsibility |
|---|---|---|
| Attributes | Order.cs |
Domain model + DDD semantics |
| Configuration | OrderConfiguration.cs |
Indexes, constraints, database-specific |
| Repository | OrderRepository.cs |
Domain-specific queries |
| Interface | IOrderRepository.cs |
Expose custom queries to consumers |
| Listener | OrderAuditListener.cs |
Cross-cutting save-time logic |
The generated code (ConfigurationBase, Configuration stub, Repository stub, etc.) sits in between, providing the scaffold that these customizations plug into.
Summary
Entity.Dsl's customization philosophy: generate the common case, provide hooks for everything else.
| Mechanism | What It Does | When To Use |
|---|---|---|
PreConfigure / PostConfigure |
Runs before/after all generated config | Indexes, constraints, comments, seed data |
Configure{Property} override |
Replace or augment a property's config | Collation, conversion, specific behavior |
| Partial repository | Add domain-specific query methods | Custom queries, projections, raw SQL |
IEntityListener<T> |
Pre/post save hooks per entity | Audit logging, domain events, validation |
Custom RepositoryBase<T> |
Project-wide repository behavior | Tenant filtering, soft-delete awareness |
[DbContext] options |
EF Core-level configuration | Tracking, retry, splitting |
| Multi-DbContext | Bounded context separation | Microservice prep, team ownership |
ISpecification<T> |
Reusable query criteria | Complex, paginated, filterable queries |
| Raw Fluent API | Direct EF Core configuration | Database-specific features, edge cases |
In the next part, we assemble the complete marketplace domain — all 14 entities, all relationships, all behaviors — and count the generated output.