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.
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
// 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.
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.
// 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
- livro Domain-Driven Design — Eric Evans. Addison-Wesley, 2003.
- livro Implementing Domain-Driven Design — Vaughn Vernon. Addison-Wesley, 2013.
- artigo Effective Aggregate Design — Vaughn Vernon. dddcommunity.org, 2011.
- artigo AnemicDomainModel — Martin Fowler. martinfowler.com, 2003.
- artigo Value Object — Martin Fowler. martinfowler.com, 1999.
- livro Patterns, Principles, and Practices of Domain-Driven Design — Millett & Tune. Wrox, 2015.
- livro Domain Modeling Made Functional — Scott Wlaschin. Pragmatic Bookshelf, 2018.
- livro Hands-On Domain-Driven Design with .NET — Alexey Zimarev. Packt, 2019.
- artigo Types + Properties = Software — Mark Seemann. blog.ploeh.dk, 2016.
- artigo CQRS and Event Sourcing — Greg Young. cqrs.files.wordpress.com, 2010.
- livro Unit Testing: Principles, Practices, and Patterns — Vladimir Khorikov. Manning, 2020.
- livro Applying Domain-Driven Design and Patterns — Jimmy Nilsson. Addison-Wesley, 2006.