Skip to content

Entity Configuration

EF Core needs to know how to map your entities to the database: which property is the primary key, which properties need indexes, how relationships are configured, what the column types should be. Without configuration, EF Core guesses, and sometimes guesses wrong.

Writing IEntityTypeConfiguration<T> for each entity is repetitive: every entity needs a primary key on PersistenceId, every [LogicKey] property needs a unique index, every [SoftDelete] entity needs a query filter.

The source generator produces a complete IEntityTypeConfiguration<T> for each entity, based on the attributes you’ve declared.

// ═══ Generated: EntityConfig.{Namespace}.Order.g.cs ═══
internal sealed class OrderEntityConfig : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders"); // the type name, pluralised
builder.HasKey(e => e.PersistenceId);
// A Guid key is assigned by the trait at construction, so the store must not claim it
builder.Property(e => e.PersistenceId).ValueGeneratedNever();
// [LogicKey] → unique index
builder.HasIndex(e => e.OrderNumber).IsUnique();
// private set → EF writes the field, not the property
builder.Property(e => e.OrderNumber).UsePropertyAccessMode(PropertyAccessMode.Field).IsRequired();
// [SoftDelete] → a *named* EF query filter, plus an index on the flag
builder.HasQueryFilter("SoftDelete", e => !e.IsDeleted);
builder.HasIndex(e => e.IsDeleted);
// [Relation.OneToMany<LineItem>] declared on the parent → Cascade
builder.HasMany(e => e.LineItems)
.WithOne(e => e.Order)
.OnDelete(DeleteBehavior.Cascade);
}
}

Three things that are not in there:

  • The concurrency token. [ConcurrencyAware] produces a shadow property, declared in the generated DbContext and shaped by the provider; there is no e.RowVersion to configure.
  • The audit fields. [Auditable] adds the four properties to the entity; nothing configures them here, so they take EF’s conventions.
  • The filtered unique index. On a [SoftDelete] entity the logic-key index has to exclude deleted rows, or a re-insert with the same key fails. That filter is raw provider SQL, so it is emitted in the DbContext where the provider is known: modelBuilder.Entity<Order>().HasIndex(e => e.OrderNumber).IsUnique().HasFilter("\"IsDeleted\" = 0").

The SG applies these rules when generating property configuration:

C# Type Generated Configuration Notes
string .IsRequired() if not nullable .HasMaxLength(n) if [MaxLength(n)] present
decimal .HasPrecision(18, 2) Default precision for monetary values
byte[] (RowVersion) .IsRowVersion() Only for [ConcurrencyAware] entities
enum Stored as int Default EF Core behavior
DateTimeOffset .IsRequired() if not nullable Audit fields: CreatedAt is required, UpdatedAt is optional

For each [Relation.*] attribute on the entity class, the SG generates the corresponding Fluent API calls:

// [Relation.OneToMany<LineItem>] on Order: declared by the parent, so Cascade
builder.HasMany(e => e.LineItems)
.WithOne(e => e.Order)
.OnDelete(DeleteBehavior.Cascade);
// [Relation.ManyToOne<Customer>] on Order: declared by the child, so Restrict
builder.HasOne(e => e.Customer)
.WithMany()
.HasForeignKey(e => e.CustomerId)
.IsRequired()
.OnDelete(DeleteBehavior.Restrict);
// [Relation.OneToOne<RelationSide>]: the principal holds the FK
builder.HasOne(e => e.RelationSide)
.WithOne()
.HasForeignKey<Order>(e => e.RelationSideId)
.OnDelete(DeleteBehavior.Restrict);

The navigation names come from the target type, pluralised for collections: LineItems, not any property you wrote. .WithNavigation("Items") on the relation renames it.

The delete behaviour depends on which side declares the relation: [Relation.OneToMany] on the parent gives Cascade, [Relation.ManyToOne] on the child gives Restrict, and an inverse the generator derives on its own gets NoAction. See Relationships: the default is not the cautious one.

The generated configuration includes two complementary filtering mechanisms:

1. EF Core Global Query Filter (in EntityConfiguration)

Section titled “1. EF Core Global Query Filter (in EntityConfiguration)”
builder.HasQueryFilter("SoftDelete", e => !e.IsDeleted);

EF Core’s built-in mechanism, applied at the model level. It is a named filter, so it can be lifted on its own with IgnoreQueryFilters(["SoftDelete"]) rather than all-or-nothing.

2. Repository-Level Query Filter (IQueryFilter pipeline)

Section titled “2. Repository-Level Query Filter (IQueryFilter pipeline)”
// In the generated repository
var filter = _filterProvider?.GetCombinedFilter<Order>(context);
query = query.Where(filter);

This is Pragmatic’s filter pipeline. It supports runtime toggle (IQueryFilterToggle), filter modes, and navigation-level filtering.

Why both? The EF Core filter provides a safety net: even if code bypasses the repository and queries the DbSet directly, soft-deleted records are still excluded. The Pragmatic filter pipeline adds runtime configurability (disable, modes, navigation filtering).

For entities with [Inheritance], a separate static configuration class is generated:

// ═══ Generated: {Namespace}.Fee.InheritanceMapping.g.cs ═══
public static class FeeInheritanceConfiguration
{
public static void Configure(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Fee>()
.HasDiscriminator<string>("FeeType")
.HasValue<ServiceFee>("ServiceFee")
.HasValue<CancellationFee>("CancellationFee");
}
}

This is called in OnModelCreating after the ApplyConfiguration calls:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Entity configurations
modelBuilder.ApplyConfiguration(new FeeEntityConfiguration());
modelBuilder.ApplyConfiguration(new ServiceFeeEntityConfiguration());
// Inheritance mappings (after configurations)
FeeInheritanceConfiguration.Configure(modelBuilder);
}

The order matters: entity configurations define properties and relationships, then inheritance configuration defines the discriminator and type mappings.