Artigo 44 — Clean Architecture em Go: camadas e dependências
Curso: Dominando Go em 1 Ano Prof. Ricardo Matos Módulo 8 — Arquitetura e Padrões
Por que arquitetura importa
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")
)
// 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
}
// 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"
"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"
"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
}
// 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) {
// Resolve produtos e valida disponibilidade
var itensPedido []domain.ItemPedido
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
if err := produto.RetirarEstoque(itemInput.Quantidade); err != nil {
return nil, fmt.Errorf("produto %s: %w", produto.Nome(), err)
}
// Atualiza estoque no repositório
if err := uc.produtoRepo.Atualizar(ctx, produto); err != nil {
return nil, fmt.Errorf("atualizar estoque: %w", err)
}
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
}
// Persiste
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
}
Implementação de repositório: infraestrutura
// internal/infra/postgres/produto_repo.go
package postgres
import (
"context"
"database/sql"
"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
setarID(p, id)
return nil
}
// setarID usa um mecanismo para injetar o ID no domínio sem exportar o campo
// Em projetos reais, use um método SetID no domínio ou um construtor de
// reconstituição separado do construtor de criação
func setarID(p *domain.Produto, id int64) {
// Solução pragmática: método de reconstituição no domínio
p.SetID(id)
}
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 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
}
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"
usecasepedido "loja/internal/usecase/pedido"
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)
pedidoRepo := postgres.NewPedidoRepository(db)
// 3. Casos de uso (lógica de negócio)
criarProduto := usecaseproduto.NovoCriarProdutoUseCase(produtoRepo)
buscarProduto := usecaseproduto.NovoBuscarProdutoUseCase(produtoRepo)
listarProdutos := usecaseproduto.NovoListarProdutosUseCase(produtoRepo)
criarPedido := usecasepedido.NovoCriarPedidoUseCase(pedidoRepo, produtoRepo)
// 4. Handlers (adaptadores de entrada)
produtoHandler := handler.NovoProdutoHandler(criarProduto, buscarProduto, listarProdutos)
pedidoHandler := handler.NovoPedidoHandler(criarPedido)
// 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())
r.Route("/pedidos", pedidoHandler.Rotas())
})
// 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/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)
}
}
Resumo do que foi coberto
Este artigo apresentou Clean Architecture em Go de forma completa: a estrutura de camadas com dependências apontando para dentro, a camada de domínio com entidades encapsuladas e regras de negócio como métodos, interfaces de repositório como contratos sem implementação, casos de uso orquestrando o domínio, implementações de repositório em PostgreSQL, handlers HTTP como adaptadores de entrada, composição no main.go com injeção de dependências e testes de casos de uso com mocks simples. O próximo artigo explora Domain-Driven Design aplicado a projetos Go.
Referências e leituras complementares
-
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
-
Dependency Injection in Go — Técnicas de injeção de dependências sem framework. https://go.dev/wiki/DependencyInjection
-
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
Próximo artigo: Artigo 45 — Domain-Driven Design aplicado a projetos Go**
you asked
Sim