Exception VS Domain Notification

Domain notification é uma forma de validar entidades de domínio acumulando mensagens sobre o que está errado, em vez de interromper a execução com uma exception. Então você precisa validar suas entidades e tem duas opções: talvez a mais óbvia seja usar exceptions, mas será que ela é a melhor escolha?

Neste artigo, vamos abrir espaço para o eterno debate entre exceptions e domain notifications. Além disso, vou apresentar os motivos pelos quais uso domain notifications nas validações do meu domínio.

O que é uma exception

Uma exceção, oras, é algo que não deveria acontecer, mas aconteceu. Ou seja, um evento quase imprevisível no fluxo normal da aplicação.

A própria Microsoft segue essa linha nas suas boas práticas para exceções no .NET. Segundo o texto, condições que acontecem com frequência devem ser tratadas no código, de forma a evitar a exceção.

Por outro lado, a documentação recomenda o tratamento de exceções para eventos raros, que realmente indicam erro. Nesse cenário, menos código roda em condições normais.

O próprio .NET dá exemplos disso com o padrão Try. Por exemplo, Int32.Parse lança uma exceção quando o texto é inválido, enquanto Int32.TryParse apenas retorna false.

O que é um domain notification

Um domain notification é uma notificação, uma mensagem sobre algo que aconteceu no seu domínio. Em vez de parar tudo, a entidade registra o problema e segue em frente.

Ao final do processo, quem chamou a entidade verifica se existem notificações. Se existirem, decide o que fazer com elas, como devolver a lista para a tela.

Essa ideia tem nome conhecido: Notification Pattern. Martin Fowler descreve o padrão no artigo Replacing Throwing Exceptions with Notification in Validations, publicado em 2014.

No texto, Fowler aponta dois problemas de usar exceções em validações. Primeiro, uma falha de validação de dados vindos do usuário é um comportamento esperado, e não excepcional.

Segundo, a exceção para na primeira falha. Dessa forma, o usuário não vê todos os erros de uma vez e precisa corrigir um por um.

Fowler também cita uma frase do livro The Pragmatic Programmer. Em resumo, ela diz que exceções raramente devem fazer parte do fluxo normal do programa e devem ficar reservadas para eventos inesperados.

O que acontece quando temos uma exception

Quando uma exceção acontece, a execução para naquele ponto. Além disso, dependendo da hospedagem, o evento vai para os logs do host, como o Event Viewer no caso do IIS.

Isso gera um alarde maior, como se fosse um erro da aplicação. Também temos um custo maior, afinal registramos tudo nos eventos do servidor.

A documentação da Microsoft reconhece esse ponto quando trata do custo de desempenho das exceções. Por isso, várias APIs do .NET oferecem métodos alternativos, como TryGetValue em um dicionário.

Veja como fica uma entidade validada com exceções.

public class Customer
{
    public string Name { get; private set; }
    public string Document { get; private set; }
    public string Email { get; private set; }

    public Customer(string name, string document, string email)
    {
        // Cada validação interrompe o fluxo na primeira falha
        if (string.IsNullOrWhiteSpace(name))
            throw new ArgumentException("Name is required", nameof(name));

        if (string.IsNullOrWhiteSpace(document) || document.Length != 11)
            throw new ArgumentException("Invalid document", nameof(document));

        if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
            throw new ArgumentException("Invalid email", nameof(email));

        Name = name;
        Document = document;
        Email = email;
    }
}

O código funciona, mas repare no comportamento. Se o nome estiver vazio, o construtor nunca chega a validar o documento nem o e-mail.

Devo parar de usar exceptions

Não! As exceções têm o seu uso, e domain notification não substitui todas elas.

Por exemplo, se você não consegue se conectar ao banco de dados, adianta prosseguir com o request? Esse é um belo exemplo de cenário em que devemos usar exceções.

O mesmo vale para falhas de infraestrutura, como um arquivo de sistema ausente ou um serviço externo fora do ar. Nesses casos, algo realmente inesperado aconteceu e continuar a execução não faz sentido.

Também faz sentido lançar exceções para erros de programação. Fowler cita esse caso: dados que já deveriam ter sido validados e chegam inválidos indicam um bug, e não uma entrada ruim do usuário.

