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 = 100e 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:
- Alocação na Stack: Para os IDs definidos como
record struct, a alocação é extremamente eficiente, reduzindo a pressão no Garbage Collector. - 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?
- Imutabilidade Intermediária: Se a função
AddShippingInfofalhar, você ainda tem a variávelcartoriginal intacta. OfinalCartsó existe se tudo der certo. - Testabilidade: É fácil isolar e testar cada uma das funções e, se falhar, o escopo do erro é muito menor.
- 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:
“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.
“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.
