MÓDULO 15 · CONCEITO 09 DE 12

DDD Tático II — Repository, Domain Service, Domain Event

A abstração de coleção sobre persistência, lógica que não pertence a nenhuma entidade, e o que aconteceu no domínio

Tempo de leitura ~20 min Pré-requisito 08 · DDD Tático I Próximo 10 · DDD Estratégico

O conceito anterior cobriu os blocos mais fundamentais do DDD tático: Entity, Value Object, e Aggregate. Este conceito cobre os três restantes: Repository, Domain Service, e Domain Event. Juntos, eles completam o vocabulário tático de Evans — o conjunto de ferramentas que permite implementar um domínio rico com regras de negócio encapsuladas, persistência abstraída, e comunicação assíncrona entre aggregates sem acoplamento direto.

Repository — abstração de coleção

O padrão Repository tem uma metáfora simples: o domínio vê o repositório como uma coleção em memória de aggregates. Você pede um Order pelo ID, o repositório o retorna. Você adiciona um Order à coleção, ele é persistido. O domínio não sabe se por baixo há PostgreSQL, MongoDB, uma lista em memória para testes, ou qualquer outra implementação.

Essa metáfora tem uma implicação importante: Repository é interface definida no domínio e implementada na infrastructure. Não é uma classe que wrappeia um ORM — é um contrato que o domínio usa para pedir agregados sem saber como eles são armazenados. A implementação concreta (PostgresOrderRepository, MongoOrderRepository, InMemoryOrderRepository) vive em Infrastructure e satisfaz o contrato.

Uma regra prática de Evans: Repository existe apenas para Aggregate Roots. Você não tem repositório para OrderLine porque OrderLine é interno ao Aggregate Order. Se você precisa de um OrderLine específico, você carrega o Order via seu repositório e acessa as linhas através do root.

O Repository também define o que é consultável. Não é um object-relational mapper genérico com queries arbitrárias — é uma interface que expõe apenas as queries que fazem sentido para o domínio. Se o domínio precisa de "pedidos pendentes do cliente X", isso vira um método FindPendingByCustomerId. Queries complexas para relatórios geralmente ficam fora do repositório (em query services separados, ou via CQRS com read models).

repository não é DAO

DAO (Data Access Object) é uma abstração sobre a tecnologia de persistência — ele fala a linguagem do banco. Repository é uma abstração sobre o domínio — ele fala a linguagem do negócio. FindByCustomerId vs SELECT * FROM orders WHERE customer_id = ?. Repository reconstrói aggregates inteiros; DAO retorna linhas ou documentos brutos.

Domain Service — lógica sem lar

Às vezes há lógica de negócio que não pertence naturalmente a nenhuma Entity ou Value Object. Isso acontece principalmente em dois casos: a lógica envolve múltiplos aggregates simultaneamente (e não pode ser colocada em nenhum dos dois sem criar dependência inadequada), ou a lógica é intrinsecamente stateless (não há estado a manter).

Domain Service é onde essa lógica vive. Diferente de um Application Service (que orquestra use cases e chama repositórios), um Domain Service contém lógica de domínio pura — ele conhece o vocabulário do domínio, aplica regras de negócio, mas não persiste, não dispara eventos de infraestrutura, e não sabe de HTTP ou qualquer tecnologia.

Um exemplo: a política de desconto que determina quanto desconto um cliente recebe em um pedido dado o histórico de compras, o tier do cliente, e o valor do pedido. Essa lógica envolve Customer e Order — não pertence a nenhum dos dois exclusivamente. Um DiscountPolicy Domain Service recebe ambos e retorna o desconto aplicável.

O sinal de que você precisa de um Domain Service: você quer colocar lógica numa Entity, mas para fazê-lo precisaria que a Entity tivesse acesso a outra Entity (violando o princípio de aggregates se referenciarem por ID). Ou você quer colocar lógica num Application Service, mas ela claramente é regra de negócio, não orquestração.

Domain Event — o que aconteceu

Domain Events capturam algo que aconteceu no domínio — algo que outros partes do sistema podem precisar saber. "OrderPlaced", "PaymentApproved", "StockDepleted" são exemplos. Domain Events são imutáveis (representam algo que já aconteceu, não pode ser mudado), nomeados no passado, e carregam apenas os dados relevantes do que aconteceu.

A motivação primária de Domain Events no DDD tático é comunicação entre aggregates sem acoplamento direto. Se o Aggregate Order precisa notificar o Aggregate Inventory que um pedido foi feito, ele não pode chamar um método do Inventory diretamente (isso criaria dependência entre aggregates). Em vez disso, Order publica um OrderPlaced Event; Inventory o consome (via Application Layer ou diretamente via event dispatcher) e reage.

Na implementação mais simples, Domain Events são coletados pelo Aggregate Root durante a operação e publicados pela Application Layer após o commit. Isso garante que o evento só é publicado se a transação foi bem-sucedida.

Application Service — orquestrador fino

