Nexo
net10.0 · EF Core 10

CQRS and data access, integrated, for .NET

Organise commands, queries and events with handlers that only hold business rules. Nexo ties validation, persistence, transactions and domain events into one consistent flow.

ICreate<T>IUpdate<T>IDelete<T>IQueryable<T>IReadByExternalId<T>Page<T>IPipeline

The whole handler

That is it. Nothing else is hidden.

No repository to write, no SaveChanges to remember, no transaction to open, no event to publish by hand.

CreateProductHandler.cs
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

Two packages, two calls

Shell
dotnet add package Nexo.EntityFrameworkCore
dotnet add package Nexo.FluentValidation
Program.cs
builder.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

Decisions made for you, and explained

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.

No repository

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.

Domain events that mean it

The entity records them with Raise; the pipeline collects them after the save — once the keys exist — and dispatches them in the same transaction.

Auditing and soft delete, opt in

Implement IHaveAudit or ISoftDeletable and the interceptor handles the rest. An entity that implements neither stays a plain POCO.

Validation that does not leak

FluentValidation runs before the handler, and a failure arrives as ValidationFailedException. Your web layer never references FluentValidation.

Paging that is not a table scan

PageRequest clamps whatever came off the query string, and ToPageAsync returns the page plus the total in two round trips.

Reading

LINQ, joins, subqueries and projections

The injected IQueryable<T> composes and does not track. Project before you materialise and the database returns only the columns the API sends.

ProductQueryHandlers.cs
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 entity announces what happened

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.

Product.cs
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));
    }
}