No SaveChanges
The unit of work pipeline opens the transaction, saves once after the handler returns, and commits. Three writes across three tables become one atomic change.
Organise commands, queries and events with handlers that only hold business rules. Nexo ties validation, persistence, transactions and domain events into one consistent flow.
The whole handler
No repository to write, no SaveChanges to remember, no transaction to open, no event to publish by hand.
public sealed class CreateProductHandler(
ICreate<Product> create,
IReadByExternalId<Category> categories)
: ICommandHandler<CreateProduct, Guid>
{
public async Task<Guid> HandleAsync(CreateProduct command, CancellationToken ct)
{
var category = await categories.ReadRequiredAsync(command.CategoryId, ct);
var product = await create.CreateAsync(
new Product(category.Id, command.Sku, command.Price),
ct);
return product.ExternalId;
// No SaveChanges. No transaction. No event to publish.
// The pipeline opens the transaction, saves once, dispatches the
// domain events and commits. If anything throws, nothing is written.
}
}Install
dotnet add package Nexo.EntityFrameworkCore
dotnet add package Nexo.FluentValidationbuilder.Services.AddDbContext<CatalogDbContext>((sp, options) => options
.UseSqlServer(connectionString)
.UseNexo(sp));
builder.Services.AddNexo<CatalogDbContext>(nexo => nexo
.AddHandlersFromAssemblyOf<Program>()
.AddValidatorsFromAssemblyOf<Program>()
.UseCurrentUser<HttpCurrentUserAccessor>());AddNexo<TContext> assembles the pipelines in the right order: logging, enrichment, validation and unit of work — that last one closest to the handler, so the transaction covers the handler and its events and nothing more.
What you get
The unit of work pipeline opens the transaction, saves once after the handler returns, and commits. Three writes across three tables become one atomic change.
ICreate<T>, IUpdate<T>, IDelete<T>, IQueryable<T> and the reads by key, injected directly. The handler declares in its constructor exactly what it is allowed to do.
The entity records them with Raise; the pipeline collects them after the save — once the keys exist — and dispatches them in the same transaction.
Implement IHaveAudit or ISoftDeletable and the interceptor handles the rest. An entity that implements neither stays a plain POCO.
FluentValidation runs before the handler, and a failure arrives as ValidationFailedException. Your web layer never references FluentValidation.
PageRequest clamps whatever came off the query string, and ToPageAsync returns the page plus the total in two round trips.
Reading
The injected IQueryable<T> composes and does not track. Project before you materialise and the database returns only the columns the API sends.
public Task<Page<ProductSummary>> HandleAsync(SearchProducts query, CancellationToken ct) =>
products
.Where(p => query.Term == null || p.Sku.Contains(query.Term))
.OrderBy(p => p.Sku)
.Select(p => new ProductSummary(
p.ExternalId,
p.Sku,
p.Price,
p.Category!.Name,
priceHistory.Count(h => h.ProductId == p.Id)))
.ToPageAsync(query.Request, ct);Writing
The pipeline collects events after the save — once the database-generated keys exist — and dispatches them inside the same transaction. If an event handler throws, the whole command is undone.
public sealed class Product : AggregateRoot, IHaveAudit, ISoftDeletable
{
public Product(string sku, decimal price)
{
Sku = sku;
Price = price;
Raise(new ProductCreated(ExternalId, sku, price));
}
public void Reprice(decimal price)
{
if (price == Price) return;
var previous = Price;
Price = price;
Raise(new ProductRepriced(ExternalId, previous, price));
}
}