A Microsoft resume bem a ideia ao afirmar que um app que falha é mais confiável e mais fácil de diagnosticar do que um app com comportamento indefinido. Portanto, a exceção continua sendo a ferramenta certa para o que é de fato excepcional.

Devo usar exceptions no domínio

Depende! Em tese, não vejo um cenário em que eu teria, por exemplo, uma conexão com o banco dentro do meu domínio.

Juntando isso com a interrupção da execução e com o registro nos logs, o uso de exceções no domínio acaba se complicando. Por isso, o domain notification costuma se encaixar melhor nas validações de entidades.

A validação picada sem domain notification

Imagine um cenário em que você lança uma exceção no cadastro de clientes, no momento em que valida o documento. Afinal, não podemos salvar um cliente com documento inválido.

Em primeiro lugar, se essa fosse a primeira de N validações, o fluxo pararia ali e voltaria para a tela. Isso incomoda bastante o usuário, porque a validação chega picada.

Ele corrige o documento e descobre que o nome está errado. Então corrige o nome, e em seguida a data de nascimento falha, e assim por diante.

Com domain notification, a entidade executa todas as validações e devolve a lista completa. Dessa forma, o usuário corrige tudo de uma vez.

O custo em escala e o domain notification

Além disso, como comentei antes, a exceção consome mais recursos quando o host registra cada uma nos eventos da máquina. No caso, mais recursos de disco.

Agora imagine um cenário com milhões de cadastros. Ou então um simples ataque de força bruta, que seria o bastante para lotar a máquina de registros.

Desta forma, quase nunca uso exceptions no meu domínio. Em vez disso, uso domain notification com o Notification Pattern e o Flunt.

Implementando domain notification sem biblioteca

Antes de partir para uma biblioteca, vale entender o mecanismo por trás dela. Na essência, um domain notification precisa de pouco: uma mensagem, uma chave e uma lista para acumular os itens.

Fowler mostra essa estrutura no artigo dele, com um objeto Notification que recebe os erros durante a validação. Em C#, uma versão mínima fica assim.

public record Notification(string Key, string Message);

public abstract class Entity
{
    private readonly List<Notification> _notifications = new();

    // Somente leitura para quem está fora da entidade
    public IReadOnlyCollection<Notification> Notifications => _notifications;

    public bool IsValid => _notifications.Count == 0;

    protected void AddNotification(string key, string message)
        => _notifications.Add(new Notification(key, message));
}

Com essa base, qualquer entidade herda de Entity e registra as próprias falhas. Por exemplo, um produto pode validar o preço e o nome no construtor.

public class Product : Entity
{
    public string Title { get; private set; }
    public decimal Price { get; private set; }

    public Product(string title, decimal price)
    {
        Title = title;
        Price = price;

        // As duas regras rodam, mesmo que a primeira falhe
        if (string.IsNullOrWhiteSpace(title))
            AddNotification("Title", "Title is required");

        if (price <= 0)
            AddNotification("Price", "Price must be greater than zero");
    }
}

Repare que a lista fica encapsulada. Assim, ninguém de fora consegue limpar as notificações ou adicionar itens sem passar pela entidade.

Essa implementação resolve o básico, mas cresce rápido quando surgem regras repetidas, como e-mail, CPF ou tamanho de texto. Nesse ponto, uma biblioteca de domain notification economiza código e padroniza as mensagens.

Domain notification com Flunt

O Flunt é uma biblioteca open source que implementa o Notification Pattern de forma fluente. Segundo o README do projeto, ela concentra as mudanças da entidade e facilita o acesso às notificações quando você precisa delas.

Você instala o pacote pelo NuGet com o comando abaixo.

dotnet add package Flunt

Se quiser um panorama completo da biblioteca, leia o artigo sobre o Flunt aqui no balta.io.

Entidade com domain notification

Com o Flunt, a entidade herda de Notifiable<Notification> e passa a acumular notificações. Veja o mesmo cliente, agora sem exceções.

using Flunt.Notifications;
using Flunt.Validations;

public class Customer : Notifiable<Notification>
{
    public string Name { get; private set; }
    public string Document { get; private set; }
    public string Email { get; private set; }

