Artigo 45 — Domain-Driven Design aplicado a projetos Go
Curso: Dominando Go em 1 Ano Prof. Ricardo Matos Módulo 8 — Arquitetura e Padrões
DDD: o domínio como coração do sistema
Domain-Driven Design — introduzido por Eric Evans no livro homônimo de 2003 — é uma abordagem de desenvolvimento onde o modelo de domínio do negócio guia todas as decisões de design. Não é uma arquitetura técnica, mas uma filosofia: o código deve falar a linguagem dos especialistas do negócio, e a estrutura do sistema deve refletir a estrutura do problema que ele resolve.
DDD e Clean Architecture se complementam naturalmente. Clean Architecture define onde colocar as peças. DDD define como modelar essas peças de forma que reflitam o negócio com precisão e clareza.
Os blocos de construção do DDD
Ubiquitous Language: a linguagem compartilhada
O primeiro princípio do DDD é criar uma linguagem compartilhada entre desenvolvedores e especialistas do negócio. Essa linguagem deve aparecer no código — nomes de tipos, métodos, pacotes e variáveis devem refletir os termos que o negócio usa.
// Ruim: terminologia técnica
type Record struct {
UserID int
ItemID int
Qty int
UnitCost float64
}
func (r *Record) Calc() float64 { return float64(r.Qty) * r.UnitCost }
// Bom: terminologia do negócio (e-commerce)
type ItemPedido struct {
ProdutoID int64
Quantidade int
PrecoUnit float64
}
func (i *ItemPedido) Subtotal() float64 {
return float64(i.Quantidade) * i.PrecoUnit
}
Entidades: identidade ao longo do tempo
Uma entidade é um objeto com identidade única que persiste ao longo do tempo. Dois objetos com o mesmo ID são a mesma entidade, independentemente de seus outros atributos.
// internal/domain/cliente/cliente.go
package cliente
import (
"errors"
"regexp"
"time"
)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
var (
ErrEmailInvalido = errors.New("email inválido")
ErrNomeObrigatorio = errors.New("nome é obrigatório")
ErrClienteInativo = errors.New("cliente está inativo")
)
// ClienteID é o identificador único de um cliente.
// Usar um tipo nomeado evita confusão com IDs de outros agregados.
type ClienteID int64
// Cliente é uma entidade — sua identidade é o ClienteID.
type Cliente struct {
id ClienteID
nome string
email string
telefone string
ativo bool
criadoEm time.Time
eventos []Evento // eventos de domínio pendentes
}
type Evento interface {
NomeEvento() string
}
type ClienteCriado struct {
ClienteID ClienteID
Email string
OcorridoEm time.Time
}
func (e ClienteCriado) NomeEvento() string { return "cliente.criado" }
type EmailAlterado struct {
ClienteID ClienteID
EmailAnterior string
EmailNovo string
OcorridoEm time.Time
}
func (e EmailAlterado) NomeEvento() string { return "cliente.email_alterado" }
// NovoCliente cria um Cliente validado.
func NovoCliente(nome, email, telefone string) (*Cliente, error) {
if nome == "" {
return nil, ErrNomeObrigatorio
}
if !emailRegex.MatchString(email) {
return nil, ErrEmailInvalido
}
c := &Cliente{
nome: nome,
email: email,
telefone: telefone,
ativo: true,
criadoEm: time.Now().UTC(),
}
c.registrarEvento(ClienteCriado{
Email: email,
OcorridoEm: time.Now().UTC(),
})
return c, nil
}
// ReconstituirCliente cria uma entidade a partir de dados persistidos.
// Não dispara eventos — a entidade já existia.
func ReconstituirCliente(id ClienteID, nome, email, telefone string, ativo bool, criadoEm time.Time) *Cliente {
return &Cliente{
id: id,
nome: nome,
email: email,
telefone: telefone,
ativo: ativo,
criadoEm: criadoEm,
}
}
func (c *Cliente) ID() ClienteID { return c.id }
func (c *Cliente) Nome() string { return c.nome }
func (c *Cliente) Email() string { return c.email }
func (c *Cliente) Telefone() string { return c.telefone }
func (c *Cliente) Ativo() bool { return c.ativo }
func (c *Cliente) CriadoEm() time.Time { return c.criadoEm }
func (c *Cliente) AlterarEmail(novoEmail string) error {
if !emailRegex.MatchString(novoEmail) {
return ErrEmailInvalido
}
if novoEmail == c.email {
return nil
}
emailAnterior := c.email
c.email = novoEmail
c.registrarEvento(EmailAlterado{
ClienteID: c.id,
EmailAnterior: emailAnterior,
EmailNovo: novoEmail,
OcorridoEm: time.Now().UTC(),
})
return nil
}
func (c *Cliente) Desativar() error {
if !c.ativo {
return ErrClienteInativo
}
c.ativo = false
return nil
}
// Eventos retorna os eventos de domínio acumulados e limpa a fila.
func (c *Cliente) Eventos() []Evento {
eventos := c.eventos
c.eventos = nil
return eventos
}
func (c *Cliente) registrarEvento(e Evento) {
c.eventos = append(c.eventos, e)
}
Value Objects: igualdade por valor
Value Objects não têm identidade — dois Value Objects com os mesmos atributos são equivalentes. São imutáveis: qualquer "mudança" cria um novo objeto.
// internal/domain/shared/dinheiro.go
package shared
import (
"errors"
"fmt"
)
var (
ErrMoedaDiferente = errors.New("operação entre moedas diferentes")
ErrValorNegativo = errors.New("valor monetário não pode ser negativo")
)
// Dinheiro é um Value Object que representa um valor monetário.
// É imutável — operações retornam um novo Dinheiro.
type Dinheiro struct {
valor int64 // centavos — evita problemas de float
moeda string // ISO 4217: BRL, USD, EUR
}
func NovoDinheiro(valor int64, moeda string) (Dinheiro, error) {
if valor < 0 {
return Dinheiro{}, ErrValorNegativo
}
if moeda == "" {
moeda = "BRL"
}
return Dinheiro{valor: valor, moeda: moeda}, nil
}
func DinheiroEmReais(centavos int64) Dinheiro {
d, _ := NovoDinheiro(centavos, "BRL")
return d
}
func (d Dinheiro) Valor() int64 { return d.valor }
func (d Dinheiro) Moeda() string { return d.moeda }
func (d Dinheiro) EmReais() float64 { return float64(d.valor) / 100 }
func (d Dinheiro) Adicionar(outro Dinheiro) (Dinheiro, error) {
if d.moeda != outro.moeda {
return Dinheiro{}, ErrMoedaDiferente
}
return Dinheiro{valor: d.valor + outro.valor, moeda: d.moeda}, nil
}
func (d Dinheiro) Subtrair(outro Dinheiro) (Dinheiro, error) {
if d.moeda != outro.moeda {
return Dinheiro{}, ErrMoedaDiferente
}
if d.valor < outro.valor {
return Dinheiro{}, ErrValorNegativo
}
return Dinheiro{valor: d.valor - outro.valor, moeda: d.moeda}, nil
}
func (d Dinheiro) Multiplicar(fator int64) Dinheiro {
return Dinheiro{valor: d.valor * fator, moeda: d.moeda}
}
func (d Dinheiro) IgualA(outro Dinheiro) bool {
return d.valor == outro.valor && d.moeda == outro.moeda
}
func (d Dinheiro) String() string {
return fmt.Sprintf("%s %.2f", d.moeda, d.EmReais())
}
// CPF é um Value Object para CPF brasileiro.
type CPF struct {
valor string
}
func NovoCPF(valor string) (CPF, error) {
limpo := limparCPF(valor)
if !validarCPF(limpo) {
return CPF{}, errors.New("CPF inválido")
}
return CPF{valor: limpo}, nil
}
func (c CPF) String() string {
if len(c.valor) != 11 {
return c.valor
}
return fmt.Sprintf("%s.%s.%s-%s",
c.valor[0:3], c.valor[3:6],
c.valor[6:9], c.valor[9:11])
}
func (c CPF) Valor() string { return c.valor }
func (c CPF) IgualA(outro CPF) bool { return c.valor == outro.valor }
func limparCPF(cpf string) string {
resultado := ""
for _, r := range cpf {
if r >= '0' && r <= '9' {
resultado += string(r)
}
}
return resultado
}
func validarCPF(cpf string) bool {
if len(cpf) != 11 {
return false
}
// Verificação dos dígitos verificadores
soma := 0
for i := 0; i < 9; i++ {
soma += int(cpf[i]-'0') * (10 - i)
}
resto := (soma * 10) % 11
if resto == 10 || resto == 11 {
resto = 0
}
if resto != int(cpf[9]-'0') {
return false
}
soma = 0
for i := 0; i < 10; i++ {
soma += int(cpf[i]-'0') * (11 - i)
}
resto = (soma * 10) % 11
if resto == 10 || resto == 11 {
resto = 0
}
return resto == int(cpf[10]-'0')
}
Agregados: consistência transacional
Um Agregado é um cluster de entidades e Value Objects tratados como uma unidade para fins de mudança de dados. O Aggregate Root é a única porta de entrada para modificações.
// internal/domain/pedido/pedido.go
package pedido
import (
"errors"
"fmt"
"time"
"loja/internal/domain/shared"
)
// PedidoID é o identificador do agregado Pedido.
type PedidoID int64
// ItemID é o identificador de um item dentro do pedido.
type ItemID int64
var (
ErrPedidoNaoPodeSerModificado = errors.New("pedido não pode ser modificado neste status")
ErrItemNaoEncontrado = errors.New("item não encontrado no pedido")
ErrQuantidadeInvalida = errors.New("quantidade deve ser maior que zero")
)
// StatusPedido é um Value Object que representa o estado do pedido.
type StatusPedido string
const (
StatusRascunho StatusPedido = "rascunho"
StatusConfirmado StatusPedido = "confirmado"
StatusPago StatusPedido = "pago"
StatusEnviado StatusPedido = "enviado"
StatusEntregue StatusPedido = "entregue"
StatusCancelado StatusPedido = "cancelado"
)
// transicoesPermitidas define quais transições de status são válidas
var transicoesPermitidas = map[StatusPedido][]StatusPedido{
StatusRascunho: {StatusConfirmado, StatusCancelado},
StatusConfirmado: {StatusPago, StatusCancelado},
StatusPago: {StatusEnviado},
StatusEnviado: {StatusEntregue},
}
// Item é uma entidade interna ao agregado Pedido.
// Só pode ser criado/modificado através do Pedido (Aggregate Root).
type Item struct {
id ItemID
produtoID int64
nomeProduto string
quantidade int
precoUnit shared.Dinheiro
}
func (i *Item) ID() ItemID { return i.id }
func (i *Item) ProdutoID() int64 { return i.produtoID }
func (i *Item) NomeProduto() string { return i.nomeProduto }
func (i *Item) Quantidade() int { return i.quantidade }
func (i *Item) PrecoUnit() shared.Dinheiro { return i.precoUnit }
func (i *Item) Subtotal() shared.Dinheiro {
return i.precoUnit.Multiplicar(int64(i.quantidade))
}
// Pedido é o Aggregate Root do agregado de mesmo nome.
// Toda modificação nos itens passa obrigatoriamente pelo Pedido.
type Pedido struct {
id PedidoID
clienteID int64
itens []*Item
status StatusPedido
criadoEm time.Time
eventos []interface{}
proximoItemID ItemID
}
// NovoPedido cria um Pedido em estado de Rascunho.
func NovoPedido(clienteID int64) *Pedido {
return &Pedido{
clienteID: clienteID,
status: StatusRascunho,
criadoEm: time.Now().UTC(),
proximoItemID: 1,
}
}
func ReconstituirPedido(
id PedidoID,
clienteID int64,
itens []*Item,
status StatusPedido,
criadoEm time.Time,
) *Pedido {
proximoID := ItemID(1)
for _, item := range itens {
if item.id >= proximoID {
proximoID = item.id + 1
}
}
return &Pedido{
id: id,
clienteID: clienteID,
itens: itens,
status: status,
criadoEm: criadoEm,
proximoItemID: proximoID,
}
}
func (p *Pedido) ID() PedidoID { return p.id }
func (p *Pedido) ClienteID() int64 { return p.clienteID }
func (p *Pedido) Status() StatusPedido { return p.status }
func (p *Pedido) CriadoEm() time.Time { return p.criadoEm }
func (p *Pedido) Itens() []*Item { return p.itens }
// AdicionarItem adiciona um produto ao pedido.
// Só é permitido quando o pedido está em Rascunho.
func (p *Pedido) AdicionarItem(produtoID int64, nome string, quantidade int, preco shared.Dinheiro) error {
if p.status != StatusRascunho {
return ErrPedidoNaoPodeSerModificado
}
if quantidade <= 0 {
return ErrQuantidadeInvalida
}
// Verifica se o produto já está no pedido — incrementa quantidade
for _, item := range p.itens {
if item.produtoID == produtoID {
item.quantidade += quantidade
return nil
}
}
// Novo item
item := &Item{
id: p.proximoItemID,
produtoID: produtoID,
nomeProduto: nome,
quantidade: quantidade,
precoUnit: preco,
}
p.itens = append(p.itens, item)
p.proximoItemID++
return nil
}
// RemoverItem remove um item do pedido pelo ID do item.
func (p *Pedido) RemoverItem(itemID ItemID) error {
if p.status != StatusRascunho {
return ErrPedidoNaoPodeSerModificado
}
for i, item := range p.itens {
if item.id == itemID {
p.itens = append(p.itens[:i], p.itens[i+1:]...)
return nil
}
}
return ErrItemNaoEncontrado
}
// Total calcula o valor total do pedido.
func (p *Pedido) Total() (shared.Dinheiro, error) {
total := shared.DinheiroEmReais(0)
for _, item := range p.itens {
var err error
total, err = total.Adicionar(item.Subtotal())
if err != nil {
return shared.Dinheiro{}, err
}
}
return total, nil
}
// TransicionarStatus altera o status seguindo as regras de transição.
func (p *Pedido) TransicionarStatus(novoStatus StatusPedido) error {
permitidos, existe := transicoesPermitidas[p.status]
if !existe {
return fmt.Errorf("%w: %s não permite transições", ErrPedidoNaoPodeSerModificado, p.status)
}
for _, permitido := range permitidos {
if permitido == novoStatus {
p.status = novoStatus
p.registrarEvento(StatusAlterado{
PedidoID: p.id,
StatusAnterior: p.status,
StatusNovo: novoStatus,
OcorridoEm: time.Now().UTC(),
})
return nil
}
}
return fmt.Errorf("%w: %s → %s não é permitido", ErrPedidoNaoPodeSerModificado, p.status, novoStatus)
}
// Eventos de domínio
type StatusAlterado struct {
PedidoID PedidoID
StatusAnterior StatusPedido
StatusNovo StatusPedido
OcorridoEm time.Time
}
func (p *Pedido) Eventos() []interface{} {
eventos := p.eventos
p.eventos = nil
return eventos
}
func (p *Pedido) registrarEvento(e interface{}) {
p.eventos = append(p.eventos, e)
}
Domain Services: lógica que não pertence a uma entidade
Quando uma operação de domínio envolve múltiplos agregados e não pertence naturalmente a nenhum deles, ela vai para um Domain Service:
// internal/domain/servico/politica_desconto.go
package servico
import (
"loja/internal/domain/cliente"
"loja/internal/domain/pedido"
"loja/internal/domain/shared"
)
// PoliticaDesconto calcula descontos baseados em regras de negócio
// que envolvem tanto o cliente quanto o pedido.
type PoliticaDesconto struct{}
func (s *PoliticaDesconto) Calcular(
cli *cliente.Cliente,
ped *pedido.Pedido,
) (shared.Dinheiro, error) {
total, err := ped.Total()
if err != nil {
return shared.Dinheiro{}, err
}
// Regra 1: cliente VIP recebe 10% de desconto
// Regra 2: pedidos acima de R$ 500 recebem 5% de desconto
// Regras são cumulativas
desconto := shared.DinheiroEmReais(0)
if cli.EhVIP() {
dez := total.Multiplicar(10)
porCento, _ := shared.NovoDinheiro(dez.Valor()/100, "BRL")
desconto, _ = desconto.Adicionar(porCento)
}
if total.Valor() >= 50000 { // R$ 500,00 em centavos
cinco := total.Multiplicar(5)
porCento, _ := shared.NovoDinheiro(cinco.Valor()/100, "BRL")
desconto, _ = desconto.Adicionar(porCento)
}
return desconto, nil
}
Bounded Contexts: fronteiras explícitas
Em sistemas grandes, o mesmo conceito pode ter significados diferentes em contextos diferentes. Um "Cliente" no contexto de vendas tem atributos diferentes de um "Cliente" no contexto de suporte.
loja/
├── internal/
│ ├── vendas/ ← Bounded Context: Vendas
│ │ ├── domain/
│ │ │ ├── cliente.go ← Cliente com histórico de compras
│ │ │ └── pedido.go
│ │ ├── repository/
│ │ └── usecase/
│ │
│ ├── suporte/ ← Bounded Context: Suporte
│ │ ├── domain/
│ │ │ └── cliente.go ← Cliente com tickets de suporte
│ │ ├── repository/
│ │ └── usecase/
│ │
│ └── catalogo/ ← Bounded Context: Catálogo
│ ├── domain/
│ │ └── produto.go ← Produto com atributos de catálogo
│ ├── repository/
│ └── usecase/
Eventos de domínio: comunicação entre contextos
// internal/shared/eventbus/bus.go
package eventbus
import "sync"
type Handler func(evento interface{})
type Bus struct {
mu sync.RWMutex
handlers map[string][]Handler
}
func Novo() *Bus {
return &Bus{handlers: make(map[string][]Handler)}
}
func (b *Bus) Registrar(nomeEvento string, handler Handler) {
b.mu.Lock()
defer b.mu.Unlock()
b.handlers[nomeEvento] = append(b.handlers[nomeEvento], handler)
}
func (b *Bus) Publicar(nomeEvento string, evento interface{}) {
b.mu.RLock()
handlers := b.handlers[nomeEvento]
b.mu.RUnlock()
for _, h := range handlers {
go h(evento) // assíncrono — para síncrono, remova o go
}
}
// Uso no caso de uso — publica eventos após persistência
func (uc *CriarClienteUseCase) Executar(ctx context.Context, input Input) (*Output, error) {
cli, err := cliente.NovoCliente(input.Nome, input.Email, input.Telefone)
if err != nil {
return nil, err
}
if err := uc.repo.Criar(ctx, cli); err != nil {
return nil, err
}
// Publica eventos APÓS a persistência bem-sucedida
for _, evento := range cli.Eventos() {
switch e := evento.(type) {
case cliente.ClienteCriado:
uc.bus.Publicar(e.NomeEvento(), e)
}
}
return &Output{ID: int64(cli.ID())}, nil
}
Resumo do que foi coberto
Este artigo apresentou Domain-Driven Design aplicado a Go: a Ubiquitous Language como base para nomenclatura idiomática, Entidades com identidade e eventos de domínio, Value Objects imutáveis com validação encapsulada — Dinheiro e CPF — Agregados com Aggregate Root controlando consistência transacional, máquina de estados com transições explícitas no Pedido, Domain Services para lógica que envolve múltiplos agregados, Bounded Contexts para isolar modelos em contextos distintos e um Event Bus simples para comunicação entre contextos. O próximo artigo explora injeção de dependência sem frameworks.
Referências e leituras complementares
-
Domain-Driven Design — Eric Evans — O livro original e fundamental. https://www.domainlanguage.com/ddd/
-
Implementing DDD — Vaughn Vernon — Versão mais acessível e prática. https://vaughnvernon.com/books
-
DDD in Go — artigo de referência — Aplicação prática de DDD com Go. https://threedots.tech/post/ddd-lite-in-go-introduction/
-
ThreeDots Tech Blog — Blog com artigos aprofundados sobre DDD e Go. https://threedots.tech
-
go-ddd — exemplo de referência — Repositório com DDD completo em Go. https://github.com/marcusolsson/goddd
-
EventStorming — Técnica de modelagem de domínio colaborativa. https://www.eventstorming.com
Próximo artigo: Artigo 46 — Injeção de dependência sem frameworks**
you asked
Sim