CRUD e task based: como capturar a intenção do usuário

CRUD e task based: como capturar a intenção do usuário

Escolher entre CRUD e task based define se o seu sistema registra apenas o que mudou ou também o motivo de cada mudança. Neste artigo, partimos da discussão proposta por Derek Comartin e mostramos, com exemplos em .NET 10, quando cada modelo faz sentido.

O problema de transformar tudo em update

Imagine um sistema em que cancelar um pedido significa apenas alterar o status. Da mesma forma, aprovar um documento também altera o status, e concluir um fluxo segue o mesmo caminho.

Com o tempo, toda operação vira um update. À primeira vista, isso parece ótimo, porque o código fica simples e previsível.

No entanto, existe uma perda importante nesse caminho: a intenção do usuário. Você sabe o que mudou no banco, mas não sabe por que aquilo mudou.

Até dá para tentar inferir o motivo olhando os campos alterados. Ainda assim, a inferência é frágil, e o sistema nunca registrou o que a pessoa realmente queria fazer.

É justamente nesse ponto que a escolha entre CRUD e task based deixa de ser detalhe de implementação. Ela passa a definir quanto do negócio o seu código consegue representar.

A diferença entre CRUD e task based

No modelo CRUD, o usuário edita um registro e devolve o registro alterado. Por outro lado, no modelo task based, o usuário executa uma ação de negócio com nome, dados e regras próprias.

Em outras palavras, o CRUD fala a língua do banco de dados, enquanto o task based fala a língua do negócio. Por isso, a diferença entre CRUD e task based não é técnica, e sim de modelagem.

Na prática, um mesmo banco de dados pode atender aos dois estilos sem problema algum. O que muda entre CRUD e task based é a forma como a API e a interface expõem as operações ao usuário.

Atualizar dados ou executar uma ação

Pense em uma tela de edição de uma carga, com campos de status, quantidade de volumes, peso e datas. Então o cliente liga e pede o cancelamento da carga.

O que exatamente o operador deve fazer nesse momento? Talvez exista um botão de excluir carga na tela.

Se for uma exclusão física, você perde dados de forma definitiva. Se for uma exclusão lógica, a interface mente, porque nada foi excluído de fato.

Outra opção comum é mudar o status para cancelado. Mesmo assim, a pergunta continua aberta sobre o que realmente aconteceu.

Cancelar não é excluir nem mudar status

Um cancelamento costuma exigir informações próprias. Por exemplo, quem cancelou, qual foi o motivo, quem pediu e se existem observações adicionais.

Excluir a carga, mudar um status e registrar um cancelamento são coisas muito diferentes. Somente a última opção captura a intenção completa do usuário.

Por que CRUD e task based não são rivais

A reação natural seria concluir que o CRUD é ruim e que tudo deveria virar comando, como no CQRS. Contudo, essa conclusão está errada.

O CRUD é extremamente útil quando você está apenas gerenciando dados. Na prática, o problema surge quando ele ocupa o lugar de capacidades de negócio reais.

O contrário também acontece com frequência. Algumas equipes transformam tudo em comando, mesmo quando a tela é um cadastro simples.

Portanto, a pergunta certa sobre CRUD e task based não é qual dos dois escolher. A pergunta é se você tem apenas dados para manter ou capacidades de negócio cuja intenção precisa ser registrada.

Por que o CRUD é tão atraente

Construir CRUD é simples, porque existe muita ferramenta, framework e biblioteca em volta dele. Além disso, o fluxo é fácil de entender.

O cliente chama uma API HTTP, que busca um registro no banco e devolve esse registro. Em seguida, o cliente altera alguns campos e envia o mesmo modelo de volta.

Boa parte das ferramentas mapeia métodos HTTP para operações SQL. Assim, GET vira SELECT, POST vira INSERT, PUT e PATCH viram UPDATE e DELETE vira DELETE.

Derek Comartin cita um termo usado por Alex Moore para esse estilo: objetos como recursos. Não há nada de errado com isso quando o objetivo é apenas manter dados.

Quando o CRUD é exatamente o que você precisa

Em um sistema de logística, o cadastro de clientes é um bom exemplo de CRUD legítimo. O usuário altera email, contato principal, telefone ou endereço.

