Nexo

Documentação

Operações de dados

ICreate, IUpdate, IDelete, IQueryable e as leituras por chave: injetadas direto no handler, sem repositório.

Tracking: a regra que evita surpresa

A regra é simples e vale para tudo: query injetada não rastreia; leitura por chave rastreia. O motivo é o uso real de cada uma. Você faz query para listar e projetar — tracking ali só custa memória. Você lê por id para mudar algo.

C#
// Update completo. Sem IUpdate, sem SaveChanges.
public async Task HandleAsync(RenameProduct command, CancellationToken ct)
{
    var product = await byExternalId.ReadRequiredAsync(command.ProductId, ct);
    product.Rename(command.Name);
}

Precisa de uma query rastreada — filtrar por algo que IReadById não cobre e depois mutar? Use IDataStore.QueryTracked<T>().

C#
var stale = await store
    .QueryTracked<Product>()
    .Where(p => p.UpdatedOn < cutoff)
    .ToListAsync(ct);

foreach (var product in stale)
{
    product.Archive();   // rastreado: o pipeline salva
}

Quando IUpdate realmente serve

Se a leitura já vem rastreada, IUpdate<T> é redundante na maior parte do tempo. Ele existe para o caso em que você tem uma instância destacada: remontada de um payload HTTP ou vinda de um cache.

Anexar uma instância destacada é seguro para o que ela não tem como saber:

  • Auditoria de criação: CreatedOn e CreatedBy ficam congelados no update, então um payload sem esses campos não apaga o registro de criação.
  • Identificador público: o ExternalId é somente leitura depois do insert. Uma instância remontada ganha um Guid novo no construtor; esse valor é ignorado, e o que está no banco fica. Mudar o ExternalId de uma entidade rastreada faz o save falhar.
  • Soft delete: as colunas de soft delete não entram no update de uma instância destacada. Um IsDeleted = false que só está lá porque a instância foi recém-construída não ressuscita uma linha apagada.

Restaurar algo apagado é, então, uma operação à parte: IRestore<T>. A linha precisa ser lida por cima do filtro de soft delete — e só dele:

C#
public async Task HandleAsync(RestoreProduct command, CancellationToken ct)
{
    var product = await products
                      .IgnoreQueryFilters([NexoQueryFilters.SoftDelete])
                      .FirstOrDefaultAsync(p => p.ExternalId == command.ProductId, ct)
                  ?? throw new EntityNotFoundException(typeof(Product), command.ProductId);

    // Limpa IsDeleted, DeletedOn e DeletedBy e grava só essas colunas.
    await restore.RestoreAsync(product, ct);
}

IQueryable injetado

O IQueryable<T> injetado é o DbSet da sessão atual, sem tracking. Todo operador LINQ compõe normalmente, e ToListAsync funciona direto na instância injetada.

C#
public sealed class ProductQueryHandlers(
    IQueryable<Product> products,
    IQueryable<Category> categories)
    : IQueryHandler<GetProduct, ProductDetails?>
{
    public Task<ProductDetails?> HandleAsync(GetProduct query, CancellationToken ct) =>
        products
            .Where(p => p.ExternalId == query.ProductId)
            .Join(
                categories,
                product => product.CategoryId,
                category => category.Id,
                (product, category) => new ProductDetails(
                    product.ExternalId,
                    product.Sku,
                    product.Price,
                    category.Name))
            .FirstOrDefaultAsync(ct);
}

Projete antes de materializar. O Select num record faz o banco devolver só as colunas que a API envia, e mantém a entidade dentro da camada de dados.

Um handler que não quer depender do EF Core usa os operadores assíncronos de Nexo.Linq, no pacote de abstrações. Eles delegam para o provider registrado — o EF Core, na aplicação — e caem para LINQ em memória num teste com uma lista:

C#
using Nexo.Linq;   // em vez de Microsoft.EntityFrameworkCore

var names = await products
    .Where(p => p.Price > 100m)
    .Select(p => p.Name)
    .ToListAsync(ct);

