Comparison
"No tool is right for every situation. The question is not 'is Entity.Dsl good?' but 'is it good for this project?'"
The Landscape
When building an EF Core data layer in .NET, developers choose from several approaches. Each has trade-offs. This part compares Entity.Dsl against six alternatives, then offers a decision framework.
vs Hand-Written Fluent API
The baseline. Most .NET developers write EF Core configurations by hand using the Fluent API.
The Same Entity, Two Ways
Hand-written (35 lines):
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.OrderNumber).IsRequired().HasMaxLength(50);
builder.Property(e => e.Total).HasPrecision(18, 2);
builder.Property(e => e.Status).HasConversion<string>();
builder.HasMany(e => e.Items)
.WithOne(e => e.Order)
.HasForeignKey(e => e.OrderId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(e => e.Customer)
.WithMany(e => e.Orders)
.HasForeignKey(e => e.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
}
}public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedOnAdd();
builder.Property(e => e.OrderNumber).IsRequired().HasMaxLength(50);
builder.Property(e => e.Total).HasPrecision(18, 2);
builder.Property(e => e.Status).HasConversion<string>();
builder.HasMany(e => e.Items)
.WithOne(e => e.Order)
.HasForeignKey(e => e.OrderId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(e => e.Customer)
.WithMany(e => e.Orders)
.HasForeignKey(e => e.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
}
}Plus a separate IOrderRepository, OrderRepository, DbContext registration, and DI wiring.
Entity.Dsl (15 lines):
[AggregateRoot("Order")]
[Table("Orders")]
[Timestampable]
[Blameable]
public partial class Order
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(50)]
public string OrderNumber { get; set; } = "";
[Precision(18, 2)]
public decimal Total { get; set; }
[EnumStorage(AsString = true)]
public OrderStatus Status { get; set; }
public Guid CustomerId { get; set; }
[Aggregation]
[HasOne(WithMany = "Orders", ForeignKey = "CustomerId")]
public Customer Customer { get; set; } = null!;
[Composition]
[HasMany(WithOne = "Order", ForeignKey = "OrderId")]
public List<OrderItem> Items { get; set; } = new();
}[AggregateRoot("Order")]
[Table("Orders")]
[Timestampable]
[Blameable]
public partial class Order
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(50)]
public string OrderNumber { get; set; } = "";
[Precision(18, 2)]
public decimal Total { get; set; }
[EnumStorage(AsString = true)]
public OrderStatus Status { get; set; }
public Guid CustomerId { get; set; }
[Aggregation]
[HasOne(WithMany = "Orders", ForeignKey = "CustomerId")]
public Customer Customer { get; set; } = null!;
[Composition]
[HasMany(WithOne = "Order", ForeignKey = "OrderId")]
public List<OrderItem> Items { get; set; } = new();
}Configuration, repository, UoW, DI registration — all generated.
Comparison
| Criterion | Hand-Written | Entity.Dsl |
|---|---|---|
| Lines of code per entity | ~60 (config + repo + DI) | ~15 (attributed POCO) |
| Consistency across entities | Depends on developer discipline | Guaranteed by generator |
| Delete behavior correctness | Manual — easy to forget | DDD attribute → correct default |
| Repository pattern | Manual per entity | Generated with [Injectable] |
| Customization | Full control | Full control via Generation Gap |
| Learning curve | EF Core Fluent API | Entity.Dsl attributes + EF Core |
| Debugging | Straightforward | Generated code is readable but indirect |
Verdict: For projects with 5+ entities, Entity.Dsl saves significant time and eliminates consistency drift. For 1-3 entities, hand-written code is simpler.
vs EF Core Conventions
EF Core's built-in conventions handle a lot without explicit configuration: FK inference from {Navigation}Id properties, cascade delete for non-nullable FKs, table names from DbSet property names.
What Conventions Get Right
- FK naming:
CustomerIdonOrderautomatically becomes the FK toCustomer - Cascade behavior: Non-nullable FK → Cascade, nullable FK → ClientSetNull
- Table names:
DbSet<Order> Orders→ table nameOrders - Key detection: Property named
Idor{ClassName}Idis automatically the PK
What Conventions Miss
- No DDD semantics: Conventions do not know about Composition vs Aggregation. A non-nullable FK always cascades — even when you want Restrict (e.g., Order → Customer).
- No repository generation: Conventions configure mappings, not infrastructure.
- No behaviors: No Timestampable, SoftDeletable, Blameable, Sluggable.
- No Generation Gap: No way to override a specific convention for a specific entity without writing a full
IEntityTypeConfiguration<T>. - Convention drift: When conventions surprise you (wrong cascade, unexpected column name), the fix is a Fluent API override. Over time, the project accumulates exceptions to conventions.
Comparison
| Criterion | EF Core Conventions | Entity.Dsl |
|---|---|---|
| Setup cost | Zero (built-in) | NuGet packages + learning |
| FK inference | Yes | Yes (plus explicit [HasMany]/[HasOne]) |
| Delete behavior | Based on nullability | Based on DDD relationship type |
| Repository/UoW | No | Generated |
| Behaviors | No | Five built-in |
| Explicitness | Implicit (invisible rules) | Explicit (attributes = documentation) |
Verdict: Conventions are a great starting point. Entity.Dsl is for when you outgrow conventions — when you need explicit DDD semantics, generated infrastructure, and cross-cutting behaviors.
vs Data Annotations
System.ComponentModel.DataAnnotations provides attributes like [Required], [MaxLength], [Key], [Table], [Column]. They look similar to Entity.Dsl's attributes.
What Data Annotations Provide
[Table("Orders")]
public class Order
{
[Key]
public Guid Id { get; set; }
[Required]
[MaxLength(50)]
public string OrderNumber { get; set; } = "";
[Column(TypeName = "decimal(18,2)")]
public decimal Total { get; set; }
}[Table("Orders")]
public class Order
{
[Key]
public Guid Id { get; set; }
[Required]
[MaxLength(50)]
public string OrderNumber { get; set; } = "";
[Column(TypeName = "decimal(18,2)")]
public decimal Total { get; set; }
}What They Cannot Do
- No relationship configuration (no
[HasMany],[HasOne],[Composition]) - No delete behavior control
- No repository generation
- No behaviors (Timestampable, SoftDeletable, etc.)
- No Generation Gap — cannot override a specific property's configuration
- Limited to simple scalar mapping
Data Annotations are a subset of what Fluent API can do. Entity.Dsl attributes are a superset — they express DDD semantics, relationship types, behaviors, and generate infrastructure.
Comparison
| Criterion | Data Annotations | Entity.Dsl |
|---|---|---|
| Relationship config | No | Yes |
| Delete behavior | No | Yes (DDD-aware) |
| Behaviors | No | Five built-in |
| Generated infrastructure | No | Yes |
| Standard .NET | Yes (System namespace) | No (custom package) |
| Attribute density | Low (few per entity) | Higher (more attributes) |
Verdict: Data Annotations are fine for simple CRUD with no DDD. Entity.Dsl is for domain-rich applications where relationships, lifecycle, and behaviors matter.
vs EF Core Compiled Models
EF Core Compiled Models (introduced in EF Core 6) pre-compile the model at build time to reduce startup time. They are a performance optimization, not a modeling approach.
Entity.Dsl and Compiled Models are complementary, not competitive. Entity.Dsl generates the configuration. Compiled Models pre-compile that configuration for faster startup.
<!-- Use both: Entity.Dsl generates the config, Compiled Models pre-compile it -->
<PackageReference Include="FrenchExDev.Net.Entity.Dsl.SourceGenerator"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<!-- EF Core build-time compilation -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" /><!-- Use both: Entity.Dsl generates the config, Compiled Models pre-compile it -->
<PackageReference Include="FrenchExDev.Net.Entity.Dsl.SourceGenerator"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<!-- EF Core build-time compilation -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />vs NHibernate / FluentNHibernate
NHibernate was the original .NET ORM, predating EF Core by over a decade. It solved the same problem Entity.Dsl addresses — but with different tools.
| Criterion | NHibernate XML | FluentNHibernate | Entity.Dsl |
|---|---|---|---|
| Configuration format | XML mappings | C# fluent code | Attributes + source generation |
| Runtime vs compile-time | Runtime (XML parsing) | Runtime (reflection) | Compile-time (source generation) |
| IDE support | XML schema validation | IntelliSense for fluent API | IntelliSense + generated code navigation |
| Repository generation | No | No | Yes |
| Convention support | Yes (auto-mapping) | Yes (conventions) | Yes (attribute-driven) |
| Performance overhead | XML parsing + reflection | Reflection | Zero runtime overhead |
| Maturity | 20+ years | 15+ years | Newer |
| Ecosystem | Established, fewer new features | Stable | Growing |
NHibernate's approach was revolutionary for its time. Entity.Dsl applies the same idea — declarative mapping — but leverages modern tooling (Source Generators, Roslyn) to eliminate runtime costs and generate more infrastructure.
Verdict: If you are already on NHibernate, migration is a significant effort. For new projects on EF Core, Entity.Dsl is a more modern approach to the same problem NHibernate solved.
vs Dapper / Micro-ORMs
Dapper and other micro-ORMs take a fundamentally different approach: SQL-first, with thin mapping.
// Dapper: you write the SQL, Dapper maps the result
var orders = await connection.QueryAsync<Order>(
"SELECT * FROM Orders WHERE CustomerId = @CustomerId ORDER BY OrderDate DESC",
new { CustomerId = customerId });// Dapper: you write the SQL, Dapper maps the result
var orders = await connection.QueryAsync<Order>(
"SELECT * FROM Orders WHERE CustomerId = @CustomerId ORDER BY OrderDate DESC",
new { CustomerId = customerId });Different Philosophies
| Dapper (SQL-first) | Entity.Dsl (Model-first) | |
|---|---|---|
| Query language | Raw SQL | LINQ (translated to SQL) |
| Change tracking | No | Yes |
| Migrations | Manual (or tool-assisted) | EF Core Migrations |
| Performance | Maximum (raw SQL) | Good (EF Core overhead) |
| Productivity | Lower (write SQL) | Higher (LINQ + generated repos) |
| Schema changes | Manual SQL updates | Automatic migration generation |
| Complex joins | SQL expertise needed | LINQ Include/ThenInclude |
| Bulk operations | Fast (raw SQL) | Slower (tracked entities) |
When Dapper Is Better
- Read-heavy: Reporting, dashboards, analytics — raw SQL with Dapper is faster and more readable than complex LINQ
- Existing schema: Database-first projects where the schema is owned by DBAs
- Performance-critical: Sub-millisecond queries where EF Core's overhead matters
- Simple CRUD: No DDD, no behaviors, no aggregate boundaries — just tables
When Entity.Dsl Is Better
- DDD-rich domains: Aggregates, compositions, value objects, lifecycle ownership
- Team standardization: Generated infrastructure ensures consistency across developers
- Rapid development: LINQ + generated repositories + UoW vs writing SQL + mapping
- Schema evolution: EF Core Migrations from the model, not manual DDL
Verdict: Dapper and Entity.Dsl solve different problems. Many projects use both — Entity.Dsl for the write side (domain model, business logic) and Dapper for the read side (queries, reports, projections).
vs Other Source Generators
Several EF Core source generators exist in the .NET ecosystem:
| Generator | Focus | DDD-Aware? | Behaviors? | Generation Gap? |
|---|---|---|---|---|
| Entity.Dsl | Full domain infrastructure | Yes | Yes (5 built-in) | Yes |
| EF Core Scaffolding | Database-first reverse engineering | No | No | No |
| EntityFrameworkCore.Generator | Configuration from model conventions | No | No | No |
| Meziantou.Polyfill | Backporting newer EF Core APIs | No | No | No |
Entity.Dsl's differentiators:
- DDD awareness: Composition/Aggregation/Association → delete behavior
- Behavior system: Timestampable, SoftDeletable, Blameable, Sluggable, Versionable
- Generation Gap pattern: Three-layer override hierarchy
- Injectable integration: Automatic DI registration via
[Injectable] - Association classes: First-class M:N with payload and specialized repositories
- Emit model architecture: DSL-to-DSL generation capability
Entity.Dsl Shines When
- Domain-rich: Aggregates, compositions, value objects, lifecycle ownership
- Team consistency: Multiple developers, need to prevent convention drift
- Large models: 10+ entities where generated infrastructure pays for itself
- DDD-first: The domain model drives the persistence, not the other way around
- Behaviors needed: Timestamps, soft-delete, audit trails are common across entities
Entity.Dsl Is Overkill When
- Small projects: 1-5 entities, simple CRUD, no DDD
- Database-first: Schema is owned by DBAs, you adapt to it
- Dapper-first: SQL is the primary query language
- Prototype: Speed of iteration matters more than architecture
- Team unfamiliarity: If no one on the team knows Source Generators, the learning curve adds risk
Learning Curve
Entity.Dsl introduces new concepts: DDD attributes, the Generation Gap pattern, behavior composition, emit models. A developer familiar with EF Core needs 1-2 days to be productive, and a week to understand the generated code deeply.
Debugging Complexity
Generated code is readable but adds indirection. When a query does not behave as expected, the developer must trace through:
- The attribute on the entity → 2. The generated configuration → 3. The EF Core translation → 4. The SQL output.
This is one more step than hand-written code (where step 2 is the developer's own configuration). The trade-off: you trace less often because the generated code is correct by construction.
Build-Time Overhead
Source Generators add time to the build. Entity.Dsl's incremental generator minimizes this, but the first build (cold cache) takes longer. On a large project (50+ entities), expect 1-3 seconds of additional build time.
Dependency Risk
Entity.Dsl is a custom framework. If the project is abandoned or the team moves on, the generated code still works — it is standard EF Core. But the attributes become dead decorators, and new entities must be configured manually. The migration path is: remove the generator, keep the generated .g.cs files, edit them as regular code.
The Exit Strategy
This deserves emphasis because it is often overlooked when evaluating code generation tools. Entity.Dsl's exit strategy is clean:
- Enable
EmitCompilerGeneratedFilesin your.csprojto write generated files to disk - Copy the generated
.g.csfiles fromobj/GeneratedFiles/into your source tree - Remove the
FrenchExDev.Net.Entity.Dsl.SourceGeneratorNuGet reference - Remove
OutputItemType="Analyzer"from the package reference - Keep the Entity.Dsl.Abstractions and Ddd.Attributes packages (they are runtime dependencies)
- Build — everything compiles. The generated files are now regular source files.
From this point, the project uses standard EF Core with standard files. The attributes remain as documentation but have no effect. New entities are configured manually. Existing entities continue to work.
This is possible because Entity.Dsl generates standard EF Core code, not a custom runtime. There is no Entity.Dsl runtime layer, no proxy classes, no reflection hooks. The generator produces the same code a developer would write by hand — just automatically.
Pattern 1: Greenfield with Entity.Dsl from Day One
Start a new project with Entity.Dsl. Every entity uses attributes. The team learns the pattern together.
Pros: Maximum consistency, maximum ROI, team builds muscle memory early.
Cons: Learning curve front-loaded. First sprint may be slower.
Pattern 2: Incremental Adoption in Brownfield
Add Entity.Dsl to an existing project. New entities use attributes. Existing entities keep their hand-written configurations.
Pros: No migration risk. Team learns incrementally. Existing code untouched.
Cons: Two patterns coexist. Cognitive load increases temporarily. Generated and hand-written configurations must not conflict.
Pattern 3: Entity.Dsl for Write Side, Dapper for Read Side
Use Entity.Dsl for the domain model (commands, mutations, business logic) and Dapper for the query side (reports, dashboards, search).
Pros: Best of both worlds. Domain model gets generated infrastructure. Queries get raw SQL performance.
Cons: Two data access patterns. Team needs both skill sets.
Pattern 4: Entity.Dsl as Reference Implementation
Use Entity.Dsl to generate the initial infrastructure. Then eject (exit strategy above) and maintain the generated code by hand.
Pros: Fast bootstrapping. Generated code serves as a high-quality starting point.
Cons: Loses ongoing generation benefits. One-time ROI.
A Word on Code Generation Philosophy
Entity.Dsl belongs to a broader movement in .NET: attribute-driven code generation. The pattern appears across the ecosystem:
[JsonSerializable](System.Text.Json) — generates serializers at compile time[LoggerMessage](Microsoft.Extensions.Logging) — generates high-performance log methods[GeneratedRegex](System.Text.RegularExpressions) — compiles regex at build time[Injectable](FrenchExDev) — generates DI registration from attributes- Entity.Dsl — generates EF Core infrastructure from domain attributes
The pattern is consistent: declare intent with an attribute, let the compiler generate the implementation. Each generation shifts work from runtime to compile time, reduces boilerplate, and makes the attribute itself serve as documentation.
Entity.Dsl extends this pattern to the domain layer — the part of the application where most of the complexity lives and where consistency matters most.
Feature-by-Feature Comparison Table
A detailed breakdown of what each approach supports out of the box:
| Feature | Hand-Written | Conventions | Data Annotations | NHibernate | Dapper | Entity.Dsl |
|---|---|---|---|---|---|---|
| Table mapping | Manual | Auto | [Table] |
XML/Fluent | N/A | [Table] |
| PK configuration | Manual | Convention | [Key] |
XML/Fluent | N/A | [PrimaryKey] |
| Composite keys | Manual | No | No | XML/Fluent | N/A | [PrimaryKey(Order)] |
| Column mapping | Manual | Convention | [Column] |
XML/Fluent | N/A | [Column] |
| Required/MaxLength | Manual | No | [Required]/[MaxLength] |
XML/Fluent | N/A | [Required]/[MaxLength] |
| Relationships | Manual Fluent | Auto FK | No | XML/Fluent | N/A | [HasMany]/[HasOne] |
| Delete behavior | Manual | Nullability | No | XML/Fluent | N/A | DDD-aware auto |
| Composition/Aggregation | Manual | No | No | No | N/A | [Composition]/[Aggregation] |
| Owned types | Manual | No | No | Component | N/A | [Owned]/[OwnedEntity] |
| Complex types | Manual | No | No | No | N/A | [ComplexType]/[ValueObject] |
| Inheritance (TPH/TPT/TPC) | Manual | No | No | XML/Fluent | N/A | [Inheritance] |
| Many-to-many | Manual | Auto (EF5+) | No | XML/Fluent | N/A | [ManyToMany]/[AssociationClass] |
| Self-reference | Manual | No | No | XML/Fluent | N/A | [SelfReference] |
| Timestamps | Manual | No | No | Interceptor | N/A | [Timestampable] |
| Soft delete | Manual | No | No | Filter | N/A | [SoftDeletable] |
| Audit trail | Manual | No | No | Interceptor | N/A | [Blameable] |
| URL slugs | Manual | No | No | No | N/A | [Sluggable] |
| Concurrency | Manual | No | [Timestamp] |
Versioning | N/A | [Versionable] |
| Repository generation | No | No | No | No | No | Yes |
| UnitOfWork generation | No | No | No | No | No | Yes |
| DI registration | Manual | No | No | No | No | [Injectable] auto |
| Generation Gap | No | No | No | No | N/A | Yes (3-layer) |
| Compile-time validation | No | No | No | No | N/A | Diagnostics (EDSL0xxx) |
| Enum storage | Manual | Convention | No | XML | N/A | [EnumStorage] |
| Computed columns | Manual | No | No | Formula | N/A | [ComputedColumn] |
| Default values | Manual | No | No | XML | N/A | [DefaultValue] |
| Database comments | Manual | No | No | No | N/A | [Comment] |
| JSON columns | Manual | No | No | No | N/A | [OwnedEntity(JsonColumn)] |
| Specification pattern | Manual | No | No | Criteria | Manual | Built-in ISpecification<T> |
| Association repository | Manual | No | No | No | No | IAssociationRepository auto |
| Custom repo base | Manual | No | No | Manual | N/A | [DbContext(RepositoryBase)] |
Summary Matrix
| Approach | Best For | Generated Code | DDD-Aware | Behaviors | Learning Curve |
|---|---|---|---|---|---|
| Hand-written Fluent API | Small projects, full control | No | No | No | Low |
| EF Core Conventions | Convention-over-configuration shops | No | No | No | Lowest |
| Data Annotations | Simple CRUD, minimal mapping | No | No | No | Low |
| NHibernate | Legacy projects, XML-era | No | Partial | Partial | Medium |
| Dapper | Read-heavy, SQL-first | No | No | No | Low |
| Entity.Dsl | DDD-rich domains, team consistency | Yes (13x amplification) | Yes | Yes (5 built-in) | Medium |
"Does Entity.Dsl support database-first workflows?"
No. Entity.Dsl is model-first by design. You define entities with attributes, and the generator produces configurations. If your schema is owned by DBAs, use EF Core scaffolding or Dapper. Entity.Dsl is for projects where the domain model drives the database schema.
"Can I use Entity.Dsl with non-SQL databases?"
Entity.Dsl generates standard EF Core configuration. If EF Core supports your database (Cosmos DB, PostgreSQL, MySQL, SQLite), Entity.Dsl works. However, database-specific features (JSON columns, PostGIS geography, Cosmos DB partitioning) require raw Fluent API in the partial Configuration class.
"How does Entity.Dsl handle migrations?"
Entity.Dsl does not generate migrations. It generates EF Core configurations. You use dotnet ef migrations add as usual. The migration tooling reads the generated configurations and produces migration files. This is transparent — the migration tool does not know or care that the configurations were generated.
"Can I use Entity.Dsl in a project that already has hand-written configurations?"
Yes. Entity.Dsl generates configurations for attributed entities only. Hand-written configurations for non-attributed entities continue to work. The two approaches coexist — the generated DbContext's RegisterConfigurations method only registers configurations for attributed entities. You call modelBuilder.ApplyConfiguration(new MyHandWrittenConfig()) in the PostModelCreating hook for the rest.
"What happens if I remove an attribute?"
The generator stops producing files for that entity. The next build will fail with missing type errors (the repository interface, the configuration, etc.). Remove the references to the generated types, and the project compiles with one less generated entity. Or add the hand-written equivalents.
"Is there runtime overhead?"
Zero. Entity.Dsl is purely compile-time. The generated code is standard C# that compiles to the same IL as hand-written code. No reflection, no proxy classes, no runtime scanning. The attributes themselves are compiled into the assembly metadata but never read at runtime.
Closing Thought
Entity.Dsl is not a replacement for understanding EF Core. It is a multiplier for developers who already understand it. The generated code is standard EF Core — readable, debuggable, and overridable. The attributes are documentation that the compiler enforces. The behaviors are cross-cutting concerns that compose without conflict.
The pitch from Part I holds: you describe what your domain model is. The compiler produces how EF Core configures it. And if the compiler gets it wrong, the Generation Gap pattern lets you fix it without losing the automation.
The marketplace domain we built — 14 entities, 7 aggregates, 5 behaviors, 1 inheritance hierarchy — is real-world complexity. Entity.Dsl handled it with 210 lines of input and 2,800 lines of generated output. Whether that trade-off is worth it depends on your project, your team, and your domain.
For domains rich enough to need DDD patterns, the answer is usually yes.