    public Customer(string name, string document, string email)
    {
        Name = name;
        Document = document;
        Email = email;

        // Todas as regras rodam e cada falha vira uma notificação
        AddNotifications(new Contract<Customer>()
            .Requires()
            .IsNotNullOrEmpty(Name, "Name", "Name is required")
            .IsNotNullOrEmpty(Document, "Document", "Document is required")
            .IsEmail(Email, "Email", "Invalid email"));
    }
}

A diferença está no comportamento. Mesmo que o nome esteja vazio, o contrato também verifica o documento e o e-mail.

Quem cria o cliente, por sua vez, consulta a propriedade IsValid antes de salvar.

var customer = new Customer(name, document, email);

if (!customer.IsValid)
{
    // Devolve todas as mensagens de uma vez para a tela
    return Results.BadRequest(customer.Notifications);
}

await repository.SaveAsync(customer);

Adicionando notificações manualmente

Nem toda regra cabe em um contrato pronto. Nesse caso, o Flunt permite adicionar uma notificação diretamente, com uma chave e uma mensagem.

// Trecho da classe Customer
public bool Active { get; private set; } = true;

public void Deactivate(bool hasOpenOrders)
{
    // Regra de negócio específica, fora do contrato
    if (hasOpenOrders)
    {
        AddNotification("Customer", "Customer with open orders cannot be deactivated");
        return;
    }

    Active = false;
}

Repare que a informação sobre pedidos em aberto chega por parâmetro. Assim, o domínio não precisa consultar o banco, e o domain notification registra a regra violada sem lançar exceção.

Devolvendo as notificações para a tela

Depois de validar, falta entregar as mensagens para quem consome a API. Como cada notificação tem chave e mensagem, a serialização em JSON já produz um formato fácil de ler no front-end.

[
  { "key": "Name", "message": "Name is required" },
  { "key": "Email", "message": "Invalid email" }
]

Com essa lista, a tela consegue destacar cada campo com a mensagem correspondente. Em outras palavras, a chave funciona como o elo entre a regra do domínio e o campo do formulário.

Outro ponto importante é o status HTTP. Em geral, uma falha de domain notification vira um 400 Bad Request, porque o problema está nos dados enviados, e não no servidor.

Já uma exceção de infraestrutura costuma virar um 500 Internal Server Error. Dessa forma, o cliente da API distingue com clareza um erro de entrada de uma falha interna.

Essa separação também ajuda no monitoramento. Afinal, os alertas de erro do servidor deixam de disparar a cada formulário preenchido de forma incorreta.

Fail fast validations

Outra abordagem que uso são as fail fast validations. Nelas, faço uma validação inicial nos meus commands, antes de os dados chegarem às entidades.

Com isso, removo validações triviais das entidades e não as sobrecarrego. Afinal, partimos do princípio de que as informações devem ao menos chegar íntegras às entidades.

A wiki do Flunt mostra esse padrão com uma interface IValidatable, implementada pelo command.

public interface IValidatable
{
    void Validate();
}

public class CreateCustomerCommand : Notifiable<Notification>, IValidatable
{
    public string Name { get; set; }
    public string Document { get; set; }
    public string Email { get; set; }

    public void Validate()
    {
        // Validações rasas: formato e presença dos dados
        AddNotifications(new Contract<CreateCustomerCommand>()
            .Requires()
            .IsNotNullOrEmpty(Name, "Name", "Name is required")
            .IsEmail(Email, "Email", "Invalid email"));
    }
}

O handler chama Validate logo no início. Se o command for inválido, a execução termina ali, sem criar nenhuma entidade.

public ICommandResult Handle(CreateCustomerCommand command)
{
    command.Validate();
    if (!command.IsValid)
        return new CommandResult(false, "Invalid customer data", command.Notifications);

    var customer = new Customer(command.Name, command.Document, command.Email);
    // regras de domínio e persistência
    return new CommandResult(true, "Customer created", customer);
}

Dessa forma, o command cuida das validações rasas e a entidade cuida das regras de negócio. Os dois usam domain notification, então a resposta para a tela segue o mesmo formato.

Testes de unidade com domain notification

Trazer a regra de negócio para o domínio tem um motivo: testes de unidade! Se você brigou tanto com o DBA para sair das procedures e não escreve testes, não adianta nada.

