Domain-Driven Design aplicado a projetos Go

Domain-Driven Design aplicado a projetos Go

Domain-Driven Design em Go: linguagem compartilhada, entidades com eventos, Value Objects como Dinheiro e um CPF que recusa dígitos repetidos, agregado com máquina de estados, política de desconto com cliente VIP e um event bus assíncrono e seus riscos.
Golang

• • 19 min de leitura

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
    vip       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, vip bool, criadoEm time.Time) *Cliente {
    return &Cliente{
        id:        id,
        nome:      nome,
        email:     email,
        telefone:  telefone,
        ativo:     ativo,
        vip:       vip,
        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) EhVIP() bool        { return c.vip }
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
}

// TornarVIP promove o cliente. Só clientes ativos podem ser promovidos.
func (c *Cliente) TornarVIP() error {
    if !c.ativo {
        return ErrClienteInativo
    }
    c.vip = true
    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
    }
    // Dígitos todos iguais (000.000.000-00, 111.111.111-11...) passam no
    // cálculo abaixo, mas não são CPFs válidos
    iguais := true
    for i := 1; i < 11; i++ {
        if cpf[i] != cpf[0] {
            iguais = false
            break
        }
    }
    if iguais {
        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()

    // Assíncrono: cada handler roda na sua goroutine, em paralelo com os
    // outros, e Publicar retorna antes de eles terminarem. Para síncrono,
    // remova o go
    for _, h := range handlers {
        go h(evento)
    }
}

O caso de uso publica os eventos só depois de persistir o cliente, para que ninguém reaja a um cadastro que não chegou ao banco:

// internal/usecase/cliente/criar.go (trecho) — publica eventos após persistir
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
}

O go h(evento) tem consequências que precisam ser conhecidas. Num teste com um handler que levava 1 ms, logo depois de publicar 1.000 eventos só 379 tinham sido processados, e até 241 handlers rodaram ao mesmo tempo; os 1.000 só chegaram ao fim porque o processo continuou vivo. Por isso cada handler precisa ser seguro para concorrência: o mesmo teste, com um contador comum no lugar de um atomic, foi acusado pelo -race. Eventos publicados pouco antes de o processo encerrar se perdem, e um panic num handler derruba o programa inteiro, porque acontece numa goroutine sem recover. Para eventos que não podem se perder, o caminho habitual é gravar o evento no banco, na mesma transação da mudança, e publicá-lo depois a partir dali, o padrão conhecido como outbox.

DDD é menos sobre pastas e mais sobre linguagem: o código usa os termos do negócio, as entidades protegem as próprias regras, os Value Objects como Dinheiro e CPF não deixam um valor inválido existir, e o agregado é a única porta para mudar o que precisa mudar junto. Em Go, os campos não exportados e os construtores fazem esse papel sem nenhum framework. O que sustenta o modelo, porém, é o rigor nas regras pequenas: um CPF com dígitos repetidos aceito pelo cálculo, uma regra que cita um conceito que a entidade não tem, um evento entregue numa goroutine que ninguém espera. Testes de domínio, que rodam sem banco e em milissegundos, são o lugar natural para pegar esses casos.

Fontes e leituras recomendadas

Exercícios

Exercício 1

A primeira versão do validarCPF deste artigo aceitava 111.111.111-11 e 000.000.000-00. O cálculo dos dígitos verificadores estava correto. Como esses números passaram, e por que o Value Object é o lugar certo da correção?

Ver resposta

✓ Resposta: Num CPF com os onze dígitos iguais, as somas ponderadas são múltiplos do próprio dígito, e os dois verificadores calculados coincidem com os que estão no número. Matematicamente, eles passam; pela regra da Receita, não são CPFs válidos. Num teste, a versão antiga aceitou 111.111.111-11 e 000.000.000-00, e recusou corretamente 529.982.247-26, com o último dígito trocado. A versão do artigo recusa, antes do cálculo, qualquer número com os onze dígitos iguais, e no mesmo teste os dois foram recusados. O Value Object é o lugar certo porque toda criação de um CPF passa por NovoCPF: corrigida ali, a regra vale para cadastros, importações e qualquer outro caminho, e nenhuma parte do sistema precisa lembrar de repeti-la.

Exercício 2

A PoliticaDesconto chamava cli.EhVIP(), mas o Cliente não tinha esse método nem nenhum dado sobre VIP. O que o erro de compilação revela sobre o modelo, e onde a informação de VIP deveria morar?

Ver resposta

