Elarion

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 no nameof.
  • Columns must be non-nullable (ELKEY003) for deterministic SQL ordering.
  • The final column should be unique (typically the key); if the entity has an Id that 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 report ELKEY002.

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.

On this page