Elarion

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 pass

The 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.

On this page