MÓDULO 15 · CONCEITO 08 DE 12

DDD Tático I — Entity, Value Object, Aggregate

Eric Evans 2003 — identidade vs. valor, imutabilidade, fronteira de consistência, e por que aggregates pequenos são saudáveis

Tempo de leitura ~22 min Pré-requisito 07 · Strangler Fig · Módulos 00-02 Próximo 09 · DDD Tático II

Em 2003, Eric Evans publicou Domain-Driven Design: Tackling Complexity in the Heart of Software. O livro tinha dois objetivos: estabelecer um vocabulário compartilhado para falar sobre modelagem de domínio, e apresentar padrões táticos para implementá-la. A distinção entre "tático" e "estratégico" é do próprio Evans: padrões táticos são os blocos de construção — Entity, Value Object, Aggregate, Repository, Domain Service — que você usa dentro de um bounded context. Padrões estratégicos são sobre como bounded contexts se relacionam. Os dois conceitos seguintes cobrem o DDD tático; o décimo conceito cobre o estratégico.

Entity — identidade sobre estrutura

Uma Entity é um objeto que tem identidade contínua e distinta — uma identidade que persiste através do tempo e do espaço, independente de seus atributos. Dois objetos com os mesmos atributos são entidades diferentes se têm identidades diferentes. Um cliente com o mesmo nome e email que outro cliente é um cliente diferente — porque tem um ID diferente.

Isso tem implicações diretas na implementação. Entities são iguais quando seus IDs são iguais — não quando seus atributos são iguais. Se você mudar o email de um cliente, ele ainda é o mesmo cliente. Se você criar um novo cliente com o mesmo email, é um cliente diferente.

Entities geralmente têm comportamento — métodos que encapsulam as regras de negócio que mudam seus próprios atributos. Um Order.Confirm() encapsula as regras de quando um pedido pode ser confirmado. Um Customer.ChangeAddress() encapsula as regras de validação de endereço. Entities com comportamento são o oposto do Anemic Domain Model: não são sacos de getters e setters, são objetos com responsabilidade de defender seus próprios invariantes.

Value Object — igualdade por valor

Um Value Object é um objeto cujo conceito de identidade é definido inteiramente pelos seus atributos. Dois Value Objects são iguais se todos os seus atributos são iguais. Dinheiro é o exemplo canônico: R$10,00 e R$10,00 são o mesmo dinheiro — não importa de onde vierem ou quem os criou. Um endereço é outro exemplo: Rua X 123 e Rua X 123 são o mesmo endereço.

A propriedade mais importante de Value Objects é a imutabilidade. Quando você "muda" um Value Object, não muda o objeto existente — cria um novo. Você não muda um endereço; você cria um novo endereço com a rua atualizada e substitui o antigo. Isso tem uma consequência importante: Value Objects são segros para compartilhar. Você pode passar um Value Object para múltiplos objetos sem risco de que um deles o mude por baixo dos outros.

Value Objects também tornam o código mais expressivo. Em vez de receber um string email, você recebe um Email. Em vez de receber um decimal price, você recebe um Money. O tipo carrega semântica — e pode carregar validação. Um Email que não é válido nunca existe; uma Money com valor negativo nunca existe. Invariantes de negócio ficam codificadas no tipo, não espalhadas por validações no código consumidor.

entity vs value object

A pergunta que diferencia os dois: "importa se são o mesmo objeto, ou apenas se têm os mesmos valores?" Um endereço de entrega numa Order importa pela igualdade de valor (mesmo rua/número/cidade = mesmo endereço). O pedido importa pela identidade (mesmo se os itens mudaram, é o mesmo pedido).

Aggregate — fronteira de consistência

O conceito mais importante e mais mal-compreendido do DDD tático é o Aggregate. Um Aggregate é um cluster de objetos de domínio (Entities e Value Objects) que são tratados como uma unidade para propósitos de mudança de dados. O Aggregate tem um elemento especial chamado Aggregate Root (raiz do aggregate), que é a única Entity que o mundo externo pode referenciar diretamente.

A razão de existir do Aggregate é a consistência. As regras de negócio (invariantes) que envolvem múltiplos objetos precisam ser aplicadas atomicamente — ou tudo muda junto, ou nada muda. Um Aggregate define exatamente quais objetos precisam ser consistentes juntos.

Considere um pedido (Order) com suas linhas (OrderLines). A invariante "um pedido confirmado deve ter ao menos uma linha" envolve tanto o Order quanto seus OrderLines. Eles precisam estar no mesmo Aggregate para que essa invariante possa ser verificada e aplicada de forma atômica. O Order é o Aggregate Root; OrderLine é uma Entity interna ao Aggregate.

