Um projeto que começa com vinte arquivos e cresce para duzentos sem uma organização deliberada torna-se progressivamente mais difícil de modificar. Mudanças em uma parte quebram outra parte inesperada. Testes exigem o banco de dados inteiro rodando. Trocar a biblioteca HTTP exige modificar metade do código.
Clean Architecture — popularizada por Robert C. Martin — resolve esses problemas com um princípio central: as dependências de código só podem apontar para dentro. Camadas internas não conhecem camadas externas. O domínio do negócio não sabe que existe um banco de dados, um servidor HTTP ou um arquivo de configuração.
As camadas
┌─────────────────────────────────────┐
│ Frameworks & Drivers │ HTTP, banco de dados, CLI
├─────────────────────────────────────┤
│ Interface Adapters │ Handlers, repositórios, presenters
├─────────────────────────────────────┤
│ Use Cases │ Regras de negócio da aplicação
├─────────────────────────────────────┤
│ Entities │ Regras de negócio do domínio
└─────────────────────────────────────┘
↑ dependências apontam para dentro
Em Go, essa estrutura se traduz em pacotes:
loja/
├── cmd/
│ └── api/
│ └── main.go ← ponto de entrada
├── internal/
│ ├── domain/ ← entidades e regras de domínio
│ │ ├── produto.go
│ │ ├── pedido.go
│ │ └── usuario.go
│ ├── usecase/ ← casos de uso
│ │ ├── produto/
│ │ │ ├── criar.go
│ │ │ ├── buscar.go
│ │ │ └── listar.go
│ │ └── pedido/
│ │ ├── criar.go
│ │ └── processar.go
│ ├── repository/ ← interfaces de repositório
│ │ ├── produto.go
│ │ └── pedido.go
│ ├── handler/ ← adaptadores HTTP
│ │ ├── produto.go
│ │ └── pedido.go
│ └── infra/ ← implementações de infraestrutura
│ ├── postgres/
│ │ ├── produto_repo.go
│ │ └── pedido_repo.go
│ └── redis/
│ └── cache.go
└── pkg/
└── validator/
Camada de domínio: entidades e regras
A camada de domínio não importa nada de fora do projeto. Ela define tipos, erros e regras de negócio puras:
// internal/domain/produto.go
package domain
import (
"errors"
"time"
)
// Erros de domínio — regras de negócio violadas
var (
ErrNomeProdutoVazio = errors.New("nome do produto não pode ser vazio")
ErrPrecoProdutoInvalido = errors.New("preço do produto deve ser maior que zero")
ErrEstoqueNegativo = errors.New("estoque não pode ser negativo")
ErrEstoqueInsuficiente = errors.New("estoque insuficiente para esta operação")
ErrProdutoNaoEncontrado = errors.New("produto não encontrado")
)
// Produto representa um produto no catálogo da loja.
type Produto struct {
id int64
nome string
descricao string
preco float64
estoque int
categoriaID int64
ativo bool
criadoEm time.Time
}
// NovoProduto cria um Produto validado.
// Retorna erro se qualquer regra de negócio for violada.
func NovoProduto(nome, descricao string, preco float64, categoriaID int64) (*Produto, error) {
if nome == "" {
return nil, ErrNomeProdutoVazio
}
if preco <= 0 {
return nil, ErrPrecoProdutoInvalido
}
return &Produto{
nome: nome,
descricao: descricao,
preco: preco,
categoriaID: categoriaID,
ativo: true,
criadoEm: time.Now().UTC(),
}, nil
}
// ReconstituirProduto recria um Produto a partir de dados já persistidos.
// Não repete as validações de NovoProduto: os dados foram validados ao entrar.
// É usado pelas implementações de repositório, e não pelos casos de uso.
func ReconstituirProduto(id int64, nome, descricao string, preco float64,
estoque int, categoriaID int64, ativo bool, criadoEm time.Time) *Produto {
return &Produto{
id: id,
nome: nome,
descricao: descricao,
preco: preco,
estoque: estoque,
categoriaID: categoriaID,
ativo: ativo,
criadoEm: criadoEm,
}
}
// DefinirID atribui o ID gerado pela persistência. Só tem efeito enquanto
// o produto ainda não tem ID, para que ninguém o troque depois.
func (p *Produto) DefinirID(id int64) {
if p.id == 0 {
p.id = id
}
}
// Getters — acesso controlado ao estado interno
func (p *Produto) ID() int64 { return p.id }
func (p *Produto) Nome() string { return p.nome }
func (p *Produto) Descricao() string { return p.descricao }
func (p *Produto) Preco() float64 { return p.preco }
func (p *Produto) Estoque() int { return p.estoque }
func (p *Produto) CategoriaID() int64 { return p.categoriaID }
func (p *Produto) Ativo() bool { return p.ativo }
func (p *Produto) CriadoEm() time.Time { return p.criadoEm }
// Regras de negócio como métodos do domínio
// AtualizarPreco altera o preço do produto.
// O novo preço deve ser maior que zero.
func (p *Produto) AtualizarPreco(novoPreco float64) error {
if novoPreco <= 0 {
return ErrPrecoProdutoInvalido
}
p.preco = novoPreco
return nil
}
// AdicionarEstoque incrementa o estoque do produto.
func (p *Produto) AdicionarEstoque(quantidade int) error {
if quantidade <= 0 {
return errors.New("quantidade deve ser maior que zero")
}
p.estoque += quantidade
return nil
}
// RetirarEstoque decrementa o estoque.
// Retorna ErrEstoqueInsuficiente se não houver estoque suficiente.
func (p *Produto) RetirarEstoque(quantidade int) error {
if quantidade <= 0 {
return errors.New("quantidade deve ser maior que zero")
}
if p.estoque < quantidade {
return ErrEstoqueInsuficiente
}
p.estoque -= quantidade
return nil
}
// Desativar marca o produto como inativo.
func (p *Produto) Desativar() {
p.ativo = false
}
// internal/domain/pedido.go
package domain
import (
"errors"
"fmt"
"time"
)
var (
ErrPedidoVazio = errors.New("pedido não pode ter zero itens")
ErrStatusInvalido = errors.New("transição de status inválida")
ErrPedidoJaFinalizado = errors.New("pedido já foi finalizado")
)
type StatusPedido string
const (
StatusPendente StatusPedido = "pendente"
StatusConfirmado StatusPedido = "confirmado"
StatusEnviado StatusPedido = "enviado"
StatusEntregue StatusPedido = "entregue"
StatusCancelado StatusPedido = "cancelado"
)
type ItemPedido struct {
ProdutoID int64
Nome string
Quantidade int
PrecoUnit float64
}
func (i ItemPedido) Subtotal() float64 {
return float64(i.Quantidade) * i.PrecoUnit
}
type Pedido struct {
id int64
usuarioID int64
itens []ItemPedido
status StatusPedido
total float64
criadoEm time.Time
}
func NovoPedido(usuarioID int64, itens []ItemPedido) (*Pedido, error) {
if len(itens) == 0 {
return nil, ErrPedidoVazio
}
total := 0.0
for _, item := range itens {
total += item.Subtotal()
}
return &Pedido{
usuarioID: usuarioID,
itens: itens,
status: StatusPendente,
total: total,
criadoEm: time.Now().UTC(),
}, nil
}
func (p *Pedido) ID() int64 { return p.id }
func (p *Pedido) UsuarioID() int64 { return p.usuarioID }
func (p *Pedido) Itens() []ItemPedido { return p.itens }
func (p *Pedido) Status() StatusPedido { return p.status }
func (p *Pedido) Total() float64 { return p.total }
func (p *Pedido) CriadoEm() time.Time { return p.criadoEm }
// Confirmar transiciona o pedido de Pendente para Confirmado.
func (p *Pedido) Confirmar() error {
if p.status != StatusPendente {
return fmt.Errorf("%w: %s → confirmado", ErrStatusInvalido, p.status)
}
p.status = StatusConfirmado
return nil
}
// Cancelar cancela o pedido se ainda não foi enviado.
func (p *Pedido) Cancelar() error {
if p.status == StatusEnviado || p.status == StatusEntregue {
return fmt.Errorf("%w: não é possível cancelar pedido %s", ErrPedidoJaFinalizado, p.status)
}
p.status = StatusCancelado
return nil
}
Interfaces de repositório: contratos sem implementação
// internal/repository/produto.go
package repository
import (
"context"
"loja/internal/domain"
)
// ProdutoRepository define o contrato para persistência de produtos.
// Implementações concretas ficam em internal/infra.
type ProdutoRepository interface {
Criar(ctx context.Context, produto *domain.Produto) error
BuscarPorID(ctx context.Context, id int64) (*domain.Produto, error)
Listar(ctx context.Context, filtro ProdutoFiltro) ([]*domain.Produto, error)
Atualizar(ctx context.Context, produto *domain.Produto) error
Deletar(ctx context.Context, id int64) error
}
type ProdutoFiltro struct {
CategoriaID *int64
ApenasAtivo bool
Limite int
Offset int
}
// PedidoRepository define o contrato para persistência de pedidos.
type PedidoRepository interface {
Criar(ctx context.Context, pedido *domain.Pedido) error
BuscarPorID(ctx context.Context, id int64) (*domain.Pedido, error)
ListarPorUsuario(ctx context.Context, usuarioID int64) ([]*domain.Pedido, error)
Atualizar(ctx context.Context, pedido *domain.Pedido) error
}
Casos de uso: orquestrando o domínio
// internal/usecase/produto/criar.go
package produto
import (
"context"
"fmt"
"time"
"loja/internal/domain"
"loja/internal/repository"
)
type CriarProdutoInput struct {
Nome string
Descricao string
Preco float64
CategoriaID int64
}
type CriarProdutoOutput struct {
ID int64
Nome string
Preco float64
CriadoEm string
}
type CriarProdutoUseCase struct {
repo repository.ProdutoRepository
}
func NovoCriarProdutoUseCase(repo repository.ProdutoRepository) *CriarProdutoUseCase {
return &CriarProdutoUseCase{repo: repo}
}
func (uc *CriarProdutoUseCase) Executar(ctx context.Context, input CriarProdutoInput) (*CriarProdutoOutput, error) {
// 1. Cria entidade de domínio — valida regras de negócio
produto, err := domain.NovoProduto(
input.Nome,
input.Descricao,
input.Preco,
input.CategoriaID,
)
if err != nil {
return nil, fmt.Errorf("criar produto: %w", err)
}
// 2. Persiste através do repositório
if err := uc.repo.Criar(ctx, produto); err != nil {
return nil, fmt.Errorf("persistir produto: %w", err)
}
// 3. Retorna output
return &CriarProdutoOutput{
ID: produto.ID(),
Nome: produto.Nome(),
Preco: produto.Preco(),
CriadoEm: produto.CriadoEm().Format(time.RFC3339),
}, nil
}
As consultas seguem o mesmo formato. Buscar devolve o erro do repositório sem trocá-lo, para que o handler reconheça domain.ErrProdutoNaoEncontrado e responda 404:
// internal/usecase/produto/buscar.go
package produto
import (
"context"
"time"
"loja/internal/domain"
"loja/internal/repository"
)
// ProdutoOutput é a visão de um produto devolvida pelas consultas.
type ProdutoOutput struct {
ID int64
Nome string
Preco float64
Estoque int
CriadoEm string
}
func paraOutput(p *domain.Produto) ProdutoOutput {
return ProdutoOutput{
ID: p.ID(),
Nome: p.Nome(),
Preco: p.Preco(),
Estoque: p.Estoque(),
CriadoEm: p.CriadoEm().Format(time.RFC3339),
}
}
type BuscarProdutoUseCase struct {
repo repository.ProdutoRepository
}
func NovoBuscarProdutoUseCase(repo repository.ProdutoRepository) *BuscarProdutoUseCase {
return &BuscarProdutoUseCase{repo: repo}
}
// Executar devolve o erro do repositório sem trocá-lo: o handler precisa
// reconhecer domain.ErrProdutoNaoEncontrado com errors.Is para responder 404.
func (uc *BuscarProdutoUseCase) Executar(ctx context.Context, id int64) (*ProdutoOutput, error) {
p, err := uc.repo.BuscarPorID(ctx, id)
if err != nil {
return nil, err
}
out := paraOutput(p)
return &out, nil
}
// internal/usecase/produto/listar.go
package produto
import (
"context"
"loja/internal/repository"
)
type ListarProdutosUseCase struct {
repo repository.ProdutoRepository
}
func NovoListarProdutosUseCase(repo repository.ProdutoRepository) *ListarProdutosUseCase {
return &ListarProdutosUseCase{repo: repo}
}
func (uc *ListarProdutosUseCase) Executar(ctx context.Context) ([]ProdutoOutput, error) {
produtos, err := uc.repo.Listar(ctx, repository.ProdutoFiltro{ApenasAtivo: true})
if err != nil {
return nil, err
}
// make com tamanho 0, e não var: sem produtos, o JSON sai [] e não null
out := make([]ProdutoOutput, 0, len(produtos))
for _, p := range produtos {
out = append(out, paraOutput(p))
}
return out, nil
}
// internal/usecase/pedido/criar.go
package pedido
import (
"context"
"fmt"
"loja/internal/domain"
"loja/internal/repository"
)
type ItemInput struct {
ProdutoID int64
Quantidade int
}
type CriarPedidoInput struct {
UsuarioID int64
Itens []ItemInput
}
type CriarPedidoOutput struct {
PedidoID int64
Total float64
Status string
}
type CriarPedidoUseCase struct {
pedidoRepo repository.PedidoRepository
produtoRepo repository.ProdutoRepository
}
func NovoCriarPedidoUseCase(
pedidoRepo repository.PedidoRepository,
produtoRepo repository.ProdutoRepository,
) *CriarPedidoUseCase {
return &CriarPedidoUseCase{
pedidoRepo: pedidoRepo,
produtoRepo: produtoRepo,
}
}
func (uc *CriarPedidoUseCase) Executar(ctx context.Context, input CriarPedidoInput) (*CriarPedidoOutput, error) {
// Fase 1: carrega e valida todos os itens, sem gravar nada. Se um item
// falhar, nenhum estoque foi tocado
var itensPedido []domain.ItemPedido
var produtos []*domain.Produto
for _, itemInput := range input.Itens {
produto, err := uc.produtoRepo.BuscarPorID(ctx, itemInput.ProdutoID)
if err != nil {
return nil, fmt.Errorf("produto %d: %w", itemInput.ProdutoID, err)
}
if !produto.Ativo() {
return nil, fmt.Errorf("produto %d está inativo", itemInput.ProdutoID)
}
// Regra de domínio: verifica estoque (só na memória, por enquanto)
if err := produto.RetirarEstoque(itemInput.Quantidade); err != nil {
return nil, fmt.Errorf("produto %s: %w", produto.Nome(), err)
}
produtos = append(produtos, produto)
itensPedido = append(itensPedido, domain.ItemPedido{
ProdutoID: produto.ID(),
Nome: produto.Nome(),
Quantidade: itemInput.Quantidade,
PrecoUnit: produto.Preco(),
})
}
// Cria o pedido no domínio
pedido, err := domain.NovoPedido(input.UsuarioID, itensPedido)
if err != nil {
return nil, err
}
// Confirma automaticamente
if err := pedido.Confirmar(); err != nil {
return nil, err
}
// Fase 2: grava. Estas escritas ainda não são atômicas nem protegidas
// contra pedidos simultâneos; o texto após o código explica o que falta
for _, produto := range produtos {
if err := uc.produtoRepo.Atualizar(ctx, produto); err != nil {
return nil, fmt.Errorf("atualizar estoque: %w", err)
}
}
if err := uc.pedidoRepo.Criar(ctx, pedido); err != nil {
return nil, fmt.Errorf("persistir pedido: %w", err)
}
return &CriarPedidoOutput{
PedidoID: pedido.ID(),
Total: pedido.Total(),
Status: string(pedido.Status()),
}, nil
}
O caso de uso trabalha em duas fases. A primeira carrega e valida todos os itens antes de gravar qualquer coisa: numa versão anterior, que gravava o estoque item a item, um pedido cujo segundo item não tinha estoque deixava o primeiro já descontado, sem pedido nenhum. Num teste com repositórios em memória, o produto 1 perdeu duas unidades assim; com as duas fases, o estoque ficou intacto. A segunda fase ainda tem dois problemas, que a arquitetura sozinha não resolve. As gravações não são atômicas: se pedidoRepo.Criar falhar, o estoque já foi baixado. E dois pedidos simultâneos pela última unidade leem o mesmo estoque, passam na validação e gravam os dois: no mesmo teste, com uma unidade e dois pedidos ao mesmo tempo, os dois foram aceitos.
A solução habitual tem duas partes. Para a atomicidade, um contrato de transação na camada de repositório, como Transacionar(ctx, func(ctx context.Context) error), implementado na infraestrutura com BeginTx e uma transação levada pelo contexto até os repositórios. Para a concorrência, a baixa de estoque condicional no próprio banco, com UPDATE produtos SET estoque = estoque - $2 WHERE id = $1 AND estoque >= $2 e a verificação de que uma linha foi afetada, num método como RetirarEstoque no repositório. A regra de negócio continua no domínio; a garantia de que ela vale sob concorrência vem do banco.
Implementação de repositório: infraestrutura
// internal/infra/postgres/produto_repo.go
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"loja/internal/domain"
"loja/internal/repository"
)
type produtoRepo struct {
db *sql.DB
}
// NewProdutoRepository retorna uma implementação PostgreSQL de ProdutoRepository.
func NewProdutoRepository(db *sql.DB) repository.ProdutoRepository {
return &produtoRepo{db: db}
}
func (r *produtoRepo) Criar(ctx context.Context, p *domain.Produto) error {
query := `
INSERT INTO produtos (nome, descricao, preco, estoque, categoria_id, ativo, criado_em)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`
var id int64
err := r.db.QueryRowContext(ctx, query,
p.Nome(), p.Descricao(), p.Preco(),
p.Estoque(), p.CategoriaID(), p.Ativo(), p.CriadoEm(),
).Scan(&id)
if err != nil {
return fmt.Errorf("inserir produto: %w", err)
}
// Injeta o ID gerado pelo banco — necessário porque o domínio
// não conhece a estratégia de geração de IDs
p.DefinirID(id)
return nil
}
func (r *produtoRepo) BuscarPorID(ctx context.Context, id int64) (*domain.Produto, error) {
query := `
SELECT id, nome, descricao, preco, estoque, categoria_id, ativo, criado_em
FROM produtos
WHERE id = $1 AND deletado_em IS NULL
`
var (
pid int64
nome string
descricao string
preco float64
estoque int
categoriaID int64
ativo bool
criadoEm time.Time
)
err := r.db.QueryRowContext(ctx, query, id).Scan(
&pid, &nome, &descricao, &preco,
&estoque, &categoriaID, &ativo, &criadoEm,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("produto %d: %w", id, domain.ErrProdutoNaoEncontrado)
}
if err != nil {
return nil, fmt.Errorf("buscar produto: %w", err)
}
// Reconstitui a entidade a partir dos dados persistidos
return domain.ReconstituirProduto(pid, nome, descricao, preco, estoque, categoriaID, ativo, criadoEm), nil
}
func (r *produtoRepo) Listar(ctx context.Context, f repository.ProdutoFiltro) ([]*domain.Produto, error) {
query := `
SELECT id, nome, descricao, preco, estoque, categoria_id, ativo, criado_em
FROM produtos
WHERE deletado_em IS NULL
AND ($1::bigint IS NULL OR categoria_id = $1)
AND (NOT $2 OR ativo)
ORDER BY id
LIMIT $3 OFFSET $4
`
limite := f.Limite
if limite <= 0 {
limite = 20
}
rows, err := r.db.QueryContext(ctx, query, f.CategoriaID, f.ApenasAtivo, limite, f.Offset)
if err != nil {
return nil, fmt.Errorf("listar produtos: %w", err)
}
defer rows.Close()
var produtos []*domain.Produto
for rows.Next() {
var (
pid, categoriaID int64
nome, descricao string
preco float64
estoque int
ativo bool
criadoEm time.Time
)
if err := rows.Scan(&pid, &nome, &descricao, &preco,
&estoque, &categoriaID, &ativo, &criadoEm); err != nil {
return nil, fmt.Errorf("ler produto: %w", err)
}
produtos = append(produtos, domain.ReconstituirProduto(
pid, nome, descricao, preco, estoque, categoriaID, ativo, criadoEm))
}
return produtos, rows.Err()
}
func (r *produtoRepo) Atualizar(ctx context.Context, p *domain.Produto) error {
res, err := r.db.ExecContext(ctx, `
UPDATE produtos
SET nome = $2, descricao = $3, preco = $4, estoque = $5, categoria_id = $6, ativo = $7
WHERE id = $1 AND deletado_em IS NULL
`, p.ID(), p.Nome(), p.Descricao(), p.Preco(), p.Estoque(), p.CategoriaID(), p.Ativo())
if err != nil {
return fmt.Errorf("atualizar produto: %w", err)
}
return exigirUmaLinha(res, p.ID())
}
// Deletar faz exclusão lógica: preenche deletado_em, que as consultas filtram
func (r *produtoRepo) Deletar(ctx context.Context, id int64) error {
res, err := r.db.ExecContext(ctx,
`UPDATE produtos SET deletado_em = NOW() WHERE id = $1 AND deletado_em IS NULL`, id)
if err != nil {
return fmt.Errorf("deletar produto: %w", err)
}
return exigirUmaLinha(res, id)
}
func exigirUmaLinha(res sql.Result, id int64) error {
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("produto %d: %w", id, domain.ErrProdutoNaoEncontrado)
}
return nil
}
Handler HTTP: adaptador de entrada
// internal/handler/produto.go
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"loja/internal/domain"
usecaseproduto "loja/internal/usecase/produto"
)
type ProdutoHandler struct {
criar *usecaseproduto.CriarProdutoUseCase
buscar *usecaseproduto.BuscarProdutoUseCase
listar *usecaseproduto.ListarProdutosUseCase
}
func NovoProdutoHandler(
criar *usecaseproduto.CriarProdutoUseCase,
buscar *usecaseproduto.BuscarProdutoUseCase,
listar *usecaseproduto.ListarProdutosUseCase,
) *ProdutoHandler {
return &ProdutoHandler{criar: criar, buscar: buscar, listar: listar}
}
func (h *ProdutoHandler) Rotas() func(chi.Router) {
return func(r chi.Router) {
r.Get("/", h.Listar)
r.Post("/", h.Criar)
r.Get("/{id}", h.Buscar)
}
}
type criarProdutoRequest struct {
Nome string `json:"nome"`
Descricao string `json:"descricao"`
Preco float64 `json:"preco"`
CategoriaID int64 `json:"categoria_id"`
}
func (h *ProdutoHandler) Criar(w http.ResponseWriter, r *http.Request) {
var req criarProdutoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
responderErro(w, http.StatusBadRequest, "corpo da requisição inválido")
return
}
output, err := h.criar.Executar(r.Context(), usecaseproduto.CriarProdutoInput{
Nome: req.Nome,
Descricao: req.Descricao,
Preco: req.Preco,
CategoriaID: req.CategoriaID,
})
if err != nil {
// Traduz erros de domínio para status HTTP
switch {
case errors.Is(err, domain.ErrNomeProdutoVazio),
errors.Is(err, domain.ErrPrecoProdutoInvalido):
responderErro(w, http.StatusUnprocessableEntity, err.Error())
default:
responderErro(w, http.StatusInternalServerError, "erro interno")
}
return
}
responderJSON(w, http.StatusCreated, output)
}
func (h *ProdutoHandler) Buscar(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
responderErro(w, http.StatusBadRequest, "id inválido")
return
}
output, err := h.buscar.Executar(r.Context(), id)
if err != nil {
if errors.Is(err, domain.ErrProdutoNaoEncontrado) {
responderErro(w, http.StatusNotFound, "produto não encontrado")
return
}
responderErro(w, http.StatusInternalServerError, "erro interno")
return
}
responderJSON(w, http.StatusOK, output)
}
func (h *ProdutoHandler) Listar(w http.ResponseWriter, r *http.Request) {
output, err := h.listar.Executar(r.Context())
if err != nil {
responderErro(w, http.StatusInternalServerError, "erro interno")
return
}
responderJSON(w, http.StatusOK, output)
}
func responderJSON(w http.ResponseWriter, status int, dados any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(dados)
}
func responderErro(w http.ResponseWriter, status int, mensagem string) {
responderJSON(w, status, map[string]string{"erro": mensagem})
}
Composição no main: injeção de dependências
// cmd/api/main.go
package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
_ "github.com/lib/pq"
"loja/internal/handler"
"loja/internal/infra/postgres"
usecaseproduto "loja/internal/usecase/produto"
)
func main() {
// 1. Infraestrutura
db, err := sql.Open("postgres",
"postgres://usuario:senha@localhost:5432/loja?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 2. Repositórios (infraestrutura)
produtoRepo := postgres.NewProdutoRepository(db)
// 3. Casos de uso (lógica de negócio)
criarProduto := usecaseproduto.NovoCriarProdutoUseCase(produtoRepo)
buscarProduto := usecaseproduto.NovoBuscarProdutoUseCase(produtoRepo)
listarProdutos := usecaseproduto.NovoListarProdutosUseCase(produtoRepo)
// 4. Handlers (adaptadores de entrada)
produtoHandler := handler.NovoProdutoHandler(criarProduto, buscarProduto, listarProdutos)
// 5. Roteamento
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Route("/api/v1", func(r chi.Router) {
r.Route("/produtos", produtoHandler.Rotas())
// Pedidos entram do mesmo jeito: postgres.NewPedidoRepository,
// usecasepedido.NovoCriarPedidoUseCase(pedidoRepo, produtoRepo) e um
// PedidoHandler com as suas Rotas, no padrão dos de produto
})
// 6. Servidor
fmt.Println("API em :8080")
log.Fatal(http.ListenAndServe(":8080", r))
}
Testando casos de uso com mocks
// internal/usecase/produto/criar_test.go
package produto_test
import (
"context"
"errors"
"testing"
"loja/internal/domain"
"loja/internal/repository"
"loja/internal/usecase/produto"
)
// Mock do repositório
type mockProdutoRepo struct {
criarFn func(ctx context.Context, p *domain.Produto) error
}
func (m *mockProdutoRepo) Criar(ctx context.Context, p *domain.Produto) error {
return m.criarFn(ctx, p)
}
func (m *mockProdutoRepo) BuscarPorID(_ context.Context, _ int64) (*domain.Produto, error) {
return nil, nil
}
func (m *mockProdutoRepo) Listar(_ context.Context, _ repository.ProdutoFiltro) ([]*domain.Produto, error) {
return nil, nil
}
func (m *mockProdutoRepo) Atualizar(_ context.Context, _ *domain.Produto) error { return nil }
func (m *mockProdutoRepo) Deletar(_ context.Context, _ int64) error { return nil }
func TestCriarProduto_Sucesso(t *testing.T) {
repo := &mockProdutoRepo{
criarFn: func(_ context.Context, _ *domain.Produto) error {
return nil // simula persistência bem-sucedida
},
}
uc := produto.NovoCriarProdutoUseCase(repo)
output, err := uc.Executar(context.Background(), produto.CriarProdutoInput{
Nome: "Notebook",
Preco: 4500.00,
CategoriaID: 1,
})
if err != nil {
t.Fatalf("esperava sucesso, obteve: %v", err)
}
if output.Nome != "Notebook" {
t.Errorf("nome esperado 'Notebook', obtido '%s'", output.Nome)
}
}
func TestCriarProduto_NomeVazio(t *testing.T) {
repo := &mockProdutoRepo{}
uc := produto.NovoCriarProdutoUseCase(repo)
_, err := uc.Executar(context.Background(), produto.CriarProdutoInput{
Nome: "", // inválido
Preco: 100.0,
})
if !errors.Is(err, domain.ErrNomeProdutoVazio) {
t.Errorf("esperava ErrNomeProdutoVazio, obteve: %v", err)
}
}
Clean Architecture em Go se resume a fazer as dependências apontarem para dentro: o domínio não importa nada do projeto, os casos de uso dependem de interfaces de repositório, e a infraestrutura implementa essas interfaces e é ligada no main. O ganho aparece nos testes, em que um caso de uso roda com um repositório em memória, e nas trocas de tecnologia, que ficam restritas a uma camada. Mas as camadas não garantem, sozinhas, que o sistema esteja correto. A ordem das gravações, a atomicidade e a concorrência continuam sendo responsabilidade de quem escreve o caso de uso, e o banco é quem garante uma regra como "não vender o que não há" quando dois pedidos chegam juntos. E o compilador é um aliado: uma implementação que não cumpre a interface não compila, desde que exista um ponto do código em que ela seja usada como a interface.
Fontes e leituras recomendadas
-
Clean Architecture — Robert C. Martin — O livro original que define a arquitetura. https://www.amazon.com/Clean-Architecture-Craftsmans-Software-Structure/dp/0134494164
-
Go Clean Architecture — exemplo no GitHub — Implementação de referência da comunidade. https://github.com/bxcodec/go-clean-arch
-
Screaming Architecture — Artigo de Uncle Bob sobre arquitetura que "grita" o domínio. https://blog.cleancoder.com/uncle-bob/2011/09/30/Screaming-Architecture.html
-
Wire — Google — Gerador de código para injeção de dependências em Go. https://github.com/google/wire
-
Standard Go Project Layout — Estrutura de projeto amplamente adotada. https://github.com/golang-standards/project-layout
Exercícios
Exercício 1
A primeira versão do repositório PostgreSQL deste artigo implementava só Criar e BuscarPorID, e a interface pedia cinco métodos. Onde o compilador acusa isso, e por que é útil que NewProdutoRepository devolva a interface, e não *produtoRepo?
Ver resposta
✓ Resposta: Em Go, um tipo satisfaz uma interface implicitamente, sem declarar nada. O compilador só verifica a correspondência quando o tipo é usado como a interface, e o return &produtoRepo{db: db} numa função que devolve repository.ProdutoRepository é esse ponto. Num teste com a versão antiga, o erro apareceu ali: *produtoRepo does not implement repository.ProdutoRepository (missing method Atualizar). Se a função devolvesse *produtoRepo, o pacote compilaria, e o erro só apareceria no main, na hora de passar o repositório a um caso de uso, longe da implementação incompleta. Quando não há um construtor assim, a forma idiomática de fazer a verificação no próprio pacote é uma declaração que não gera código: var _ repository.ProdutoRepository = (*produtoRepo)(nil).
Exercício 2
Uma versão anterior do caso de uso de pedido buscava cada produto, baixava o estoque e chamava Atualizar dentro do mesmo laço. Um cliente pede 2 unidades do produto 1, que tem 5, e 1 do produto 2, que tem 0. O pedido é recusado. Qual é o estoque do produto 1 depois?
Ver resposta
✓ Resposta: 3. O laço processou o produto 1 primeiro: validou, descontou e gravou o estoque. Só no produto 2 a regra falhou, e o caso de uso devolveu o erro, mas a gravação do produto 1 já tinha acontecido e nada a desfez. Num teste com repositórios em memória, foi exatamente isso: o pedido recusado com estoque insuficiente para esta operação, nenhum pedido gravado, e o produto 1 com 3 unidades em vez de 5. Duas unidades sumiram do estoque sem venda. A versão do artigo separa as fases: primeiro valida todos os itens na memória, depois grava, e com ela o mesmo pedido deixou o estoque em 5. A regra geral é não gravar nada enquanto houver uma validação pendente; e, como a fase de gravação também pode falhar no meio, ela precisa de uma transação.
Exercício 3
Com a versão em duas fases, resta uma unidade de um produto, e dois clientes finalizam a compra no mesmo instante. Os dois pedidos são aceitos. Por que a validação de estoque do domínio não impediu, e como impedir?
Ver resposta
✓ Resposta: Cada pedido carrega o produto, valida e grava o novo estoque calculado na memória. As duas requisições leem o estoque 1 antes que qualquer uma grave, as duas passam em RetirarEstoque, e as duas gravam 0. Num teste com repositórios em memória e dois pedidos simultâneos pela última unidade, os dois foram gravados e o estoque terminou em 0, e não em -1, o que esconde ainda mais o problema. A regra do domínio está certa, mas foi aplicada sobre uma cópia que ficou velha. A garantia precisa vir de quem vê todas as requisições, o banco: UPDATE produtos SET estoque = estoque - $2 WHERE id = $1 AND estoque >= $2, conferindo se uma linha foi afetada. Esse método entra na interface do repositório, por exemplo como RetirarEstoque(ctx, id, quantidade), e a validação do domínio continua útil para dar ao usuário uma mensagem clara antes de tentar.
Exercício 4
O handler responde 404 quando errors.Is(err, domain.ErrProdutoNaoEncontrado). Num teste, GET /produtos/9 com um produto inexistente respondeu 404 {"erro":"produto não encontrado"}. O erro passa pelo repositório, que o embrulha com fmt.Errorf("produto %d: %w", ...), e pelo caso de uso. O que quebraria o 404 se alguém mudasse uma dessas camadas?
Ver resposta
✓ Resposta: O errors.Is percorre a cadeia de erros embrulhados com %w até encontrar o erro sentinela. Enquanto cada camada usar %w, ou devolver o erro sem mexer, como faz o BuscarProdutoUseCase, o handler reconhece o caso. Se uma camada trocar %w por %v, ou criar um erro novo com errors.New(err.Error()), a mensagem continua a mesma, mas a ligação com domain.ErrProdutoNaoEncontrado se perde, e o handler cai no ramo genérico, respondendo 500 para um produto que só não existe. É uma quebra silenciosa: os logs mostram o texto certo, e só o código de status muda. Por isso o sentinela mora no domínio, onde todas as camadas o enxergam, e um teste do handler com um repositório falso, como o que produziu a resposta acima, protege esse contrato.
Exercício 5
No mesmo teste, GET /produtos/9 com o produto existente respondeu {"ID":9,"Nome":"Caneca","Preco":30,"Estoque":2,"CriadoEm":"2026-01-02T03:04:05Z"}, com chaves em maiúsculas. O front-end esperava id e nome. De onde vem esse formato, e em que camada ele deveria ser definido?
Ver resposta
✓ Resposta: O handler serializa diretamente o ProdutoOutput do caso de uso, e o encoding/json, sem tags, usa o nome de cada campo exportado como chave, com a maiúscula inicial. Há duas saídas. Acrescentar tags json:"id" ao output do caso de uso é simples, mas faz a camada de aplicação conhecer o formato da API HTTP. Na linha da Clean Architecture, o formato da resposta pertence ao adaptador: o handler define o seu próprio tipo de resposta, com as tags, e converte o output nele, do mesmo jeito que já converte a requisição em CriarProdutoInput. Parece duplicação, mas é o que permite mudar a API, por exemplo para uma versão 2 com outros nomes, sem tocar no caso de uso, e servir o mesmo caso de uso por gRPC ou por uma CLI com formatos diferentes.