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!
Oldest comments (2)
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! ✌🏾