Domínio como composição

Ao modelar o domínio funcionalmente, pensamos fundamentalmente em composições — tanto de tipos quanto de funções. As ideias táticas do DDD, como Raízes de Agregação e Bounded Contexts, não só se aplicam aqui, como ganham uma expressividade nova, livre do peso do gerenciamento de estado da Orientação a Objetos clássica.

Para termos uma base de comparação, veja como modelaríamos uma entidade Cart simplificada em C# tradicional (OO):

public class Cart(Guid id, Guid customerId)
{
    private readonly List<CartItem> _items = [];

    public Guid Id { get; } = id;
    public Guid CustomerId { get; } = customerId;
    public CartStatus Status { get; private set; } = CartStatus.Active;

    public IEnumerable<CartItem> Items => _items;

    public void AddItem(Guid productId, int quantity)
    {
        if (Status != CartStatus.Active)
        {
            throw new InvalidOperationException("Não é possível adicionar itens a um carrinho inativo.");
        }

        var existingItem = _items.FirstOrDefault(item => item.ProductId == productId);

        if (existingItem != null)
        {
            existingItem.Quantity += quantity; // Mutação direta
        }
        else
        {
            _items.Add(new CartItem(productId, quantity)); // Mutação da lista
        }
    }

    public void RemoveItem(Guid productId)
    {
        // Implementação omitida para brevidade
    }
}

public class CartItem(Guid productId, int quantity)
{
    public Guid ProductId { get; } = productId;
    public int Quantity { get; set; } = quantity; // Mutável
}

Do ponto de vista de OO, este código é aceitável. Dados encapsulados, lógica centralizada. Mas, se pararmos para analisar com um olhar mais experiente, sentimos alguns smells:

Alguns smells da Mutabilidade

As operações AddItem causam efeito colateral no objeto. Em OO, aceitamos isso como normal, mas isso nos traz problemas sérios:

  • Idempotência e Testabilidade: Chamar o método duas vezes muda o resultado. Se um teste falha, foi a lógica da função ou o estado prévio que estava sujo?
  • Transações Complexas: Implementar Undo/Redo ou transações em memória se torna um pesadelo. Você não pode simplesmente descartar a nova versão; você precisa saber como reverter cirurgicamente o estado interno.
  • Thread Safety: Se uma thread itera sobre Items enquanto outra chama AddItem, você terá bugs de concorrência difíceis de rastrear.
  • O Falso Encapsulamento: Protegemos a lista _items, mas retornamos objetos CartItem mutáveis. Um consumidor desavisado pode chamar cart.Items.First().Quantity = 100 e corromper sua regra de negócio sem passar pelo método AddItem.

Uma outra forma de ver o domínio

Vamos remodelar nossa entidade e ver, na prática, o que muda.

// Entidade (Identidade + Dados)
public record Cart(CartId Id, CustomerId CustomerId, IImmutableList<CartItem> Items, CartStatus Status);

// Value Objects (Estruturas leves)
public readonly record struct CartItem(ProductId ProductId, int Quantity);
public readonly record struct CustomerId(Guid Value);
public readonly record struct ProductId(Guid Value);
public readonly record struct CartId(Guid Value);

Falamos sobre Primitive Obsession no último capítulo.
Caso tenha perdido, pode conferir aqui

Primeiro, passamos a usar records. É totalmente possível usar classes para isso, trabalhando com setters privados ou init-only, mas temos algumas vantagens com esta abordagem:

  1. Alocação na Stack: Para os IDs definidos como record struct, a alocação é extremamente eficiente, reduzindo a pressão no Garbage Collector.
  2. Boilerplate Zero: Desconstrução, ToString, GetHashCode e Equals são automáticos.

Embora, para Entidades, a implementação clássica faça comparação por ID, em um modelo funcional (especialmente mirando Event Sourcing), a igualdade estrutural do record é útil para detectar mudanças de estado. Se cartV1 != cartV2, houve alteração.

Mas é anêmico!
Calma que não é. Não estamos propondo criar “sacos de getters e setters” manipulados por serviços procedurais. O que proponho é que você estenda esses dados com funções puras.

Veja como o comportamento é implementado usando Extension Methods no mesmo pacote:

public static class CartOperations
{
    extension(Cart cart)
    {
        public static Cart Create(CustomerId customerId)
        {
            return new Cart(new CartId(Guid.NewGuid()), customerId, CartStatus.Open, ImmutableList<CartItem>.Empty);
        }

        // Função Pura: (Estado Atual, Input) -> Resultado<Novo Estado>
        Result<Cart> AddItem(CartItem item) =>
            cart.CanModifyItems() switch 
            {
                true => Result.Ok(cart with { Items = cart.Items.Add(item) }),
                false => Result.Fail<Cart>("Não é possível adicionar itens a um carrinho inativo.")
            };

        Result<Cart> RemoveItem(ProductId productId) =>
            cart.CanModifyItems() switch
            {
                true => Result.Ok(cart with { Items = cart.Items.RemoveAll(i => i.ProductId == productId) }),
                false => Result.Fail<Cart>("Não é possível remover itens de um carrinho inativo.")
            };

        private bool CanModifyItems() =>
            cart.Status == CartStatus.Open;
    }
}

Funções puras e efeito colateral
Relembrando o post anterior: quando digo que estas funções são puras, significa que elas recebem valores, retornam novos valores e não dependem de nada externo nem alteram o input.

Sobre efeito colateral, as funções não modificam o objeto original (ou qualquer outra coisa, isso vale para I/O também). Elas usam a expressão with para criar uma cópia modificada (mutação não destrutiva).

