Aula 11 · 60–80 minutos

Repositório real com banco de dados

Aula 11 — memoria.PedidoRepository se aposenta; postgres.PedidoRepository entra em cena com pgx, ON CONFLICT DO UPDATE, e uma armadilha real do Go idiomático que violaria a Regra de Dependência se não fosse evitada de propósito.

Resultado e mapa da aula

Desde a Lição 4, memoria.PedidoRepository foi explicitamente chamada de placeholder: "em produção, PedidoRepository seria implementada por algo que fala com Postgres — isso chega na Lição 11". Chegou. Esta aula aposenta a implementação em memória, cria postgres.PedidoRepository com um banco de dados de verdade, e usa uma decisão de design real — onde converter StatusPedido para texto — para mostrar que até um idioma padrão de Go pode violar a Regra de Dependência se aplicado sem pensar.

  1. O problema: memoria.PedidoRepository perde tudo a cada reinício do programa — inaceitável para qualquer sistema real.
  2. Subindo um Postgres local, seguindo a restrição de escopo desta trilha (Docker, sem Dockerfile nem compose).
  3. Fonte primária: pgx, o driver e toolkit Postgres mais usado do ecossistema Go — e por que ele, não database/sql puro.
  4. Uma abordagem plausível, mas incorreta: fazer domain.StatusPedido implementar sql.Scanner/driver.Valuer — um idioma real de Go que, aqui, vazaria uma dependência de banco de dados para dentro do domínio.
  5. A correção: a conversão fica dentro do adapter, usando String() (já existente) e um novo ParseStatusPedido.
  6. postgres.PedidoRepository: Salvar com ON CONFLICT DO UPDATE, Buscar traduzindo pgx.ErrNoRows para o sentinela já conhecido.
  7. Trocando a peça em main.go — duas linhas — e por que os testes de AprovarPedido não mudam nem uma linha.
  8. O que ainda falta, de propósito: context.Background() como placeholder, até a Lição 26 explicar o que context.Context realmente permite fazer.

O problema: um repositório que esquece tudo

memoria.PedidoRepository guarda pedidos num map[string]domain.Pedido — uma variável comum, na memória do processo. Assim que o programa termina (ou trava, ou é reiniciado para uma atualização), esse mapa desaparece por completo. Isso nunca foi um segredo: era exatamente o ponto da Lição 4, uma implementação real o suficiente para provar que a arquitetura funciona, mas claramente inadequada para qualquer uso além de aprendizado. Um sistema de aprovação de pedidos que esquece todas as decisões a cada reinício não serve para nada em produção — é hora de trocar por uma implementação que sobrevive ao processo que a criou.

Subindo um Postgres local

docker run --name aprovador-pedidos-db \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=aprovador_pedidos \
  -p 5432:5432 \
  -d postgres:16

Isso sobe um Postgres 16 local, escutando na porta padrão, com um banco chamado aprovador_pedidos já criado. A string de conexão que o projeto vai usar:

postgres://postgres:postgres@localhost:5432/aprovador_pedidos

Com o banco no ar, falta a tabela. Sem uma ferramenta de migração formal (fora de escopo por enquanto — o projeto ainda é pequeno o suficiente para isso ser aceitável), rode o SQL diretamente:

CREATE TABLE pedidos (
    id     TEXT PRIMARY KEY,
    total  NUMERIC NOT NULL,
    status TEXT NOT NULL
);

Repare na escolha de status TEXT, não status INTEGER — essa escolha é o assunto central desta aula, explicado a seguir.

Fonte primária: pgx, o driver e toolkit mais usado do ecossistema Go

Instale com go get github.com/jackc/pgx/v5, e conecte um pool de conexões — o padrão recomendado para uma aplicação que pode atender múltiplas requisições ao mesmo tempo:

// main.go
pool, err := pgxpool.New(context.Background(), "postgres://postgres:postgres@localhost:5432/aprovador_pedidos")
if err != nil {
    fmt.Println("erro ao conectar ao banco:", err)
    return
}
defer pool.Close()

Uma abordagem plausível, mas incorreta: Scanner e Valuer dentro de domain

Go tem um idioma real e bem estabelecido para converter tipos customizados de e para colunas de banco de dados: implementar as interfaces sql.Scanner (ler do banco) e driver.Valuer (escrever no banco), ambas da biblioteca padrão. Um desenvolvedor Go experiente, ao ver StatusPedido — um tipo customizado sobre int, desde a Lição 6 — pensaria imediatamente nesse idioma:

