Desempenho: Toda regra tem exceção?

William Santos - Nov 4 '22 - - Dev Community

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!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .