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.
ELID001 (Warning, category Elarion.Identifiers) flags every Guid.NewGuid() reference — called or
passed as a method group — and names Guid.CreateVersion7() as the replacement. Flagging every site is
deliberate: whether a given id becomes a key is not decidable at the call site. At the unpredictable-id site,
keep v4 and suppress ELID001 with a justification — that turns the exception into an argued, visible choice
instead of an accidental one.
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.
Value-shape conventions
Three conveniences every EF consumer ends up hand-rolling. All opt-in, all no-configuration: the call is the decision, and per-property deviation is already expressible in that property's own configuration.
Enums as text
modelBuilder.UseElarionEnumStringConversions(...) stores enum properties as their name rather than their
ordinal. A text column survives reordering or inserting enum members and reads as itself in a database you
migrate and debug; an ordinal column silently re-points every existing row when the enum is edited. Call it as
a post-pass, after the configurations have run, and pass the assemblies that own your entities:
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
ConfigureEntities(modelBuilder); // generated
modelBuilder.UseElarionEnumStringConversions(typeof(Invoice).Assembly);
}Calling it with no arguments applies the pass to every entity type in the model — including types a
third-party library or another Elarion package mapped into the same DbContext, whose enum columns then
change from integer to text. That is a schema change on someone else's model, so scope the call unless the
context's model is entirely application-owned. The generated client-assigned-keys pass scopes itself by
entity assembly for the same reason.
Nullable enums are covered. Explicit configuration always wins — a property that already declares a value converter or a provider CLR type is left alone, so one enum stored as an ordinal opts out by saying so:
builder.Property(e => e.Status).HasConversion<int>(); // untouched by the passA string[] column, converter and comparer together
HasElarionJsonStringArray() stores a string[] as a JSON text column and attaches the matching
comparer in the same call:
builder.Property(e => e.Tags).HasElarionJsonStringArray();
// A nullable string[]? property: PropertyBuilder<T> is invariant, so the null-forgiving operator
// selects the PropertyBuilder<string[]> the method is declared on.
builder.Property(e => e.OptionalTags!).HasElarionJsonStringArray();The pairing is the point. A converted collection with no comparer is compared by reference, so EF misses
every in-place edit (entity.Tags[0] = "…" never saves). And a hand-rolled comparer is where the subtle bug
lives: EF snapshots the property to detect changes, so its equality and hashing halves must agree —
pairing an order-independent equality with an order-dependent hash (or the reverse) breaks the
GetHashCode contract, and change detection then depends on how the elements happened to be ordered.
ElarionValueComparers.Sequence<T>() (arrays) and ElarionValueComparers.SequenceList<T>() (List<T>)
expose that comparer for your own converters. Both are consistently order-dependent: equality is
SequenceEqual, the hash aggregates the same elements in order, and the snapshot is a shallow copy that
carries null through unchanged — reordering a stored list is a change, which is the right default when the
persisted order is part of the value.
builder.Property(e => e.Tags)
.HasConversion(
tags => string.Join(',', tags),
value => value.Split(',', StringSplitOptions.RemoveEmptyEntries),
ElarionValueComparers.Sequence<string>());Serialization goes through the package's own source-generated JSON context, so the column encoding is reflection-free (trim/AOT-safe) and never moves when a host retunes its wire JSON.
Nulls are not converted: EF builds the converter with its default convertsNulls: false and short circuits
them, so a null property writes SQL NULL and a NULL column reads back as a null array — not an empty
one. What the converter does absorb is an empty string, the value an AddColumn migration with a ""
default leaves in existing rows, which reads back as an empty array instead of throwing on invalid JSON.
Sortable DateTimeOffset on SQLite — a recipe, not an API
SQLite has no native DateTimeOffset: EF stores it as text, and ORDER BY on that text is not a
chronological order across differing offsets. If a SQLite-backed query needs newest-first at the database
(rather than sorting client-side), store a sortable surrogate — UtcTicks as long:
builder.Property(e => e.OccurredAt)
.HasConversion(
value => value.UtcTicks,
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));Two consequences to accept deliberately: the original offset is discarded — every value reads back as
UTC, so keep the local offset in its own column if the application needs it — and the column is opaque in a
SQL client. This stays a documented recipe rather than a shipped API because it is provider-specific and
lossy; Elarion's centre of gravity is PostgreSQL, where timestamptz sorts correctly with no surrogate.
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.
Data-rate shaping
Shape high-frequency data with a write-behind buffer, a keyed conflater, a bounded MPSC command queue, and a staged-batch flusher for producer-owned hot state.
Multi-tenancy
Ambient per-tenant isolation — one marker interface attaches a model-level read filter and a write-time stamp, so a forgotten predicate cannot leak across tenants and a forgotten stamp cannot orphan a row.