Aula 06 · 50–70 minutos
Um terceiro use case: rejeitar, e o estado que faltava
Aula 6 — por que um bool não basta para representar o status de um pedido, o anti-padrão de dois bools independentes, e o tipo StatusPedido que torna o estado impossível irrepresentável.
Resultado e mapa da aula
O aprovador-pedidos hoje só sabe fazer uma coisa: aprovar automaticamente um pedido dentro de um limite, e guardar o resultado como um bool solto — aprovado, nem sequer um campo do próprio Pedido. Mas todo sistema de aprovação real também precisa rejeitar pedidos que não se qualificam para aprovação automática. Ao adicionar esse terceiro caminho de decisão, um problema que estava escondido aparece: um bool não consegue representar três situações diferentes. Esta aula resolve isso — e, ao resolver, ensina um princípio de design que vai muito além deste projeto.
- O problema: por que
aprovado booljá não é suficiente assim que "rejeitar" existe. - Uma correção plausível, mas incorreta: dois bools independentes — e por que ela piora o problema em vez de resolvê-lo.
- O princípio: tornar estados impossíveis irrepresentáveis, com a fonte que cunhou o termo.
- A correção real: um tipo
StatusPedidoenumerado, com o valor zero como estado padrão — outro princípio de Go, citado da fonte oficial. - Sintaxe nova de Go:
iota, e por que mutar um campo exige um pointer receiver — e por que, uma vez que um método precisa, todos os métodos do tipo deveriam usar o mesmo padrão. RejeitarPedido: o segundo arquivo dentro do pacoteusecase, exatamente como a Lição 5 previu que esse pacote cresceria.
O problema: um bool não sobrevive a um terceiro estado
Relembre a interface de repositório da Lição 4:
// internal/usecase/aprovar_pedido.go — como ficou na Lição 4
type PedidoRepository interface {
Salvar(p domain.Pedido, aprovado bool) error
}
aprovado bool funciona perfeitamente enquanto só existem dois casos possíveis: aprovado ou não aprovado. O problema aparece assim que o negócio pede um terceiro caminho — pedidos que não se qualificam para o limite de aprovação automática (Lição 4) não deveriam ser silenciosamente tratados como "recusados": eles deveriam ficar pendentes, à espera de uma decisão explícita, até que alguém (ou uma regra futura) os aprove manualmente ou os rejeite de fato.
Tente representar três situações — pendente, aprovado, rejeitado — com um único bool. Não dá. aprovado = false teria que significar duas coisas ao mesmo tempo: "ainda não decidido" e "decidido, e a decisão foi negativa". São situações diferentes para o negócio (uma pede uma ação futura; a outra é definitiva), mas indistinguíveis no dado armazenado. Esse é o sintoma concreto que motiva toda a aula: o tipo de dado escolhido não consegue expressar todos os estados que o negócio realmente tem.
Uma correção plausível, mas incorreta: dois bools independentes
O instinto mais comum, ao perceber que falta um terceiro estado, é adicionar um segundo bool:
// internal/domain/pedido.go — abordagem incorreta
package domain
type Pedido struct {
ID string
Total float64
Aprovado bool
Rejeitado bool
}
À primeira vista isso parece resolver o problema: agora existem quatro combinações, e três delas fazem sentido — false/false (pendente), true/false (aprovado), false/true (rejeitado). Mas sobra uma quarta combinação que o compilador aceita sem reclamar: Aprovado: true, Rejeitado: true. Nada no tipo Pedido impede essa struct de existir. Um bug em qualquer lugar do código — um teste mal escrito, uma migração de dados, uma race condition em código concorrente (tema da Lição 20) — pode produzir um Pedido simultaneamente aprovado e rejeitado, e o compilador não vai avisar. Pior: cada função que lê esse Pedido (um handler HTTP, um relatório, um outro use case) precisa decidir, por conta própria, o que fazer com esse caso impossível — e é provável que decisões diferentes apareçam em lugares diferentes do código, porque ninguém formalizou qual delas é a "certa".
À esquerda, dois bools independentes: quatro combinações possíveis, e uma delas — aprovado e rejeitado ao mesmo tempo — não deveria existir, mas o compilador não a impede. À direita, um StatusPedido enumerado: o valor zero é Pendente, e as únicas transições possíveis levam a Aprovado ou a Rejeitado — o quarto caso simplesmente não é um valor válido do tipo.
O princípio: tornar estados impossíveis irrepresentáveis
Repare no paralelo exato com o Pedido de dois bools: "aprovado e rejeitado ao mesmo tempo" é o equivalente deste projeto ao "contato sem nenhum endereço" do artigo. A correção segue o mesmo caminho — não é adicionar uma checagem manual comparando Aprovado e Rejeitado, repetida em todo lugar do código que lê um Pedido. É trocar o tipo de dado por um que não permita a combinação inválida.
A correção: um tipo StatusPedido enumerado
Go não tem um tipo nativo de enum como outras linguagens, mas tem o padrão idiomático para construir um: um tipo nomeado sobre int, com constantes relacionadas declaradas via iota.
// internal/domain/pedido.go — o tipo novo
package domain
type StatusPedido int
const (
StatusPendente StatusPedido = iota
StatusAprovado
StatusRejeitado
)
func (s StatusPedido) String() string {
switch s {
case StatusAprovado:
return "aprovado"
case StatusRejeitado:
return "rejeitado"
default:
return "pendente"
}
}
iota é um contador que o compilador incrementa a cada linha dentro de um bloco const — StatusPendente recebe 0, StatusAprovado recebe 1, StatusRejeitado recebe 2. Você não escreve os números: o compilador atribui, e a ordem em que as constantes aparecem no bloco é o que determina o valor de cada uma. O método String() não é obrigatório para o tipo funcionar, mas é a convenção de Go para que fmt.Println(status) e mensagens de log mostrem "aprovado" em vez de 1 — o método com essa assinatura exata é reconhecido automaticamente por qualquer código que use o pacote fmt, via a interface Stringer da biblioteca padrão.
O campo Status entra na struct Pedido, e os dois bools desaparecem:
// internal/domain/pedido.go — struct atualizada
type Pedido struct {
ID string
Total float64
Status StatusPedido
}
const limiteAprovacaoAutomatica = 1000.0
Agora compare com a versão de dois bools: não existe, e não pode existir, um Pedido simultaneamente aprovado e rejeitado — Status é um único campo, e só pode conter um dos três valores declarados. O estado impossível não precisa ser proibido por uma checagem em runtime, porque ele nunca foi representável em primeiro lugar.
Sintaxe nova: mutar Pedido exige um pointer receiver
A Lição 4 declarou PodeAprovarAutomaticamente com receiver por valor: func (p Pedido) PodeAprovarAutomaticamente() bool. Isso funcionava porque o método só lia campos de Pedido, nunca os alterava. Agora que Pedido precisa transicionar de estado — de StatusPendente para StatusAprovado ou StatusRejeitado — o método precisa alterar o campo Status do valor original, não de uma cópia. Isso exige um pointer receiver:
// internal/domain/pedido.go — métodos que mutam
func (p *Pedido) AprovarAutomaticamente() {
if p.Total <= limiteAprovacaoAutomatica {
p.Status = StatusAprovado
}
}
func (p *Pedido) Rejeitar() {
p.Status = StatusRejeitado
}
Isso levanta uma pergunta: PodeAprovarAutomaticamente (que só lê) precisa virar pointer receiver também, já que AprovarAutomaticamente e Rejeitar (que mutam) já são? A convenção oficial de revisão de código do Go responde que sim — por consistência, não por necessidade técnica de cada método individual:
O terceiro caminho: RejeitarPedido
Com o domínio corrigido, a interface do repositório perde o parâmetro redundante — o status agora vive dentro do próprio Pedido, então não há mais razão para passá-lo como um argumento separado:
// internal/usecase/aprovar_pedido.go — interface simplificada
package usecase
import "github.com/allangrds/aprovador-pedidos/internal/domain"
type PedidoRepository interface {
Salvar(p domain.Pedido) error
}
type AprovarPedido struct {
repo PedidoRepository
}
func NovoAprovarPedido(r PedidoRepository) *AprovarPedido {
return &AprovarPedido{repo: r}
}
func (uc *AprovarPedido) Executar(p domain.Pedido) error {
p.AprovarAutomaticamente()
return uc.repo.Salvar(p)
}
E, como a Lição 5 já previu ao decidir por package-by-layer, o terceiro caminho de decisão entra como um arquivo novo dentro do pacote usecase já existente — não um pacote novo:
// internal/usecase/rejeitar_pedido.go
package usecase
import "github.com/allangrds/aprovador-pedidos/internal/domain"
type RejeitarPedido struct {
repo PedidoRepository
}
func NovoRejeitarPedido(r PedidoRepository) *RejeitarPedido {
return &RejeitarPedido{repo: r}
}
func (uc *RejeitarPedido) Executar(p domain.Pedido) error {
p.Rejeitar()
return uc.repo.Salvar(p)
}
RejeitarPedido reutiliza a mesma interface PedidoRepository que AprovarPedido já declarou — não há necessidade de declarar uma segunda interface idêntica só porque o caso de uso é outro. A implementação em memória também simplifica, guardando o Pedido inteiro em vez de um mapa de booleanos:
// internal/memoria/pedido_repository.go
package memoria
import "github.com/allangrds/aprovador-pedidos/internal/domain"
type PedidoRepository struct {
pedidos map[string]domain.Pedido
}
func NovoPedidoRepository() *PedidoRepository {
return &PedidoRepository{pedidos: map[string]domain.Pedido{}}
}
func (r *PedidoRepository) Salvar(p domain.Pedido) error {
r.pedidos[p.ID] = p
return nil
}
E os testes, em ambos os pacotes, confirmam a transição de estado em vez de um bool solto:
// internal/domain/pedido_test.go
package domain
import "testing"
func TestAprovarAutomaticamente_DentroDoLimite(t *testing.T) {
p := &Pedido{ID: "1", Total: 500}
p.AprovarAutomaticamente()
if p.Status != StatusAprovado {
t.Errorf("esperava StatusAprovado, obteve %v", p.Status)
}
}
func TestAprovarAutomaticamente_AcimaDoLimite_PermanecePendente(t *testing.T) {
p := &Pedido{ID: "2", Total: 5000}
p.AprovarAutomaticamente()
if p.Status != StatusPendente {
t.Errorf("esperava StatusPendente, obteve %v", p.Status)
}
}
func TestRejeitar(t *testing.T) {
p := &Pedido{ID: "3", Total: 5000}
p.Rejeitar()
if p.Status != StatusRejeitado {
t.Errorf("esperava StatusRejeitado, obteve %v", p.Status)
}
}
// internal/usecase/rejeitar_pedido_test.go
package usecase
import (
"testing"
"github.com/allangrds/aprovador-pedidos/internal/domain"
)
func TestExecutar_RejeitaPedido(t *testing.T) {
fake := &repositorioFake{salvos: map[string]domain.Pedido{}}
uc := NovoRejeitarPedido(fake)
err := uc.Executar(domain.Pedido{ID: "4", Total: 5000})
if err != nil {
t.Fatalf("erro inesperado: %v", err)
}
if fake.salvos["4"].Status != domain.StatusRejeitado {
t.Errorf("esperava pedido salvo como rejeitado, obteve %v", fake.salvos["4"].Status)
}
}
repositorioFake, declarado no teste de AprovarPedido da Lição 4, também precisa acompanhar a mudança de assinatura — salvos passa a guardar map[string]domain.Pedido em vez de map[string]bool, o mesmo ajuste refletido em memoria.PedidoRepository acima.
Prática guiada: adicione um estado de cancelamento
Raciocínio antes do código
A pergunta (1) testa se a lição ficou clara: cancelamento é um quarto estado de negócio genuinamente diferente dos outros três (não é "pendente", não é "aprovado", não é "rejeitado" — é algo que só acontece depois de aprovado), então ele pertence ao mesmo iota que os outros. A pergunta (2) testa a regra de transição — nem toda transição de estado é válida a partir de qualquer estado atual, e o método deveria recusar transições inválidas em vez de aceitá-las silenciosamente.
Uma abordagem plausível, mas incorreta: aceitar a transição sem checar o estado atual
// domain/pedido.go — abordagem incorreta
func (p *Pedido) Cancelar() {
p.Status = StatusCancelado
}
Por que falha: esse método aceita cancelar um pedido StatusPendente ou StatusRejeitado, transições que a regra de negócio do exercício proíbe explicitamente. O tipo StatusPedido sozinho resolve o problema desta aula (estados que não deveriam coexistir), mas não resolve um problema diferente: transições que não deveriam ser permitidas a partir de certos estados. Ter um tipo correto não substitui validar a transição — são duas responsabilidades diferentes. Em produção, esse bug permitiria cancelar um pedido que nunca foi aprovado, um estado que faz tão pouco sentido de negócio quanto o "aprovado e rejeitado ao mesmo tempo" que a aula já eliminou — só que agora reintroduzido por um caminho diferente (uma transição errada, não um valor de dado impossível).
A solução correta: validar a transição, retornando um erro
// domain/pedido.go — solução correta
import "errors"
var ErrTransicaoInvalida = errors.New("pedido não está em um estado que permite essa transição")
func (p *Pedido) Cancelar() error {
if p.Status != StatusAprovado {
return ErrTransicaoInvalida
}
p.Status = StatusCancelado
return nil
}
Por que está correto: o método agora expressa duas coisas que a versão incorreta misturava — qual é o novo estado (StatusCancelado) e quando essa transição é permitida (só a partir de StatusAprovado). Retornar error em vez de falhar silenciosamente segue a mesma convenção que PedidoRepository.Salvar já usa desde a Lição 4 — erros em Go são valores explícitos, verificados pelo chamador, não exceções lançadas e ignoráveis. errors.New cria um valor de erro reutilizável, comparável com == no chamador (if errors.Is(err, domain.ErrTransicaoInvalida)), em vez de uma nova string a cada chamada.
Verificação: escreva dois testes — um chamando Cancelar() num pedido cujo Status já é StatusAprovado, confirmando Status == StatusCancelado e err == nil; outro chamando Cancelar() num pedido recém-criado, sem Status definido (zero value, portanto StatusPendente), confirmando que o erro retornado é ErrTransicaoInvalida e que Status permanece StatusPendente, inalterado.
Perguntas de entrevista e de mercado
"Por que um bool deixou de ser suficiente para representar o status de um pedido neste projeto?" Explique a progressão concreta: um bool representa exatamente dois estados; assim que o negócio introduziu um terceiro estado real ("pendente", distinto de "rejeitado"), o bool ficou sobrecarregado — false teria que significar duas coisas diferentes ao mesmo tempo. Cite o sintoma prático, não só a teoria: dois bools independentes pareciam resolver isso, mas introduziam uma quarta combinação sem sentido de negócio que o compilador aceitava.
"O que significa 'tornar estados impossíveis irrepresentáveis', e como isso difere de simplesmente validar o dado depois de criado?" A distinção central: validar depois significa que o estado inválido já existe em algum momento — foi construído, e só então rejeitado, o que exige lembrar de validar em todo lugar que o dado é criado ou lido. Tornar irrepresentável significa que o próprio tipo não permite construir o estado inválido — não há "depois" em que ele exista para ser pego. Cite o exemplo do artigo de Scott Wlaschin (contato sem nenhum endereço) e o paralelo direto com StatusPedido neste projeto.
"Por que StatusPendente foi declarado como a primeira constante do bloco iota, e isso importa?" Aponte que a primeira constante de um bloco iota recebe o valor 0, que é o valor zero do tipo — o que todo Pedido recém-criado sem inicializar Status já tem, de graça. Cite o proverbio de Rob Pike, "Make the zero value useful", e explique a consequência prática de errar essa ordem: qualquer código que esqueça de definir Status explicitamente criaria, silenciosamente, um pedido com um status de negócio errado.
"Este projeto usa pointer receiver em todos os métodos de Pedido, mas nem todos mutam o valor. Isso não é desnecessário para os que só leem?" Não é desnecessário — é uma convenção deliberada, não uma sobra de código. Cite a recomendação do Go Code Review Comments: misturar receivers de ponteiro e de valor no mesmo tipo é a inconsistência que a comunidade Go recomenda evitar; a escolha correta é ponteiro para todos os métodos assim que qualquer um deles precisar mutar o receiver, mesmo que isso custe uma pequena perda de simetria nos métodos que só leem.
Recupere da memória: 10 questões
Recupere da memória: 10 questões
Por que um único bool aprovado deixou de representar corretamente o status de um pedido nesta aula?
No design com dois bools independentes (Aprovado e Rejeitado), qual combinação é aceita pelo compilador mas não tem sentido de negócio?
Segundo o artigo de Scott Wlaschin citado nesta aula, qual é a diferença entre validar um estado inválido depois de criado e tornar esse estado irrepresentável?
No bloco const com iota usado para StatusPedido, qual valor StatusPendente recebe, e por que essa ordem foi escolhida deliberadamente?
Qual Go Proverb, de Rob Pike, justifica a ordem escolhida para as constantes de StatusPedido?
Por que os métodos AprovarAutomaticamente e Rejeitar precisam de um pointer receiver (*Pedido), diferente de PodeAprovarAutomaticamente na Lição 4?
Segundo o Go Code Review Comments citado nesta aula, o que se deve fazer quando pelo menos um método de um tipo precisa de pointer receiver?
Depois da correção desta aula, o que mudou na assinatura de PedidoRepository.Salvar, e por quê?
Onde, fisicamente, o caso de uso RejeitarPedido foi adicionado ao projeto?
No exercício desta aula (adicionar StatusCancelado), por que a versão incorreta de Cancelar() ainda é um bug, mesmo usando corretamente o tipo StatusPedido?
Feche o modelo mental
Um bool representa exatamente dois estados; assim que "pendente" precisou existir como um terceiro estado real, ele deixou de bastar. Dois bools independentes pareciam resolver isso, mas introduziam uma quarta combinação — aprovado e rejeitado ao mesmo tempo — que o compilador aceita, mas que não tem sentido de negócio nenhum: um estado impossível, representável por acidente. A correção não foi validar esse caso depois de criado; foi trocar o tipo por um StatusPedido enumerado via iota, onde StatusPendente — deliberadamente a primeira constante, o valor zero — é o padrão seguro para todo Pedido recém-criado. Mutar Status exigiu pointer receivers em Pedido, e a convenção de Go pede consistência: um método pointer, todos pointer. RejeitarPedido entrou como um arquivo novo dentro de usecase, sem pacote novo — exatamente como a Lição 5 previu que este projeto cresceria.
Próxima aula planejada: Domain Events — nomeando o que já acontecia escondido, quando um pedido muda de status e ninguém mais no sistema fica sabendo.
Referências primárias para aprofundar
O princípio central desta aula vem de Designing with types: Making illegal states unrepresentable, Scott Wlaschin, citando Yaron Minsky, Effective ML. A sintaxe e a convenção de Go usadas na correção vêm de três fontes oficiais: os Go Proverbs de Rob Pike ("Make the zero value useful"), Effective Go — Pointers vs. Values, e Go Code Review Comments — Receiver Type.
Consulte também o glossário desta trilha.