✓ Resposta: O compilador acusou cli.EhVIP undefined (type *cliente.Cliente has no field or method EhVIP), mas o problema não é de digitação: a regra de negócio falava de um conceito, cliente VIP, que o modelo não conhecia. Em DDD, isso é uma lacuna na linguagem compartilhada, e a correção começa por perguntar ao negócio o que torna um cliente VIP e quem decide isso. O artigo resolveu com um estado no próprio Cliente: o campo vip, o método EhVIP e uma operação TornarVIP, que recusa clientes inativos, mais o parâmetro correspondente em ReconstituirCliente. Se ser VIP dependesse do histórico de compras, a resposta seria outra: um serviço de domínio calcularia a condição a partir dos pedidos. Com a regra no lugar, um cliente VIP com um pedido de R$ 600,00 recebeu R$ 90,00 de desconto, os 10% de VIP mais os 5% acima de R$ 500.

Exercício 3

Um handler registrado no event bus incrementa um contador comum, sem sincronização, a cada evento. Num teste, depois de 1.000 eventos publicados, o contador marca menos de 1.000 mesmo depois de uma espera. O que está acontecendo?

Ver resposta

✓ Resposta: O Publicar dispara cada handler com go h(evento), e com 1.000 eventos há centenas de goroutines executando o mesmo handler ao mesmo tempo: num teste com um handler de 1 ms, chegaram a 241 simultâneas. Um contador++ comum é ler, somar e gravar, e duas goroutines que leem o mesmo valor gravam o mesmo resultado, perdendo um incremento. Num teste com o contador comum, o go test -race acusou DATA RACE, e o total ficou abaixo de 1.000; com atomic.Int64, chegou a 1.000. A correção é tornar o handler seguro para concorrência, com atomic, mutex ou canal, ou fazer o bus entregar os eventos de forma sequencial. O Registrar e o Publicar do bus usam um RWMutex, mas ele protege só o mapa de handlers, e não o que cada handler faz.

Exercício 4

Um comando de linha de comando cria um cliente e termina logo em seguida. O evento ClienteCriado deveria disparar um e-mail de boas-vindas, mas o e-mail às vezes não sai. O que o event bus tem a ver com isso, e como garantir a entrega?

Ver resposta

✓ Resposta: O Publicar retorna assim que dispara as goroutines, sem esperar por elas. Num teste com 1.000 eventos, só 379 tinham sido processados no momento em que o Publicar do último retornou. Quando o main termina, o programa encerra junto com todas as goroutines em andamento, e o handler do e-mail pode nem ter começado. Num servidor que roda continuamente isso quase não aparece, e por isso o defeito passa despercebido até alguém usar o mesmo caso de uso num processo curto. Há duas saídas, de alcance diferente. Uma é o bus acompanhar as goroutines com um sync.WaitGroup e oferecer um método para esperá-las antes de encerrar. A outra, que também resiste a quedas e reinícios, é o padrão outbox: gravar o evento numa tabela, na mesma transação do cliente, e deixar um processo separado ler e entregar os eventos pendentes.

Exercício 5

Um desenvolvedor sugere tirar o ReconstituirCliente e carregar clientes do banco com NovoCliente, "para não duplicar código". Que efeitos isso teria?

Ver resposta

✓ Resposta: Os dois construtores representam momentos diferentes da vida da entidade. NovoCliente representa o nascimento: valida o nome e o e-mail, define criadoEm como agora, marca o cliente como ativo e registra o evento ClienteCriado. Usá-lo para carregar um cliente do banco teria três efeitos. Todo carregamento registraria um ClienteCriado novo, e, se os eventos fossem publicados, cada consulta dispararia de novo o e-mail de boas-vindas. A data de criação e os estados ativo e vip se perderiam, substituídos pelos valores iniciais. E um registro antigo que não passasse nas validações de hoje, como um e-mail aceito por uma regra mais frouxa no passado, deixaria de poder ser carregado. O ReconstituirCliente recebe o estado completo e não dispara eventos, porque a entidade já existia; a duplicação aparente é a diferença entre criar e recuperar.

Comentários

Mais em Golang

Introdução ao database/sql e drivers em Go
Introdução ao database/sql e drivers em Go

O pacote database/sql em Go: drivers e import em branco, o pool de conexões e…

Maps: criação, iteração e boas práticas
Maps: criação, iteração e boas práticas

Maps em Go: make e literais, o map nil que aceita leitura e não aceita…

Compilação cruzada, binários estáticos e Docker com Go
Compilação cruzada, binários estáticos e Docker com Go

Compilação cruzada e Docker com Go: -ldflags e o que o debug.ReadBuildInfo já…