// internal/domain/pedido.go — abordagem incorreta
package domain

import (
    "database/sql/driver"
    "fmt"
)

func (s *StatusPedido) Scan(value any) error {
    str, ok := value.(string)
    if !ok {
        return fmt.Errorf("tipo inesperado para StatusPedido: %T", value)
    }
    parsed, err := ParseStatusPedido(str)
    if err != nil {
        return err
    }
    *s = parsed
    return nil
}

func (s StatusPedido) Value() (driver.Value, error) {
    return s.String(), nil
}

Isso não é um código malfeito — é exatamente como a documentação da biblioteca padrão e inúmeros tutoriais de Go ensinam a fazer essa conversão. pgx inclusive suporta essas duas interfaces nativamente, tornando essa abordagem tecnicamente funcional.

Por que falha, especificamente neste projeto: repare no import "database/sql/driver" no topo do arquivo. domain/pedido.go, desde a Lição 4, nunca importou nada além da biblioteca padrão de uso geral (time, fmt, errors) — nunca um pacote que existe especificamente para lidar com bancos de dados. database/sql/driver é exatamente isso: um pacote cuja única razão de existir é definir interfaces para drivers de banco de dados. Importá-lo dentro de domain significa que o pacote mais interno do projeto — aquele que a Lição 1 descreveu como não devendo "saber nada sobre banco de dados, HTTP, ou qualquer framework" — passa a depender, mesmo que levemente, de um conceito de infraestrutura. Se amanhã o projeto trocasse Postgres por outro banco com um mecanismo de tipos completamente diferente (ou nenhum banco relacional, um armazenamento de documentos, por exemplo), esse import dentro de domain teria que ser revisitado — exatamente o tipo de acoplamento que a Regra de Dependência existe para evitar.

A correção: a conversão vive dentro do adapter

A correção não exige nenhuma interface nova de database/sql. StatusPedido já tem String(), desde a Lição 6, para a direção Go → texto. Falta só a direção inversa, texto → Go — uma função pura, sem nenhum import de banco de dados, porque interpretar um texto canônico ("aprovado", "pendente", "rejeitado") é lógica de domínio, não uma preocupação de infraestrutura:

// internal/domain/pedido.go — nova função, sem nenhum import novo
func ParseStatusPedido(s string) (StatusPedido, error) {
    switch s {
    case "pendente":
        return StatusPendente, nil
    case "aprovado":
        return StatusAprovado, nil
    case "rejeitado":
        return StatusRejeitado, nil
    default:
        return StatusPedido(0), fmt.Errorf("status de pedido desconhecido: %q", s)
    }
}
Onde converter StatusPedido para texto de banco de dadosIncorreto: domain.StatusPedido implementa sql.Scanner e driver.Valuer, importando database/sql/driver dentro do pacote domain. Correto: a conversão para texto acontece dentro do pacote postgres, usando String() e ParseStatusPedido, que ja existem ou sao puramente logica de dominio, sem nenhum import de banco de dados dentro de domain.Incorretodomain.StatusPedidoimplementa Scan() e Value()import database/sql/driver aqui dentrodomain passa a saber quebancos de dados existemCorretopostgres.PedidoRepositorySalvar: status.String()Buscar: domain.ParseStatusPedido(texto)domain.StatusPedidosem nenhum import de banco de dadosString() já existia desde a Lição 6; ParseStatusPedido é lógica dedomínio pura (interpretar um texto canônico) — nenhuma das duasfunções sabe que Postgres, ou qualquer banco, existe.

Incorreto: domain.StatusPedido implementa Scan/Value, importando database/sql/driver — domain passa a saber que bancos de dados existem. Correto: a conversão acontece dentro de postgres, chamando String() (já existente) e ParseStatusPedido (lógica de domínio pura) — nenhuma das duas funções sabe que Postgres, ou qualquer banco, existe.

Repare que ParseStatusPedido retorna um erro para qualquer string que não seja uma das três reconhecidas — a mesma disciplina de "erros são valores" já usada desde a Lição 8: se um dia a coluna status tiver um valor corrompido ou inesperado, o adapter recebe um erro explícito, não um StatusPendente silenciosamente errado.

postgres.PedidoRepository: Salvar e Buscar de verdade

// internal/postgres/pedido_repository.go
package postgres

import (
    "context"
    "errors"
    "fmt"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"

    "github.com/allangrds/aprovador-pedidos/internal/domain"
)

