Clean Architecture em Go: Camadas, Inversão de Controle e Pragmatismo
Como separar regras de negócio de detalhes externos em Go com camadas, inversão de controle e o pragmatismo necessário para não exagerar.
Publicada em · 7 min de leitura
Clean Architecture em Go: Camadas, Inversão de Controle e Pragmatismo
A Clean Architecture (proposta por Robert C. Martin) e seus padrões irmãos como Ports and Adapters (Arquitetura Hexagonal) e Onion Architecture têm como objetivo primário isolar as regras de negócio essenciais de detalhes externos e voláteis, como bancos de dados relacionais, frameworks HTTP, filas de mensageria ou provedores de nuvem.
Em linguagens como Java ou C#, a Clean Architecture costuma ser acompanhada de frameworks pesados de injeção de dependência via reflexão (como Spring ou .NET IoC Containers) e dezenas de interfaces burocráticas. Em Go, a Clean Architecture é aplicada de forma minimalista, explícita e idiomática: aproveitando o sistema de tipagem estrutural do Go (duck typing), a regra “accept interfaces, return structs” e composição manual de dependências na função main().
1. A Regra de Dependência (The Dependency Rule)
O princípio fundamental da Clean Architecture afirma que as dependências de código-fonte devem apontar exclusivamente para dentro, em direção às políticas de mais alto nível (o Domínio). O núcleo do seu sistema não pode importar pacotes de infraestrutura (como database/sql, gorm, net/http ou amqp091-go).
As 4 Camadas em Go:
- Domain Core (Entidades e Regras Puras): Structs e métodos que representam os conceitos de negócio. Não dependem de nenhuma biblioteca externa além da Standard Library do Go.
- Application / Use Cases: Orquestram o fluxo de execução para atingir um objetivo de negócio (ex:
CreateOrder,CancelSubscription). Definem Portas (interfaces) para o que precisam consumir do mundo externo. - Interface Adapters: Traduzem dados do formato do Use Case para o formato do mundo exterior e vice-versa (HTTP Handlers, DTOs, Repositórios SQL, Serializers JSON/Protobuf).
- Frameworks & Drivers (Infraestrutura): Ferramentas técnicas de borda: o roteador HTTP (
net/http), o driver PostgreSQL (pgx), o broker RabbitMQ ou o SDK da AWS S3.
2. Fluxo de Execução vs Inversão de Dependência (DIP)
Existe uma diferença crucial entre a direção em que o código é executado em tempo de execução (Runtime Control Flow) e a direção em que os pacotes dependem uns dos outros em tempo de compilação (Compile-Time Dependency):
- Em Tempo de Execução: A requisição HTTP entra no Handler, que invoca o Use Case, que por sua vez invoca o Repositório para gravar os dados no PostgreSQL.
- Em Tempo de Compilação (DIP): O Use Case não conhece o PostgreSQL. Ele define uma interface (
OrderRepository). O pacote de infraestrutura do PostgreSQL é quem importa o Use Case para implementar essa interface. Se você trocar o PostgreSQL pelo MongoDB, o Use Case não sofre 1 linha de alteração.
3. Organização Canônica de Diretórios (Standard Go Layout)
A estrutura abaixo é o padrão consolidado na comunidade Go corporativa:
meu-projeto/
├── cmd/
│ └── api/
│ └── main.go # Composition Root: inicializa e conecta tudo
├── internal/
│ ├── domain/ # Camada 1: Núcleo puro
│ │ ├── entity/
│ │ │ └── order.go
│ │ └── errdomain/
│ │ └── errors.go
│ ├── usecase/ # Camada 2: Regras da aplicação
│ │ ├── create_order.go
│ │ ├── create_order_test.go
│ │ └── ports.go # Interfaces de Repositórios e Gateways
│ └── adapter/ # Camada 3 e 4: Adaptadores de Entrada e Saída
│ ├── handler/
│ │ └── http/
│ │ └── order_handler.go
│ ├── repository/
│ │ └── postgres/
│ │ └── order_repository.go
│ └── gateway/
│ └── stripe/
│ └── payment_gateway.go
├── go.mod
└── go.sum
4. A Regra de Ouro das Interfaces em Go
Em linguagens tradicionais orientadas a objetos, é comum ver interfaces criadas no mesmo pacote da sua implementação (ex: package postgres; type OrderRepository interface; type OrderRepositoryImpl struct).
5. Implementação Completa Passo a Passo
5.1 Camada 1: Domínio Puro (internal/domain/entity/order.go)
package entity
import (
"errors"
"time"
)
var (
ErrInvalidAmount = errors.New("o valor total do pedido deve ser maior que zero")
ErrEmptyUserID = errors.New("o identificador do cliente é obrigatório")
)
type OrderStatus string
const (
StatusPending OrderStatus = "PENDING"
StatusPaid OrderStatus = "PAID"
StatusCancelled OrderStatus = "CANCELLED"
)
type Order struct {
ID string
UserID string
TotalAmount float64
Status OrderStatus
CreatedAt time.Time
}
// NewOrder valida as invariantes de negócio na criação da entidade
func NewOrder(id, userID string, totalAmount float64) (*Order, error) {
if userID == "" {
return nil, ErrEmptyUserID
}
if totalAmount <= 0 {
return nil, ErrInvalidAmount
}
return &Order{
ID: id,
UserID: userID,
TotalAmount: totalAmount,
Status: StatusPending,
CreatedAt: time.Now().UTC(),
}, nil
}
5.2 Camada 2: Caso de Uso e Portas (internal/usecase/create_order.go)
package usecase
import (
"context"
"fmt"
"time"
"meu-projeto/internal/domain/entity"
)
// OrderRepository define a Porta de Saída exigida pelo Caso de Uso
type OrderRepository interface {
Save(ctx context.Context, order *entity.Order) error
}
// PaymentGateway define outra Porta de Saída para cobrança externa
type PaymentGateway interface {
Charge(ctx context.Context, userID string, amount float64) (transactionID string, err error)
}
// DTOs de entrada e saída desacoplados de protocolos HTTP ou JSON
type CreateOrderInput struct {
UserID string
TotalAmount float64
}
type CreateOrderOutput struct {
OrderID string
Status string
Total float64
CreatedAt string
}
type CreateOrderUseCase struct {
repo OrderRepository
payment PaymentGateway
}
func NewCreateOrderUseCase(repo OrderRepository, payment PaymentGateway) *CreateOrderUseCase {
return &CreateOrderUseCase{repo: repo, payment: payment}
}
func (uc *CreateOrderUseCase) Execute(ctx context.Context, input CreateOrderInput) (*CreateOrderOutput, error) {
// 1. Cria a entidade respeitando regras puras de domínio
orderID := fmt.Sprintf("ord_%d", time.Now().UnixNano())
order, err := entity.NewOrder(orderID, input.UserID, input.TotalAmount)
if err != nil {
return nil, fmt.Errorf("falha de validação de domínio: %w", err)
}
// 2. Cobra via Payment Gateway
_, err = uc.payment.Charge(ctx, order.UserID, order.TotalAmount)
if err != nil {
return nil, fmt.Errorf("falha ao processar pagamento: %w", err)
}
order.Status = entity.StatusPaid
// 3. Persiste no repositório
if err := uc.repo.Save(ctx, order); err != nil {
return nil, fmt.Errorf("falha ao persistir pedido: %w", err)
}
return &CreateOrderOutput{
OrderID: order.ID,
Status: string(order.Status),
Total: order.TotalAmount,
CreatedAt: order.CreatedAt.Format(time.RFC3339),
}, nil
}
5.3 Camada 3: Adaptador HTTP (internal/adapter/handler/http/order_handler.go)
package http
import (
"encoding/json"
"errors"
"net/http"
"meu-projeto/internal/domain/entity"
"meu-projeto/internal/usecase"
)
type OrderHandler struct {
createOrderUC *usecase.CreateOrderUseCase
}
func NewOrderHandler(uc *usecase.CreateOrderUseCase) *OrderHandler {
return &OrderHandler{createOrderUC: uc}
}
type createOrderRequest struct {
UserID string `json:"user_id"`
Amount float64 `json:"amount"`
}
func (h *OrderHandler) HandleCreateOrder(w http.ResponseWriter, r *http.Request) {
var req createOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"payload_invalido"}`, http.StatusBadRequest)
return
}
input := usecase.CreateOrderInput{
UserID: req.UserID,
TotalAmount: req.Amount,
}
output, err := h.createOrderUC.Execute(r.Context(), input)
if err != nil {
// Mapeia erros de domínio para códigos HTTP sem vazar detalhes de banco
if errors.Is(err, entity.ErrInvalidAmount) || errors.Is(err, entity.ErrEmptyUserID) {
w.WriteHeader(http.StatusUnprocessableEntity)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
http.Error(w, `{"error":"erro_interno_servidor"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(output)
}
5.4 Camada 4: Adaptador PostgreSQL (internal/adapter/repository/postgres/order_repo.go)
Utilizando o driver estudado em PostgreSQL com pgx ou SQLC - Type-Safe SQL:
package postgres
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"meu-projeto/internal/domain/entity"
)
type OrderRepository struct {
pool *pgxpool.Pool
}
func NewOrderRepository(pool *pgxpool.Pool) *OrderRepository {
return &OrderRepository{pool: pool}
}
func (r *OrderRepository) Save(ctx context.Context, order *entity.Order) error {
query := `
INSERT INTO orders (id, user_id, total_amount, status, created_at)
VALUES ($1, $2, $3, $4, $5)
`
_, err := r.pool.Exec(ctx, query, order.ID, order.UserID, order.TotalAmount, string(order.Status), order.CreatedAt)
if err != nil {
return fmt.Errorf("erro ao inserir pedido no postgres: %w", err)
}
return nil
}
5.5 O Composition Root: cmd/api/main.go
O único ponto da aplicação inteira que tem permissão para conhecer todas as implementações concretas é a função main(). É ela quem conecta os blocos:
package main
import (
"context"
"log"
"net/http"
"os"
"github.com/jackc/pgx/v5/pgxpool"
httpAdapter "meu-projeto/internal/adapter/handler/http"
"meu-projeto/internal/adapter/repository/postgres"
"meu-projeto/internal/adapter/gateway/stripe"
"meu-projeto/internal/usecase"
"meu-projeto/internal/middleware"
)
func main() {
ctx := context.Background()
// 1. Infraestrutura / Drivers
dbPool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("Erro ao conectar ao PostgreSQL: %v", err)
}
defer dbPool.Close()
// 2. Repositórios e Gateways (Adapters)
orderRepo := postgres.NewOrderRepository(dbPool)
paymentGateway := stripe.NewPaymentGateway(os.Getenv("STRIPE_KEY"))
// 3. Casos de Uso (Application)
createOrderUC := usecase.NewCreateOrderUseCase(orderRepo, paymentGateway)
// 4. Handlers (Interface Adapters)
orderHandler := httpAdapter.NewOrderHandler(createOrderUC)
// 5. Roteador HTTP
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/orders", orderHandler.HandleCreateOrder)
server := &http.Server{
Addr: ":8080",
Handler: middleware.Chain(middleware.RequestID(), middleware.Logger())(mux),
}
log.Println("API iniciada na porta :8080")
server.ListenAndServe()
}
6. Testabilidade Extrema: Testes Unitários de Casos de Uso
Como o Use Case depende exclusivamente de interfaces Go simples, seus testes unitários rodam em microssegundos, sem Docker ou banco de dados:
package usecase_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"meu-projeto/internal/domain/entity"
"meu-projeto/internal/usecase"
)
type mockOrderRepo struct {
saved *entity.Order
}
func (m *mockOrderRepo) Save(ctx context.Context, order *entity.Order) error {
m.saved = order
return nil
}
type mockPaymentGateway struct{}
func (m *mockPaymentGateway) Charge(ctx context.Context, userID string, amount float64) (string, error) {
return "tx_mock_123", nil
}
func TestCreateOrderUseCase_Success(t *testing.T) {
repo := &mockOrderRepo{}
payment := &mockPaymentGateway{}
uc := usecase.NewCreateOrderUseCase(repo, payment)
input := usecase.CreateOrderInput{UserID: "usr_10", TotalAmount: 199.90}
output, err := uc.Execute(context.Background(), input)
require.NoError(t, err)
assert.NotEmpty(t, output.OrderID)
assert.Equal(t, "PAID", output.Status)
assert.Equal(t, 199.90, repo.saved.TotalAmount)
}
7. Quando Usar e Quando Evitar (Pragmatismo Go)
| Cenário | Recomendação |
|---|---|
| Sistemas Corporativos Core (Faturamento, Checkout, Gestão de Clientes) | Clean Architecture Total: protege regras de negócio complexas e permite evoluir banco e APIs sem risco. |
| Microsserviços de Média/Alta Escala | Clean Architecture Simplificada: Domínio + UseCase + Adapters diretos. |
| Provedores de Telemetria / Webhook Ingestion simples | Evite: Se o serviço apenas recebe um JSON e grava no banco, dividi-lo em 5 camadas é desperdício de tempo e código. Use um handler simples com SQLC - Type-Safe SQL. |
8. Resumo Conceitual
- A Clean Architecture isola regras de domínio de frameworks técnicos e bibliotecas externas.
- O fluxo de dependência aponta sempre para o centro: o Domínio nunca importa a Infraestrutura.
- Aplique o mantra: “Accept interfaces, return structs”.
- As interfaces de porta de saída pertencem ao pacote que as consome (Use Case).
- Utilize a pasta protegida
internal/para encapsular a arquitetura da sua aplicação. - Conecte todas as instâncias e implementações manualmente na função
main()(Composition Root), ou automatize com Injeção de Dependências com Google Wire. - Facilite testes unitários ultrarrápidos utilizando dublês de teste leves conforme explorado em Mocks e Dublês de Teste.