var any = await products.IncludeDeleted().AnyAsync(p => p.Sku == sku, ct);

await foreach (var product in products.AsAsyncEnumerable().WithCancellation(ct))
{
    // …
}

ReadRequiredAsync

As leituras devolvem TEntity?. Quando a ausência é um erro — e quase sempre é — use ReadRequiredAsync, que lança EntityNotFoundException.

Repetido em todo handler
var product = await byExternalId.ReadAsync(command.ProductId, ct);

if (product is null)
{
    throw new ProductNotFoundException(command.ProductId);
}

product.Rename(command.Name);
Uma linha
var product = await byExternalId.ReadRequiredAsync(command.ProductId, ct);

product.Rename(command.Name);

Mapeie EntityNotFoundException para 404 num lugar só — veja Erros e HTTP.

Operações em lote

C#
// Leitura em lote: um único IN, não N consultas.
var found = await byExternalIdRange.ReadAsync([firstId, secondId, thirdId], ct);

// Criação em lote: um batch de INSERT, atômico com o resto do comando.
await createRange.CreateAsync(
    command.Products.Select(p => new Product(p.Sku, p.Price)),
    ct);

// Sobrecargas params, quando os itens estão soltos.
await createRange.CreateAsync(first, second);
await deleteRange.DeleteAsync(stale, expired);

Os ranges devolvem IReadOnlyList<T>, não IEnumerable<T>: não há como reenumerar uma query por acidente, e Count é barato.

Uma lista vazia não vai ao banco. ReadAsync([]) devolve vazio sem consultar, e CreateAsync([]) não faz nada.

Quando o que você quer mudar é "todas as linhas que batem com isto", carregar cada uma para alterar e salvar é desperdício. IBulkUpdate<T> e IBulkDelete<T> viram um único comando SQL:

C#
// UPDATE … SET Price = Price * 1.1, UpdatedOn = @now, UpdatedBy = @user WHERE CategoryId = @id
var repriced = await bulkUpdate.UpdateWhereAsync(
    p => p.CategoryId == command.CategoryId,
    set => set.Set(p => p.Price, p => p.Price * 1.1m),
    ct);

// Com ISoftDeletable: UPDATE … SET IsDeleted = 1, DeletedOn = @now, DeletedBy = @user WHERE …
var purged = await bulkDelete.DeleteWhereAsync(p => p.Discontinued, ct);
  • Rodam na hora, dentro da transação que o comando tem aberta — não esperam o save do fim.
  • Passam por fora do change tracker: uma entidade já carregada no mesmo comando não vê o efeito, e nenhum evento de domínio é levantado.
  • Respeitam o filtro de soft delete: linhas já apagadas não são tocadas.
  • Só em providers relacionais. O MongoDB não tem o comando, e a chamada lança.

IDataStore, a fachada

Um handler que toca cinco tipos de entidade teria um construtor com quinze parâmetros. Para esses casos existe IDataStore, que expõe tudo com a entidade como parâmetro genérico.

C#
public sealed class CloseMonthHandler(IDataStore store) : ICommandHandler<CloseMonth>
{
    public async Task HandleAsync(CloseMonth command, CancellationToken ct)
    {
        var invoices = await store
            .QueryTracked<Invoice>()
            .Where(i => i.Period == command.Period && !i.Closed)
            .ToListAsync(ct);

        foreach (var invoice in invoices)
        {
            invoice.Close();
        }

        await store.CreateAsync(new MonthlyReport(command.Period, invoices.Count), ct);
    }
}

Por que não um repositório

Um IProductRepository começa com quatro métodos e termina com trinta, porque cada nova tela pede uma consulta nova. Ele também esconde o IQueryable, o que obriga a escolher entre um método por consulta ou devolver tudo e filtrar em memória.

As operações granulares invertem isso: a composição fica onde a consulta é escrita, no handler, e o que a camada de dados oferece é um conjunto pequeno e fechado de capacidades. O handler continua testável, porque cada capacidade é uma interface de um método.