Result Pattern: Melhorando a legibilidade da sua aplicação

Na implementação de qualquer caso de uso, há um “caminho feliz” onde tudo ocorre conforme o esperado: desde a entrada das informações, a conexão com o banco de dados, até as integrações com outros sistemas, resultando na conclusão bem-sucedida do caso de uso. Por exemplo, em um método simples cujo objetivo é converter uma string em um número inteiro, podemos ter o seguinte código:
public int ConvertToInt(string value)
{
return Int32.Parse(value);
}
Aqui estamos considerando que o valor recebido será sempre válido e não causará erro. No entanto, também existem fluxos alternativos. Caso não seja possível converter o valor para inteiro, a aplicação deve prever essa situação e tratá-la da melhor forma.
public int ConvertToInt(string value)
{
try
{
return Int32.Parse(value);
}
catch
{
throw new Exception("Value is invalid");
}
}
Ou até mesmo dessa forma:
public int ConvertToInt(string value)
{
if (Int32.TryParse(value, out int result))
{
return result;
}
throw new Exception("Value is invalid");
}
Embora pareça muito conveniente utilizar exceções para controle de fluxo, inclusive no .Net 8 com o manipulador de exceções global ao invés da utilização de um middleware, isso não é controle de fluxo. O manipulador de exceções global geralmente encerrará a execução porque indica que ocorreu um erro irrecuperável. Controle de fluxo significa que você tem a oportunidade de investigar o erro e decidir como proceder de uma maneira que permita ao código continuar “fluindo” naturalmente. Exceções, por definição, interrompem o fluxo e, portanto, não podem ser usadas como controle de fluxo.
Result Pattern
O uso do Result Pattern oferece várias vantagens significativas sobre o uso de exceções para o controle de fluxo, especialmente no que diz respeito a legibilidade, desempenho e manutenção do código.
Legibilidade do código: O Result Pattern deixa claro que a função pode falhar e quais tipos de falha podem ocorrer, sem a necessidade de ler uma lista de exceções possíveis. Isso torna o código mais fácil de entender.
Desempenho: Evita o custo de construir e manipular pilhas de exceções, resultando em melhor desempenho, especialmente em cenários onde falhas são esperadas como parte do fluxo normal do programa.
Manutenabilidade: Melhora a capacidade de manutenção do código ao tornar o fluxo de erro explícito e reduzindo a necessidade de documentar e rastrear exceções que podem ser lançadas.
Tratamento inadequado de erros: Exceções devem ser reservadas para situações verdadeiramente excepcionais e inesperadas, não para o fluxo de controle normal. O Result Pattern promove essa prática, incentivando um design de software mais robusto e intuitivo.
Implementação
Inicialmente vamos criar nossa estrutura de erro. Ela possui apenas dois parâmetros mas podemos incrementar com mais informações dependendo da necessidade. Nossa struct implementa o método Failure como builder para criar nosso objeto de erro.
public readonly record struct Error
{
private Error(string code, string description)
{
Code = code;
Description = description;
}
public string Code { get; }
public string Description { get; }
public static Error Failure(
string code = "General.Failure",
string description = "A failure has occurred.") =>
new(code, description);
};
Abaixo teremos nossa struct Resut, é um tipo genérico inde vai receber o resultado da nossa operação ou um ou mais erros que cairam em nosso fluxo de exceção.
public readonly record struct Result<TValue>
{
private readonly TValue? _value = default;
private readonly List<Error>? _errors = null;
public Result()
{
throw new InvalidOperationException();
}
public Result(Error error)
{
_errors = [error];
}
public Result(List<Error> errors)
{
if (errors is null)
{
throw new ArgumentNullException(nameof(errors));
}
if (errors is null || errors.Count == 0)
{
throw new ArgumentException("Cannot create an Result<TValue> from an empty collection of errors.", nameof(errors));
}
_errors = errors;
}
public Result(TValue value)
{
if (value is null)
{
throw new ArgumentNullException(nameof(value));
}
_value = value;
}
public bool IsError => _errors is not null;
public TValue Value
{
get
{
if (IsError)
{
throw new InvalidOperationException("Check IsError before accessing Value.");
}
return _value;
}
}
}
Agora podemos ver a implementação do nosso método que realiza a conversão de uma string para inteiro utilizando o Result Pattern.
public Result<int> ConvertToInt(string value)
{
if (Int32.TryParse(value, out int result))
{
return new Result<int>(result);
}
return new Result<int>(Error.Failure());
}
Podemos melhorar essa solução tornando os construtores privados e fazendo o uso do implicit operator para tornar o código mais legível.
public static implicit operator Result<TValue>(TValue value)
{
return new Result<TValue>(value);
}
public static implicit operator Result<TValue>(Error error)
{
return new Result<TValue>(error);
}
public static implicit operator Result<TValue>(List<Error> errors)
{
return new Result<TValue>(errors);
}
Logo, nossa implementação ficará dessa forma:
public Result<int> ConvertToInt(string value)
{
if (Int32.TryParse(value, out int result))
{
return result;
}
return Error.Failure();
}
Conclusão
Diferente do Result Pattern, exceções são projetadas para lidar com condições inesperadas, não para controlar o fluxo da sua aplicação. Usá-las para essa finalidade distorce o propósito original das exceções e pode levar a um design de software menos intuitivo. O Result Pattern também não requer métodos ou abstrações complexas tornando sua implementação bastante simples.
Existem alguns pacotes NuGet que implementam o Result Pattern, como o FluentResults e o ErrorOr, que podem ser facilmente configurados e utilizados em seu projeto. Alternativamente, você pode implementar seu próprio Result Pattern.
Repositório: https://github.com/FcoJunior/ResultPattern