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

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.

Diagram

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);
    }
}

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");
    }
}

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);
}

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);
}

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);
}

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;
}

Register in DI:

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
}

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;
        }
    }
}

Declare it on the 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) { }
}

The hierarchy becomes:

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 { }

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;
}

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 { }

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 { /* ... */ }

The generator creates separate UnitOfWork interfaces for each context:

// 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));

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; }
}

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);
    }
}

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);
}

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);
}

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"));
    }
}

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; } = "";
}

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");
    }
}

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!;

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);
    }
}

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
}

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.