Olá!
Este é mais um post da seção Desempenho e, desta vez, trago uma perspectiva diferente para o uso do que alguns chamam de "business exceptions" ou "domain exceptions", que são exceções customizadas, criadas por desenvolvedores, para indicar a violação de uma regra de negócio.
Vamos lá!
O que são "business exceptions"?
Este é um termo, longe de ser convencional, que descreve exceções customizadas, que são lançadas como reação adversa à validação de uma regra de negócio ou invariante do domínio.
Vejamos o exemplo abaixo:
public static void ValidateUser(UserCredentials userCredentials)
{
if(string.IsNullOrWhiteSpace(userCredentials.UserName))
throw new UserValidationException("A username must be provided.");
}
Parece comum. Certo?
Vamos verificar agora, como este código se comporta diante de diversas chamadas. Para isso, vamos utilizar um benckmark (link para o código no final do post), e simular as duas situações possíveis no bloco acima: o nome do usuário estar, ou não, preenchido.
Veja que impressionante! Apenas por lançar uma exception, o código apresentou um desempenho inferior em uma ordem de grandeza. É muita coisa!
Por que tão caro?
A pergunta que salta aos olhos neste momento é: por que esse custo todo para lançar uma simples exceção? O problema não está no lançamento, mas sim no que acontece em seguida.
Quando uma exceção é lançada, o runtime do .NET vai buscar por um bloco catch
capaz de lidar com ela. Essa busca começa no método onde a exceção foi lançada, e percorre a pilha de chamadas em sentido inverso até encontrar um bloco catch
e transferir o controle da execução a ele ou, então, encerrar a aplicação por falta de tratamento.
Este percurso da pilha de chamadas, e a busca pelo bloco catch
é bem custoso e, por isso, lançar exceções no caminho da crítico da aplicação, para tratar de erros previsíveis em suas regras de negócio não é uma boa ideia.
O que fazer?
Uma alternativa, que se mantém semanticamente coerente com a programação orientada a objeto, não conflita com o modelo de exceções do C# e, ao mesmo tempo, economiza recursos, é tipo Result
, que indica se o resultado de uma operação foi um sucesso ou erro e, em sua versão Result<T>
, além de servir como indicador de sucesso ou falha, também age como um envelope, carregando um valor do tipo T
em caso de retorno satisfatório.
Vamos testar?
Aqui utilizo uma versão simples de Result
, que não carrega qualquer resultado, e apenas indica se a operação de validação do usuário foi ou não bem sucedida.
public readonly ref struct Result
{
private Result(bool isOk, string? errorMessage)
{
IsOk = isOk;
ErrorMessage = errorMessage;
}
public bool IsOk { get; }
public string? ErrorMessage { get; }
public static Result Ok() => new (true, default);
public static Result Failure(string errorMessage) => new (false, errorMessage);
}
Abaixo, a implementação responsável por retornar Result
:
public static Result ValidateUser(UserCredentials userCredentials)
{
if (string.IsNullOrWhiteSpace(userCredentials.UserName))
return Result.Failure("A username must be provided.");
return Result.Ok();
}
Agora, o benchmark atualizado:
Impressionante! Não?
Considerações sobre Result
e design
Ótimo! Então, agora, basta trocar exceções por Result
e estará tudo resolvido. Certo?
Pois bem: não exatamente!
Apesar de ser um ótimo recurso, Result
demanda certa atenção. Isso porque é possível que seu uso se confunda com a possibilidade do lançamento de exceções, fazendo com que as mesmas não sejam previstas pelo método invocador e acabem não sendo tratadas se não o forem no método que as lança.
Imagine o seguinte exemplo:
public Result<User> GetById(Guid id)
{
...
var user = connection.QuerySingle<User>(sql);
...
}
No código acima, onde usa-se Dapper para obter um usuário junto a uma base de dados, em uma borda da aplicação, é possível haver exceções como SqlException
ou InvalidOperationException
, caso o número de linhas retornadas seja diferente de 1. Neste caso, o que fazer?
Minha recomendação é que as exceções possíveis sejam tratadas dentro do método que retornará Result<User>
, fazendo com que, ao tratá-las, seja retornado um resultado padrão para o método invocador. Desta forma, o código acima mudaria para o seguinte:
public Result<User> GetById(Guid id)
{
...
try
{
var user = connection.QuerySingle<User>(sql);
}
catch
{
/*Log the exception*/
return Result<User>.Failure("Unable to find the user.");
}
...
}
Com isso, as exceções lançadas dentro do método GetById
seriam devidamente registradas, e um erro indicaria a impossibilidade do retorno de um usuário.
Considerações sobre modelo de domínio
É possível que seu modelo de domínio atue com guard clauses que, uma vez violadas, lancem exceções como ArgumentNullException
ou InvalidOperationException
. Nestes casos, a solução é mais simples: pode-se substituir as exceções por Result
cuja mensagem de erro seria a mesma da exceção antes lançada.
Entretanto, há situações como OverflowException
que são um tanto mais difíceis de prever, e que podem ocorrer em seu modelo de domínio. Minha recomendação, neste caso, é deixar a exceção ser lançada normalmente, tratando-a na borda da aplicação, onde a operação é iniciada (em seu Controller, no caso de uma WebAPI, por exemplo), mantendo o uso de Result
em sua operação para o caso dela ser concluída.
Considerações finais
Exceções tem uma razão de ser: indicar que houve uma falha em nível de infraestrutura na aplicação. Exceções são necessárias para ajudar a entender onde ocorrem erros que demandam ações do time de desenvolvimento para restaurar a saúde da aplicação. Por este motivo, o ideal é que se mantenham excepcionais. Para erros de domínio conhecidos, que não demandem atenção urgente do time, Result
é uma ótima alternativa, evitando o desperdício de recursos gerado pelo lançamento de exceções e, ao mesmo tempo, mantendo um isolamento satisfatório entre auditoria e falhas.
Aqui você encontra o código de exemplo deste post no Github. E, aqui, o pacote NuGet CSharpFunctionalExtensions, que contém a implementação completa de Result
e Result<T>
.
Gostou? Me deixe saber pelos indicadores. Tem dúvidas ou sugestões? Deixe um comentário ou me procure pelas redes sociais.
Até a próxima!
Top comments (6)
Já vi em alguns projetos a ideia de ter um BaseResponse.Erro() e um BaseResponse. Sucesso()
E todo erro mapeado era lançado para um handler de erro com o mediator.
Creio que com a implementação desse result, descartaria a ideia de lançar notificações via mediator e faria o tratamento posteriormente com os resultados encima do result.
Totalmente!
O uso de um mediator, neste caso, é overengineering (portanto dispensável). Se o esperado é o resultado de uma operação, um retorno tipado (Result) faz mais sentido que implementar uma semântica de evento.
Simplicidade, geralmente, é o que te faz ganhar o jogo.
Achei esse padrão muito interessante para alguns casos que conheço, tem um nome para esse tipo de design?
Fala, João Victor! Tudo bom?
Não existe um nome consagrado, até onde eu saiba. Já vi quem chamasse de Notification Pattern, mas achei esse nome bem ruim, porque gera confusão.
O tipo Result é um tipo de mônada. Acho que vale a pena conhecer a ideia. Abaixo, um link que fala a respeito.
mikhail.io/2018/07/monads-explaine...
Valeu!
Pode ser uma boa armazenar a exception no Result também quando acontecer alguma exceção fora das regras de negócio.
Fala, Vanderlei. Tudo bom?
Essa é uma ideia que me desagrada, porque obriga o dev a sempre verificar um Result em busca de uma exception – como acontece com uma Task em estado de falha.
Prefiro manter os propósitos separados, e cada um segundo seu modelo: exceptions sendo lançadas quando necessário, e Result para informar a conclusão e estado de um processamento.
Pode ser purismo meu, mas me parece resultar numa carga cognitiva menor, o que ajuda a compreender melhor o código.
Valeu! ✌🏾