Documentação
Operações de dados
ICreate, IUpdate, IDelete, IQueryable e as leituras por chave: injetadas direto no handler, sem repositório.
O catálogo
Cada interface é uma capacidade. Você injeta as que o handler usa — e só elas. Um handler que recebe IQueryable<Product> e ICreate<Product> declara, no construtor, que ele lê e cria produtos, e nada mais. Note o que a assinatura não diz: em qual banco. A operação encontra o DbContext que mapeia a entidade, então com vários bancos este código é exatamente o mesmo.
| Interface | O que faz | Tracking |
|---|---|---|
IQueryable<T> | Query composável para filtros, joins e projeções. | não rastreia |
ICreate<T> / ICreateRange<T> | Insere. | — |
IUpdate<T> / IUpdateRange<T> | Anexa uma instância destacada e marca como modificada. | — |
IDelete<T> / IDeleteRange<T> | Remove, ou marca se a entidade for ISoftDeletable. | — |
IRestore<T> | Desfaz um soft delete: limpa a marca, o instante e o autor. | — |
IBulkUpdate<T> / IBulkDelete<T> | Um UPDATE/DELETE por predicado, sem carregar nada. Auditoria e soft delete no mesmo comando SQL. | — |
IReadById<T> / IReadById<T, TId> | Lê por chave primária. | rastreia |
IReadByExternalId<T> | Lê pelo Guid público. | rastreia |
IReadByIdRange<T> / IReadByExternalIdRange<T> | Lê várias chaves num IN. | rastreia |
IExistsById<T> / IExistsByExternalId<T> | EXISTS sem materializar a entidade. | — |
IDataStore | Fachada com tudo acima, numa dependência só. | ambos |
IUnitOfWork | Save e transações. Quase nunca é necessá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.
// 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>().
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:
CreatedOneCreatedByficam 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 umGuidnovo no construtor; esse valor é ignorado, e o que está no banco fica. Mudar oExternalIdde 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 = falseque 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:
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.
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:
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.
var product = await byExternalId.ReadAsync(command.ProductId, ct);
if (product is null)
{
throw new ProductNotFoundException(command.ProductId);
}
product.Rename(command.Name);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
// 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:
// 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.
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.