É possível testar exceções, por exemplo com o atributo ExpectedException do MSTest. No entanto, esse tipo de teste é mais frágil.

O falso positivo com exceptions

Suponha que você espera uma ArgumentNullException no Name do Customer. Porém, ocorre outra ArgumentNullException, desta vez no Email.

[TestMethod]
[ExpectedException(typeof(ArgumentNullException))]
public void Constructor_NullName_ThrowsException()
{
    // Passa mesmo que a exceção venha do Email, e não do Name
    var customer = new Customer(null, "12345678901", null);
}

Nesse caso, o teste passa e dá um falso positivo. Afinal, o atributo só confere o tipo da exceção, e não qual regra a disparou.

Testando notificações por chave

Com domain notification, você confere exatamente qual regra falhou. Cada notificação tem uma chave, então o teste fica preciso.

public class CustomerTests
{
    [Fact]
    public void Constructor_EmptyName_AddsNameNotification()
    {
        // Arrange e Act
        var customer = new Customer("", "12345678901", "john@example.com");

        // Assert
        Assert.False(customer.IsValid);
        Assert.Contains(customer.Notifications, n => n.Key == "Name");
        Assert.DoesNotContain(customer.Notifications, n => n.Key == "Email");
    }

    [Fact]
    public void Constructor_ValidData_IsValid()
    {
        var customer = new Customer("John", "12345678901", "john@example.com");

        Assert.True(customer.IsValid);
    }
}

Além disso, você testa vários erros no mesmo cenário sem escrever um teste por exceção. Para revisar a base, veja os fundamentos dos testes de unidade.

Domain notification, exception ou Result Pattern

O debate não termina em duas opções. Hoje, muitos times também usam o Result Pattern, em que o método devolve um objeto com sucesso ou erro em vez de lançar exceção.

A ideia é parecida com a do domain notification: tratar falhas esperadas como parte do fluxo. O artigo sobre exceptions vs Result Pattern no .NET compara as duas abordagens em detalhes.

Na prática, um bom critério para escolher é este:

  • Exceptions: falhas inesperadas e de infraestrutura, como banco fora do ar.
  • Domain notification: validações de entidades e commands, quando você quer devolver todos os erros juntos.
  • Result Pattern: métodos que precisam devolver sucesso ou um erro específico de forma explícita.

Mesmo com domain notification no domínio, as exceções de infraestrutura continuam existindo. Por isso, vale ter um tratamento global na API, como mostra o artigo sobre IExceptionHandler no .NET 10.

Curso 1975

Há ainda um mundo de coisas que eu poderia escrever aqui. Falei tanto sobre isso que transformei o tema em um curso, o 1975: Modelando Domínios Ricos, que está gratuito no balta.io.

No curso, o domain notification aparece junto com entidades ricas, value objects, commands e testes de unidade. Ou seja, você vê o padrão aplicado em um domínio completo.

👉 1975: Modelando Domínios Ricos

Conclusão

Exceptions e domain notifications não competem: cada um tem o seu lugar. As exceções servem para o que é realmente inesperado, como falhas de infraestrutura e erros de programação.

Já o domain notification resolve as validações do domínio sem interromper o fluxo e sem gerar alarde nos logs. Além disso, ele devolve todos os erros de uma vez e deixa os testes de unidade mais precisos.

Com o Flunt, você aplica esse padrão em poucas linhas. Assim, entidades e commands falam a mesma língua quando algo não está certo.

FAQ

Domain notification substitui todas as exceptions?

Não. Falhas de infraestrutura e erros de programação continuam sendo casos para exceções.

Qual a vantagem de domain notification para o usuário?

A entidade executa todas as validações e devolve a lista completa de erros. Assim, o usuário corrige tudo de uma vez.

Preciso do Flunt para usar Notification Pattern?

Não, você pode implementar o padrão com uma classe própria de notificações. O Flunt apenas oferece uma API fluente e contratos prontos.

Onde ficam as fail fast validations?

Nos commands, antes de os dados chegarem às entidades. Elas cuidam de validações rasas, como presença e formato.

Por que testes com ExpectedException podem dar falso positivo?

Porque o atributo confere apenas o tipo da exceção. Se outra regra lançar o mesmo tipo, o teste passa mesmo com o comportamento errado.