A regra de ouro de Aggregates: objetos externos só podem referenciar o Aggregate Root. Ninguém fora do Aggregate tem referência direta a um OrderLine específico. Para modificar um OrderLine, você pega o Order (o root) e chama um método nele que modifica o line. Isso garante que todas as invariantes do Aggregate possam ser verificadas antes que a mudança seja commitada.

Por que aggregates devem ser pequenos

Um dos erros mais comuns em DDD é criar aggregates grandes demais. Quando tudo que "pertence logicamente a um domínio" é colocado em um único aggregate, você cria:

Lock contention. Em sistemas com concorrência, múltiplos usuários tentando modificar o mesmo aggregate ao mesmo tempo causam conflitos. Um Customer aggregate que contém todos os pedidos do cliente vai ter conflitos sempre que dois usuários toquam no mesmo cliente ao mesmo tempo.

Transações gigantes. Salvar um aggregate grande é uma transação grande. Transações grandes têm mais chance de conflito com outras transações e travam mais recursos por mais tempo.

Carregamento desnecessário. Para validar uma invariante simples, você precisa carregar o aggregate inteiro — incluindo todas as entidades internas que não são relevantes para aquela operação.

A regra de Vernon (de Implementing Domain-Driven Design): se uma invariante não envolve dois objetos juntos, eles não precisam estar no mesmo aggregate. Aggregates se comunicam via referência de ID, não de objeto — e inconsistência eventual entre aggregates diferentes é aceitável na maioria dos casos.

Implementação dos três blocos táticos

C# — records para Value Objects, Entity com igualdade por ID, Aggregate Root
// Value Objects — imutáveis, igualdade por valor
public record Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0m, currency);

    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Moedas diferentes");
        return new Money(Amount + other.Amount, Currency);
    }

    // record gera Equals e GetHashCode por valor automaticamente
}

public record Email
{
    public string Value { get; }

    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
            throw new ArgumentException("Email inválido", nameof(value));
        Value = value.ToLowerInvariant();
    }
    // record: igualdade por valor
}

// Entity — identidade persiste
public abstract class Entity<TId>
{
    public TId Id { get; protected init; }
    public override bool Equals(object? obj) =>
        obj is Entity<TId> other && EqualityComparer<TId>.Default.Equals(Id, other.Id);
    public override int GetHashCode() => Id!.GetHashCode();
}

// Aggregate Root — fronteira de consistência
public class Order : Entity<OrderId>
{
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
    public OrderStatus Status { get; private set; } = OrderStatus.Draft;
    public Money Total => _lines.Aggregate(Money.Zero("BRL"), (acc, l) => acc.Add(l.Subtotal));

    private Order() { } // para ORM

    public static Order Create(OrderId id, CustomerId customerId)
        => new() { Id = id, CustomerId = customerId };

    public void AddLine(ProductId productId, int qty, Money unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Pedido já confirmado");
        if (qty <= 0)
            throw new ArgumentException("Quantidade deve ser positiva");
        _lines.Add(new OrderLine(OrderLineId.New(), productId, qty, unitPrice));
    }

    public void Confirm()
    {
        if (!_lines.Any())
            throw new InvalidOperationException("Pedido não pode ser confirmado sem linhas");
        Status = OrderStatus.Confirmed;
    }

    public CustomerId CustomerId { get; private init; }
}

// OrderLine — Entity interna ao Aggregate (só acessível via Order)
public class OrderLine : Entity<OrderLineId>
{
    public ProductId ProductId { get; private set; }
    public int Quantity { get; private set; }
    public Money UnitPrice { get; private set; }
    public Money Subtotal => UnitPrice.Add(UnitPrice) with { Amount = UnitPrice.Amount * Quantity };

    internal OrderLine(OrderLineId id, ProductId productId, int qty, Money unitPrice)
    {
        Id = id; ProductId = productId; Quantity = qty; UnitPrice = unitPrice;
    }
}

// Teste de invariante — sem banco
[Fact]
public void Confirm_WithoutLines_ThrowsInvalidOperation()
{
    var order = Order.Create(OrderId.New(), CustomerId.New());
    Assert.Throws<InvalidOperationException>(() => order.Confirm());
}

[Fact]
public void Confirm_WithLines_ChangesStatusToConfirmed()
{
    var order = Order.Create(OrderId.New(), CustomerId.New());
    order.AddLine(ProductId.New(), 2, new Money(50m, "BRL"));
    order.Confirm();
    Assert.Equal(OrderStatus.Confirmed, order.Status);
}

C# record gera Equals e GetHashCode por valor para Value Objects. Entity<TId> usa ID para igualdade — não atributos.

Python — frozen dataclasses para Value Objects, métodos de domínio em Order
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Optional
import uuid