Nesse caso, criar um comando para trocar o email não traz benefício algum. Afinal, não existe comportamento relevante associado a essa mudança.

O código abaixo mostra um endpoint de atualização com Minimal API no .NET 10. Ele recebe os dados, aplica na entidade e salva.

// Cadastro simples: o cliente é apenas dado a ser mantido
public sealed class Customer
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string Phone { get; set; } = string.Empty;
    public string PrimaryContact { get; set; } = string.Empty;
}

public sealed record UpdateCustomerRequest(
    string Name,
    string Email,
    string Phone,
    string PrimaryContact);

app.MapPut("/customers/{id:guid}", async (
    Guid id,
    UpdateCustomerRequest request,
    AppDbContext db,
    CancellationToken ct) =>
{
    var customer = await db.Customers.FindAsync([id], ct);
    if (customer is null)
        return Results.NotFound();

    // Nenhuma regra de negócio: apenas copiamos os valores
    customer.Name = request.Name;
    customer.Email = request.Email;
    customer.Phone = request.Phone;
    customer.PrimaryContact = request.PrimaryContact;

    await db.SaveChangesAsync(ct);
    return Results.NoContent();
});

Esse endpoint é direto, fácil de testar e fácil de manter. Para dados sem comportamento, essa é a escolha mais honesta.

Aqui, aplicar o estilo task based no lugar do CRUD só adicionaria cerimônia. Você teria mais classes, mais rotas e mais testes, sem nenhuma regra nova para proteger.

Onde o CRUD começa a falhar

O problema começa quando o modelo de dados e o modelo de recurso são exatamente a mesma coisa. Você devolve a entidade, o cliente altera e manda de volta.

Isso funciona bem até existir comportamento real por trás das mudanças. Muitas vezes, esse comportamento vive apenas na cabeça do usuário e nunca chega ao sistema.

Veja o mesmo padrão aplicado à carga.

public sealed record UpdateShipmentRequest(
    string Status,
    int Pieces,
    decimal Weight,
    DateTimeOffset? PickupDate);

app.MapPut("/shipments/{id:guid}", async (
    Guid id,
    UpdateShipmentRequest request,
    AppDbContext db,
    CancellationToken ct) =>
{
    var shipment = await db.Shipments.FindAsync([id], ct);
    if (shipment is null)
        return Results.NotFound();

    // O que aconteceu aqui? Carregamento, cancelamento ou correção de digitação?
    shipment.Status = request.Status;
    shipment.Pieces = request.Pieces;
    shipment.Weight = request.Weight;
    shipment.PickupDate = request.PickupDate;

    await db.SaveChangesAsync(ct);
    return Results.NoContent();
});

Para o software, o usuário está apenas trocando propriedades. Por isso, quando você precisa entender o que ele fez e por quê, esse modelo deixa de responder.

Status igual a carregado não é a mesma coisa

Suponha que alguém edite a carga e mude o status para carregado. Nesse caso, você sabe qual era o número do conhecimento de embarque?

Também não fica claro se é possível exigir esse dado ou validar o peso e os volumes carregados. Da mesma forma, nenhum evento de negócio significativo nasce dessa alteração.

Editar a carga e trocar o status é bem diferente de registrar que a mercadoria entrou no veículo. Essa diferença tem nome: intenção.

Esse é o caso clássico em que o CRUD perde para o task based. Afinal, o formulário genérico aceita qualquer combinação de campos, inclusive combinações que o negócio jamais permitiria.

Uma carga é mais do que um status

Agora observe o fluxo real de uma carga. Primeiro, o pedido é despachado, com data, hora, veículo ou transportadora.

Depois, o veículo chega ao embarcador, e o sistema registra quando isso aconteceu. Em seguida, a mercadoria entra no veículo, com volumes, peso e conhecimento de embarque.

Por fim, o veículo deixa o embarcador e segue para a entrega. Cada etapa representa um resultado explícito dentro do fluxo.

Tarefas explícitas geram resultados explícitos

Esses resultados existem porque as tarefas também são explícitas. Despachar, chegar, carregar e partir são ações que guiam o usuário pelo processo.

Além disso, a interface pode refletir essas etapas. Cada tarefa pede apenas as informações necessárias para aquela ação específica.

