Entity Framework Core
Optional source generation for DbSet properties and entity configuration application — driven by [EntityConfiguration], applied to your concrete DbContext, explicit and AOT-friendly.
The EF Core package is optional. Use it when you want the same compile-time, explicit-convention
style for persistence wiring that the rest of Elarion uses — generated DbSet<T> properties and
direct IEntityTypeConfiguration<T> calls instead of reflection-based assembly scanning.
It generates, onto your concrete DbContext, a DbSet<T> for every entity you configure plus a
ConfigureEntities(ModelBuilder) method that applies your configurations. Handlers use that DbContext
directly — its DbSets, LINQ, raw SQL, and provider functions. There is no repository layer, and no
context interface in front of it: the database is application logic, not an abstraction.
This is deliberate (see Why Elarion → The database is application logic).
A per-entity IClientRepository, or an IAppDbContext interface, only hides the model you are actually
programming against and forces a leaky escape hatch the moment you need raw SQL. With full LINQ,
projections, change tracking, and provider features available on the DbContext, you query it directly.
See Handlers → Accessing data.
Setup
Reference the Elarion.EntityFrameworkCore package — it bundles the EF Core source generator — in
every project that declares [EntityConfiguration] configurations or a [GenerateDbSets] context.
Configurations describe the one shared model every feature draws on, so they are part of the shared
data layer, not feature-owned: they live in a shared Persistence layer alongside the concrete
DbContext (the database is application logic — see
Solution structure), while entities themselves stay plain classes.
The generator discovers configurations wherever they live, so placement is a convention. If you do split
the configurations and the DbContext into separate assemblies, each assembly that declares the
configurations and the one that holds the DbContext need this reference: the former so its generator
emits the per-assembly configuration manifest, the latter so the generator reads that manifest to fill the
context with DbSets:
<ItemGroup>
<PackageReference Include="Elarion.EntityFrameworkCore" Version="0.2.6" />
</ItemGroup>Configuring entities
Entities themselves are plain classes — there is no entity marker attribute (previously [DbEntity]).
Participation is driven entirely by an [EntityConfiguration] on the entity's
IEntityTypeConfiguration<TEntity> implementation. That configuration class is the single source of
truth: it drives both the generated DbSet<TEntity> and the Configure(...) application. A configured
entity is a discovered entity.
// Domain/Invoice.cs — plain class, no attribute
namespace MyApp.Domain;
public sealed class Invoice {
public Guid Id { get; set; }
}// Persistence/InvoiceConfiguration.cs — the single source of truth
using Elarion.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MyApp.Persistence;
[EntityConfiguration]
public sealed class InvoiceConfiguration : IEntityTypeConfiguration<Invoice> {
public void Configure(EntityTypeBuilder<Invoice> builder) { /* ... */ }
}Only a configuration carrying [EntityConfiguration] is discovered. A plain
IEntityTypeConfiguration<T> with no attribute is ignored — it produces neither a DbSet nor a
Configure(...) call. A single [EntityConfiguration] class may implement
IEntityTypeConfiguration<T> more than once; each implemented entity gets its own DbSet and its own
Configure(...) call.
An [EntityConfiguration] class that implements no IEntityTypeConfiguration<T> is reported as
ELEFC001 (Warning, category Elarion.EntityFrameworkCore) — nothing is generated for it.
Entity identity
Elarion entities own their ids. The application mints the id in code at the creation site, the
database never generates domain keys, and the generated model declares exactly that. Use
Guid.CreateVersion7() — a UUIDv7's time-ordered prefix keeps primary-key b-tree inserts append-mostly,
where random v4 ids (Guid.NewGuid()) scatter across the whole index:
var invoice = new Invoice {
Id = Guid.CreateVersion7(),
CreatedAt = clock.GetUtcNow(),
// …
};
db.Invoices.Add(invoice);Because the id exists before SaveChanges, handlers can wire foreign keys, soft references, and links
inside one unit of work — and return the id from a create handler — without a round trip.
Two conscious trade-offs come with v7: the id embeds its creation instant (readable wherever the id is
visible, e.g. in URLs), and ids created within the same millisecond are mutually unordered — never sort
business data by id; that is what CreatedAt is for. The one place to keep a random v4 Guid.NewGuid() is
an id that must be unpredictable (a capability-style token) — precisely because a v7 id leaks its creation
timestamp.
The model declares client-assigned keys
EF Core's default convention claims the opposite of the idiom above: a Guid primary key is declared
generated (ValueGeneratedOnAdd, client-side), and EF's insert-vs-update heuristic rides that claim
— a set value on a "generated" key means "this row already exists". The mismatch detonates in one specific
spot, the natural replace the children update on a tracked parent:
var tenant = await db.Tenants.Include(t => t.Contacts).SingleAsync(t => t.Id == id, ct);
tenant.Contacts.Clear();
tenant.Contacts.Add(new Contact { Id = Guid.CreateVersion7(), … });
await db.SaveChangesAsync(ct);
// Under the convention's claim: UPDATE … WHERE id = <never-inserted-guid> → 0 rows
// → DbUpdateConcurrencyException (dotnet/efcore#35090)The generated ConfigureEntities therefore ends with a client-assigned-keys pass that declares every
domain entity's single-property Guid primary key ValueGenerated.Never. It is scoped to the
assemblies of the discovered [EntityConfiguration] entities, so navigation-discovered children —
exactly where the heuristic detonates — are covered, while Identity, DataProtection, and Elarion feature
tables keep their packaged generation. Deliberate generation choices always win: explicit or
data-annotation ValueGenerated configuration, a custom HasValueGenerator, and store defaults
(HasDefaultValueSql and friends) are never overridden. One entity that should use EF-generated ids
opts back in inside its configuration:
builder.Property(e => e.Id).ValueGeneratedOnAdd(); // explicit configuration wins over the passThe change is schema-neutral: adopting it on an existing app produces an empty migration whose only
purpose is re-syncing the model snapshot (EF refuses to Migrate() with pending model changes). An app
that previously left Id unset and relied on EF's client-side generator must now set ids in code
(recommended) or configure ValueGeneratedOnAdd() explicitly.
The EF InMemory provider skips the affected-rows check (SQLite does not enforce it either), so a
write misclassified by the generated-key heuristic stays green in unit tests and only fails on a real
database. Cover replace-children behavior with a real-database integration test (e.g. Testcontainers
PostgreSQL) — creates always work (Add forces the whole graph Added), so a create-path test proves
nothing here. See Testing persistence
for the fixture pattern and why InMemory hides this class of bug.
The full rationale — including why database-side uuidv7() and an EF ValueGenerator were rejected —
lives in ADR-0038.
Generating DbSets
Annotate your concrete partial DbContext with [GenerateDbSets]. The generator emits, onto that
class, a DbSet<T> for every configured entity and a ConfigureEntities(ModelBuilder) method — call it
from OnModelCreating:
using Elarion.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace MyApp.Persistence;
[GenerateDbSets]
public sealed partial class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) {
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
ConfigureEntities(modelBuilder); // generated
}
}The DbContext, its configurations, and the migrations all live together in the application's
Persistence layer; provider registration (UseNpgsql(...) + the connection string) is the host's job.
Handlers inject the concrete AppDbContext and query its DbSets.
Multiple contexts with scopes
For multiple contexts, use string-constant scopes — on each [EntityConfiguration] and on each
[GenerateDbSets] context — so each context only sees the entities it owns:
public static class PersistenceScopes {
public const string Main = "main";
public const string AiAgent = "ai-agent";
}
[EntityConfiguration(PersistenceScopes.Main)] public sealed class InvoiceConfiguration : IEntityTypeConfiguration<Invoice> { /* ... */ }
[EntityConfiguration(PersistenceScopes.AiAgent)] public sealed class ChatSessionConfiguration : IEntityTypeConfiguration<ChatSession> { /* ... */ }
[EntityConfiguration(PersistenceScopes.Main, PersistenceScopes.AiAgent)] public sealed class UserConfiguration : IEntityTypeConfiguration<User> { /* ... */ }
[GenerateDbSets(PersistenceScopes.Main)] public sealed partial class MainDbContext(/* ... */) : DbContext { /* ... */ }
[GenerateDbSets(PersistenceScopes.AiAgent)] public sealed partial class AiAgentDbContext(/* ... */) : DbContext { /* ... */ }A scope is an application-layer partition (a separate DbContext over, by default, the same database
and schema) — not physical data isolation; that is a bounded context.
Scope behavior:
[GenerateDbSets]without scopes includes every[EntityConfiguration].[GenerateDbSets("scope")]includes only configurations whose[EntityConfiguration(...)]scopes intersect.[EntityConfiguration]without scopes participates only in unscoped/global contexts.- Shared configurations can list multiple scopes.
Entity configuration
The generator discovers [EntityConfiguration] implementations in the current and referenced
assemblies and emits a reflection-free, AOT-friendly ConfigureEntities(ModelBuilder) built from
direct ApplyConfiguration<T> calls, equivalent to:
modelBuilder.ApplyConfiguration<Invoice>(new InvoiceConfiguration());A single instance is reused across the entities a multi-entity configuration configures. Because the
[EntityConfiguration] drives both the DbSet and the schema, every configured entity gets a DbSet
— there is no separate "schema-only" path. An unscoped context applies every discovered configuration;
a scoped context filters configurations to the selected scope, so unrelated scoped contexts do not
configure each other's entities.
This avoids ApplyConfigurationsFromAssembly(...) reflection scanning and keeps model wiring
inspectable and AOT-friendly. The trade-off is explicit participation: each entity opts in through its
[EntityConfiguration], and scoped contexts require matching scopes.
In-assembly configurations are discovered through the syntax provider, so dotnet build and the CLI
always reflect the current source. If a newly added same-assembly [EntityConfiguration] does not
produce its DbSet in Rider/ReSharper until you restart, that is the IDE's source-generator host
refreshing lazily, not the generator — build the project or Invalidate Caches / Restart to refresh
it. See Troubleshooting.
Blob entities are not part of [EntityConfiguration] / [GenerateDbSets]. A provider such as
Elarion.Blobs.PostgreSql configures its tables directly in your OnModelCreating (via
UseElarionBlobStorage()), so they join your context's model without opting into entity
generation — see Blob storage.
Pagination
List handlers page against the DbContext and return the shared, transport-neutral Page<T>
envelope (keyset cursors or offset totals). The keyset definition generator ([Keyset<TEntity>])
ships in the EF Core generators package alongside the DbSet generation, but the runtime helpers,
contracts, and the row-value seek note all live with the rest of pagination.