EF Core
dotnet add package Nullean.Entities.EntityFrameworkCore
EF entities are changed in place, so Apply cannot see a "before". This package adds a SaveChangesInterceptor that uses EF's own change tracking instead. On every
SaveChanges and SaveChangesAsync it checks each tracked entity whose type has a registered set:
| Entry state | What runs |
|---|---|
Added |
Validate(entity): the point-in-time rules |
Modified |
Check(original, entity): every rule, with the row as EF loaded it as the before, so Transitions, Monotonic and Conserves work |
Deleted, Unchanged, Detached |
Nothing |
When any rule reports an error, it throws one ConstraintViolationException with the violations of every offending entity, before EF writes anything.
services.AddDbContext<ShopContext>(options => options
.UseSqlite(connection)
.UseConstraints(c => c.Add<Order>()));
- Order declares its rules; or c.Add(OrderRules.All) for a companion's set
The rules are ordinary constraints on the entity class:
public sealed partial class Order
{
public int Id { get; set; }
public decimal Total { get; set; }
public OrderStatus Status { get; set; }
public int Version { get; set; }
[Constraint]
public static readonly Constraint<Order> TotalNotNegative = Constraints<Order>.Check(
o => o.Total >= 0 ? [] : [Violation.Error(nameof(Total), "must not be negative")]);
[Constraint]
public static readonly Constraint<Order> StatusFlow = Constraints<Order>
.Transitions(o => o.Status)
.Allow(OrderStatus.Draft, OrderStatus.Placed)
.Allow(OrderStatus.Placed, OrderStatus.Shipped);
[Constraint]
public static readonly Constraint<Order> VersionOnlyGrows = Constraints<Order>.Monotonic(o => o.Version, MonotonicDirection.NonDecreasing);
}
var order = await db.Orders.SingleAsync(o => o.Id == id);
order.Status = OrderStatus.Draft;
await db.SaveChangesAsync();
- Placed, Version 3
- throws: status-flow does not allow the transition from Placed to Draft
A set registered for a base type also checks the types derived from it. c.Time sets the TimeProvider the rules get, and c.OnNotices receives the warnings and hints of
an entity that saved: they never block a save.
These snippets are tested in
tests/Nullean.Entities.EntityFrameworkCore.Tests against EF Core on SQLite in
memory: DependencyInjectionTests.cs (the registration), Model.cs (the entity) and SaveChangesTests.cs (the rejected change, sync and async).
- The current entity is the tracked instance, with whatever navigations are loaded. A rule over a collection (
Uniqueoverorder.Lines) sees what is in memory, not the table. - The before comes from
entry.OriginalValues.ToObject(): a new instance with the scalar properties EF loaded. It has no navigations, so a sequential rule that compares a collection before and after sees an empty collection before. - A change to a child does not modify its parent. Adding a line to a loaded
order.Linesleaves theOrderentryUnchanged, so the order's rules do not run. Register rules for the child type too, or change a property of the parent (a version column) in the same operation.NavigationTests.csshows both cases. - Owned entities are tracked as entries of their own type: register a set for the owned type to check it.
- Rules run on what is tracked.
ExecuteUpdate,ExecuteDeleteand raw SQL bypass the change tracker and are not checked. WithAutoDetectChangesEnabledoff, callDetectChangesyourself first.
With ASP.NET Core's AddConstraintProblemDetails() (and app.UseExceptionHandler()), the ConstraintViolationException thrown on SaveChanges becomes a 400
ValidationProblem keyed by Violation.Member. When several entities fail, their messages are grouped under the same member names.
The package uses no reflection beyond walking an entity type's base types, and builds without trim or AOT analyzer warnings, but it is not marked AOT compatible: EF Core's
NativeAOT support is experimental (it needs a compiled model and precompiled queries). It is built on EF Core 11 for net11.0 and EF Core 10 for net10.0 (EF Core 11 targets net11.0 only).
The package is one small interceptor
(ConstraintSaveChangesInterceptor). To write
your own, override SavingChanges and SavingChangesAsync, and for each entry of ChangeTracker.Entries<Order>() call Order.Constraints.Validate(entry.Entity) when it is
added and Order.Constraints.Check((Order)entry.OriginalValues.ToObject(), entry.Entity) when it is modified.
To audit rows already in the database, read them and call Validate: examples/Apps has an audit of existing rows.