Embora não seja um dos "blocos táticos" de Evans, o Application Service (ou Use Case Handler) merece ser mencionado aqui porque é frequentemente confundido com Domain Service.

Application Service é o orquestrador externo ao domínio: ele recebe um comando (PlaceOrderCommand), carrega os aggregates necessários via Repository, chama métodos de domínio (ou Domain Services), salva via Repository, e publica Domain Events. Ele não contém regras de negócio — apenas coordena quem as executa.

Se você se pega colocando lógica de negócio em um Application Service (um if que decide qual regra aplicar, uma computação de valor que deveria estar no domínio), é sinal de que essa lógica deveria estar em uma Entity ou Domain Service.

Implementação dos três padrões

C# — IOrderRepository, TierBasedDiscountPolicy, OrderPlacedEvent
// Repository — interface no domínio
public interface IOrderRepository
{
    Task<Order?> FindByIdAsync(OrderId id, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> FindPendingByCustomerAsync(CustomerId id, CancellationToken ct = default);
    Task SaveAsync(Order order, CancellationToken ct = default);
}

// Domain Service — lógica que envolve múltiplos aggregates
public interface IDiscountPolicy
{
    Money CalculateDiscount(Order order, Customer customer);
}

public class TierBasedDiscountPolicy : IDiscountPolicy
{
    // Regra de negócio pura — sem banco, sem HTTP
    public Money CalculateDiscount(Order order, Customer customer)
    {
        var rate = customer.Tier switch
        {
            CustomerTier.Gold    => 0.10m,
            CustomerTier.Silver  => 0.05m,
            _                    => 0.00m
        };
        if (order.Total.Amount > 1000m) rate += 0.02m; // bônus volume
        return new Money(order.Total.Amount * rate, order.Total.Currency);
    }
}

// Domain Event — imutável, nomeado no passado
public record OrderPlacedEvent(
    OrderId OrderId,
    CustomerId CustomerId,
    Money Total,
    DateTimeOffset OccurredAt
) : IDomainEvent;

// Aggregate Root coleta events
public class Order : AggregateRoot<OrderId>
{
    private readonly List<IDomainEvent> _events = new();
    public IReadOnlyList<IDomainEvent> DomainEvents => _events.AsReadOnly();

    public void Confirm()
    {
        if (!_lines.Any()) throw new InvalidOperationException("Pedido vazio");
        Status = OrderStatus.Confirmed;
        _events.Add(new OrderPlacedEvent(Id, CustomerId, Total, DateTimeOffset.UtcNow));
    }

    public void ClearEvents() => _events.Clear();
}

// Application Service — orquestra, não decide regras
public class PlaceOrderHandler
{
    private readonly IOrderRepository _orders;
    private readonly ICustomerRepository _customers;
    private readonly IDiscountPolicy _discount;
    private readonly IEventPublisher _publisher;

    public async Task HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
    {
        var order = Order.Create(OrderId.New(), cmd.CustomerId, cmd.Lines);
        var customer = await _customers.FindByIdAsync(cmd.CustomerId, ct);

        // Domain Service aplica regra de negócio envolvendo dois aggregates
        var disc = _discount.CalculateDiscount(order, customer!);
        order.ApplyDiscount(disc);
        order.Confirm(); // regra de negócio no aggregate — gera Domain Event

        await _orders.SaveAsync(order, ct);

        // Publica Domain Events após commit
        foreach (var ev in order.DomainEvents)
            await _publisher.PublishAsync(ev, ct);
        order.ClearEvents();
    }
}
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal

# Repository — interface no domínio
class OrderRepository(ABC):
    @abstractmethod
    async def find_by_id(self, order_id: str) -> "Order | None": ...
    @abstractmethod
    async def find_pending_by_customer(self, customer_id: str) -> "list[Order]": ...
    @abstractmethod
    async def save(self, order: "Order") -> None: ...

# Domain Service
class DiscountPolicy(ABC):
    @abstractmethod
    def calculate_discount(self, order: "Order", customer: "Customer") -> Decimal: ...

class TierBasedDiscountPolicy(DiscountPolicy):
    _RATES = {"gold": Decimal("0.10"), "silver": Decimal("0.05"), "standard": Decimal("0")}

    def calculate_discount(self, order: "Order", customer: "Customer") -> Decimal:
        rate = self._RATES.get(customer.tier, Decimal("0"))
        if order.total > Decimal("1000"):
            rate += Decimal("0.02")  # bônus volume
        return order.total * rate

# Domain Event
@dataclass(frozen=True)
class OrderPlacedEvent:
    order_id: str
    customer_id: str
    total: Decimal
    occurred_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

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

    @property
    def domain_events(self) -> tuple:
        return tuple(self._events)

    def clear_events(self) -> None:
        self._events.clear()

    def confirm(self) -> None:
        if not self._lines:
            raise ValueError("Pedido vazio")
        self.status = "confirmed"
        self._events.append(OrderPlacedEvent(self.id, self.customer_id, self.total))

# Application Service
class PlaceOrderService:
    def __init__(
        self,
        orders: OrderRepository,
        customers: "CustomerRepository",
        discount: DiscountPolicy,
        publisher: "EventPublisher",
    ):
        self._orders = orders
        self._customers = customers
        self._discount = discount
        self._publisher = publisher

