The Pitch
"You describe what your domain model is. The compiler produces how EF Core configures it."
The Problem: Hand-Written EF Core
Every .NET developer who has worked with Entity Framework Core knows the ritual. You write a domain class, then you write a configuration class to tell EF Core how to map it. Then you write a repository. Then you wire it into the DbContext. Then you register it in DI.
For one entity, it looks manageable:
// The domain class — clean, focused, readable
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public string? Description { get; set; }
public decimal Price { get; set; }
public string Sku { get; set; } = "";
public bool IsActive { get; set; }
}// The domain class — clean, focused, readable
public class Product
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public string? Description { get; set; }
public decimal Price { get; set; }
public string Sku { get; set; } = "";
public bool IsActive { get; set; }
}Now the configuration:
// ProductConfiguration.cs — 35 lines of plumbing
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("Products");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(200);
builder.Property(e => e.Description)
.HasMaxLength(2000);
builder.Property(e => e.Price)
.HasPrecision(18, 2);
builder.Property(e => e.Sku)
.IsRequired()
.HasMaxLength(50);
builder.Property(e => e.IsActive)
.IsRequired()
.HasDefaultValue(true);
}
}// ProductConfiguration.cs — 35 lines of plumbing
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("Products");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(200);
builder.Property(e => e.Description)
.HasMaxLength(2000);
builder.Property(e => e.Price)
.HasPrecision(18, 2);
builder.Property(e => e.Sku)
.IsRequired()
.HasMaxLength(50);
builder.Property(e => e.IsActive)
.IsRequired()
.HasDefaultValue(true);
}
}Then the repository:
// IProductRepository.cs
public interface IProductRepository
{
ValueTask<Product?> FindByIdAsync(Guid id);
Task<IReadOnlyList<Product>> FindAllAsync(CancellationToken ct = default);
void Add(Product entity);
void Update(Product entity);
void Remove(Product entity);
// ... and more
}
// ProductRepository.cs — 30+ lines
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context) => _context = context;
public ValueTask<Product?> FindByIdAsync(Guid id)
=> _context.Products.FindAsync(id);
public async Task<IReadOnlyList<Product>> FindAllAsync(CancellationToken ct)
=> await _context.Products.ToListAsync(ct);
public void Add(Product entity) => _context.Products.Add(entity);
public void Update(Product entity) => _context.Products.Update(entity);
public void Remove(Product entity) => _context.Products.Remove(entity);
}// IProductRepository.cs
public interface IProductRepository
{
ValueTask<Product?> FindByIdAsync(Guid id);
Task<IReadOnlyList<Product>> FindAllAsync(CancellationToken ct = default);
void Add(Product entity);
void Update(Product entity);
void Remove(Product entity);
// ... and more
}
// ProductRepository.cs — 30+ lines
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context) => _context = context;
public ValueTask<Product?> FindByIdAsync(Guid id)
=> _context.Products.FindAsync(id);
public async Task<IReadOnlyList<Product>> FindAllAsync(CancellationToken ct)
=> await _context.Products.ToListAsync(ct);
public void Add(Product entity) => _context.Products.Add(entity);
public void Update(Product entity) => _context.Products.Update(entity);
public void Remove(Product entity) => _context.Products.Remove(entity);
}Then the DbContext:
public class AppDbContext : DbContext
{
public DbSet<Product> Products { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new ProductConfiguration());
}
}public class AppDbContext : DbContext
{
public DbSet<Product> Products { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new ProductConfiguration());
}
}Then DI registration:
services.AddDbContext<AppDbContext>(o => o.UseSqlite("..."));
services.AddScoped<IProductRepository, ProductRepository>();services.AddDbContext<AppDbContext>(o => o.UseSqlite("..."));
services.AddScoped<IProductRepository, ProductRepository>();That is one entity. A real domain has twenty, thirty, fifty entities. Each needs the same five-file ceremony. The configuration drifts from the domain class. The repository is 90% identical across entities. The DbContext grows a new DbSet and a new ApplyConfiguration line for every entity. The DI registration grows a new AddScoped line for every repository.
This is not a complexity problem. It is a repetition problem. And repetition breeds drift.
The Entity.Dsl Answer
What if you could write this instead:
using FrenchExDev.Net.Ddd.Attributes;
using FrenchExDev.Net.Entity.Dsl.Attributes;
[AggregateRoot("Product")]
[Table("Products")]
public partial class Product
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
[MaxLength(2000)]
public string? Description { get; set; }
[Precision(18, 2)]
public decimal Price { get; set; }
[Required]
[MaxLength(50)]
public string Sku { get; set; } = "";
[DefaultValue(Value = "true")]
public bool IsActive { get; set; }
}
[DbContext]
public partial class MarketplaceDbContext : DbContext { }using FrenchExDev.Net.Ddd.Attributes;
using FrenchExDev.Net.Entity.Dsl.Attributes;
[AggregateRoot("Product")]
[Table("Products")]
public partial class Product
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
[MaxLength(2000)]
public string? Description { get; set; }
[Precision(18, 2)]
public decimal Price { get; set; }
[Required]
[MaxLength(50)]
public string Sku { get; set; } = "";
[DefaultValue(Value = "true")]
public bool IsActive { get; set; }
}
[DbContext]
public partial class MarketplaceDbContext : DbContext { }And the compiler generated everything else?
That is Entity.Dsl. Two attributed classes. The Source Generator reads the attributes at compile time and emits:
- 3 configuration files per entity (base, partial stub, registration)
- 2 repository files per entity (interface, partial stub with
[Injectable]) - 3 DbContext files (base, partial stub, DI registration extension)
- 3 UnitOfWork files (interface, base, partial stub with
[Injectable])
For our Product entity and MarketplaceDbContext, that is 11 generated files from 2 source files.
What Gets Generated
Let us trace every generated file for the Product entity. This is not pseudocode — these are the actual outputs from the Entity.Dsl source generator.
1. ProductConfigurationBase.g.cs
The abstract base class. Always regenerated. Contains all virtual Configure* methods that the developer can override.
// <auto-generated/>
#nullable enable
namespace Marketplace.Domain.Configuration;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public abstract class ProductConfigurationBase
{
protected virtual void PreConfigure(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder) { }
protected virtual void PostConfigure(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder) { }
protected virtual void ConfigureTable(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.ToTable("Products");
}
protected virtual void ConfigurePrimaryKey(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
}
protected virtual void ConfigureName(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(200);
}
protected virtual void ConfigureDescription(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.Description)
.HasMaxLength(2000);
}
protected virtual void ConfigurePrice(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.Price)
.HasPrecision(18, 2);
}
protected virtual void ConfigureSku(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.Sku)
.IsRequired()
.HasMaxLength(50);
}
protected virtual void ConfigureIsActive(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.IsActive)
.HasDefaultValue(true);
}
public void Configure(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
PreConfigure(builder);
ConfigureTable(builder);
ConfigurePrimaryKey(builder);
ConfigureName(builder);
ConfigureDescription(builder);
ConfigurePrice(builder);
ConfigureSku(builder);
ConfigureIsActive(builder);
PostConfigure(builder);
}
}// <auto-generated/>
#nullable enable
namespace Marketplace.Domain.Configuration;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public abstract class ProductConfigurationBase
{
protected virtual void PreConfigure(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder) { }
protected virtual void PostConfigure(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder) { }
protected virtual void ConfigureTable(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.ToTable("Products");
}
protected virtual void ConfigurePrimaryKey(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
}
protected virtual void ConfigureName(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(200);
}
protected virtual void ConfigureDescription(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.Description)
.HasMaxLength(2000);
}
protected virtual void ConfigurePrice(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.Price)
.HasPrecision(18, 2);
}
protected virtual void ConfigureSku(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.Sku)
.IsRequired()
.HasMaxLength(50);
}
protected virtual void ConfigureIsActive(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.IsActive)
.HasDefaultValue(true);
}
public void Configure(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
PreConfigure(builder);
ConfigureTable(builder);
ConfigurePrimaryKey(builder);
ConfigureName(builder);
ConfigureDescription(builder);
ConfigurePrice(builder);
ConfigureSku(builder);
ConfigureIsActive(builder);
PostConfigure(builder);
}
}Every property gets its own virtual method. The orchestrator calls them in order, bracketed by PreConfigure and PostConfigure hooks. The developer can override any of them without touching any generated file.
2. ProductConfiguration.g.cs
The partial stub. Always regenerated but intentionally empty. The developer extends this via a second partial file.
// <auto-generated/>
#nullable enable
namespace Marketplace.Domain.Configuration;
public partial class ProductConfiguration : ProductConfigurationBase
{
}// <auto-generated/>
#nullable enable
namespace Marketplace.Domain.Configuration;
public partial class ProductConfiguration : ProductConfigurationBase
{
}3. ProductConfigurationRegistration.g.cs
The bridge to EF Core's IEntityTypeConfiguration<T>. This is what modelBuilder.ApplyConfiguration() calls.
// <auto-generated/>
#nullable enable
namespace Marketplace.Domain.Configuration;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public sealed class ProductConfigurationRegistration
: Microsoft.EntityFrameworkCore.IEntityTypeConfiguration<global::Marketplace.Domain.Product>
{
public void Configure(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
new ProductConfiguration().Configure(builder);
}
}// <auto-generated/>
#nullable enable
namespace Marketplace.Domain.Configuration;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public sealed class ProductConfigurationRegistration
: Microsoft.EntityFrameworkCore.IEntityTypeConfiguration<global::Marketplace.Domain.Product>
{
public void Configure(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
new ProductConfiguration().Configure(builder);
}
}4. IProductRepository.g.cs
The typed repository interface. Extends IRepository<Product> from Entity.Dsl.Abstractions.
// <auto-generated/>
#nullable enable
namespace Marketplace.Domain.Repositories;
public interface IProductRepository : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IRepository<global::Marketplace.Domain.Product>
{
global::System.Threading.Tasks.ValueTask<global::Marketplace.Domain.Product?> FindByIdAsync(object id);
}// <auto-generated/>
#nullable enable
namespace Marketplace.Domain.Repositories;
public interface IProductRepository : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IRepository<global::Marketplace.Domain.Product>
{
global::System.Threading.Tasks.ValueTask<global::Marketplace.Domain.Product?> FindByIdAsync(object id);
}The IRepository<T> base provides: FindAsync, FindAllAsync, FindWhereAsync, FindBySpecAsync, ExistsAsync, CountAsync, Query, Add, AddRange, Update, Remove, RemoveRange, Attach.
5. ProductRepository.g.cs
The repository implementation. Note the [Injectable] attribute — this integrates with the Injectable source generator for automatic DI registration.
// <auto-generated/>
#nullable enable
namespace Marketplace.Domain.Repositories;
[global::FrenchExDev.Net.Injectable.Attributes.Injectable(
Scope = global::FrenchExDev.Net.Injectable.Attributes.Scope.Scoped,
As = new global::System.Type[] { typeof(IProductRepository) })]
public partial class ProductRepository : global::FrenchExDev.Net.Entity.Dsl.Abstractions.RepositoryBase<global::Marketplace.Domain.Product>, IProductRepository
{
public ProductRepository(global::Marketplace.Domain.MarketplaceDbContext context) : base(context) { }
public virtual global::System.Threading.Tasks.ValueTask<global::Marketplace.Domain.Product?> FindByIdAsync(object id)
=> FindByIdAsync(new object[] { id });
}// <auto-generated/>
#nullable enable
namespace Marketplace.Domain.Repositories;
[global::FrenchExDev.Net.Injectable.Attributes.Injectable(
Scope = global::FrenchExDev.Net.Injectable.Attributes.Scope.Scoped,
As = new global::System.Type[] { typeof(IProductRepository) })]
public partial class ProductRepository : global::FrenchExDev.Net.Entity.Dsl.Abstractions.RepositoryBase<global::Marketplace.Domain.Product>, IProductRepository
{
public ProductRepository(global::Marketplace.Domain.MarketplaceDbContext context) : base(context) { }
public virtual global::System.Threading.Tasks.ValueTask<global::Marketplace.Domain.Product?> FindByIdAsync(object id)
=> FindByIdAsync(new object[] { id });
}Because this is a partial class, the developer can add custom query methods:
// ProductRepository.cs (developer-owned, never overwritten)
public partial class ProductRepository
{
public async Task<Product?> FindBySkuAsync(string sku, CancellationToken ct = default)
=> await Query.FirstOrDefaultAsync(p => p.Sku == sku, ct);
}// ProductRepository.cs (developer-owned, never overwritten)
public partial class ProductRepository
{
public async Task<Product?> FindBySkuAsync(string sku, CancellationToken ct = default)
=> await Query.FirstOrDefaultAsync(p => p.Sku == sku, ct);
}6. MarketplaceDbContextBase.g.cs
The abstract DbContext with DbSets, lifecycle hooks, and SaveChanges interception.
// <auto-generated/>
#nullable enable
using global::System.Linq;
namespace Marketplace.Domain;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public abstract class MarketplaceDbContextBase : Microsoft.EntityFrameworkCore.DbContext
{
protected MarketplaceDbContextBase(
Microsoft.EntityFrameworkCore.DbContextOptions options,
global::System.IServiceProvider? serviceProvider = null)
: base(options)
{
_serviceProvider = serviceProvider;
}
private readonly global::System.IServiceProvider? _serviceProvider;
public Microsoft.EntityFrameworkCore.DbSet<global::Marketplace.Domain.Product> Products { get; set; } = null!;
protected virtual void PreModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder) { }
protected virtual void PostModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder) { }
protected virtual void RegisterConfigurations(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new global::Marketplace.Domain.Configuration.ProductConfigurationRegistration());
}
protected sealed override void OnModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
PreModelCreating(modelBuilder);
RegisterConfigurations(modelBuilder);
PostModelCreating(modelBuilder);
}
protected virtual void OnEntitiesAdding(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries) { }
protected virtual void OnEntitiesModifying(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries) { }
protected virtual void OnEntitiesDeleting(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries) { }
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
OnBeforeSaveChanges();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override global::System.Threading.Tasks.Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess,
global::System.Threading.CancellationToken cancellationToken = default)
{
OnBeforeSaveChanges();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void OnBeforeSaveChanges()
{
var entries = ChangeTracker.Entries().ToList();
OnEntitiesAdding(entries.Where(e => e.State == Microsoft.EntityFrameworkCore.EntityState.Added));
OnEntitiesModifying(entries.Where(e => e.State == Microsoft.EntityFrameworkCore.EntityState.Modified));
OnEntitiesDeleting(entries.Where(e => e.State == Microsoft.EntityFrameworkCore.EntityState.Deleted));
}
}// <auto-generated/>
#nullable enable
using global::System.Linq;
namespace Marketplace.Domain;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public abstract class MarketplaceDbContextBase : Microsoft.EntityFrameworkCore.DbContext
{
protected MarketplaceDbContextBase(
Microsoft.EntityFrameworkCore.DbContextOptions options,
global::System.IServiceProvider? serviceProvider = null)
: base(options)
{
_serviceProvider = serviceProvider;
}
private readonly global::System.IServiceProvider? _serviceProvider;
public Microsoft.EntityFrameworkCore.DbSet<global::Marketplace.Domain.Product> Products { get; set; } = null!;
protected virtual void PreModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder) { }
protected virtual void PostModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder) { }
protected virtual void RegisterConfigurations(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new global::Marketplace.Domain.Configuration.ProductConfigurationRegistration());
}
protected sealed override void OnModelCreating(Microsoft.EntityFrameworkCore.ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
PreModelCreating(modelBuilder);
RegisterConfigurations(modelBuilder);
PostModelCreating(modelBuilder);
}
protected virtual void OnEntitiesAdding(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries) { }
protected virtual void OnEntitiesModifying(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries) { }
protected virtual void OnEntitiesDeleting(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries) { }
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
OnBeforeSaveChanges();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override global::System.Threading.Tasks.Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess,
global::System.Threading.CancellationToken cancellationToken = default)
{
OnBeforeSaveChanges();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void OnBeforeSaveChanges()
{
var entries = ChangeTracker.Entries().ToList();
OnEntitiesAdding(entries.Where(e => e.State == Microsoft.EntityFrameworkCore.EntityState.Added));
OnEntitiesModifying(entries.Where(e => e.State == Microsoft.EntityFrameworkCore.EntityState.Modified));
OnEntitiesDeleting(entries.Where(e => e.State == Microsoft.EntityFrameworkCore.EntityState.Deleted));
}
}Notice: OnModelCreating is sealed. The developer hooks into PreModelCreating or PostModelCreating instead. This guarantees the configuration registration always runs.
7. MarketplaceDbContext.g.cs
The partial stub.
// <auto-generated/>
#nullable enable
using global::System.Linq;
namespace Marketplace.Domain;
public partial class MarketplaceDbContext : MarketplaceDbContextBase
{
public MarketplaceDbContext(
Microsoft.EntityFrameworkCore.DbContextOptions<MarketplaceDbContext> options,
global::System.IServiceProvider? serviceProvider = null)
: base(options, serviceProvider) { }
}// <auto-generated/>
#nullable enable
using global::System.Linq;
namespace Marketplace.Domain;
public partial class MarketplaceDbContext : MarketplaceDbContextBase
{
public MarketplaceDbContext(
Microsoft.EntityFrameworkCore.DbContextOptions<MarketplaceDbContext> options,
global::System.IServiceProvider? serviceProvider = null)
: base(options, serviceProvider) { }
}8. MarketplaceDbContextRegistration.g.cs
The DI registration extension method.
// <auto-generated/>
#nullable enable
namespace Microsoft.Extensions.DependencyInjection;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public static class MarketplaceDbContextRegistration
{
public static IServiceCollection AddMarketplaceDbContext(
this IServiceCollection services,
global::System.Action<Microsoft.EntityFrameworkCore.DbContextOptionsBuilder> configureDbContext)
{
services.AddDbContext<Marketplace.Domain.MarketplaceDbContext>(configureDbContext);
return services;
}
}// <auto-generated/>
#nullable enable
namespace Microsoft.Extensions.DependencyInjection;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public static class MarketplaceDbContextRegistration
{
public static IServiceCollection AddMarketplaceDbContext(
this IServiceCollection services,
global::System.Action<Microsoft.EntityFrameworkCore.DbContextOptionsBuilder> configureDbContext)
{
services.AddDbContext<Marketplace.Domain.MarketplaceDbContext>(configureDbContext);
return services;
}
}9. IMarketplaceDbContextUnitOfWork.g.cs
The UnitOfWork interface with typed repository properties.
// <auto-generated/>
#nullable enable
namespace Marketplace.Domain;
public interface IMarketplaceDbContextUnitOfWork : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IUnitOfWork<global::Marketplace.Domain.MarketplaceDbContext>
{
global::Marketplace.Domain.Repositories.IProductRepository Products { get; }
}// <auto-generated/>
#nullable enable
namespace Marketplace.Domain;
public interface IMarketplaceDbContextUnitOfWork : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IUnitOfWork<global::Marketplace.Domain.MarketplaceDbContext>
{
global::Marketplace.Domain.Repositories.IProductRepository Products { get; }
}10. MarketplaceDbContextUnitOfWorkBase.g.cs
The abstract UnitOfWork base with lazy repository initialization and virtual factory methods.
// <auto-generated/>
#nullable enable
namespace Marketplace.Domain;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public abstract class MarketplaceDbContextUnitOfWorkBase : IMarketplaceDbContextUnitOfWork
{
private readonly global::Marketplace.Domain.MarketplaceDbContext _context;
private global::Marketplace.Domain.Repositories.IProductRepository? _products;
protected MarketplaceDbContextUnitOfWorkBase(global::Marketplace.Domain.MarketplaceDbContext context)
{
_context = context;
}
public global::Marketplace.Domain.MarketplaceDbContext Context => _context;
public global::Marketplace.Domain.Repositories.IProductRepository Products
=> _products ??= CreateProductsRepository();
protected virtual global::Marketplace.Domain.Repositories.IProductRepository CreateProductsRepository()
=> new global::Marketplace.Domain.Repositories.ProductRepository(_context);
public virtual global::System.Threading.Tasks.Task<int> SaveChangesAsync(global::System.Threading.CancellationToken ct = default)
=> _context.SaveChangesAsync(ct);
public virtual int SaveChanges()
=> _context.SaveChanges();
public virtual async global::System.Threading.Tasks.Task<global::FrenchExDev.Net.Entity.Dsl.Abstractions.IUnitOfWorkTransaction> BeginTransactionAsync(global::System.Threading.CancellationToken ct = default)
{
var tx = await _context.Database.BeginTransactionAsync(ct);
return new UnitOfWorkTransaction(tx);
}
public virtual void DetachAll() => _context.ChangeTracker.Clear();
public virtual bool HasChanges => _context.ChangeTracker.HasChanges();
public void Dispose() => _context.Dispose();
public global::System.Threading.Tasks.ValueTask DisposeAsync() => _context.DisposeAsync();
private sealed class UnitOfWorkTransaction : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IUnitOfWorkTransaction
{
private readonly Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction _tx;
public UnitOfWorkTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction tx) => _tx = tx;
public global::System.Threading.Tasks.Task CommitAsync(global::System.Threading.CancellationToken ct) => _tx.CommitAsync(ct);
public global::System.Threading.Tasks.Task RollbackAsync(global::System.Threading.CancellationToken ct) => _tx.RollbackAsync(ct);
public global::System.Threading.Tasks.ValueTask DisposeAsync() => _tx.DisposeAsync();
}
}// <auto-generated/>
#nullable enable
namespace Marketplace.Domain;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public abstract class MarketplaceDbContextUnitOfWorkBase : IMarketplaceDbContextUnitOfWork
{
private readonly global::Marketplace.Domain.MarketplaceDbContext _context;
private global::Marketplace.Domain.Repositories.IProductRepository? _products;
protected MarketplaceDbContextUnitOfWorkBase(global::Marketplace.Domain.MarketplaceDbContext context)
{
_context = context;
}
public global::Marketplace.Domain.MarketplaceDbContext Context => _context;
public global::Marketplace.Domain.Repositories.IProductRepository Products
=> _products ??= CreateProductsRepository();
protected virtual global::Marketplace.Domain.Repositories.IProductRepository CreateProductsRepository()
=> new global::Marketplace.Domain.Repositories.ProductRepository(_context);
public virtual global::System.Threading.Tasks.Task<int> SaveChangesAsync(global::System.Threading.CancellationToken ct = default)
=> _context.SaveChangesAsync(ct);
public virtual int SaveChanges()
=> _context.SaveChanges();
public virtual async global::System.Threading.Tasks.Task<global::FrenchExDev.Net.Entity.Dsl.Abstractions.IUnitOfWorkTransaction> BeginTransactionAsync(global::System.Threading.CancellationToken ct = default)
{
var tx = await _context.Database.BeginTransactionAsync(ct);
return new UnitOfWorkTransaction(tx);
}
public virtual void DetachAll() => _context.ChangeTracker.Clear();
public virtual bool HasChanges => _context.ChangeTracker.HasChanges();
public void Dispose() => _context.Dispose();
public global::System.Threading.Tasks.ValueTask DisposeAsync() => _context.DisposeAsync();
private sealed class UnitOfWorkTransaction : global::FrenchExDev.Net.Entity.Dsl.Abstractions.IUnitOfWorkTransaction
{
private readonly Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction _tx;
public UnitOfWorkTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction tx) => _tx = tx;
public global::System.Threading.Tasks.Task CommitAsync(global::System.Threading.CancellationToken ct) => _tx.CommitAsync(ct);
public global::System.Threading.Tasks.Task RollbackAsync(global::System.Threading.CancellationToken ct) => _tx.RollbackAsync(ct);
public global::System.Threading.Tasks.ValueTask DisposeAsync() => _tx.DisposeAsync();
}
}11. MarketplaceDbContextUnitOfWork.g.cs
The partial stub with [Injectable].
// <auto-generated/>
#nullable enable
namespace Marketplace.Domain;
[global::FrenchExDev.Net.Injectable.Attributes.Injectable(
Scope = global::FrenchExDev.Net.Injectable.Attributes.Scope.Scoped,
As = typeof(IMarketplaceDbContextUnitOfWork))]
public partial class MarketplaceDbContextUnitOfWork : MarketplaceDbContextUnitOfWorkBase
{
public MarketplaceDbContextUnitOfWork(global::Marketplace.Domain.MarketplaceDbContext context) : base(context) { }
}// <auto-generated/>
#nullable enable
namespace Marketplace.Domain;
[global::FrenchExDev.Net.Injectable.Attributes.Injectable(
Scope = global::FrenchExDev.Net.Injectable.Attributes.Scope.Scoped,
As = typeof(IMarketplaceDbContextUnitOfWork))]
public partial class MarketplaceDbContextUnitOfWork : MarketplaceDbContextUnitOfWorkBase
{
public MarketplaceDbContextUnitOfWork(global::Marketplace.Domain.MarketplaceDbContext context) : base(context) { }
}The Generation Gap Pattern
The generated code follows the Generation Gap pattern — a three-layer hierarchy that separates generated code from developer code:
Layer 1 (Base) contains all the generated logic. It is always regenerated — safe to delete, safe to ignore. Every method is virtual, so Layer 3 can override anything.
Layer 2 (Generated Partial) is an empty partial class that inherits from Layer 1. It exists so that the developer can add a second partial file without touching generated code.
Layer 3 (Developer Partial) is optional and entirely developer-owned. The source generator never writes to it. The developer overrides specific methods when the generated defaults are not enough.
This pattern means:
- Regeneration never destroys developer customization
- The developer sees the full generated code in the IDE (Analyzers node)
- Customization is granular — override one property's configuration without touching the rest
- The base class documents every hook via its virtual methods
Using It
The developer's Program.cs:
var builder = WebApplication.CreateBuilder(args);
// One line: registers the DbContext
builder.Services.AddMarketplaceDbContext(o =>
o.UseSqlite("Data Source=marketplace.db"));
// One line: registers all [Injectable] services (repositories, UoW, etc.)
builder.Services.AddMarketplaceDomainInjectables();
var app = builder.Build();var builder = WebApplication.CreateBuilder(args);
// One line: registers the DbContext
builder.Services.AddMarketplaceDbContext(o =>
o.UseSqlite("Data Source=marketplace.db"));
// One line: registers all [Injectable] services (repositories, UoW, etc.)
builder.Services.AddMarketplaceDomainInjectables();
var app = builder.Build();Using the UnitOfWork in a service:
public class ProductService
{
private readonly IMarketplaceDbContextUnitOfWork _uow;
public ProductService(IMarketplaceDbContextUnitOfWork uow) => _uow = uow;
public async Task<Product> CreateProductAsync(string name, decimal price, string sku)
{
var product = new Product
{
Name = name,
Price = price,
Sku = sku,
IsActive = true
};
_uow.Products.Add(product);
await _uow.SaveChangesAsync();
return product;
}
public async Task<Product?> GetBySkuAsync(string sku)
=> await _uow.Products.Query.FirstOrDefaultAsync(p => p.Sku == sku);
}public class ProductService
{
private readonly IMarketplaceDbContextUnitOfWork _uow;
public ProductService(IMarketplaceDbContextUnitOfWork uow) => _uow = uow;
public async Task<Product> CreateProductAsync(string name, decimal price, string sku)
{
var product = new Product
{
Name = name,
Price = price,
Sku = sku,
IsActive = true
};
_uow.Products.Add(product);
await _uow.SaveChangesAsync();
return product;
}
public async Task<Product?> GetBySkuAsync(string sku)
=> await _uow.Products.Query.FirstOrDefaultAsync(p => p.Sku == sku);
}Two lines of DI registration. No manual repository wiring. No ApplyConfiguration calls. No AddScoped lines. The source generator and [Injectable] handle all of it.
The Numbers
For one entity and one DbContext:
| What | Developer Wrote | Generator Produced |
|---|---|---|
| Domain class | 20 lines | — |
| DbContext declaration | 2 lines | — |
| Configuration (3 files) | — | ~85 lines |
| Repository (2 files) | — | ~25 lines |
| DbContext (3 files) | — | ~75 lines |
| UnitOfWork (3 files) | — | ~70 lines |
| Total | 22 lines | ~255 lines |
That is a 11.6x amplification factor. For a domain with 14 entities (which we build in this series), the developer writes ~180 lines and the generator produces ~2,800 lines across 42+ files.
The real savings are not in line count — they are in consistency. Every entity gets the same patterns. Every repository has the same interface. Every configuration follows the same Generation Gap structure. No entity is the special snowflake that someone forgot to wire up.
What Comes Next
This was the pitch — one entity, one DbContext, eleven generated files. The rest of this series builds a complete online marketplace domain:
- Part II dives into how the source generator works under the hood
- Part III introduces aggregate boundaries with
[Composition]and[Aggregation] - Parts IV-VII add value objects, associations, behaviors, and inheritance
- Part VIII shows how to customize when the generator is not enough
- Part IX assembles the complete domain
- Part X compares Entity.Dsl against the alternatives
The Product entity we defined here will evolve throughout the series — gaining relationships, behaviors, and customizations. By the end, it will be one of fourteen entities in a fully-generated marketplace infrastructure.
NuGet Packages
To follow along, add these package references:
<ItemGroup>
<!-- Runtime abstractions (IRepository, IUnitOfWork, etc.) -->
<PackageReference Include="FrenchExDev.Net.Entity.Dsl.Abstractions" />
<!-- Attribute definitions ([Table], [PrimaryKey], [Column], etc.) -->
<PackageReference Include="FrenchExDev.Net.Entity.Dsl.Attributes" />
<!-- Source generator (analyzer) -->
<PackageReference Include="FrenchExDev.Net.Entity.Dsl.SourceGenerator"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<!-- DDD attributes ([Entity], [AggregateRoot], [Composition], etc.) -->
<PackageReference Include="FrenchExDev.Net.Ddd.Attributes" />
<!-- Injectable for auto DI registration -->
<PackageReference Include="FrenchExDev.Net.Injectable.Attributes" />
</ItemGroup><ItemGroup>
<!-- Runtime abstractions (IRepository, IUnitOfWork, etc.) -->
<PackageReference Include="FrenchExDev.Net.Entity.Dsl.Abstractions" />
<!-- Attribute definitions ([Table], [PrimaryKey], [Column], etc.) -->
<PackageReference Include="FrenchExDev.Net.Entity.Dsl.Attributes" />
<!-- Source generator (analyzer) -->
<PackageReference Include="FrenchExDev.Net.Entity.Dsl.SourceGenerator"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<!-- DDD attributes ([Entity], [AggregateRoot], [Composition], etc.) -->
<PackageReference Include="FrenchExDev.Net.Ddd.Attributes" />
<!-- Injectable for auto DI registration -->
<PackageReference Include="FrenchExDev.Net.Injectable.Attributes" />
</ItemGroup>