Esta é a terceira parte da série sobre programação funcional em C#. Se a Parte 2 foi sobre compor o domínio, aqui é sobre compor o domínio com falhas sem mentir no contrato.

Caso tenha perdido:

Duas perguntas surgiram com frequência nos posts anteriores: ‘Por que sempre um factory method?’ e ‘O que é esse Result<>?’. Hoje vamos responder ambas. Identificar o problema que elas resolvem e entender a aplicação correta.

A Mentira da Assinatura (The Signature Lie)

    Order GetOrderById(OrderId id);

O método acima recebe um id e retorna uma instância do objeto Order, certo? Deveria ser, mas a verdade é que isso quase nunca é verdade. Você chama esse método e tem que lembrar que o retorno pode ser null, pode ser vários tipos de exception ou o Order prometido.

Um dos princípios que temos que abordar quando falamos de programação funcional, e eu espero que você leve para OO também, é a Honestidade da assinatura. A função deve aderir estritamente ao seu contrato. Se ela pode falhar, a falha deve estar explícita na assinatura. Se ela busca algo que não tem garantia de retorno, idem.

Além do princípio, temos o custo técnico. Exceções são caras quando usadas incorretamente (stack unwinding etc), drenam recursos e, semanticamente, agem como um GOTO invisível que quebra o fluxo da aplicação.

E se exceções são um problema, o null é outro. Tony Hoare, criador do ALGOL W (e consequentemente do null), chama isso de “O erro de um bilhão de dólares”. Ele se refere ao custo acumulado global de falhas de sistemas causadas por referências nulas. Vale muito a pena ver a palestra dele na QCon abordando o tema.

Eu nem preciso explicar muito, quantos bugs em produção você já teve por conta de referências nulas?

Ao final deste post, você será capaz de modelar fluxos de domínio que não permitem estados inválidos, e evitar exceções para controle de fluxo. Faremos a distinção de onde elas realmente ajudam no decorrer do artigo.

Railway Oriented Programming (ROP)

Nós compusemos funções no último post, mas sempre assumindo o “caminho feliz”. No mundo real, nossa composição precisa lidar com “bifurcações”. Validações falham, operações são interrompidas, recursos que não existem.

Railway Oriented Programming, termo cunhado por Scott Wlaschin, resolve isso usando a metáfora de trilhos de trem, um trilho para o caminho do sucesso e outro para falhas/erros.

Diagrama mostrando dois trilhos paralelos: trilho superior de sucesso e trilho inferior de erro, com funções que podem desviar entre eles

A mágica está na composição: uma vez que entramos no trilho de erro, todas as próximas funções são automaticamente ignoradas, evitando aquele código defensivo cheio de if (x != null) e try/catch aninhados.

Imagine que precisamos processar um pedido com várias etapas:

  Result<Order> ProcessarPedido(OrderId id)
  {
      return ObterPedido(id) // Result<Order>
          .Bind(ValidarEstoque) // Order -> Result<Order>
          .Bind(AplicarDesconto)
          .Bind(ProcessarPagamento)
          .Bind(EnviarEmailConfirmacao)
          .Bind(AtualizarInventario);
  }

O que acontece aqui?

  1. Se ObterPedido() não encontrar o pedido → retorna erro, resto do pipeline é ignorado
  2. Se ValidarEstoque() encontrar produto sem estoque → retorna erro, pagamento nunca é processado
  3. Se ProcessarPagamento() falhar → erro, email nunca é enviado
  4. Se todas as etapas funcionarem → retorna Result com sucesso

Compare com a versão tradicional:

    public Order ProcessarPedido(OrderId id)
    {
        var order = _repository.GetById(id);
        if (order == null)
            throw new OrderNotFoundException(id);

        if (!_estoque.TemDisponibilidade(order))
            throw new EstoqueInsuficienteException();

        var orderComDesconto = _descontos.Aplicar(order);

        var pagamento = _pagamentos.Processar(orderComDesconto);
        
        if (!pagamento.Aprovado)
            throw new PagamentoRecusadoException();

        _email.EnviarConfirmacao(orderComDesconto);
        _inventario.Atualizar(orderComDesconto);

        return orderComDesconto;
    }

Repare nos problemas:

  • Exceções para controle de fluxo: cada validação vira uma exception
  • Lógica de compensação complexa: se email falha, o que fazemos com o pagamento já processado?
  • Dificuldade em compor: não dá para reutilizar ValidarEstoque() em outro pipeline facilmente
  • Assinatura mentirosa: retorna Order mas pode lançar 5 tipos diferentes de exceção

