DEV Community

Cover image for Desempenho: Toda regra tem exceção?
William Santos
William Santos

Posted on

Desempenho: Toda regra tem exceção?

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.");
}

Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Agora, o benchmark atualizado:

Image description

Impressionante! Não?

Considerações sobre Result e design

Ótimo! Então, agora, basta trocar exceções por Resulte 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);
    ...
}

Enter fullscreen mode Exit fullscreen mode

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.");
    }
    ...
}

Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
wmscode profile image
Willian Menezes

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.

Collapse
 
wsantosdev profile image
William Santos

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.

Collapse
 
victor_dicous_dev profile image
João Victor Duarte

Achei esse padrão muito interessante para alguns casos que conheço, tem um nome para esse tipo de design?

Collapse
 
wsantosdev profile image
William Santos

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!

Collapse
 
vanderlei-dev profile image
Vanderlei Adriano Morais

Pode ser uma boa armazenar a exception no Result também quando acontecer alguma exceção fora das regras de negócio.

Collapse
 
wsantosdev profile image
William Santos

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! ✌🏾