Entity Framework Core
Optional source generation for DbSet properties and entity configuration application — interface-first, 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.
Its main purpose is to give you a generated application-context interface (IAppDbContext) that
handlers depend on directly. That interface is your data-access abstraction: application code in the
application layer queries DbSets and calls SaveChangesAsync without referencing the infrastructure
project that owns the concrete DbContext.
This is the recommended alternative to a per-entity repository layer. With full LINQ, projections,
and EF Core change tracking already available through IAppDbContext, a hand-written
IClientRepository/IInvoiceRepository per table usually adds indirection without adding value.
Introduce a dedicated abstraction only when a query genuinely needs to be reused or hidden behind a
domain operation. See Handlers → Accessing data.
Setup
Reference the marker package and the generator analyzer in the project that owns your entities and context abstraction:
<ItemGroup>
<PackageReference Include="Elarion.EntityFrameworkCore" Version="0.1.0" />
<PackageReference Include="Elarion.EntityFrameworkCore.Generators" Version="0.1.0" PrivateAssets="all" />
</ItemGroup>Marking entities
Entities opt in explicitly with [DbEntity]:
using Elarion.EntityFrameworkCore;
namespace MyApp.Domain.Entities;
[DbEntity]
public sealed class Invoice {
public Guid Id { get; set; }
}Generating DbSets
Annotate an application-level context interface with [GenerateDbSets]. This is the single source
of truth for generated DbSets:
using Microsoft.EntityFrameworkCore;
using Elarion.EntityFrameworkCore;
namespace MyApp.Application;
[GenerateDbSets]
public partial interface IAppDbContext {
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
DbContext AsDbContext();
}The generator emits DbSet<T> members into the interface and matching concrete members into any
partial DbContext class that implements it:
using Microsoft.EntityFrameworkCore;
using MyApp.Application;
namespace MyApp.Infrastructure.Data;
public sealed partial class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options), IAppDbContext {
public DbContext AsDbContext() => this;
protected override void OnModelCreating(ModelBuilder modelBuilder) {
base.OnModelCreating(modelBuilder);
ConfigureEntities(modelBuilder); // generated
}
}Do not add [GenerateDbSets] to the concrete context. Class generation is inferred from the
generated interface so applications have one declaration point.
Multiple contexts with scopes
For multiple contexts, use string-constant scopes so each context only sees the entities it owns:
public static class PersistenceScopes {
public const string Main = "main";
public const string AiAgent = "ai-agent";
}
[DbEntity(PersistenceScopes.Main)] public sealed class Invoice { /* ... */ }
[DbEntity(PersistenceScopes.AiAgent)] public sealed class ChatSession { /* ... */ }
[DbEntity(PersistenceScopes.Main, PersistenceScopes.AiAgent)] public sealed class User { /* ... */ }
[GenerateDbSets(PersistenceScopes.Main)] public partial interface IMainDbContext;
[GenerateDbSets(PersistenceScopes.AiAgent)] public partial interface IAiAgentDbContext;Scope behavior:
[GenerateDbSets]without scopes keeps global behavior and includes every[DbEntity].[GenerateDbSets("scope")]includes only entities whose[DbEntity(...)]scopes intersect.[DbEntity]without scopes participates only in unscoped/global generated interfaces.- Shared entities can list multiple scopes.
Entity configuration
The generator discovers IEntityTypeConfiguration<T> implementations in the current and referenced
assemblies and emits direct calls, equivalent to:
new InvoiceConfiguration().Configure(modelBuilder.Entity<Invoice>());Unscoped interfaces apply every discovered configuration, including schema-only configuration types
that are not exposed as DbSet<T>. Scoped interfaces filter configurations to the selected
[DbEntity] set, 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: entities must opt in with
[DbEntity], and scoped contexts require matching scopes.
Pagination
List handlers return one shared, transport-neutral envelope, Page<T> (from
Elarion.Abstractions.Paging), so every list looks identical across JSON-RPC, MCP, HTTP, and the
generated TypeScript client:
public sealed record Page<T> {
public required IReadOnlyList<T> Items { get; init; }
public string? StartCursor { get; init; } // keyset only
public string? EndCursor { get; init; } // keyset only
public bool HasNext { get; init; }
public bool HasPrevious { get; init; }
public int? Total { get; init; } // offset only
}Keyset (seek) pagination is the default for feeds and large lists; offset pagination is available where a UI needs page numbers and a total count.
Pagination ships in a separate package so the marker package stays dependency-free. Reference it (plus the generators analyzer) in the project that owns your entities and handlers:
<PackageReference Include="Elarion.Paging" Version="0.1.0" />
<PackageReference Include="Elarion.EntityFrameworkCore.Generators" Version="0.1.0" PrivateAssets="all" />Runtime types live in Elarion.Paging; the Page<T> envelope and request
contracts live in Elarion.Abstractions.Paging.
Keyset pagination
Declare the ordered key columns on a dedicated partial class with [Keyset<TEntity>] — the entity
stays free of pagination concerns, and an entity can have any number of orderings. Columns are
ascending by default; prefix a name with - for descending. The generator fills the class with a
reflection-free IKeysetDefinition<TEntity> (ordering and seek predicate are plain typed expressions
the provider translates to SQL like hand-written OrderBy/Where) plus a static Definition
singleton and an opaque cursor codec that carries the key values (no extra round-trip to reload a
reference row):
using Elarion.EntityFrameworkCore; // [DbEntity]
using Elarion.Paging; // [Keyset<T>]
[DbEntity]
public sealed class Client {
public Guid Id { get; init; }
public DateTime CreatedAt { get; init; }
public string Name { get; init; } = "";
}
// one partial class per ordering, declared near the feature that pages it
[Keyset<Client>(nameof(Client.CreatedAt), nameof(Client.Id))] // CreatedAt, then Id to break ties
public sealed partial class ClientsByCreated;For a "newest first" feed, prefix each column with - to sort descending. Keeping every column the
same direction also lets the Npgsql provider use a row-value seek (see below):
[Keyset<Post>("-CreatedAt", "-Id")] // CreatedAt desc, then Id desc to break ties
public sealed partial class RecentPosts;Handlers call ToKeysetPageAsync with the keyset definition and a server-side projection. Passing the
definition explicitly (ClientsByCreated.Definition) keeps keyset symmetric with offset paging — which
passes a SortMap — and lets one handler choose among an entity's orderings. The request implements
IKeysetPageRequest so its after/before/size fields stay flat for HTTP GET query binding:
[RpcMethod("clients.list")]
[HttpEndpoint("clients")]
public sealed class ListClients(IAppDbContext db, ICurrentUser user)
: IHandler<ListClients.Query, Result<Page<ListClients.Item>>> {
public sealed record Query : IKeysetPageRequest {
public string? After { get; init; }
public string? Before { get; init; }
public int Size { get; init; } = 20;
public string? Search { get; init; }
}
public sealed record Item(Guid Id, string Name, DateTime CreatedAt);
public async ValueTask<Result<Page<Item>>> HandleAsync(Query query, CancellationToken ct) {
var rows = db.Clients.Where(c => c.OwnerId == user.UserId);
if (!string.IsNullOrEmpty(query.Search)) {
rows = rows.Where(c => c.Name.Contains(query.Search));
}
return await rows.ToKeysetPageAsync(
query,
ClientsByCreated.Definition,
c => new Item(c.Id, c.Name, c.CreatedAt),
maxSize: 100, ct);
}
}The selector is the only projection — it runs server-side, composed with the key columns so the
query reads just your columns plus the keys (untracked, no extra round-trip) and encodes the boundary
cursors from those keys. That is why it is passed in rather than applied as a separate .Select
before paging: the cursor needs the key values your DTO does not carry.
Pass After (an EndCursor from a prior page) to page forward, Before (a StartCursor) to page
backward, or neither for the first page. Size is clamped to maxSize.
Cursors are opaque but not a security boundary — they encode the boundary row's key values and
are not signed. Keep applying your own authorization filter (e.g.
Where(c => c.OwnerId == user.UserId)); never trust a cursor for access control.
Keyset column rules (enforced by generator diagnostics):
- Column names must match a property (
ELKEY001), so a typo — or a rename you forget to propagate — is a build error, not a runtime bug. That is why the"-Column"string form needs nonameof. - Columns must be non-nullable (
ELKEY003) for deterministic SQL ordering. - The final column should be unique (typically the key); if the entity has an
Idthat is not in the keyset, the generator warns (ELKEY004) that paging may skip or repeat rows. - Supported column types include the integral types,
decimal/double/float,string,Guid,DateTime,DateTimeOffset,DateOnly,TimeOnly, and enums; others reportELKEY002.
Provider-optimized seek (Npgsql row values)
By default the seek predicate is a portable lexicographic comparison
(a > x || (a == x && b > y)) that every provider can translate. PostgreSQL can do better: a
row-value expression
(a, b) > (x, y) is a single comparison that a composite index on (a, b) satisfies directly. EF
Core has no provider-neutral row-value API yet (dotnet/efcore#26822),
so this is an explicit opt-in that keeps the default output provider-neutral.
Add the assembly-level attribute (typically in the project that owns your entities) to switch the generated seek to row values:
using Elarion.EntityFrameworkCore;
[assembly: UseElarionEntityFrameworkCore(Provider = EfCoreProvider.Npgsql)]With Npgsql selected, a keyset of two or more columns that share one direction emits
EF.Functions.GreaterThan/LessThan over ValueTuples, which Npgsql renders as a row value:
// [Keyset<Client>(nameof(Client.CreatedAt), nameof(Client.Id))] with the Npgsql provider:
__e => EF.Functions.GreaterThan(
ValueTuple.Create(__e.CreatedAt, __e.Id),
ValueTuple.Create(__key0, __key1));The generator falls back to the portable predicate automatically when row values do not apply: a
single-column keyset, mixed sort directions (e.g. -CreatedAt, Id), or the default Portable
provider. The opt-in requires the Npgsql.EntityFrameworkCore.PostgreSQL package, which supplies the
EF.Functions.GreaterThan(ValueTuple, ValueTuple) overloads the generated code calls.
Offset pagination
When a UI needs random page access and a total count, use ToOffsetPageAsync with a SortMap — an
AOT-safe whitelist that binds sort keys to typed selectors (no dynamic LINQ, and clients can only sort
by columns you allow). Each entry may chain ThenBy tiebreakers (each with a SortDirection) for a
stable composite order. The map is a fixed whitelist that doesn't depend on the request, so build it
once with CreateBuilder(...).Add(...).Build() and keep the immutable result in a static readonly
field rather than rebuilding per call:
private static readonly SortMap<Client> Sort = SortMap<Client>
.CreateBuilder("createdAt", c => c.CreatedAt, SortDirection.Descending) // default sort
.ThenBy(c => c.Id, SortDirection.Descending) // stable tiebreaker
.Add("name", c => c.Name)
.ThenBy(c => c.Id)
.Build();
public async ValueTask<Result<Page<Item>>> HandleAsync(Query query, CancellationToken ct) {
return await db.Clients
.Where(c => c.OwnerId == user.UserId)
.ToOffsetPageAsync(query, c => new Item(c.Id, c.Name, c.CreatedAt), Sort, maxSize: 100, ct);
}Build() produces an immutable map backed by a FrozenDictionary, so a shared static instance is
safe for concurrent requests.
A request sort of "name" orders by Name then Id; a leading -/+ flips only the entry's
primary column (so "-name" sorts Name descending, Id ascending). Unknown or blank keys fall back
to the default entry. Offset pages fill Total; keyset pages fill the cursors instead.
Events
An in-process eventing subsystem split by its relationship to the database transaction — inline domain events and after-commit integration events, with a durable EF Core outbox for reliable delivery.
Blob storage
Store binary content behind provider-neutral contracts, with an optional PostgreSQL-backed implementation.