    async def execute(self, cmd: "PlaceOrderCommand") -> None:
        order = Order(cmd.order_id, cmd.customer_id)
        for line in cmd.lines:
            order.add_line(line.product_id, line.qty, line.unit_price)

        customer = await self._customers.find_by_id(cmd.customer_id)
        discount = self._discount.calculate_discount(order, customer)
        order.apply_discount(discount)
        order.confirm()

        await self._orders.save(order)
        for ev in order.domain_events:
            await self._publisher.publish(ev)
        order.clear_events()
// domain/repository.go
package domain

import "context"

type OrderRepository interface {
    FindByID(ctx context.Context, id string) (*Order, error)
    FindPendingByCustomer(ctx context.Context, customerID string) ([]*Order, error)
    Save(ctx context.Context, order *Order) error
}

// domain/discount.go — Domain Service
type DiscountPolicy interface {
    Calculate(order *Order, customer *Customer) float64
}

type TierBasedDiscount struct{}

func (d TierBasedDiscount) Calculate(order *Order, customer *Customer) float64 {
    rates := map[string]float64{"gold": 0.10, "silver": 0.05}
    rate := rates[customer.Tier]
    if order.Total() > 1000 {
        rate += 0.02 // bônus volume
    }
    return order.Total() * rate
}

// domain/events.go — Domain Events
package domain

import "time"

type DomainEvent interface{ eventName() string }

type OrderPlacedEvent struct {
    OrderID    string
    CustomerID string
    Total      float64
    OccurredAt time.Time
}

func (e OrderPlacedEvent) eventName() string { return "OrderPlaced" }

// domain/order.go — Aggregate Root com domain events
type Order struct {
    ID         string
    CustomerID string
    lines      []*OrderLine
    Status     string
    events     []DomainEvent
}

func (o *Order) Confirm() error {
    if len(o.lines) == 0 {
        return errors.New("pedido vazio")
    }
    o.Status = "confirmed"
    o.events = append(o.events, OrderPlacedEvent{
        OrderID:    o.ID,
        CustomerID: o.CustomerID,
        Total:      o.Total(),
        OccurredAt: time.Now().UTC(),
    })
    return nil
}

func (o *Order) DomainEvents() []DomainEvent { return append([]DomainEvent{}, o.events...) }
func (o *Order) ClearEvents()                { o.events = o.events[:0] }

// app/place_order.go — Application Service
type PlaceOrderHandler struct {
    orders     domain.OrderRepository
    customers  domain.CustomerRepository
    discount   domain.DiscountPolicy
    publisher  EventPublisher
}

func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) error {
    order := domain.NewOrder(cmd.OrderID, cmd.CustomerID)
    for _, l := range cmd.Lines {
        if err := order.AddLine(l.ProductID, l.Qty, l.UnitPrice); err != nil { return err }
    }

    customer, err := h.customers.FindByID(ctx, cmd.CustomerID)
    if err != nil { return err }

    disc := h.discount.Calculate(order, customer)
    order.ApplyDiscount(disc)

    if err := order.Confirm(); err != nil { return err }
    if err := h.orders.Save(ctx, order); err != nil { return err }

    for _, ev := range order.DomainEvents() {
        h.publisher.Publish(ctx, ev)
    }
    order.ClearEvents()
    return nil
}

Referências

  1. Evans, E. — Domain-Driven Design. Addison-Wesley, 2003. Caps 6-7: Repository, Domain Service, Domain Event.
  2. Vernon, V. — Implementing Domain-Driven Design. Addison-Wesley, 2013. Caps 8-12: implementação detalhada.
  3. Fowler, M. — "Repository". martinfowler.com, 2002. A definição do padrão.
  4. Young, G. — "CQRS Documents". cqrs.files.wordpress.com, 2010. Domain Events em contexto de CQRS.
  5. Fowler, M. — "Domain Event". martinfowler.com, 2005. O padrão formalizado.
  6. Millett, S.; Tune, N. — Patterns, Principles, and Practices of DDD. Wrox, 2015. Caps 14-16: Repository e Domain Services.
  7. Khorikov, V. — Unit Testing: Principles, Practices, and Patterns. Manning, 2020. Como testar Application Services e Domain Services separadamente.
  8. Brandolini, A. — Introducing Event Storming. Leanpub, 2021. Como Domain Events emergem da modelagem.
  9. Richardson, C. — Microservices Patterns. Manning, 2018. Domain Events como mecanismo de integração entre bounded contexts.
  10. Seemann, M. — Dependency Injection in .NET. Manning, 2011. Como injetar Domain Services e Repositories.
  11. Dahan, U. — "Domain Events – Salvation". udidahan.com, 2009. Domain Events como mecanismo de desacoplamento.
  12. Zimarev, A. — Hands-On Domain-Driven Design with .NET. Packt, 2019. Implementação C# completa.