Aula 09 · 55–75 minutos
DTOs: o bug que Executar(domain.Pedido) escondia, e o padrão que já existia na Lição 4
Aula 9 — um bug real em AprovarPedido.Executar causado por receber domain.Pedido inteiro como parâmetro; a definição de DTO de Martin Fowler, seu próprio aviso contra usá-los sem necessidade, e o Input que corrige o bug sem contradizer esse aviso.
Resultado e mapa da aula
Esta aula começa por um bug real, não por uma definição. AprovarPedido.Executar, como está desde a Lição 7, recebe um domain.Pedido inteiro como parâmetro — e isso permite que quem chama o use case declare, antes de qualquer regra rodar, uma decisão que só a regra deveria poder tomar. Vamos primeiro provar que esse bug existe, depois usar Martin Fowler — tanto a definição clássica de DTO quanto o aviso, do próprio Fowler, contra usar DTOs sem necessidade — para chegar à correção certa, sem inventar burocracia nova.
- O bug: um
Statusforjado que sobrevive aExecutarsem correção. - Fonte primária: a definição de Data Transfer Object, de Martin Fowler.
- A mesma fonte, lida com cuidado: o aviso de Fowler contra DTOs em contextos locais — por que "é uma boa prática" não basta como justificativa aqui.
- O que realmente motiva a correção: não é custo de chamada remota — é a mesma lição da Lição 6, agora aplicada a um parâmetro de função, não a um campo de struct.
- A correção:
AprovarPedidoInputeRejeitarPedidoInput, cada um com só os campos que aquele caso de uso específico precisa. - Onde um DTO de verdade, no sentido de Fowler, já vivia neste projeto desde a Lição 4 — reconhecido agora, com o nome certo.
- Onde resistir à tentação: por que
ConsultarPedidocontinua devolvendodomain.Pedidodiretamente, sem um DTO de saída, por enquanto.
O bug: um Status que ninguém decidiu, mas que é salvo mesmo assim
Releia AprovarAutomaticamente, como ficou desde a Lição 7:
// internal/domain/pedido.go — como está hoje
func (p *Pedido) AprovarAutomaticamente() *PedidoAprovado {
if p.Total > limiteAprovacaoAutomatica {
return nil
}
p.Status = StatusAprovado
return &PedidoAprovado{PedidoID: p.ID, Total: p.Total, Ocorrido: time.Now()}
}
Repare com atenção no primeiro if: quando p.Total excede o limite, a função retorna nil sem tocar em p.Status. Isso parece inofensivo — a intenção é "não aprove automaticamente", deixando o pedido como estava. O problema é a pergunta que essa frase esconde: como estava o quê, exatamente? Executar, o método que chama AprovarAutomaticamente, recebe um domain.Pedido inteiro como parâmetro:
// internal/usecase/aprovar_pedido.go — como está hoje
func (uc *AprovarPedido) Executar(p domain.Pedido) (*domain.PedidoAprovado, error) {
evento := p.AprovarAutomaticamente()
if err := uc.repo.Salvar(p); err != nil {
return nil, err
}
return evento, nil
}
p chega de fora já com um Status — porque domain.Pedido tem esse campo, e nada impede quem constrói o valor de preenchê-lo com qualquer coisa antes de chamar Executar. Isso significa que o seguinte código compila, roda, e não retorna nenhum erro:
// o bug, demonstrado
pedidoForjado := domain.Pedido{
ID: "99",
Total: 5000, // acima do limite de aprovação automática (1000)
Status: domain.StatusAprovado, // uma decisão que nenhuma regra jamais tomou
}
_, err := aprovarPedido.Executar(pedidoForjado)
// err == nil
5000 está acima do limite, então AprovarAutomaticamente entra no if, retorna nil — e nunca toca em Status. O Status: domain.StatusAprovado que pedidoForjado já carregava sobrevive intacto até uc.repo.Salvar(p), que grava exatamente esse valor. O sistema acaba de persistir um pedido de 5000, marcado como aprovado, sem que a regra de aprovação automática — a única regra de negócio que decide isso, desde a Lição 4 — jamais tivesse concordado. Não é um erro hipotético de estilo: é um bug de correção, do tipo que, num sistema real, aprovaria financeiramente algo que a própria regra do negócio rejeitou.
Fonte primária: Data Transfer Object, de Martin Fowler
À primeira vista, isso parece justificar imediatamente criar AprovarPedidoInput — uma estrutura pequena, carregando só os dados que Executar precisa. Mas antes de aceitar essa conclusão, vale conferir se a motivação original de Fowler — chamadas remotas caras, serialização — se aplica aqui. Ela não se aplica: Executar é uma chamada de método comum, dentro do mesmo processo Go, sem rede, sem serialização envolvida. Isso importa, porque o próprio Fowler já respondeu a essa pergunta diretamente.
A mesma fonte, lida com cuidado: o aviso contra DTOs locais
Isso é um momento importante para ler uma fonte primária com honestidade, em vez de citá-la seletivamente para justificar uma decisão já tomada. Fowler está dizendo, com todas as letras, que criar uma estrutura de dados nova só para "parecer mais desacoplado", sem uma chamada remota real por trás, é trabalho desperdiçado. Se a única razão para AprovarPedidoInput fosse "DTOs são boa prática", esta aula deveria parar aqui e recomendar não criar nada — exatamente a mesma disciplina contra over-engineering que a Lição 5 já aplicou ao comparar package-by-layer com package-by-feature, e que a missão desta trilha explicita: introduzir uma peça de infraestrutura só quando a necessidade é concreta, não antecipada.
O que realmente motiva a correção aqui
A boa notícia é que o bug desta aula não precisa da motivação de Fowler para justificar a correção — ele tem a própria. Releia o argumento da Lição 6: um bool (depois, dois bools) permitia que Pedido representasse um estado que não deveria existir, porque o tipo de dado não impedia. Aqui, o mecanismo é o mesmo, um nível acima: o parâmetro de uma função — p domain.Pedido — permite que quem chama Executar declare um Status antes de qualquer regra de negócio ter rodado, porque o tipo do parâmetro não impede.
A diferença entre este caso e o do padrão DTO de Fowler é exatamente a diferença que o aviso de LocalDTO aponta: Fowler resolve chamadas remotas caras; este bug não tem nada a ver com custo de chamada — é sobre o que um parâmetro de função deveria, ou não deveria, permitir que quem chama decida. Isso é a mesma preocupação de "tornar estados impossíveis irrepresentáveis" (Lição 6), aplicada à fronteira de um use case em vez de a um campo de domínio. A estrutura que resolve isso parece um DTO — é uma struct de dados simples, sem comportamento — mas a motivação para criá-la é diferente da de Fowler, e vale nomear essa diferença em vez de escondê-la atrás de um termo emprestado sem crédito.
A correção: um Input por caso de uso, sem o campo que não deveria estar lá
// internal/usecase/aprovar_pedido.go
package usecase
import "github.com/allangrds/aprovador-pedidos/internal/domain"
type AprovarPedidoInput struct {
ID string
Total float64
}
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(input AprovarPedidoInput) (*domain.PedidoAprovado, error) {
pedido := domain.Pedido{ID: input.ID, Total: input.Total}
evento := pedido.AprovarAutomaticamente()
if err := uc.repo.Salvar(pedido); err != nil {
return nil, err
}
return evento, nil
}
AprovarPedidoInput não tem campo Status — não porque exista uma regra dizendo "Inputs nunca têm Status", mas porque AprovarPedido não precisa dessa informação vinda de fora; a decisão é dele. Sem esse campo, o bug da seção anterior deixa de ser possível de escrever: não existe uma forma de construir AprovarPedidoInput com um terceiro valor de Status, porque o tipo simplesmente não tem esse campo para preencher. pedido é sempre construído dentro do próprio Executar, começando do valor zero de StatusPedido — StatusPendente, exatamente como a Lição 6 desenhou de propósito.
RejeitarPedido recebe o mesmo tratamento, mas com uma observação importante: Rejeitar() não tem o mesmo bug, porque ela sobrescreve Status incondicionalmente, sem nenhum if no meio:
// internal/domain/pedido.go — para referência
func (p *Pedido) Rejeitar() *PedidoRejeitado {
p.Status = StatusRejeitado
return &PedidoRejeitado{PedidoID: p.ID, Ocorrido: time.Now()}
}
Ainda assim, RejeitarPedido ganha seu próprio Input, por consistência e porque Rejeitar() nem usa Total — o Input reflete exatamente essa necessidade menor:
// internal/usecase/rejeitar_pedido.go
package usecase
type RejeitarPedidoInput struct {
ID string
}
type RejeitarPedido struct {
repo PedidoRepository
}
func NovoRejeitarPedido(r PedidoRepository) *RejeitarPedido {
return &RejeitarPedido{repo: r}
}
func (uc *RejeitarPedido) Executar(input RejeitarPedidoInput) (*domain.PedidoRejeitado, error) {
pedido := domain.Pedido{ID: input.ID}
evento := pedido.Rejeitar()
if err := uc.repo.Salvar(pedido); err != nil {
return nil, err
}
return evento, nil
}
Antes, Executar(domain.Pedido) aceitava um Status vindo de fora — se o Total estivesse acima do limite, esse Status forjado sobrevivia sem correção, porque AprovarAutomaticamente só toca em Status no caminho de aprovação. Depois, Executar(AprovarPedidoInput) recebe só ID e Total; o domain.Pedido de verdade nasce dentro do próprio use case, sempre a partir do valor zero de StatusPedido.
main.go reflete a mudança de assinatura:
// main.go
repo := memoria.NovoPedidoRepository()
aprovarPedido := usecase.NovoAprovarPedido(repo)
evento, err := aprovarPedido.Executar(usecase.AprovarPedidoInput{ID: "1", Total: 750})
if err != nil {
fmt.Println("erro:", err)
return
}
if evento != nil {
fmt.Printf("aprovado: %s\n", evento.PedidoID)
}
Note que main.go não tem mais como construir um domain.Pedido já "decidido" e passá-lo para Executar — a única porta de entrada aceita AprovarPedidoInput, e esse tipo simplesmente não tem onde guardar essa informação.
Onde um DTO de verdade, no sentido de Fowler, já vivia aqui
A Lição 4 já tinha, sem nomear formalmente, um exemplo genuíno do padrão de Fowler — este, sim, motivado exatamente como Fowler descreve: encapsular um mecanismo de serialização, numa fronteira real (o arquivo em disco).
// internal/arquivo/pedido_repository.go — da Lição 4, exercício
type registro struct {
ID string `json:"id"`
Total float64 `json:"total"`
Aprovado bool `json:"aprovado"`
}
registro é um DTO no sentido estrito de Fowler: existe especificamente para atravessar uma fronteira de serialização (texto JSON num arquivo), carrega tags que só fazem sentido para esse formato, e é deliberadamente diferente de domain.Pedido — o mesmo raciocínio que a Lição 4 já explicou na época, sem usar o termo "DTO". Repare na diferença de motivação em relação a AprovarPedidoInput: registro existe por causa de uma fronteira de serialização real (arquivo, formato JSON); AprovarPedidoInput existe por causa de uma fronteira de responsabilidade (o que quem chama pode decidir), sem nenhuma serialização envolvida. Os dois parecem estruturalmente iguais — structs simples, sem comportamento — mas justificam-se por razões diferentes, e essa diferença é exatamente o que esta aula pediu para não borrar.
Onde resistir à tentação: por que ConsultarPedido continua igual
ConsultarPedido.Executar, da Lição 8, continua devolvendo domain.Pedido diretamente:
// internal/usecase/consultar_pedido.go — sem mudança nesta aula
func (uc *ConsultarPedido) Executar(id string) (domain.Pedido, error) {
return uc.finder.Buscar(id)
}
Seria fácil, tendo acabado de aprender sobre DTOs, "corrigir" isso também, envolvendo o retorno num PedidoConsultadoOutput qualquer. Resista a essa tentação, pela mesma razão do aviso de LocalDTO: não existe, hoje, nenhuma fronteira real motivando essa mudança — nenhuma chamada remota, nenhuma serialização, nenhum bug concreto como o desta aula. main.go, o único consumidor de ConsultarPedido até agora, lê o domain.Pedido retornado diretamente, sem problema algum. Quando um consumidor real de fora do processo existir — uma resposta HTTP, na Lição 15 — a decisão de criar um formato de saída específico terá uma necessidade concreta por trás, exatamente como todas as outras peças de infraestrutura desta trilha até agora (Postgres na Lição 11, HTTP na Lição 15). Criar essa estrutura agora seria repetir o erro que o aviso de Fowler nomeou: pagar o custo de mapeamento sem nenhum benefício real por trás.
Prática guiada: aplique o mesmo teste a um caso de uso futuro
Raciocínio antes do código
A parte (1) é a mais importante: o instinto, depois de uma aula inteira sobre Inputs, é aplicar o padrão a tudo. Mas o critério desta aula nunca foi "todo caso de uso precisa de um Input" — foi "existe algo que quem chama poderia forjar, que só a regra de negócio deveria decidir?". Um float64 sozinho, representando "o novo limite", não tem esse problema: não há um campo extra escondido que alguém possa preencher incorretamente. Um Input de um campo só, embrulhando um único float64, adicionaria uma camada sem nenhum ganho de segurança de tipo.
Uma abordagem plausível, mas incorreta: criar um Input por reflexo
// abordagem incorreta — Input sem necessidade real
type AjustarLimiteDeAprovacaoInput struct {
NovoLimite float64
}
func (uc *AjustarLimiteDeAprovacao) Executar(input AjustarLimiteDeAprovacaoInput) error {
// ...
}
Por que falha: não é tecnicamente incorreto — o código funciona — mas repete exatamente o erro que a seção "A mesma fonte, lida com cuidado" preveniu. Não existe nenhum campo adicional em AjustarLimiteDeAprovacaoInput que precisasse ser escondido de quem chama; um único float64 já não permite nenhum estado forjado, porque não há outro campo para carregar uma decisão indevida. Criar essa struct só para "seguir o padrão da aula" é pagar o custo de mapeamento (construir e desembrulhar o Input em todo lugar que chama Executar) sem nenhum ganho — o próprio cenário que o aviso de Fowler descreve como "não vale a pena".
A solução correta: um parâmetro primitivo, sem Input
// solução correta
func (uc *AjustarLimiteDeAprovacao) Executar(novoLimite float64) error {
// ...
}
Por que está correto: ConsultarPedido.Executar(id string), desde a Lição 8, já segue exatamente esse padrão — um parâmetro primitivo, sem Input, porque não há nada ali que precise ser protegido de quem chama. O teste que decide quando um Input é necessário nunca foi "este caso de uso recebe dados", porque todo caso de uso recebe dados; foi "existe um campo que, se controlado por quem chama, permitiria contornar uma decisão que só a regra de negócio deveria tomar" — exatamente o Status de AprovarPedido, e exatamente o que AjustarLimiteDeAprovacao não tem.
Para a parte (2), o teste de regressão:
// internal/usecase/aprovar_pedido_test.go
func TestExecutar_NaoPermiteStatusForjadoPeloChamador(t *testing.T) {
fake := &repositorioFake{salvos: map[string]domain.Pedido{}}
uc := NovoAprovarPedido(fake)
// Antes desta aula, seria possível construir domain.Pedido com um
// Status já preenchido e passá-lo direto para Executar. Depois da
// correção, isso não compila mais — AprovarPedidoInput não tem
// campo Status. Este teste confirma o comportamento correto: um
// pedido acima do limite, sem nenhum Status de entrada possível,
// é salvo como StatusPendente.
_, err := uc.Executar(AprovarPedidoInput{ID: "99", Total: 5000})
if err != nil {
t.Fatalf("erro inesperado: %v", err)
}
if fake.salvos["99"].Status != domain.StatusPendente {
t.Errorf("esperava StatusPendente para pedido acima do limite, obteve %v", fake.salvos["99"].Status)
}
}
Verificação: o comentário dentro do teste documenta a prova mais forte de que o bug foi corrigido — não é apenas que o teste passa, é que uma tentativa de construir AprovarPedidoInput com um valor de Status embutido não compilaria, porque o campo não existe no tipo. go vet ./... e go build ./... confirmam que nenhum código do projeto ainda tenta construir um domain.Pedido com Status pré-definido antes de chamar Executar.
Perguntas de entrevista e de mercado
"O que é um Data Transfer Object, segundo Martin Fowler, e para que problema ele foi originalmente desenhado?" Cite a definição — um objeto que carrega dados entre processos para reduzir o número de chamadas — e a motivação original: chamadas remotas são caras, então cada uma deveria carregar mais dados. Isso demonstra que você entende a origem do padrão, não só o formato (uma struct simples de dados).
"Você aplicaria DTOs em toda chamada de método dentro de um mesmo processo, para manter as camadas desacopladas?" Esta pergunta testa se o candidato aplica padrões por reflexo ou por necessidade. A resposta forte cita o próprio Fowler (LocalDTO): para chamadas locais, sem fronteira remota ou de serialização real, criar DTOs é trabalho de mapeamento sem retorno — "não vale o custo", nas palavras dele. Cite que isso não significa "nunca crie um Input pequeno", mas que a motivação, nesses casos, é outra (proteger uma fronteira de responsabilidade), não a de Fowler.
"Neste curso, qual bug concreto motivou a criação de AprovarPedidoInput, e por que ele não seria resolvido só por documentação ou convenção de equipe ('não preencha o Status ao chamar Executar')?" Descreva o bug: Executar(domain.Pedido) aceitava um Status pré-definido, que sobrevivia sem correção quando o pedido excedia o limite de aprovação automática. Documentação e convenção dependem de todo desenvolvedor lembrar e seguir a regra, para sempre — um novo membro do time, ou um momento de pressa, quebra isso silenciosamente. Remover o campo do tipo torna o erro impossível de escrever, não apenas desencorajado.
"Qual é a diferença entre o Input desta aula e um DTO no sentido clássico de Fowler, mesmo os dois parecendo, estruturalmente, a mesma coisa (uma struct simples de dados)?" A diferença está na motivação, não na forma. Um DTO de Fowler existe para atravessar uma fronteira remota ou de serialização, reduzindo chamadas ou encapsulando formato — como arquivo.registro, da Lição 4. Um Input como AprovarPedidoInput existe para restringir o que quem chama pode decidir, numa chamada local, sem nenhuma fronteira remota envolvida — motivado pela mesma lógica de "tornar estados impossíveis irrepresentáveis" da Lição 6, aplicada a um parâmetro de função em vez de um campo de struct.
Recupere da memória: 10 questões
Recupere da memória: 10 questões
Antes desta aula, o que acontecia com o campo Status de um domain.Pedido passado para AprovarPedido.Executar, quando o Total excedia o limite de aprovação automática?
Segundo a definição de Martin Fowler citada nesta aula, o que um Data Transfer Object é, e para que finalidade original ele foi desenhado?
Segundo o artigo LocalDTO de Fowler, citado nesta aula, o que ele recomenda para chamadas locais (dentro do mesmo processo, sem rede)?
Se a motivação de Fowler (chamadas remotas caras) não se aplica a AprovarPedido.Executar, por que esta aula ainda cria AprovarPedidoInput?
Depois da correção desta aula, como AprovarPedido.Executar constrói o domain.Pedido que será salvo?
Por que RejeitarPedidoInput tem só o campo ID, sem Total, diferente de AprovarPedidoInput?
Qual struct deste projeto é um exemplo genuíno de DTO no sentido estrito de Fowler (motivado por uma fronteira real de serialização), segundo esta aula?
Por que ConsultarPedido.Executar continua devolvendo domain.Pedido diretamente, sem um DTO de saída, nesta aula?
No exercício desta aula, por que AjustarLimiteDeAprovacao(novoLimite float64) não precisa de um Input dedicado, mesmo depois de toda a aula ensinar sobre Inputs?
No teste de regressão do exercício (TestExecutar_NaoPermiteStatusForjadoPeloChamador), qual é a prova mais forte de que o bug foi corrigido?
Feche o modelo mental
AprovarPedido.Executar(domain.Pedido) tinha um bug real: quem chama podia forjar um Status que sobrevivia intacto sempre que o pedido excedia o limite de aprovação automática, porque AprovarAutomaticamente só corrige Status no caminho de sucesso. A definição de DTO de Martin Fowler parece resolver isso — uma struct pequena, só com os dados necessários — mas o próprio Fowler avisa, em LocalDTO, que criar essas estruturas sem uma fronteira remota real por trás é trabalho desperdiçado. A correção desta aula não se apoia na motivação de Fowler: apoia-se na mesma lógica da Lição 6, "tornar estados impossíveis irrepresentáveis", aplicada agora a um parâmetro de função. AprovarPedidoInput e RejeitarPedidoInput carregam só o que cada caso de uso precisa, sem espaço para uma decisão forjada. O DTO genuíno de Fowler já existia no projeto — arquivo.registro, desde a Lição 4 — motivado por uma fronteira de serialização real, não por convenção. E ConsultarPedido continua sem DTO de saída, porque não há, ainda, nenhuma necessidade concreta que justifique um.
Próxima aula planejada: Input Ports — a nomenclatura oficial de Uncle Bob (Interactor, Input Boundary, Output Boundary) comparada, termo a termo, com os nomes que este projeto já usa.
Referências primárias para aprofundar
A definição central desta aula é de Data Transfer Object, Martin Fowler, lida junto com o aviso do mesmo autor em LocalDTO, Martin Fowler (bliki) — uma fonte primária usada aqui tanto para justificar quanto para limitar o escopo da correção. O princípio de fundo, "tornar estados impossíveis irrepresentáveis", vem de Designing with types, Scott Wlaschin, já citado na Lição 6.
Consulte também o glossário desta trilha.