Pular para o conteúdo

Trilha de Go · parte 29 de 37

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

Tamanho da fonte:
Largura da coluna:

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).

Camadas da Clean Architecture em Go
figura 1 · Camadas da Clean Architecture em Go

As 4 Camadas em Go:

  1. 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.
  2. 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.
  3. 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).
  4. 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):

Fluxo de Execução vs Inversão de Dependência
figura 2 · Fluxo de Execução vs Inversão de Dependência
  • 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árioRecomendaçã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 EscalaClean Architecture Simplificada: Domínio + UseCase + Adapters diretos.
Provedores de Telemetria / Webhook Ingestion simplesEvite: 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.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?