Na tarefa de carregamento, por exemplo, o usuário informa peso, volumes e conhecimento de embarque. Com isso, o sistema captura a ação e produz um evento que outros serviços podem consumir.

Implementando CRUD e task based no .NET 10

Vamos transformar o fluxo da carga em código. A ideia é manter o cadastro de clientes como CRUD e modelar a carga com tarefas explícitas no estilo task based.

Modelando o comportamento na entidade

A entidade deixa de expor setters públicos. Em vez disso, ela oferece métodos com nomes do negócio, que validam a ordem das etapas e registram eventos.

public enum ShipmentStatus
{
    Created,
    Dispatched,
    ArrivedAtShipper,
    Loaded,
    Departed,
    Canceled
}

public interface IDomainEvent;

public sealed record FreightLoaded(
    Guid ShipmentId,
    int Pieces,
    decimal Weight,
    string BillOfLading,
    int PiecesVariance,
    DateTimeOffset LoadedAt) : IDomainEvent;

public sealed record ShipmentCanceled(
    Guid ShipmentId,
    string Reason,
    string RequestedBy,
    string CanceledBy,
    string? Notes,
    DateTimeOffset CanceledAt) : IDomainEvent;

public sealed class Shipment
{
    private readonly List<IDomainEvent> _events = [];

    public Guid Id { get; private set; }
    public ShipmentStatus Status { get; private set; }
    public int OrderedPieces { get; private set; }
    public int? LoadedPieces { get; private set; }
    public decimal? LoadedWeight { get; private set; }
    public string? BillOfLading { get; private set; }
    public IReadOnlyCollection<IDomainEvent> Events => _events;

    public void LoadFreight(int pieces, decimal weight, string billOfLading, DateTimeOffset loadedAt)
    {
        // A mercadoria só pode ser carregada depois que o veículo chega
        if (Status != ShipmentStatus.ArrivedAtShipper)
            throw new InvalidOperationException("Freight can only be loaded after the vehicle arrives at the shipper.");

        if (string.IsNullOrWhiteSpace(billOfLading))
            throw new ArgumentException("Bill of lading is required.", nameof(billOfLading));

        if (pieces <= 0 || weight <= 0)
            throw new ArgumentException("Pieces and weight must be greater than zero.");

        LoadedPieces = pieces;
        LoadedWeight = weight;
        BillOfLading = billOfLading;
        Status = ShipmentStatus.Loaded;

        // A variação entre o pedido e o carregado vira informação de negócio
        _events.Add(new FreightLoaded(Id, pieces, weight, billOfLading, pieces - OrderedPieces, loadedAt));
    }

    public void Cancel(string reason, string requestedBy, string canceledBy, string? notes, DateTimeOffset canceledAt)
    {
        if (Status is ShipmentStatus.Departed or ShipmentStatus.Canceled)
            throw new InvalidOperationException("Shipment cannot be canceled in its current status.");

        if (string.IsNullOrWhiteSpace(reason))
            throw new ArgumentException("Cancellation reason is required.", nameof(reason));

        Status = ShipmentStatus.Canceled;
        _events.Add(new ShipmentCanceled(Id, reason, requestedBy, canceledBy, notes, canceledAt));
    }
}

Repare que o método LoadFreight exige o conhecimento de embarque e garante a ordem do fluxo. Da mesma forma, o método Cancel exige um motivo e guarda quem pediu e quem executou.

Os métodos Dispatch, ArriveAtShipper e Depart seguem o mesmo padrão. Para manter o exemplo curto, deixamos apenas as duas tarefas mais ricas.

Expondo as tarefas na Minimal API

Cada tarefa ganha um endpoint próprio, com um comando que carrega apenas os dados daquela ação. Assim, a rota já comunica a intenção.

public sealed record LoadFreightCommand(int Pieces, decimal Weight, string BillOfLading);

public sealed record CancelShipmentCommand(
    string Reason,
    string RequestedBy,
    string? Notes);

var shipments = app.MapGroup("/shipments/{id:guid}");