Com ROP:

  // Cada função retorna Result<Order>
  Result<Order> ObterPedido(OrderId id) { ... }
  Result<Order> ValidarEstoque(Order order) { ... }
  Result<Order> ProcessarPagamento(Order order) { ... }
  // etc.

  // Composição limpa e transparente
  var resultado = ProcessarPedido(orderId);

  // Tratamento exaustivo e tipado dos erros
  return resultado.Match(
      onSuccess: order => Ok(order),
      onFailure: error => error switch
      {
          EstoqueInsuficiente => BadRequest("Produto sem estoque"),
          PagamentoRecusado => BadRequest("Pagamento recusado"),
          PedidoNaoEncontrado => NotFound(),
          _ => InternalServerError()
      }
  );

Mas como exatamente implementamos isso em C#? Precisamos de um tipo que possa representar ambos os trilhos - sucesso ou falha - e funções que saibam navegar entre eles automaticamente.

Esse tipo é o Result<T> (ou Result<T,E> para tipos de erro mais diversos), e o padrão que faz tudo funcionar. Tem um nome intimidador mas conceito simples: Monad. Vamos construí-lo passo a passo.

Monads, e são mais simples do que você imagina

Vamos deixar a matemática de fora, parar agora para definir endofunctors, por exemplo, é inútil e o que leva muita gente a achar FP difícil. Não vamos tentar transformar o C# em Haskell.

Em termos simples, um Monad é uma estrutura que encapsula um valor (ou valores) adicionando um Contexto a ele, e fornece um mecanismo para encadear operações (Bind) lidando com esse contexto automaticamente.

Alguns monads já fazem parte do nosso dia a dia no .Net, por exemplo:

IEnumerable (Contexto de Indeterminismo/Pluralidade)

O Valor: Um dado do tipo T.

O Contexto: Pode haver um, nenhum, ou vários desses dados.

A Mágica: Quando usamos LINQ (Select, Where), aplicamos uma função em T, e a estrutura IEnumerable gerencia a iteração e o yield para nós. Não precisamos escrever foreach manualmente em cada passo.

Task (Contexto de Latência/Assincronia)

O Valor: Um dado do tipo T que existirá no futuro.

O Contexto: O dado ainda não está aqui; pode demorar ou falhar.

A Mágica: Quando usamos await (que é o açúcar sintático para o Bind do monad), escrevemos código como se o dado já estivesse lá. O Monad cuida de pausar a execução e retomar quando o contexto (a thread/IO) estiver pronto.

Nullable ou ? (Contexto de Incerteza) - Esse é quase um monad

O Valor: T.

O Contexto: O valor pode não existir.

A Mágica: O operador ?. (null conditional) propaga o null (o trilho vermelho) sem estourar NullReferenceException.

Implementando o Result<T>

Dos monads que usamos em nossa implementação de ROP, o primeiro que vamos analisar e entender o uso é o Result.

A implementação abaixo é simplificada para fins didáticos, mas funcional. No final do post, recomendo bibliotecas robustas para produção.

Aqui vamos modelar o trilho de erro como um tipo (DomainError). Em produção, você pode querer ir além e usar Result<T, E> para separar erros de domínio de erros técnicos/infra.
O ponto aqui não é o tipo do erro, mas o fato de ele ser explícito e componível.

public abstract record DomainError;

public readonly struct Result<T>
{
    private readonly T? _value;
    private readonly DomainError? _error;

    public bool IsSuccess { get; }
    public bool IsFailure => !IsSuccess;

    public T Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException();

    public DomainError Error => IsFailure
        ? _error!
        : throw new InvalidOperationException();

    private Result(T value)
    {
        IsSuccess = true;
        _value = value;
        _error = null;
    }

    private Result(DomainError error)
    {
        IsSuccess = false;
        _value = default;
        _error = error;
    }

    // Pattern Matching no final do trilho
    public TOut Match<TOut>(Func<T, TOut> onSuccess, Func<DomainError, TOut> onFailure)
        => IsSuccess ? onSuccess(_value!) : onFailure(_error!);
}

public static class Result
{
    public static Result<T> Success<T>(T value) => Result<T>.Success(value);
    public static Result<T> Failure<T>(DomainError error) => Result<T>.Failure(error);
}

Nada de outro mundo, a versão, apesar de simplificada não tira nada crucial. Teríamos algumas funcionalidades adicionais e facilidades no uso, mas já temos algo funcional aqui.

Bind é o que permite mantermos o encadeamento e o que faz nossa execução não “descarrilhar”. É a principal forma que temos de controlar o fluxo.

