Golang

Artigo 45 — Domain-Driven Design aplicado a projetos Go Já leu

13 min de leitura

Artigo 45 — Domain-Driven Design aplicado a projetos Go
Artigo 45 — Domain-Driven Design aplicado a projetos Go DDD: o domínio como coração do sistema Domain-Driven Design — introduzido por Eric Evans no livro

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


claude response

Comentários

Mais em Golang

Structs: definição, inicialização e campos embutidos
Structs: definição, inicialização e campos embutidos

Go n&atilde;o &eacute; uma linguagem orientada a objetos no sentido tradicion...

Artigo 51 — gRPC e Protocol Buffers: comunicação eficiente entre serviços
Artigo 51 — gRPC e Protocol Buffers: comunicação eficiente entre serviços

Artigo 51 — gRPC e Protocol Buffers: comunicação eficiente entre serviços Alé...

Artigo 46 — Injeção de dependência sem frameworks
Artigo 46 — Injeção de dependência sem frameworks

Artigo 46 — Injeção de dependência sem frameworks Dependências explícitas: a...