var ErrPedidoNaoEncontrado = errors.New("pedido não encontrado")

type PedidoRepository struct {
    pool *pgxpool.Pool
}

func NovoPedidoRepository(pool *pgxpool.Pool) *PedidoRepository {
    return &PedidoRepository{pool: pool}
}

func (r *PedidoRepository) Salvar(p domain.Pedido) error {
    const sql = `
        INSERT INTO pedidos (id, total, status)
        VALUES ($1, $2, $3)
        ON CONFLICT (id) DO UPDATE SET total = EXCLUDED.total, status = EXCLUDED.status
    `
    _, err := r.pool.Exec(context.Background(), sql, p.ID, p.Total, p.Status.String())
    if err != nil {
        return fmt.Errorf("salvar pedido %s: %w", p.ID, err)
    }
    return nil
}

func (r *PedidoRepository) Buscar(id string) (domain.Pedido, error) {
    const sql = `SELECT id, total, status FROM pedidos WHERE id = $1`

    var (
        pedidoID string
        total    float64
        status   string
    )
    err := r.pool.QueryRow(context.Background(), sql, id).Scan(&pedidoID, &total, &status)
    if errors.Is(err, pgx.ErrNoRows) {
        return domain.Pedido{}, ErrPedidoNaoEncontrado
    }
    if err != nil {
        return domain.Pedido{}, fmt.Errorf("buscar pedido %s: %w", id, err)
    }

    statusPedido, err := domain.ParseStatusPedido(status)
    if err != nil {
        return domain.Pedido{}, fmt.Errorf("status inválido para pedido %s: %w", id, err)
    }
    return domain.Pedido{ID: pedidoID, Total: total, Status: statusPedido}, nil
}

Três decisões merecem atenção explícita:

ON CONFLICT (id) DO UPDATE — uma cláusula padrão de SQL do Postgres (não específica de pgx), conhecida como upsert: insere uma linha nova se o id não existir, ou atualiza total e status da linha existente se existir. Isso é necessário porque Salvar é chamado tanto para criar um pedido quanto, potencialmente, para atualizá-lo — o mesmo método, para os dois casos, sem Salvar precisar saber qual dos dois está acontecendo.

errors.Is(err, pgx.ErrNoRows) — pgx.ErrNoRows é o valor sentinela que pgx retorna quando uma consulta que esperava uma linha não encontra nenhuma. O adapter o traduz para ErrPedidoNaoEncontrado — o mesmo nome e o mesmo padrão de sentinela já introduzidos em memoria.PedidoRepository, na Lição 8. Isso importa: usecase.ConsultarPedido não precisa saber que pgx.ErrNoRows existe — ele só conhece PedidoFinder.Buscar, e o contrato de erro que essa interface implica já era conhecido antes de qualquer banco de dados real existir.

fmt.Errorf(..., %w, err) — cada erro é envolvido (wrapped) com contexto adicional (qual operação, qual ID), preservando o erro original para quem quiser inspecioná-lo com errors.Is ou errors.Unwrap. Isso segue a mesma convenção já usada em internal/arquivo, no exercício da Lição 4.

Trocando a peça em main.go

// main.go — antes
repo := memoria.NovoPedidoRepository()

// main.go — depois
pool, err := pgxpool.New(context.Background(), "postgres://postgres:postgres@localhost:5432/aprovador_pedidos")
if err != nil {
    fmt.Println("erro ao conectar ao banco:", err)
    return
}
defer pool.Close()
repo := postgres.NovoPedidoRepository(pool)

aprovarPedido := usecase.NovoAprovarPedido(repo), rejeitarPedido := usecase.NovoRejeitarPedido(repo) e consultarPedido := usecase.NovoConsultarPedido(repo) não mudam nenhuma linha — postgres.PedidoRepository satisfaz usecase.PedidoRepository e usecase.PedidoFinder exatamente como memoria.PedidoRepository satisfazia, pela mesma satisfação implícita da Lição 2. Essa é a promessa feita desde a Lição 4 — "se memoria virar postgres.PedidoRepository(), isso muda uma linha em main.go e nada mais" — cumprida, palavra por palavra.

O que não muda: os testes de AprovarPedido

Talvez a parte mais importante desta aula não seja nenhum código novo — é o que não precisou mudar. AprovarPedido.Executar e seus testes, escritos na Lição 4 e ajustados na Lição 9, continuam exatamente como estavam:

// internal/usecase/aprovar_pedido_test.go — sem nenhuma mudança nesta aula
func TestExecutar_AprovaPedidoDentroDoLimite(t *testing.T) {
    fake := &repositorioFake{salvos: map[string]domain.Pedido{}}
    uc := NovoAprovarPedido(fake)

    _, err := uc.Executar(AprovarPedidoInput{ID: "1", Total: 500})
    // ...
}

Nenhum teste de usecase precisou de um Postgres real rodando, nenhum teste ficou mais lento, nenhuma linha desses arquivos foi tocada — apesar de a implementação de produção ter trocado de "um mapa em memória" para "um banco de dados relacional real, com rede e SQL envolvidos". Isso não é coincidência: é o benefício concreto, mensurável, que a Lição 1 prometeu de forma abstrata, e que esta trilha vem provando lição após lição — regras de negócio que não dependem de infraestrutura para serem testadas continuam não dependendo, mesmo quando a infraestrutura real muda por completo.

Prática guiada: uma segunda coluna, a mesma decisão de novo

Raciocínio antes do código

A parte (1) testa se o critério desta aula ficou claro: o problema não era "ter um tipo customizado", era "esse tipo, ou algo relacionado a ele, precisar de um import específico de banco de dados". time.Time é a biblioteca padrão de uso geral (a mesma categoria de fmt e errors, já aceita dentro de domain desde a Lição 7), não um pacote de driver de banco — adicioná-lo a domain.Pedido não repete o problema desta aula.

A resposta

domain.Pedido ganha o campo sem nenhuma restrição nova:

// internal/domain/pedido.go
type Pedido struct {
    ID        string
    Total     float64
    Status    StatusPedido
    CriadoEm  time.Time
}
ALTER TABLE pedidos ADD COLUMN criado_em TIMESTAMPTZ NOT NULL DEFAULT now();
// internal/postgres/pedido_repository.go — Buscar atualizado
const sql = `SELECT id, total, status, criado_em FROM pedidos WHERE id = $1`
var (
    pedidoID string
    total    float64
    status   string
    criadoEm time.Time
)
err := r.pool.QueryRow(context.Background(), sql, id).Scan(&pedidoID, &total, &status, &criadoEm)

Para a parte (3): pgx já sabe converter TIMESTAMPTZ do Postgres para time.Time do Go nativamente, escaneando direto para a variável — não é uma exceção nem um caso especial, é o comportamento padrão de pgx para tipos de data/hora, documentado como suporte de primeira classe do driver. StatusPedido precisou de ParseStatusPedido porque é um tipo inventado por este projeto (um int com significado especial, sobre um conjunto fechado de valores) — pgx não tem como adivinhar que o texto "aprovado" deveria virar o valor 1 de um tipo que só existe dentro de domain. time.Time, ao contrário, é um tipo da própria biblioteca padrão que pgx já entende universalmente, sem nenhuma tradução manual.

Verificação: depois de aplicar a migração SQL, escreva um teste manual chamando Salvar seguido de Buscar com o mesmo ID, confirmando que CriadoEm retornado não é o valor zero de time.Time — a prova de que a coluna está sendo gravada e lida corretamente, sem precisar de nenhuma conversão adicional no código Go.

Perguntas de entrevista e de mercado

"Por que este projeto usa pgx em vez de database/sql com um driver Postgres genérico?" Cite a razão prática: database/sql foi desenhado para abstrair diferenças entre bancos relacionais, permitindo trocar de banco sem trocar muito código — um objetivo válido, mas que custa acesso a funcionalidades específicas do Postgres. pgx abre mão dessa portabilidade em troca de uma API mais direta e específica para Postgres, e é o driver mais usado e mantido no ecossistema Go atual para esse banco especificamente.

"Você implementaria sql.Scanner e driver.Valuer diretamente no seu tipo de domínio, num projeto seguindo Clean Architecture?" Uma resposta forte não rejeita a técnica em si — reconhece que ela é um idioma legítimo e amplamente usado, mas explica por que aplicá-la diretamente num tipo de domain importaria um pacote específico de banco de dados (database/sql/driver) para a camada que deveria ser a mais isolada de detalhes técnicos. A alternativa correta é manter a conversão dentro do adapter, usando funções de domínio que não sabem nada sobre bancos de dados (String(), ParseStatusPedido).

"O que aconteceria com os testes de AprovarPedido se o Postgres estivesse fora do ar no momento em que os testes rodassem?" Nada — e essa é a resposta que demonstra entendimento real da arquitetura. AprovarPedido.Executar é testado com repositorioFake, uma struct em memória, sem nenhuma dependência de rede ou banco de dados. Os testes de unidade do use case não sabem, e nunca souberam, se a implementação de produção é memoria, postgres, ou qualquer outra coisa.