public static class ResultExtensions
{
    // Bind síncrono - conecta funções que retornam Result<T>
    public static Result<TOut> Bind<T, TOut>(
        this Result<T> result,
        Func<T, Result<TOut>> func)
    {
        return result.IsSuccess
            ? func(result.Value)      // Continua no trilho de sucesso
            : Result<TOut>.Failure(result.Error);  // Trilho de erro
    }
}

Nosso bind tem apenas a missão de prover a continuidade do fluxo ou direcionar para o trilho de erro. Se você já implementou um pipeline ou strategy, há grandes semelhanças.

Na implementação acima, checamos se o resultado é sucesso e direcionamos à uma das funções recebidas por parâmetro.

Podemos suportar async com as extensões abaixo. Que possuem a mesma lógica, apenas estão preparadas para receber tipos Task ou continuações assíncronas.

Nós vamos entrar a fundo no tema em nosso próximo post, que trará a ideia de Functional Core/Imperative Shell, mas cuidado com async no seu core/domínio. Você pode estar trazendo IO para onde ele não deveria ser feito.

    // BindAsync - para operações assíncronas
    public static async Task<Result<TOut>> BindAsync<T, TOut>(
        this Task<Result<T>> resultTask,
        Func<T, Task<Result<TOut>>> func)
    {
        var result = await resultTask;

        return result.IsSuccess
            ? await func(result.Value)
            : Result<TOut>.Failure(result.Error);
    }

    // Sobrecarga: Task<Result<T>> + função síncrona
    public static async Task<Result<TOut>> Bind<T, TOut>(
        this Task<Result<T>> resultTask,
        Func<T, Result<TOut>> func)
    {
        var result = await resultTask;
        return result.Bind(func);
    }

    // Sobrecarga: Result<T> + função assíncrona
    public static async Task<Result<TOut>> BindAsync<T, TOut>(
        this Result<T> result,
        Func<T, Task<Result<TOut>>> func)
    {
        return result.IsSuccess
            ? await func(result.Value)
            : Result<TOut>.Failure(result.Error);
    }

Agora, se precisarmos executar transformações? Diferente do Bind, o Map é usado quando a função apenas transforma o dado (T -> U), sem possibilidade de erro.

public static class ResultExtensions
{
    // Map - quando a função NÃO retorna Result<T>
    public static Result<TOut> Map<T, TOut>(
        this Result<T> result,
        Func<T, TOut> func)
    {
        return result.IsSuccess
            ? Result<TOut>.Success(func(result.Value))
            : Result<TOut>.Failure(result.Error);
    }

    // MapAsync - versão assíncrona
    public static async Task<Result<TOut>> MapAsync<T, TOut>(
        this Task<Result<T>> resultTask,
        Func<T, TOut> func)
    {
        var result = await resultTask;
        return result.Map(func);
    }
}

A combinação de Map e Bind, permite construções como a seguinte:

public Result<Order> ProcessarPedido(OrderId id)
{
    return ObterPedido(id)
        .Bind(ValidarEstoque)
        .Bind(AplicarDesconto)
        .Map(order => order with { Status = OrderStatus.Processado });
}

Podemos inclusive derivar outras mônadas como Maybe<T> (ou Optional) para representar resultados não encontrados que não se tratam de erros.

Qualquer semelhança com Linq não é coincidência. O Linq trouxe vários conceitos de programação funcional para a plataforma, IEnumerable é uma implementação de monad, conforme já falamos, e Select/SelectMany/OrderBy/Where, são todas funções de alta ordem (funções que processam funções, falaremos mais sobre elas em um outro post).

Dado as inclusões recentes de record structs, switch statements e extension blocks, tenho boas expectativas quanto à evolução deste aspecto funcional no C#. Quem sabe não vem por aí suporte completo para partial apply e discriminated unions?

Bibliotecas Prontas para Produção

Nossa implementação aqui é básica, pensada na medida, para você entender os conceitos e aplicação. Mas há bibliotecas prontas para produção com implementações completas e poderosas.

CSharpFunctionalExtensions, é a que eu recomendo para quem está começando. Já LanguageExt é o próximo nível, e definitivamente vale a pena explorar, oferece o ecossistema completo (Effects, IO, etc.) e é realmente mais poderosa, embora mais complexa.

Quer começar apenas com tratamento de erros? ErrorOr.

Em bibliotecas inspiradas em Haskell, você verá Either<L, R> (onde L=Left=Erro, R=Right=Sucesso) no lugar de Result. O método Bind às vezes aparece como Map ou SelectMany em outras bibliotecas.

Agora vamos olhar a criação de nossos objetos e você entenderá a preferência por Factory Methods em Entidades e Value Objects.

Blindando o Domínio: Smart Constructors

Um conceito importante em FP, mas que você também deve levar para OO pura, é NÃO permitir que estados inválidos sejam representados.

