Clean Architecture em Go: camadas e dependências

Clean Architecture em Go: camadas e dependências

Clean Architecture em Go com código que compila: domínio sem dependências, reconstituição a partir do banco, interfaces de repositório verificadas pelo compilador, casos de uso em duas fases, o que ainda exige transação e baixa condicional no banco, handler e testes.
Golang

• • 24 min de leitura

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

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.

Comentários

Mais em Golang

Criando ferramentas de linha de comando com flag e Cobra
Criando ferramentas de linha de comando com flag e Cobra

CLIs em Go com o pacote flag e o Cobra: subcomandos, grupos e o import que os…

Select, timeouts e cancelamento de operações
Select, timeouts e cancelamento de operações

Select, timeouts e cancelamento em Go: select como event loop, time.After e os…

sync.WaitGroup e sync.Mutex: coordenando goroutines
sync.WaitGroup e sync.Mutex: coordenando goroutines

WaitGroup e Mutex em Go: Add antes de go e o wg.Go do Go 1.25, Mutex e RWMutex…