i26.EntityFrameworkCore
0.2.0
See the version list below for details.
dotnet add package i26.EntityFrameworkCore --version 0.2.0
NuGet\Install-Package i26.EntityFrameworkCore -Version 0.2.0
<PackageReference Include="i26.EntityFrameworkCore" Version="0.2.0" />
<PackageVersion Include="i26.EntityFrameworkCore" Version="0.2.0" />
<PackageReference Include="i26.EntityFrameworkCore" />
paket add i26.EntityFrameworkCore --version 0.2.0
#r "nuget: i26.EntityFrameworkCore, 0.2.0"
#:package i26.EntityFrameworkCore@0.2.0
#addin nuget:?package=i26.EntityFrameworkCore&version=0.2.0
#tool nuget:?package=i26.EntityFrameworkCore&version=0.2.0
i26.EntityFrameworkCore
Everything the i26 primitives need from Entity Framework, so that the layer holding them does not need Entity Framework.
dotnet add package i26.EntityFrameworkCore
Typed identifiers
One call in the DbContext maps every typed id in the assembly:
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
=> builder.ApplyTypedIdConventions(typeof(Course).Assembly);
Each one becomes a text column with collation "C", storing the full prefixed string:
SELECT * FROM "Courses" WHERE "Id" = 'crs_01h455vb4pex5vsknk084sn02q';
Reading uses Parse, so a corrupted row — or one carrying another entity's prefix — fails loudly
instead of quietly becoming the wrong id.
text and "C" are Postgres' vocabulary, which is what the default assumes. Anywhere else, say so:
builder.ApplyTypedIdConventions(TypedIdStorage.ProviderDefault, typeof(Course).Assembly);
That leaves the column type and the collation to the provider. Ordering then depends on that
collation being binary — without one, the database and TypedId.Compare can disagree about where a
page stops.
Base entities
An Entity<TId>
declares CreatedAt, UpdatedAt and — when it is deletable — DeletedAt, and never sets any of
them: it has no clock, and one it was handed would be one more thing to pass around. This does it,
on the way into the save:
using i26.EntityFrameworkCore.Entities;
builder.Services.AddDbContext<AppDbContext>((provider, options) => options
.UseNpgsql(connectionString)
.UseEntityTimestamps() // or .UseEntityTimestamps(timeProvider) in a test
.UseDomainEvents(provider));
An insert stamps CreatedAt and UpdatedAt; an update stamps UpdatedAt; and DeletedAt is
stamped when IsDeleted becomes true, because a soft delete reaches the database as an update like
any other. The time comes from a TimeProvider, so a test decides what "now" is instead of
asserting against the clock.
The properties keep their private setters — the interceptor writes through Entity Framework's own metadata, which is what stops application code from choosing when something was created.
Hiding what was deleted
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Whatever else this configures, first…
modelBuilder.ApplySoftDeleteFilter();
}
Every entity implementing ISoftDeletable gets HasQueryFilter(row => !row.IsDeleted), built on
the concrete type rather than through the interface, since a member access through a cast has no
translation. Call it after the entity types exist: a filter applies to the model as it is at
that moment. A query that means to see them says IgnoreQueryFilters().
Cursor pagination
using i26.EntityFrameworkCore.Pagination;
var page = await db.Courses
.Where(course => course.TenantId == tenantId)
.Select(course => new CourseRow
{
Id = course.Id,
CreatedAt = course.CreatedAt,
Title = course.Title,
})
.ToPagedResponseAsync<CourseRow, CourseId>(request, cancellationToken: ct);
return page.Value.Map(row => new CourseResponse(row.Id, row.Title));
The ordering is applied for you, so the query arrives filtered and nothing else. The result is a
Result<PagedResponse<T>>: a cursor that did not come from this API — truncated, hand-written, or
carrying another entity's id — is a validation failure, which reaches the caller as a 400 rather
than a 500.
Name both types when the id is a typed one; a row whose id is a Guid infers the rest and stays
ToPagedResponseAsync(request).
The paging itself lives in i26.Core, over an
IAsyncQueryExecutor — this overload is the one for code that
already has Entity Framework in front of it. An application layer that does not pages the same way,
passing the executor:
using i26.Core.Pagination;
var page = await rows.ToPagedResponseAsync<CourseRow, CourseId>(executor, request, ct: ct);
Project with an object initializer, not a constructor. Entity Framework binds
new CourseRow { CreatedAt = … }back to the column it came from and can order by it; it cannot do the same fornew CourseRow(…). A response shape that takes a constructor is built afterwards, withMap.
Domain events
An entity records what happened to it and something else reacts. Raising them is i26.Core and running the handlers is i26.Cqrs; what this package does is take them off the entities as they are saved.
Collecting and publishing are two steps
Collecting takes the events off the entities as they are saved. Publishing hands them to their handlers. They are separate because the moment to publish is not the moment to collect: an event describes a row that a rollback would still take back, so it goes out after the transaction that carries it has committed — and only whoever began that transaction knows when that is.
The DomainEventQueue is what sits between them, one per scope.
using i26.Cqrs;
using i26.EntityFrameworkCore.DomainEvents;
builder.Services.AddHandlers(typeof(DependencyInjection).Assembly); // every handler, events included
builder.Services.AddDomainEvents(); // the queue and a dispatcher
builder.Services.AddDbContext<AppDbContext>((provider, options) => options
.UseNpgsql(connectionString)
.UseDomainEvents(provider));
UseDomainEvents adds a SaveChanges interceptor that empties the entities into the queue on the
way into the save, and publishes on the way out:
| What the interceptor does | |
|---|---|
AfterSaveChanges (default) |
Collects, and publishes once the save has succeeded — unless a transaction is open on the context, in which case the events wait for whoever began it. |
Manual |
Collects. Publication is always an explicit queue.PublishAsync(ct). |
Both modes collect before the save, not after: an entity being deleted is detached from the change tracker the moment the save completes, and its event would go with it. A save that then fails publishes nothing.
Two things worth knowing. The synchronous SaveChanges collects but never publishes — publication
is asynchronous — so a synchronous save leaves the events queued for the next publication. And a
handler that throws stops the ones behind it and surfaces out of whatever called PublishAsync,
which for AfterSaveChanges means out of SaveChangesAsync, after the data was written. If that
matters, publish somewhere you control, or dispatch to a background queue.
Publishing where the transaction ends
A decorator that owns the transaction owns the publication. It asks for the same scoped queue the interceptor filled:
internal sealed class TransactionDecorator<TCommand>(
ICommandHandler<TCommand> inner,
AppDbContext db,
DomainEventQueue events) : ICommandHandler<TCommand>
where TCommand : ICommand
{
public async Task<Result> HandleAsync(TCommand command, CancellationToken ct = default)
{
if (db.Database.CurrentTransaction is not null)
{
return await inner.HandleAsync(command, ct);
}
await using var transaction = await db.Database.BeginTransactionAsync(ct);
var result = await inner.HandleAsync(command, ct);
if (result.IsFailure)
{
await transaction.RollbackAsync(ct);
events.Clear();
return result;
}
await transaction.CommitAsync(ct);
await events.PublishAsync(ct);
return result;
}
}
This composes with the default mode rather than replacing it: saves inside the transaction find one
open and stay quiet, and the commit is what publishes. Manual is for when you would rather the
interceptor never publish at all.
PublishAsync drains the queue before each dispatch and keeps going while handlers fill it again,
so a handler that saves further changes has its own events published by the same call — there is no
second publication to remember at the end of a handler.
Awaiting a query without an ORM
using i26.EntityFrameworkCore.Queries;
builder.Services.AddEfCoreAsyncQueries();
That registers the Entity Framework backend and the executor in front of it, both singleton. The
executor picks a backend per query, by looking at IQueryable.Provider, so a second store is a
second backend and nothing in the application layer changes:
services.TryAddEnumerable(ServiceDescriptor.Singleton<IAsyncQueryBackend, MongoAsyncQueryBackend>());
With no backend able to run a query, the operator runs on the calling thread and the answer is still
right. That is what makes an application service testable against List<T>.AsQueryable() with no
database in sight — and it is also the trap to know about: a query that should have been
asynchronous and silently was not looks exactly like one that was.
What it drags in
Microsoft.EntityFrameworkCore.Relational, and i26.Core. Pinned per target framework: 8.0.x on
net8.0, 9.0.x on net9.0, 10.0.x on net10.0.
Part of i26.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- i26.Core (>= 0.2.0)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.4)
-
net8.0
- i26.Core (>= 0.2.0)
- Microsoft.EntityFrameworkCore.Relational (>= 8.0.13)
-
net9.0
- i26.Core (>= 0.2.0)
- Microsoft.EntityFrameworkCore.Relational (>= 9.0.6)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.