Observe o efeito no código:

var cart = Cart.Create(new CustomerId(Guid.NewGuid()));

var item = CartItem.Create(new ProductId(Guid.NewGuid()), 2);
var anotherItem = CartItem.Create(new ProductId(Guid.NewGuid()), 3);

// A variável 'cart' original JAMAIS é alterada
var cartWithItem = cart.AddItem(item).Value;
var updatedCart = cartWithItem.AddItem(anotherItem).Value;

Mesmo após a execução, a primeira variável cart está sem itens no carrinho, e cartWithItem continua possuindo apenas um. Normalmente, você não irá escrever código assim; o trecho acima é para demonstrar o comportamento. É mais expressivo atribuir cada operação à primeira variável.

Mas é este design que garante tranquilidade nos testes e segurança contra modificações concorrentes.

Package by Feature: Evitando o bloat

Nosso Cart está longe de estar pronto. Em um projeto real, entidades podem crescer muito. Para facilitar a leitura e manutenção, a boa prática em FP é que as funções vivam perto de onde serão realmente utilizadas.

Organizamos o domínio por Features (ou Vertical Slices). Aproveitamos a liberdade das extensões para colocar o comportamento no arquivo e namespace onde ele faz mais sentido, mantendo o Bounded Context coeso sem inchar a definição do tipo.

Composição de funções, e talvez até mais importante, Decomposição

Ao retornarmos o estado do objeto em vez de mutá-lo, permitimos construções fluentes (pipelines):

var cart = CartState.New(cartId, customerId);

// Pipeline de transformações
var finalCart = cart
    .AddItem(ssd, 1)
    .AddItem(ram, 2)
    .ApplyCoupon(coupon);

// Ou como usamos o monad Result
var finalCart = cart
    .Tap(c => c.AddItem(ssd, 1))
    .Tap(c => c.AddItem(ram, 2))
    .Tap(c => c.ApplyCoupon(coupon));

Cada etapa desse pipeline gera um novo valor imutável. Se algo der errado no passo 3, o passo 2 ainda está preservado na memória.

Mas talvez mais importante seja a Decomposição. A ideia aqui é sempre diminuir a distância entre o que entra e o que sai da função, quebrando em passos menores. Imagine uma função Cart.Checkout(): ela seria enorme, difícil de manter e talvez fizesse uso de vários helpers ou métodos privados, o que a tornaria difícil de testar, por exemplo.

Decomposição de funções é basicamente transformar este cart.Checkout() em:

public CartState PrepareCheckout(CartState cart, string userZipCode)
{°
    // Dependências (poderiam vir de injeção de dependência)
    var vipDiscount = new VipCustomerStrategy(); 
    var shippingCost = _shippingService.Calculate(userZipCode); // Serviço externo

    // O PIPELINE DE TRANSFORMAÇÃO
    var finalCart = cart
        .RecalculateTotals()
        .ApplyDiscountRule(vipDiscount)
        .AddShippingInfo(userZipCode, shippingCost)
        .MarkAsReadyForPayment();

    return finalCart;
}

Por que isso é melhor?

  1. Imutabilidade Intermediária: Se a função AddShippingInfo falhar, você ainda tem a variável cart original intacta. O finalCart só existe se tudo der certo.
  2. Testabilidade: É fácil isolar e testar cada uma das funções e, se falhar, o escopo do erro é muito menor.
  3. Mix & Match: Se você tiver um fluxo de “Checkout Rápido” (sem desconto), basta não chamar o .ApplyDiscountRule() na corrente. Você compõe o comportamento que precisa sem criar subclasses complexas ou flags booleanas (bool applyDiscount).

Pontos de Atenção (Spoilers da série)

Se você tentou aplicar o código acima em um projeto real agora mesmo, deve ter esbarrado em duas dúvidas clássicas. Não se preocupe, elas são tão importantes que terão capítulos exclusivos:

  1. “Como eu salvo isso no Entity Framework?” O Change Tracking do EF Core e a imutabilidade funcional não são melhores amigos. Em arquiteturas funcionais, frequentemente adotamos o padrão Functional Core, Imperative Shell. Isso significa que suas Entidades de Domínio (records puros) não são necessariamente as mesmas classes mapeadas no banco. No Post 4, vamos explorar essa arquitetura e como persistir nosso domínio sem corrompê-lo.

  2. “Alguém ainda pode fazer new Cart() com estado inválido?” No exemplo atual, sim. Para garantir o princípio de “Always Valid Domain”, precisamos blindar a criação do objeto. No próximo post (Parte 3), junto com o Railway Oriented Programming, veremos como usar Smart Constructors (construtores privados + factories que retornam Result) para garantir que seja impossível instanciar uma entidade inválida.

O Próximo Passo: Eliminando Exceções e Monads

Se estamos buscando pureza funcional, lançar uma exceção é uma “mentira” na assinatura do método. A função diz que retorna um Cart, mas, na verdade, ela pode explodir o fluxo de execução.

No próximo post, vamos fechar esse ciclo. Vamos falar de Railway Oriented Programming. Veremos como eliminar os try-catch e modelar erros como dados.

Vocês viram o uso de Result<T> em dois posts já. Isso é, na essência, um Monad (um wrapper para valores). Usamos eles para evitar lançar exceptions e termos um tratamento mais elegante e mais performático de erros. Vamos aprofundar em como usar Result, Maybe e Option para tratar erros de forma elegante, performática e, acima de tudo, honesta.