# Value Objects — frozen dataclass (imutável, igualdade por valor)
@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("Money não pode ser negativo")

    def add(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("Moedas diferentes")
        return Money(self.amount + other.amount, self.currency)

    @classmethod
    def zero(cls, currency: str) -> "Money":
        return cls(Decimal("0"), currency)

@dataclass(frozen=True)
class Email:
    value: str

    def __post_init__(self):
        if "@" not in self.value:
            raise ValueError(f"Email inválido: {self.value}")
        object.__setattr__(self, "value", self.value.lower())  # frozen permite isso via object.__setattr__

@dataclass(frozen=True)
class OrderId:
    value: str = field(default_factory=lambda: str(uuid.uuid4()))

@dataclass(frozen=True)
class ProductId:
    value: str

# Entity interna — instância criada apenas pelo Aggregate Root
@dataclass
class OrderLine:
    id: str
    product_id: ProductId
    quantity: int
    unit_price: Money

    @property
    def subtotal(self) -> Money:
        return Money(self.unit_price.amount * self.quantity, self.unit_price.currency)

    def __eq__(self, other: object) -> bool:
        return isinstance(other, OrderLine) and self.id == other.id

    def __hash__(self) -> int:
        return hash(self.id)

# Aggregate Root
class Order:
    def __init__(self, order_id: OrderId, customer_id: str):
        self.id = order_id
        self.customer_id = customer_id
        self._lines: list[OrderLine] = []
        self.status = "draft"

    @property
    def lines(self) -> tuple[OrderLine, ...]:
        return tuple(self._lines)

    @property
    def total(self) -> Money:
        return sum(
            (line.subtotal for line in self._lines),
            start=Money.zero("BRL"),
        )

    def add_line(self, product_id: ProductId, qty: int, unit_price: Money) -> None:
        if self.status != "draft":
            raise ValueError("Pedido já confirmado")
        if qty <= 0:
            raise ValueError("Quantidade deve ser positiva")
        self._lines.append(OrderLine(str(uuid.uuid4()), product_id, qty, unit_price))

    def confirm(self) -> None:
        if not self._lines:
            raise ValueError("Pedido não pode ser confirmado sem linhas")
        self.status = "confirmed"

    def __eq__(self, other: object) -> bool:
        return isinstance(other, Order) and self.id == other.id

    def __hash__(self) -> int:
        return hash(self.id)

# Teste puro de domínio
def test_confirm_without_lines_raises():
    order = Order(OrderId(), "cust-1")
    with pytest.raises(ValueError, match="sem linhas"):
        order.confirm()

def test_add_line_after_confirm_raises():
    order = Order(OrderId(), "cust-1")
    order.add_line(ProductId("prod-1"), 1, Money(Decimal("50"), "BRL"))
    order.confirm()
    with pytest.raises(ValueError, match="confirmado"):
        order.add_line(ProductId("prod-2"), 1, Money(Decimal("10"), "BRL"))

frozen=True mais __post_init__ para invariantes. Testes de domínio rodam sem banco — invariantes são verificadas em microssegundos.

Go — structs imutáveis por convenção, unexported fields para encapsulamento
// value_objects.go
package domain

import (
    "errors"
    "strings"
)

// Money — Value Object (imutável por convenção em Go)
type Money struct {
    Amount   float64
    Currency string
}

func NewMoney(amount float64, currency string) (Money, error) {
    if amount < 0 {
        return Money{}, errors.New("money não pode ser negativo")
    }
    return Money{Amount: amount, Currency: currency}, nil
}

func (m Money) Add(other Money) (Money, error) {
    if m.Currency != other.Currency {
        return Money{}, errors.New("moedas diferentes")
    }
    return Money{Amount: m.Amount + other.Amount, Currency: m.Currency}, nil
}

// Go não tem classes nem herança — comparação de Value Objects é por valor (struct equality)
// Money{50, "BRL"} == Money{50, "BRL"} é true

// Email — Value Object com validação
type Email struct{ value string }

func NewEmail(raw string) (Email, error) {
    v := strings.ToLower(strings.TrimSpace(raw))
    if !strings.Contains(v, "@") {
        return Email{}, errors.New("email inválido")
    }
    return Email{value: v}, nil
}

func (e Email) String() string { return e.value }

// order.go — Aggregate Root
package domain

import "errors"

type OrderStatus string
const (
    Draft     OrderStatus = "draft"
    Confirmed OrderStatus = "confirmed"
)

type Order struct {
    ID         string
    CustomerID string
    lines      []*OrderLine  // unexported — acesso apenas via métodos do root
    Status     OrderStatus
}

func NewOrder(id, customerID string) *Order {
    return &Order{ID: id, CustomerID: customerID, Status: Draft}
}

func (o *Order) AddLine(productID string, qty int, unitPrice Money) error {
    if o.Status != Draft {
        return errors.New("pedido já confirmado")
    }
    if qty <= 0 {
        return errors.New("quantidade deve ser positiva")
    }
    o.lines = append(o.lines, &OrderLine{
        ID: newID(), ProductID: productID, Quantity: qty, UnitPrice: unitPrice,
    })
    return nil
}

func (o *Order) Confirm() error {
    if len(o.lines) == 0 {
        return errors.New("pedido não pode ser confirmado sem linhas")
    }
    o.Status = Confirmed
    return nil
}

func (o *Order) Total() Money {
    total := Money{Currency: "BRL"}
    for _, l := range o.lines {
        total.Amount += l.UnitPrice.Amount * float64(l.Quantity)
    }
    return total
}

func (o *Order) Lines() []*OrderLine {
    // retorna cópia para proteger slice interno
    cp := make([]*OrderLine, len(o.lines))
    copy(cp, o.lines)
    return cp
}

// Teste de invariante — sem banco
func TestOrder_ConfirmWithoutLines_ReturnsError(t *testing.T) {
    o := NewOrder("ord-1", "cust-1")
    err := o.Confirm()
    require.ErrorContains(t, err, "sem linhas")
}

func TestOrder_AddLineAfterConfirm_ReturnsError(t *testing.T) {
    o := NewOrder("ord-1", "cust-1")
    price, _ := NewMoney(50, "BRL")
    o.AddLine("prod-1", 1, price)
    o.Confirm()
    err := o.AddLine("prod-2", 1, price)
    require.ErrorContains(t, err, "confirmado")
}

Go não tem classes — imutabilidade de Value Objects é convencional, não forçada. Campo lines unexported protege o encapsulamento do Aggregate Root.

A regra de referência entre aggregates

Um Order não referencia um Customer como objeto — referencia um CustomerId como Value Object. Isso é deliberado. Se Order tivesse uma referência direta ao Customer, qualquer mudança no aggregate Order poderia afetar o Customer (ou vice-versa). Com referência por ID, os dois aggregates são completamente independentes. Quando o código precisa do Customer completo, ele carrega via Repository usando o CustomerId armazenado no Order.

Referências para aprofundar

  1. livro Domain-Driven Design — Eric Evans. Addison-Wesley, 2003. Caps 5-6: Entities, Value Objects e Aggregates — a fonte primária de todos os blocos táticos.
  2. livro Implementing Domain-Driven Design — Vaughn Vernon. Addison-Wesley, 2013. Caps 6-10: implementação detalhada de cada bloco tático com exemplos de código extensos.
  3. artigo Effective Aggregate Design — Vaughn Vernon. dddcommunity.org, 2011. Série de 3 artigos — os princípios de design de aggregates pequenos e referência por ID.
  4. artigo AnemicDomainModel — Martin Fowler. martinfowler.com, 2003. O anti-padrão que DDD tático combate — objetos sem comportamento, lógica espalhada em serviços.
  5. artigo Value Object — Martin Fowler. martinfowler.com, 1999. Definição e benefícios dos Value Objects — igualdade por valor e imutabilidade como propriedades centrais.
  6. livro Patterns, Principles, and Practices of Domain-Driven Design — Millett & Tune. Wrox, 2015. Caps 13-17: blocos táticos com exemplos práticos em C# — o complemento mais acessível ao livro original.
  7. livro Domain Modeling Made Functional — Scott Wlaschin. Pragmatic Bookshelf, 2018. Como modelar Entity, Value Object e Aggregate em F# funcional — perspectiva alternativa reveladora.
  8. livro Hands-On Domain-Driven Design with .NET — Alexey Zimarev. Packt, 2019. Implementação C# moderna de todos os blocos táticos com EventStoreDB e RavenDB.
  9. artigo Types + Properties = Software — Mark Seemann. blog.ploeh.dk, 2016. Como tipos expressivos como Value Objects eliminam estados inválidos em tempo de compilação.
  10. artigo CQRS and Event Sourcing — Greg Young. cqrs.files.wordpress.com, 2010. Aggregates em contexto de CQRS/ES — como o bloco tático serve de base para Event Sourcing.
  11. livro Unit Testing: Principles, Practices, and Patterns — Vladimir Khorikov. Manning, 2020. Como testar Entities e Aggregates sem banco — princípios de testes de domínio puro.
  12. livro Applying Domain-Driven Design and Patterns — Jimmy Nilsson. Addison-Wesley, 2006. Implementação prática em C# dos blocos táticos de Evans — o primeiro livro a traduzir DDD para código.