shipments.MapPost("/load-freight", async (
    Guid id,
    LoadFreightCommand command,
    AppDbContext db,
    TimeProvider clock,
    CancellationToken ct) =>
{
    var shipment = await db.Shipments.FindAsync([id], ct);
    if (shipment is null)
        return Results.NotFound();

    try
    {
        shipment.LoadFreight(command.Pieces, command.Weight, command.BillOfLading, clock.GetUtcNow());
    }
    catch (InvalidOperationException ex)
    {
        // Regra de fluxo violada: devolvemos um conflito
        return Results.Problem(ex.Message, statusCode: StatusCodes.Status409Conflict);
    }

    await db.SaveChangesAsync(ct);
    return Results.NoContent();
});

shipments.MapPost("/cancel", async (
    Guid id,
    CancelShipmentCommand command,
    AppDbContext db,
    TimeProvider clock,
    ClaimsPrincipal user,
    CancellationToken ct) =>
{
    var shipment = await db.Shipments.FindAsync([id], ct);
    if (shipment is null)
        return Results.NotFound();

    var canceledBy = user.Identity?.Name ?? "unknown";

    try
    {
        shipment.Cancel(command.Reason, command.RequestedBy, canceledBy, command.Notes, clock.GetUtcNow());
    }
    catch (InvalidOperationException ex)
    {
        return Results.Problem(ex.Message, statusCode: StatusCodes.Status409Conflict);
    }

    await db.SaveChangesAsync(ct);
    return Results.NoContent();
});

As rotas /load-freight e /cancel dizem exatamente o que o usuário quer fazer. Além disso, o uso de TimeProvider facilita os testes, porque você controla o relógio.

Para padronizar as respostas de erro desses endpoints, vale conhecer o tratamento de erros com ProblemDetails e RFC 9457 no .NET 10. Se preferir evitar exceções no domínio, os union types do C# 15 aplicados a erros de domínio são uma boa alternativa.

Publicando eventos a partir das tarefas

Como cada tarefa gera um evento, outros serviços podem reagir a ela. Por exemplo, o faturamento pode escutar o evento FreightLoaded e calcular a cobrança com o peso real.

public interface IEventPublisher
{
    Task PublishAsync(IDomainEvent domainEvent, CancellationToken ct);
}

// Depois de salvar, publicamos os eventos registrados pela entidade
await db.SaveChangesAsync(ct);

foreach (var domainEvent in shipment.Events)
    await publisher.PublishAsync(domainEvent, ct);

Esse tipo de integração quase nunca nasce de um CRUD, porque o task based entrega eventos com significado claro. Um campo de status alterado, sozinho, não diz nada para o faturamento.

Em produção, considere o padrão outbox para não perder eventos entre o banco e a fila. Ainda assim, o ponto central permanece: só existe evento porque existe tarefa explícita.

Capacidades de negócio no CRUD e task based

Projetar com tarefas exige pensar em capacidades de negócio. Ou seja, você precisa entender o que o sistema realmente faz e o que o usuário tenta realizar.

Despachar, chegar ao embarcador, carregar, partir, entregar e cancelar são ações explícitas de um fluxo. Geralmente, cada uma carrega validações e regras próprias.

Algumas ações só podem acontecer depois de outras. Em outros casos, é preciso comparar os volumes pedidos com os volumes carregados.

Quando esse tipo de comportamento existe, ele precisa aparecer no código de forma explícita. A documentação da Microsoft sobre o padrão CQRS segue a mesma linha, recomendando comandos que representem tarefas de negócio.

O exagero do task based em telas de CRUD

Também é possível levar a ideia longe demais. Imagine comandos como alterar nome do cliente, atualizar telefone do cliente e modificar a segunda linha do endereço.

Que comportamento relevante existe em qualquer um deles? Provavelmente nenhum.

Ninguém ganha pontos extras por criar vinte e cinco comandos para uma tela que deveria ser um cadastro simples. Pelo contrário, isso aumenta o custo de manutenção sem entregar valor.

Por isso, tratar CRUD e task based como ideologia de arquitetura erra o alvo. O modelo certo é aquele que representa o que o software realmente faz.

Combinando CRUD e task based no mesmo sistema

A maioria dos sistemas precisa de CRUD e task based ao mesmo tempo, porque nem todas as fronteiras são iguais. Cada contexto pede um modelo de interação diferente.