"A coluna total é NUMERIC no Postgres, mas domain.Pedido.Total é float64 em Go. Isso é seguro para valores monetários?" Esta pergunta testa se o candidato reconhece um problema real, mesmo fora do escopo imediato da aula. A resposta honesta: não completamente. NUMERIC no Postgres tem precisão decimal exata, mas float64 em Go é ponto flutuante binário — a conversão na fronteira entre os dois pode, em casos extremos, perder precisão (o clássico problema de 0.1 + 0.2 não ser exatamente 0.3 em ponto flutuante). Este projeto herdou float64 para Total desde a Lição 4, antes de qualquer banco de dados existir, e esta aula não revisita essa escolha — mas um sistema de aprovação de pedidos real, em produção, tipicamente evitaria float64 para valores monetários, preferindo inteiros representando centavos (int64) ou um tipo decimal dedicado. Reconhecer essa limitação, mesmo sem corrigi-la agora, é mais forte numa entrevista do que fingir que não existe.

"Por que ON CONFLICT DO UPDATE, em vez de dois métodos separados, Inserir e Atualizar?" Explique a decisão de design: Salvar, na interface PedidoRepository, foi definida desde a Lição 4 como uma única operação — o use case decide o Pedido completo (com o Status já resolvido) e pede para persisti-lo, sem precisar saber se essa é a primeira vez que aquele ID é salvo. Separar em Inserir/Atualizar obrigaria cada use case a rastrear esse estado, uma responsabilidade que não pertence a ele. ON CONFLICT DO UPDATE resolve isso inteiramente do lado do banco de dados, mantendo a interface simples do jeito que sempre foi.

Recupere da memória: 10 questões

Recupere da memória: 10 questões

Por que memoria.PedidoRepository precisou ser substituída nesta aula?

Qual biblioteca este projeto usa para se conectar ao Postgres, e por quê?

Por que domain.StatusPedido implementar sql.Scanner e driver.Valuer diretamente seria um problema, mesmo sendo um idioma real de Go?

Onde, exatamente, a conversão entre StatusPedido e texto de banco de dados acontece, na correção desta aula?

Por que ParseStatusPedido retorna um erro para strings desconhecidas, em vez de simplesmente devolver StatusPendente como padrão?

O que ON CONFLICT (id) DO UPDATE faz, no método Salvar de postgres.PedidoRepository?

Como postgres.PedidoRepository.Buscar traduz o caso de um pedido não encontrado?

Depois de trocar memoria por postgres em main.go, quantas linhas dos testes de AprovarPedido.Executar precisaram mudar?

O que aconteceu com o pacote internal/memoria depois desta aula?

Por que este projeto usa context.Background() em toda chamada a pgx, em vez de um contexto mais elaborado?

Feche o modelo mental

memoria.PedidoRepository cumpriu seu papel desde a Lição 4 — provar que a arquitetura funciona — e se aposenta agora que existe uma implementação real. postgres.PedidoRepository, usando pgx, satisfaz as mesmas duas interfaces pequenas (PedidoRepository, PedidoFinder) que memoria satisfazia, então trocar uma pela outra em main.go custa duas linhas, exatamente como prometido desde a Lição 4. O momento mais importante desta aula não foi escrever SQL — foi resistir à tentação de implementar sql.Scanner/driver.Valuer diretamente em domain.StatusPedido, um idioma real de Go que, aplicado ali, importaria um conceito de banco de dados para a camada mais interna do projeto. A correção manteve a conversão dentro do adapter, usando só funções de domínio puras. E o resultado mais concreto de tudo isso: os testes de AprovarPedido, escritos há sete aulas, não mudaram uma linha sequer.

Próxima aula planejada: Transações — duas escritas (o Pedido e um registro em pedido_eventos) que precisam acontecer como uma só, ou nenhuma.

Referências primárias para aprofundar

O driver central desta aula é pgx, documentação oficial (pkg.go.dev) e o repositório jackc/pgx no GitHub, já listados como recurso desde o início desta trilha. O contraste com database/sql está documentado em database/sql — documentação oficial, e o padrão de organização de acesso a banco de dados segue Organising Database Access in Go, Alex Edwards, ambos já registrados em RESOURCES.md.

Consulte também o glossário desta trilha.