Uso de Exceptions vs Result Pattern no .NET
O result pattern é uma forma explícita de representar sucesso ou falha no retorno de um método, sem recorrer a exceções para controlar o fluxo. No ecossistema .NET, o uso de exceções como mecanismo principal de controle de fluxo ainda é um tema recorrente e, muitas vezes, controverso.
Embora exceções sejam um recurso poderoso do C#, o uso indiscriminado delas para falhas esperadas do domínio prejudica a legibilidade, a manutenção e até o desempenho. Por isso, este artigo analisa a prática conhecida como exception-driven flow control.
Em seguida, você verá como o result pattern oferece uma alternativa mais previsível e alinhada às regras de negócio. Além disso, vamos implementar os tipos básicos, integrar com uma API e discutir quando a exceção ainda é a melhor escolha.
O problema do uso de exceções para controle de fluxo
Exceções foram projetadas para representar situações excepcionais, ou seja, falhas inesperadas em que o fluxo normal não pode prosseguir com segurança. No entanto, muitos sistemas .NET usam exceções para cenários completamente previsíveis.
Entre esses cenários, os mais comuns são os seguintes:
- Entidades não encontradas no banco de dados.
- Falhas de validação de regras de negócio.
- Estados inválidos conhecidos previamente.
- Restrições naturais do domínio, como falta de estoque ou de vagas.
Esse uso transforma a exceção em um desvio implícito de fluxo que, na prática, funciona como um goto moderno. Assim, a lógica de negócio é interrompida de forma abrupta e transferida para um manipulador global.
Em geral, esse manipulador apenas converte a exceção em uma resposta HTTP padronizada. Com isso, quem lê o código precisa caçar os blocos catch espalhados pela aplicação para entender o comportamento real.
Além disso, o contrato do método não revela que ele pode falhar de diversas formas previsíveis. A assinatura promete um valor, mas o método pode, na verdade, lançar meia dúzia de exceções diferentes.
A própria documentação da Microsoft reforça esse ponto. O guia de boas práticas para exceções recomenda verificar condições que acontecem com frequência no código, em vez de lançar e capturar exceções.
Exemplo típico em aplicações corporativas
Em aplicações corporativas, é comum encontrar serviços de aplicação que orquestram repositórios, gateways de pagamento e serviços externos. Nesses cenários, o fluxo costuma parar com uma exceção sempre que surge uma condição de falha conhecida.
O exemplo abaixo mostra um serviço de matrícula que segue exatamente esse estilo:
public sealed class EnrollmentService(
IStudentRepository students,
ICourseRepository courses)
{
public async Task<Enrollment> EnrollAsync(Guid studentId, Guid courseId)
{
var student = await students.GetByIdAsync(studentId)
?? throw new NotFoundException("Student not found.");
var course = await courses.GetByIdAsync(courseId)
?? throw new NotFoundException("Course not found.");
// Regra de negócio previsível tratada como exceção
if (course.AvailableSeats == 0)
throw new BusinessRuleException("No seats available.");
return course.Enroll(student);
}
}
Apesar de funcionais, essas exceções raramente recebem tratamento local. Em vez disso, elas se propagam até um middleware global, que registra logs e devolve uma resposta genérica ao cliente.
Dessa forma, a exceção passa a representar uma decisão de negócio, e não uma falha inesperada do sistema. Se você quiser revisar como esse tipo de componente funciona, o artigo sobre middleware no ASP.NET explica o pipeline em detalhes.
O conceito do result pattern
O result pattern propõe uma abordagem explícita para representar o sucesso ou a falha de uma operação. Em vez de interromper o fluxo com uma exceção, o método retorna um objeto que encapsula três informações.
- Um indicador de sucesso ou de falha.
- Um valor de retorno, quando a operação produz algum.
- Uma descrição estruturada do erro, quando ocorre uma falha.
Com esse modelo, falhas esperadas viram parte natural do fluxo de execução. Portanto, o código não depende mais de mecanismos implícitos para comunicar que algo deu errado.
Na prática, o result pattern se aproxima de ideias da programação funcional, como os tipos Either e Result presentes em outras linguagens. Ele também lembra o padrão TryParse do próprio .NET, que devolve um bool em vez de lançar uma exceção.
Estrutura básica de um Result
Uma implementação típica do result pattern consiste em dois tipos principais. O Result representa uma operação sem valor de retorno, enquanto o Result<T> encapsula um valor em caso de sucesso.
Modelando o erro
Antes dos tipos de resultado, vale modelar o erro como um objeto rico. Um record com código e descrição já resolve a maioria dos casos:
public sealed record Error(string Code, string Description)
{
public static readonly Error None = new(string.Empty, string.Empty);
// Captura a pilha de chamadas no momento em que o erro nasce
public string StackTrace { get; } = Environment.StackTrace;
}
Repare na propriedade StackTrace. Ela demonstra que a obtenção do stack trace não é exclusiva das exceções, porque você pode capturá-lo de forma explícita quando precisar de diagnóstico.
Contudo, capturar a pilha também tem custo. Por isso, muitas equipes deixam esse recurso desligado em produção ou o ativam apenas para erros específicos.
Implementando Result e a versão genérica
Com o erro definido, a classe Result garante que um sucesso nunca carregue erro e que uma falha sempre carregue um:
public class Result
{
protected Result(bool isSuccess, Error error)
{
// Impede combinações inválidas de estado
if (isSuccess && error != Error.None || !isSuccess && error == Error.None)
throw new ArgumentException("Invalid error state.", nameof(error));
IsSuccess = isSuccess;
Error = error;
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
}
public sealed class Result<T> : Result
{
private readonly T? _value;
private Result(T value) : base(true, Error.None) => _value = value;
private Result(Error error) : base(false, error) { }
// Acessar o valor de uma falha é um erro de programação
public T Value => IsSuccess
? _value!
: throw new InvalidOperationException("A failed result has no value.");
public static Result<T> Success(T value) => new(value);
public static new Result<T> Failure(Error error) => new(error);
public static implicit operator Result<T>(T value) => Success(value);
public static implicit operator Result<T>(Error error) => Failure(error);
}
Note que o construtor lança ArgumentException em uma combinação inválida. Isso é coerente com o result pattern, porque esse cenário indica um bug no código, e não uma falha esperada do domínio.
Os operadores implícitos também ajudam bastante. Com eles, um método pode simplesmente retornar o valor ou o erro, sem chamar Success e Failure a todo momento.
Tornando o contrato do método mais honesto com result pattern
Ao usar exceções, o método retorna um tipo que sugere sucesso garantido, quando, na realidade, diversas falhas podem ocorrer. Com o result pattern, a assinatura deixa explícito que a operação pode falhar.
Veja o mesmo serviço de matrícula reescrito:
public sealed class EnrollmentService(
IStudentRepository students,
ICourseRepository courses)
{
public async Task<Result<Enrollment>> EnrollAsync(Guid studentId, Guid courseId)
{
var student = await students.GetByIdAsync(studentId);
if (student is null)
return EnrollmentErrors.StudentNotFound;
var course = await courses.GetByIdAsync(courseId);
if (course is null)
return EnrollmentErrors.CourseNotFound;
if (course.AvailableSeats == 0)
return EnrollmentErrors.NoSeatsAvailable;
// Sucesso: o operador implícito converte Enrollment em Result<Enrollment>
return course.Enroll(student);
}
}
Agora o tipo de retorno Result<Enrollment> transfere ao chamador a responsabilidade de lidar com a falha. Assim, a API fica mais previsível e reduz surpresas em tempo de execução.
Além disso, o fluxo fica linear. Quem lê o método enxerga todas as saídas possíveis sem precisar abrir outra classe ou procurar um catch distante.
Documentação explícita de falhas
Outro benefício relevante do result pattern é a possibilidade de centralizar e documentar os erros de uma operação. Uma classe estática com os erros conhecidos de cada contexto resolve isso de forma simples:
public static class EnrollmentErrors
{
public static readonly Error StudentNotFound =
new("Enrollment.StudentNotFound", "The student was not found.");
public static readonly Error CourseNotFound =
new("Enrollment.CourseNotFound", "The course was not found.");
public static readonly Error NoSeatsAvailable =
new("Enrollment.NoSeatsAvailable", "The course has no seats available.");
}
Ao definir esse conjunto de erros conhecidos, você ganha algumas vantagens imediatas:
- As falhas do domínio ficam explícitas em um único lugar.
- A leitura e a manutenção do código ficam mais simples.
- Os códigos estáveis facilitam gerar documentação para os consumidores da API.
Essa abordagem melhora a comunicação entre times e reduz ambiguidades no uso da aplicação. Por exemplo, o time de front-end pode tratar Enrollment.NoSeatsAvailable sem depender do texto da mensagem.
Integração com controladores e camadas externas
Quem consome um método que retorna Result decide de forma clara como lidar com cada falha. Em vez de vários blocos try-catch, o código trata explicitamente o sucesso ou a falha, normalmente com um método auxiliar como Match.
public static class ResultExtensions
{
public static TOut Match<TIn, TOut>(
this Result<TIn> result,
Func<TIn, TOut> onSuccess,
Func<Error, TOut> onFailure)
{
// Um único ponto de decisão entre sucesso e falha
return result.IsSuccess ? onSuccess(result.Value) : onFailure(result.Error);
}
}
Em uma Minimal API, o Match converte o resultado em uma resposta HTTP. Nesse caso, a falha pode virar um ProblemDetails, o formato padrão para erros em APIs HTTP:
app.MapPost("/enrollments", async (EnrollmentRequest request, EnrollmentService service) =>
{
var result = await service.EnrollAsync(request.StudentId, request.CourseId);
return result.Match(
enrollment => Results.Created($"/enrollments/{enrollment.Id}", enrollment),
error => Results.Problem(
title: error.Code,
detail: error.Description,
statusCode: error == EnrollmentErrors.NoSeatsAvailable
? StatusCodes.Status409Conflict
: StatusCodes.Status404NotFound));
});
Para aprofundar o formato da resposta de erro, consulte o artigo sobre Problem Details e a RFC 9457 no .NET 10. Ele mostra como padronizar o corpo das falhas em toda a API.
O Match simula o comportamento das discriminated unions, porque obriga o chamador a fornecer as duas ramificações. Durante muitos anos, o C# não ofereceu suporte nativo a esse tipo de estrutura.
Isso começa a mudar com o C# 15, que traz os union types a partir do .NET 11. Se quiser ver como eles afetam o tema, leia o artigo sobre union types no C# 15 substituindo o Result.
Quando ainda usar exceções no lugar do result pattern
O result pattern não elimina a necessidade de exceções. Existem cenários em que lançar uma exceção continua sendo a abordagem mais adequada.
- A aplicação está em um estado irrecuperável.
- Há falhas críticas de configuração.
- O ambiente de execução não atende aos requisitos mínimos.
- Um argumento inválido indica erro de programação, como um
nullonde nunca deveria existir.
Nesses casos, a exceção representa de fato uma condição excepcional. Por isso, mecanismos globais de erro devem tratá-la, como o IExceptionHandler.
Se você quiser montar essa camada global, o artigo sobre IExceptionHandler no .NET 10 mostra a implementação completa. Na prática, o result pattern e o tratamento global convivem bem, cada um cuidando de um tipo de falha.
Outro ponto importante envolve bibliotecas de terceiros. Um driver de banco ou um cliente HTTP pode lançar exceções, então capture-as na borda da infraestrutura e converta apenas as falhas esperadas em Result.
Considerações sobre desempenho do result pattern
Além dos ganhos semânticos, o uso excessivo de exceções pode prejudicar o desempenho da aplicação. Isso acontece porque criar e propagar uma exceção envolve capturar a pilha, procurar o manipulador e desenrolar as chamadas.
Segundo as boas práticas do ASP.NET Core, lançar e capturar exceções é lento em comparação com outros padrões de fluxo. Assim, a recomendação oficial é não usá-las como fluxo normal, sobretudo em caminhos críticos de código.
O result pattern evita esse overhead, porque trata a falha esperada como um simples retorno. Em cenários de alta frequência, como uma validação executada a cada requisição, a diferença aparece com clareza.
No entanto, não troque exceções por Result apenas por desempenho. As diretrizes de design do .NET lembram que o ganho real vem de evitar exceções em caminhos frequentes, e que você deve medir antes de otimizar.
Boas práticas ao adotar o result pattern
Algumas decisões simples evitam que o padrão vire um novo problema. Primeiro, use o result pattern para falhas esperadas do domínio e da aplicação, e não para todo e qualquer erro.
Em seguida, mantenha os códigos de erro estáveis e únicos. Dessa forma, logs, métricas e clientes da API conseguem identificar cada falha sem depender de mensagens de texto.
Também evite acessar Value sem verificar IsSuccess. Por esse motivo, a implementação mostrada lança InvalidOperationException nesse caso, deixando claro que houve um erro de programação.
Por fim, avalie bibliotecas prontas antes de criar a sua. Pacotes como FluentResults e ErrorOr já oferecem Match, composição e integração com ASP.NET Core, embora uma implementação própria continue simples de manter.
Testando serviços que usam result pattern
Um efeito colateral positivo do padrão aparece nos testes automatizados. Como a falha vira um valor de retorno, você verifica o resultado com asserções comuns, sem precisar de Assert.ThrowsAsync.
O teste abaixo, escrito com xUnit, valida o cenário de curso sem vagas:
public class EnrollmentServiceTests
{
[Fact]
public async Task EnrollAsync_ShouldFail_WhenCourseHasNoSeats()
{
// Arrange: curso lotado
var service = new EnrollmentService(
new FakeStudentRepository(),
new FakeCourseRepository(availableSeats: 0));
// Act
var result = await service.EnrollAsync(Guid.NewGuid(), Guid.NewGuid());
// Assert: a falha é um valor, não uma exceção
Assert.True(result.IsFailure);
Assert.Equal(EnrollmentErrors.NoSeatsAvailable, result.Error);
}
}
Repare que a asserção compara o erro com a instância documentada em EnrollmentErrors. Dessa forma, o teste fica preso ao contrato do domínio, e não ao texto de uma mensagem que pode mudar.
Além disso, cada cenário de falha ganha um teste próprio e fácil de ler. Com exceções, o mesmo teste costuma depender do tipo exato da exceção e, às vezes, de detalhes da mensagem.
Registrando falhas esperadas nos logs
Quando a falha deixa de ser uma exceção, ela também deixa de aparecer automaticamente nos logs de erro. Por isso, decida de forma consciente o que registrar e em qual nível.
Em geral, uma falha esperada do domínio merece nível Information ou Warning, e não Error. Afinal, um curso lotado não indica defeito no sistema, mas sim uma regra de negócio funcionando.
O código estável do erro ajuda bastante nesse ponto. Com ele, você registra Enrollment.NoSeatsAvailable como propriedade estruturada e cria métricas ou alertas sem analisar textos livres.
Enquanto isso, as exceções reais continuam fluindo para o tratamento global e para os logs de erro. Assim, o painel de erros mostra apenas o que de fato exige atenção da equipe.
Conclusão
Exceções continuam essenciais no desenvolvimento .NET, mas o uso delas precisa ser criterioso. Quando representam falhas previsíveis do domínio, elas comprometem a clareza do código e criam contratos implícitos difíceis de manter.
O result pattern surge como uma alternativa mais explícita e alinhada ao design de APIs robustas. Com ele, falhas esperadas ficam claras, documentadas e previsíveis.
Ao adotar essa abordagem de forma equilibrada, você constrói aplicações mais legíveis, testáveis e resilientes. Em resumo, use Result para o que o negócio espera e reserve as exceções para o que realmente é excepcional.
FAQ
O result pattern substitui completamente as exceções?
Não. Ele cobre falhas esperadas do domínio, enquanto as exceções continuam adequadas para erros irrecuperáveis, falhas de configuração e bugs.
O result pattern melhora o desempenho da aplicação?
Sim, em caminhos onde falhas são frequentes, porque evita o custo de criar e propagar exceções. Mesmo assim, meça antes de otimizar.
Preciso de uma biblioteca para usar o result pattern?
Não. Uma implementação própria com Result, Result<T> e Error cabe em poucas linhas, mas bibliotecas como FluentResults e ErrorOr aceleram a adoção.
Como converter um Result em resposta HTTP?
Use um método como Match para mapear o sucesso para Results.Ok ou Results.Created e a falha para Results.Problem com o status adequado.
Os union types do C# 15 tornam o Result obsoleto?
Eles oferecem suporte nativo a tipos com casos fechados e verificação exaustiva no switch. Assim, podem substituir implementações manuais, mas o conceito de modelar falhas explicitamente continua o mesmo.