Em uma aplicação de logística, a área de conformidade costuma apenas manter dados. Nesse caso, o CRUD atende muito bem.

Já o despacho e os pedidos envolvem fluxos explícitos com regras reais. Portanto, essas áreas pedem uma abordagem task based.

Uma integração com CRM ou financeiro, por sua vez, costuma apenas manter mapeamentos. Afinal, o comportamento vive nos sistemas externos.

Mesmo dentro da área de pedidos, haverá tabelas de configuração que são puro CRUD. Não existe motivo para forçar o mesmo modelo em todos esses lugares.

Como decidir entre CRUD e task based

A dúvida entre CRUD e task based fica mais simples quando você troca a pergunta. Em vez de perguntar qual modelo usar, pergunte o que o usuário está tentando fazer.

Algumas perguntas ajudam nessa análise:

  • A mudança exige dados que não fazem parte da edição comum?
  • Existe uma ordem obrigatória entre as etapas?
  • Outros serviços precisam reagir a essa mudança?
  • O negócio precisa saber quem fez, quando fez e por quê?
  • A ação tem um nome que o especialista do domínio usa no dia a dia?

Se a resposta for sim para várias delas, modele uma tarefa explícita. Caso contrário, um cadastro simples resolve o problema com menos código.

Também vale lembrar que a decisão entre CRUD e task based não precisa ser definitiva. Um cadastro pode nascer como CRUD e ganhar tarefas explícitas quando o negócio criar regras novas para ele.

Uma boa prática é começar pela linguagem do negócio. Quando o especialista diz cancelar carga, e não editar carga, esse verbo provavelmente merece um comando próprio.

Conclusão sobre CRUD e task based

O debate entre CRUD e task based não tem um vencedor universal. Se o usuário apenas mantém dados, use CRUD sem culpa.

Por outro lado, se ele executa capacidades de negócio com regras e intenção, modele essas ações como tarefas e comandos explícitos. Na prática, a maioria dos sistemas vai precisar dos dois.

O erro está em supor que tudo é apenas um update. Quando isso acontece, fica fácil saber o que mudou e muito difícil saber por que mudou.

E esse porquê, quase sempre, é onde o seu domínio de negócio realmente vive.

FAQ

Qual a diferença entre CRUD e task based?

O CRUD trata operações como criar, ler, atualizar e excluir registros. Já o task based modela ações de negócio explícitas, com dados, regras e eventos próprios.

Task based é a mesma coisa que CQRS?

Não exatamente, embora os dois combinem bem. O task based trata de como modelar as ações, enquanto o CQRS separa os modelos de leitura e escrita.

Posso usar CRUD e task based no mesmo sistema?

Sim, e essa costuma ser a melhor escolha. Use CRUD para cadastros sem comportamento e tarefas explícitas para fluxos com regras de negócio.

Como saber se uma tela deveria ser task based?

Observe se a mudança exige dados específicos, segue uma ordem obrigatória ou dispara reações em outros serviços. Se sim, uma tarefa explícita provavelmente representa melhor a intenção.

Exclusão lógica resolve o problema do cancelamento?

Não resolve, porque ela apenas esconde o registro. O cancelamento precisa guardar motivo, responsável e contexto, algo que uma exclusão lógica não captura.

Glossário

Termo Significado
CRUD Sigla para Create, Read, Update e Delete, as quatro operações básicas sobre dados persistidos
Task based Abordagem que modela o sistema em torno de tarefas de negócio explícitas, e não de edição de registros
Intenção do usuário O objetivo real por trás de uma ação, como cancelar uma carga em vez de apenas mudar um status
Comando Mensagem que pede ao sistema a execução de uma ação de negócio e pode ser recusada
Evento de domínio Registro de algo relevante que já aconteceu no negócio, como a mercadoria carregada
CQRS Padrão que separa as operações de escrita (comandos) das operações de leitura (consultas)
Exclusão lógica Técnica que marca um registro como excluído sem removê-lo fisicamente do banco
Conhecimento de embarque Documento que comprova o recebimento da mercadoria pelo transportador (bill of lading)
Padrão outbox Técnica que grava eventos na mesma transação do banco para publicá-los com segurança depois
Minimal API Forma enxuta de criar endpoints HTTP no ASP.NET Core sem controllers

Fontes