Só que ao mesmo tempo, queremos evitar exceptions para controle de fluxo, e uma vez que entramos no construtor, somos obrigados a devolver um objeto do tipo da classe ou lançar uma exceção.

A solução é usar Smart Constructors, que você já conhece como Factory Methods e vão nos permitir criar nossa entidade sem quebrar nenhum dos dois princípios.

Como implementamos:

public sealed record InvalidName(string Message) : DomainError;
public sealed record InvalidEmail(string Message) : DomainError;
public sealed record UnderageCustomer(string Message) : DomainError;

public readonly record struct Name(string Value)
{
    public static Result<Name> Create(string value)
        => string.IsNullOrWhiteSpace(value)
            ? Result.Failure<Name>(new InvalidName("Nome é obrigatório."))
            : Result.Success(new Name(value.Trim()));
}

public readonly record struct Email(string Value)
{
    public static Result<Email> Create(string value)
        => string.IsNullOrWhiteSpace(value) || !value.Contains('@')
            ? Result.Failure<Email>(new InvalidEmail("Email inválido."))
            : Result.Success(new Email(value.Trim()));
}

// 2. A Entidade (Aggregate Root)
public record Customer
{
    public Guid Id { get; }
    public Name Name { get; }
    public Email Email { get; }
    public DateOnly BirthDate { get; }
    
    // Construtor Privado!
    // Ninguém fora da classe pode fazer 'new Customer(...)'
    private Customer(Guid id, Name name, Email email, DateOnly birthDate)
    {
        Id = id;
        Name = name;
        Email = email;
        BirthDate = birthDate;
    }

    // Factory Method (Smart Constructor)
    public static Result<Customer> Create(string nameStr, string emailStr, DateOnly birthDate)
    {
        // 1. Fail-fast nos Value Objects
        var nameResult = Name.Create(nameStr);
        if (nameResult.IsFailure) return Result.Failure<Customer>(nameResult.Error);

        var emailResult = Email.Create(emailStr);
        if (emailResult.IsFailure) return Result.Failure<Customer>(emailResult.Error);

        // 2. Valida Regra de Negócio da Entidade (Agregado)
        var age = CalculateAge(birthDate);
        if (age < 18)
        {
            return Result.Failure<Customer>(
                new UnderageCustomer("Cliente deve ser maior de 18 anos."));
        }

        // 3. Só agora alocamos o objeto, garantidamente válido
        return Result.Success(new Customer(
            Guid.NewGuid(), 
            nameResult.Value, 
            emailResult.Value, 
            birthDate
        ));
    }
}

Isso nos dá uma garantia poderosa: Se você tem uma instância de Customer na mão, ela é válida. Você não precisa espalhar if (customer.IsValid()) pelo código.

Até onde ir com ROP?

Embora a técnica resulte em código de qualidade, seguro e fácil de entender, há alguns cuidados. Dois principalmente que valem evitar.

1. Manter exceptions a exceção não significa nunca usá-las

Não me agrada muito a ideia de transformar todas as possíveis exceções em Results. Algumas sempre irão vazar e você terá um tratamento inconsistente do erro.

Outro ponto, diagnósticos, uma SqlException transformada em result, perde detalhes, StackTrace, que são informações relevantes neste caso.

Basicamente, use ROP sempre que possível no domínio, exerça cautela ao usar fora dele.

2. Não use Result se isso gerar complexidade adicional elevada Em alguns cenários, tentar encaixar o fluxo em uma composição com Result e Bind trará mais fricção do que segurança ou expressividade.

Aqui vale aquela velha regra de ouro. Não adapte o problema à solução.

O Próximo Passo: Unindo Pureza e Pragmatismo

Neste post, construímos nosso trilho duplo com Result e aprendemos a compor funções que podem falhar sem recorrer a exceptions.

Entendemos que honestidade de assinatura + composição + smart constructors = domínio que não mente e não quebra.

Mas um detalhe que você talvez tenha notado: mencionei várias vezes “cuidado com async no domínio” e “use ROP no domínio, cautela fora dele”.

Por quê essa distinção? Onde traçar essa linha?

No próximo post da série, vamos explorar Functional Core, Imperative Shell - uma arquitetura que resolve o dilema entre a pureza funcional e a realidade de sistemas que precisam acessar bancos de dados, fazer requisições HTTP, gravar logs e interagir com o mundo externo.

Você vai aprender:

  • Como separar lógica de negócio pura de operações de I/O
  • Por que funções puras são mais fáceis de testar (reduzindo mocks!)
  • Como estruturar sua aplicação para maximizar testabilidade e manutenibilidade
  • Quando aceitar impureza e quando evitá-la