Janeiro 2026 · ~20 min

Clean Architecture em Go: Guia Prático sem Exagero

Aprenda Clean Architecture em Go com estrutura de pastas, domínio, casos de uso, repositories, dependency injection, testes e critérios para evitar overengineering.

Clean Architecture em Go: Guia Prático sem Exagero

Resposta rápida: Clean Architecture em Go vale a pena quando você precisa proteger regras de negócio de detalhes como HTTP, PostgreSQL, Redis ou filas. Coloque o domínio e os casos de uso no centro, defina interfaces pequenas no lado consumidor e conecte implementações concretas no main. Para CRUDs pequenos, comece mais simples: não crie uma camada, DTO ou interface sem conseguir explicar qual acoplamento ela remove.

Este guia apresenta um exemplo completo com internal/, entidades, casos de uso, repositories, adapters, dependency injection e testes. Se você quer primeiro decidir quando aplicar o padrão, leia também Clean Architecture em Go sem overengineering. Para transformar a estrutura em um serviço executável, combine o exemplo com o guia de API REST em Go.

Clean Architecture em Go: quando usar

CenárioRecomendação
Script, CLI pequena ou protótipo descartávelUse poucos pacotes e funções explícitas; Clean Architecture completa tende a ser excesso
CRUD simples com um banco e pouca regraSepare transporte e persistência apenas quando os testes ou mudanças começarem a doer
Domínio com regras importantes e vários adaptersUse casos de uso, interfaces pequenas e dependências apontando para dentro
Serviço mantido por vários times ou com longa vida útilFormalize limites entre domínio, aplicação e infraestrutura
Projeto criado apenas para “parecer enterprise”Pare: arquitetura deve reduzir risco, não aumentar a quantidade de arquivos

A estrutura abaixo é um ponto de partida, não uma especificação oficial. Em Go, nomes de pacotes claros, dependências visíveis e interfaces proporcionais ao problema importam mais do que reproduzir círculos e camadas literalmente.

Por que Clean Architecture em Go?

Os Problemas Sem Arquitetura

📁 projeto-caotico/
├── main.go              # 2000 linhas
├── handlers.go          # Tudo misturado
├── database.go          # SQL espalhado
├── models.go            # Regras de negócio aqui
└── utils.go             # Deus sabe o que tem aqui

Problemas:

  • Código difícil de testar - Tudo acoplado
  • Mudanças dolorosas - Alterar DB quebra handlers
  • Regras de negócio espalhadas - Onde fica a lógica?
  • Duplicação de código - Mesma query em 5 lugares
  • Não escala - Equipe trava no código legado

A Solução: Clean Architecture

📁 projeto-organizado/
├── cmd/
│   └── api/
│       └── main.go          # Entry point (30 linhas)
├── internal/
│   ├── domain/              # Regras de negócio puras
│   ├── application/         # Casos de uso
│   ├── infrastructure/      # Detalhes (DB, HTTP, etc)
│   └── ports/               # Interfaces/contratos
└── pkg/                     # Bibliotecas compartilhadas

Benefícios:

  • Testável - Mock fácil de dependências
  • Flexível - Troca de DB sem quebrar nada
  • Regras de negócio protegidas - Isoladas do framework
  • Escalável - Times trabalham em paralelo
  • Manutenível - Cada camada tem responsabilidade única

Os Princípios da Clean Architecture

A Regra da Dependência

        ┌─────────────────────┐
        │   Camada Externa    │  ← Frameworks, UI, DB
        │   (Infrastructure)  │     Depende de internas
        ├─────────────────────┤
        │   Camada de Casos   │  ← Application
        │      de Uso         │     Orquestra fluxo
        ├─────────────────────┤
        │   Camada de Entidades│  ← Domain
        │   (Regras de Negócio)│     O coração do sistema
        └─────────────────────┘

        ⬆️ Dependências apontam para dentro
        ⬇️ Camadas internas NÃO conhecem externas

As Camadas Explicadas

CamadaResponsabilidadeExemplo
DomainEntidades e regras de negócio puroOrder, User, Validate()
ApplicationCasos de uso, orquestraçãoCreateOrderUseCase
InfrastructureFrameworks, DB, HTTP, filasPostgresRepository, GinHandler
PortsInterfaces que conectam camadasOrderRepository, UserService

Estrutura de Projeto Go

Layout Completo

📦 order-service/
├── 📁 cmd/
│   └── 📁 api/
│       └── main.go                 # Entry point da aplicação
├── 📁 internal/
│   │
│   ├── 📁 domain/                  # Regras de negócio (independentes)
│   │   ├── order.go               # Entidade Order
│   │   ├── user.go                # Entidade User
│   │   └── errors.go              # Erros de domínio
│   │
│   ├── 📁 application/             # Casos de uso
│   │   ├── dto.go                 # Data Transfer Objects
│   │   ├── order_usecase.go       # Criar pedido
│   │   └── user_usecase.go        # Gerenciar usuário
│   │
│   ├── 📁 ports/                   # Interfaces (contratos)
│   │   ├── repository.go          # Portas de repositório
│   │   └── service.go             # Portas de serviço
│   │
│   ├── 📁 infrastructure/          # Implementações concretas
│   │   ├── 📁 persistence/        # Banco de dados
│   │   │   ├── postgres/
│   │   │   │   └── order_repository.go
│   │   │   └── redis/
│   │   │       └── cache.go
│   │   ├── 📁 http/               # HTTP handlers
│   │   │   ├── handler.go
│   │   │   ├── router.go
│   │   │   └── middleware.go
│   │   ├── 📁 messaging/          # Fila/mensageria
│   │   │   └── kafka/
│   │   │       └── publisher.go
│   │   └── 📁 external/           # APIs externas
│   │       └── payment_gateway.go
│   │
│   └── 📁 config/                  # Configurações
│       └── config.go
├── 📁 pkg/                         # Código compartilhado
│   ├── logger/
│   ├── validator/
│   └── errors/
├── 📁 api/                         # Contratos de API
│   └── openapi.yaml
├── 📁 migrations/                  # Migrações de DB
│   └── 001_create_orders.sql
├── 📁 deployments/                 # Infra
│   ├── docker/
│   └── k8s/
├── go.mod
├── go.sum
└── Makefile

Por Que Usar internal/?

O diretório internal/ tem significado especial em Go:

  • Pacotes em internal/ só podem ser importados pelo módulo atual
  • Impede acoplamento acidental entre serviços
  • Protege a arquitetura de violações
// ✅ Pode importar (mesmo módulo)
import "github.com/example/order-service/internal/domain"

// ❌ Erro de compilação (outro módulo)
import "github.com/example/other-service/internal/domain"
// "use of internal package not allowed"

Implementação Prática

1. Camada de Domínio (Domain)

O coração do sistema. Não tem dependências externas.

// internal/domain/order.go
package domain

import (
	"errors"
	"time"
)

// Erros de domínio
var (
	ErrInvalidAmount    = errors.New("valor do pedido deve ser positivo")
	ErrEmptyItems       = errors.New("pedido deve ter pelo menos um item")
	ErrInvalidStatus    = errors.New("transição de status inválida")
	ErrOrderNotFound    = errors.New("pedido não encontrado")
	ErrInsufficientStock = errors.New("estoque insuficiente")
)

// Order é a entidade central do domínio
type Order struct {
	ID        string
	UserID    string
	Items     []OrderItem
	Total     Money
	Status    OrderStatus
	CreatedAt time.Time
	UpdatedAt time.Time
}

// OrderItem representa um item do pedido
type OrderItem struct {
	ProductID   string
	ProductName string
	Quantity    int
	UnitPrice   Money
}

// Money representa valores monetários (evita float)
type Money struct {
	Amount   int64  // Valor em centavos (evita problema de precisão)
	Currency string // "BRL", "USD"
}

// OrderStatus é uma enumeração tipada
type OrderStatus string

const (
	OrderStatusPending    OrderStatus = "PENDING"
	OrderStatusPaid       OrderStatus = "PAID"
	OrderStatusShipped    OrderStatus = "SHIPPED"
	OrderStatusDelivered  OrderStatus = "DELIVERED"
	OrderStatusCancelled  OrderStatus = "CANCELLED"
)

// Métodos de domínio - regras de negócio puras

// CalculateTotal recalcula o total do pedido
func (o *Order) CalculateTotal() {
	var total int64
	for _, item := range o.Items {
		total += item.UnitPrice.Amount * int64(item.Quantity)
	}
	o.Total = Money{
		Amount:   total,
		Currency: "BRL",
	}
}

// Validate verifica se o pedido é válido
func (o *Order) Validate() error {
	if o.Total.Amount <= 0 {
		return ErrInvalidAmount
	}
	if len(o.Items) == 0 {
		return ErrEmptyItems
	}
	for _, item := range o.Items {
		if item.Quantity <= 0 {
			return errors.New("quantidade deve ser positiva")
		}
	}
	return nil
}

// Pay muda o status para pago
func (o *Order) Pay() error {
	if o.Status != OrderStatusPending {
		return ErrInvalidStatus
	}
	o.Status = OrderStatusPaid
	o.UpdatedAt = time.Now()
	return nil
}

// Ship muda o status para enviado
func (o *Order) Ship() error {
	if o.Status != OrderStatusPaid {
		return ErrInvalidStatus
	}
	o.Status = OrderStatusShipped
	o.UpdatedAt = time.Now()
	return nil
}

// Cancel cancela o pedido
func (o *Order) Cancel() error {
	if o.Status != OrderStatusPending {
		return ErrInvalidStatus
	}
	o.Status = OrderStatusCancelled
	o.UpdatedAt = time.Now()
	return nil
}

// CanTransitionTo verifica se pode mudar para um status
func (o *Order) CanTransitionTo(newStatus OrderStatus) bool {
	transitions := map[OrderStatus][]OrderStatus{
		OrderStatusPending:   {OrderStatusPaid, OrderStatusCancelled},
		OrderStatusPaid:      {OrderStatusShipped},
		OrderStatusShipped:   {OrderStatusDelivered},
		OrderStatusDelivered: {},
		OrderStatusCancelled: {},
	}

	allowed, ok := transitions[o.Status]
	if !ok {
		return false
	}

	for _, s := range allowed {
		if s == newStatus {
			return true
		}
	}
	return false
}

// AddItem adiciona um item ao pedido
func (o *Order) AddItem(item OrderItem) error {
	if item.Quantity <= 0 {
		return errors.New("quantidade deve ser positiva")
	}
	if item.UnitPrice.Amount <= 0 {
		return errors.New("preço deve ser positivo")
	}

	o.Items = append(o.Items, item)
	o.CalculateTotal()
	o.UpdatedAt = time.Now()
	return nil
}

// RemoveItem remove um item do pedido
func (o *Order) RemoveItem(productID string) error {
	for i, item := range o.Items {
		if item.ProductID == productID {
			o.Items = append(o.Items[:i], o.Items[i+1:]...)
			o.CalculateTotal()
			o.UpdatedAt = time.Now()
			return nil
		}
	}
	return errors.New("item não encontrado")
}

// IsFinal retorna true se o pedido está em status final
func (o *Order) IsFinal() bool {
	return o.Status == OrderStatusDelivered || o.Status == OrderStatusCancelled
}
// internal/domain/user.go
package domain

import (
	"errors"
	"regexp"
	"time"
)

var (
	ErrInvalidEmail = errors.New("email inválido")
	ErrInvalidName  = errors.New("nome deve ter pelo menos 3 caracteres")
	ErrUserInactive = errors.New("usuário inativo")
)

type User struct {
	ID        string
	Name      string
	Email     string
	Active    bool
	CreatedAt time.Time
	UpdatedAt time.Time
}

func (u *User) Validate() error {
	if len(u.Name) < 3 {
		return ErrInvalidName
	}
	if !isValidEmail(u.Email) {
		return ErrInvalidEmail
	}
	return nil
}

func (u *User) CanPlaceOrder() error {
	if !u.Active {
		return ErrUserInactive
	}
	return nil
}

func isValidEmail(email string) bool {
	pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
	matched, _ := regexp.MatchString(pattern, email)
	return matched
}

2. Ports (Interfaces)

Define contratos que a aplicação precisa. Não implementa nada.

// internal/ports/repository.go
package ports

import (
	"context"
	"order-service/internal/domain"
)

// OrderRepository define o contrato para persistência de pedidos
// A camada de domínio não sabe (e não se importa) se é Postgres, Mongo, etc.
type OrderRepository interface {
	// Create salva um novo pedido
	Create(ctx context.Context, order *domain.Order) error

	// GetByID busca um pedido pelo ID
	GetByID(ctx context.Context, id string) (*domain.Order, error)

	// GetByUserID busca pedidos de um usuário
	GetByUserID(ctx context.Context, userID string, page, pageSize int) ([]*domain.Order, error)

	// Update atualiza um pedido existente
	Update(ctx context.Context, order *domain.Order) error

	// Delete remove um pedido
	Delete(ctx context.Context, id string) error

	// UpdateStatus atualiza apenas o status (otimização)
	UpdateStatus(ctx context.Context, id string, status domain.OrderStatus) error
}

// UserRepository define o contrato para persistência de usuários
type UserRepository interface {
	Create(ctx context.Context, user *domain.User) error
	GetByID(ctx context.Context, id string) (*domain.User, error)
	GetByEmail(ctx context.Context, email string) (*domain.User, error)
	Update(ctx context.Context, user *domain.User) error
}
// internal/ports/service.go
package ports

import (
	"context"
	"order-service/internal/domain"
)

// PaymentService define o contrato para processamento de pagamentos
// Pode ser implementado por Stripe, PagSeguro, MercadoPago, etc.
type PaymentService interface {
	// ProcessPayment processa o pagamento de um pedido
	ProcessPayment(ctx context.Context, orderID string, amount domain.Money) (*PaymentResult, error)

	// Refund solicita reembolso
	Refund(ctx context.Context, paymentID string) error

	// GetPaymentStatus consulta status do pagamento
	GetPaymentStatus(ctx context.Context, paymentID string) (PaymentStatus, error)
}

type PaymentResult struct {
	PaymentID string
	Status    PaymentStatus
	Receipt   string
}

type PaymentStatus string

const (
	PaymentStatusPending   PaymentStatus = "PENDING"
	PaymentStatusApproved  PaymentStatus = "APPROVED"
	PaymentStatusDeclined  PaymentStatus = "DECLINED"
	PaymentStatusRefunded  PaymentStatus = "REFUNDED"
)

// NotificationService define contrato para notificações
type NotificationService interface {
	SendOrderConfirmation(ctx context.Context, order *domain.Order) error
	SendShippingNotification(ctx context.Context, order *domain.Order, trackingID string) error
}

// InventoryService define contrato para estoque
type InventoryService interface {
	ReserveItems(ctx context.Context, items []domain.OrderItem) error
	ReleaseItems(ctx context.Context, items []domain.OrderItem) error
	CheckAvailability(ctx context.Context, productID string, quantity int) (bool, error)
}

3. Camada de Aplicação (Use Cases)

Orquestra o fluxo de dados entre domínio e infraestrutura.

// internal/application/dto.go
package application

import "order-service/internal/domain"

// DTOs (Data Transfer Objects) - usados na entrada/saída

// CreateOrderInput dados para criar pedido
type CreateOrderInput struct {
	UserID string            `json:"user_id" validate:"required"`
	Items  []OrderItemInput  `json:"items" validate:"required,min=1"`
}

type OrderItemInput struct {
	ProductID   string `json:"product_id" validate:"required"`
	ProductName string `json:"product_name" validate:"required"`
	Quantity    int    `json:"quantity" validate:"required,min=1"`
	UnitPrice   int64  `json:"unit_price" validate:"required,gt=0"`
}

// OrderOutput formato de saída
type OrderOutput struct {
	ID        string            `json:"id"`
	UserID    string            `json:"user_id"`
	Items     []OrderItemOutput `json:"items"`
	Total     MoneyOutput       `json:"total"`
	Status    string            `json:"status"`
	CreatedAt string            `json:"created_at"`
}

type OrderItemOutput struct {
	ProductID   string      `json:"product_id"`
	ProductName string      `json:"product_name"`
	Quantity    int         `json:"quantity"`
	UnitPrice   MoneyOutput `json:"unit_price"`
}

type MoneyOutput struct {
	Amount   int64  `json:"amount"`
	Currency string `json:"currency"`
}

// Mapeadores entre DTO e Domínio

func toDomainItems(items []OrderItemInput) []domain.OrderItem {
	result := make([]domain.OrderItem, len(items))
	for i, item := range items {
		result[i] = domain.OrderItem{
			ProductID:   item.ProductID,
			ProductName: item.ProductName,
			Quantity:    item.Quantity,
			UnitPrice: domain.Money{
				Amount:   item.UnitPrice,
				Currency: "BRL",
			},
		}
	}
	return result
}

func toOutputOrder(order *domain.Order) OrderOutput {
	items := make([]OrderItemOutput, len(order.Items))
	for i, item := range order.Items {
		items[i] = OrderItemOutput{
			ProductID:   item.ProductID,
			ProductName: item.ProductName,
			Quantity:    item.Quantity,
			UnitPrice: MoneyOutput{
				Amount:   item.UnitPrice.Amount,
				Currency: item.UnitPrice.Currency,
			},
		}
	}

	return OrderOutput{
		ID:     order.ID,
		UserID: order.UserID,
		Items:  items,
		Total: MoneyOutput{
			Amount:   order.Total.Amount,
			Currency: order.Total.Currency,
		},
		Status:    string(order.Status),
		CreatedAt: order.CreatedAt.Format(time.RFC3339),
	}
}
// internal/application/order_usecase.go
package application

import (
	"context"
	"fmt"
	"time"

	"order-service/internal/domain"
	"order-service/internal/ports"
	"order-service/pkg/logger"
)

// OrderUseCase implementa os casos de uso de pedidos
type OrderUseCase struct {
	// Dependências injetadas via construtor
	orderRepo   ports.OrderRepository
	userRepo    ports.UserRepository
	paymentSvc  ports.PaymentService
	inventorySvc ports.InventoryService
	notifier    ports.NotificationService
	logger      logger.Logger
}

// NewOrderUseCase cria uma nova instância
// Dependency Injection - recebe interfaces, não implementações
func NewOrderUseCase(
	orderRepo ports.OrderRepository,
	userRepo ports.UserRepository,
	paymentSvc ports.PaymentService,
	inventorySvc ports.InventoryService,
	notifier ports.NotificationService,
	logger logger.Logger,
) *OrderUseCase {
	return &OrderUseCase{
		orderRepo:    orderRepo,
		userRepo:     userRepo,
		paymentSvc:   paymentSvc,
		inventorySvc: inventorySvc,
		notifier:     notifier,
		logger:       logger,
	}
}

// CreateOrder executa o caso de uso de criação de pedido
func (uc *OrderUseCase) CreateOrder(ctx context.Context, input CreateOrderInput) (*OrderOutput, error) {
	uc.logger.Info("iniciando criação de pedido", "user_id", input.UserID)

	// 1. Validar usuário
	user, err := uc.userRepo.GetByID(ctx, input.UserID)
	if err != nil {
		uc.logger.Error("usuário não encontrado", "error", err)
		return nil, fmt.Errorf("usuário não encontrado: %w", err)
	}

	if err := user.CanPlaceOrder(); err != nil {
		uc.logger.Warn("usuário não pode fazer pedidos", "user_id", user.ID, "error", err)
		return nil, err
	}

	// 2. Verificar estoque
	for _, item := range input.Items {
		available, err := uc.inventorySvc.CheckAvailability(ctx, item.ProductID, item.Quantity)
		if err != nil {
			uc.logger.Error("erro ao verificar estoque", "product_id", item.ProductID, "error", err)
			return nil, fmt.Errorf("erro no estoque: %w", err)
		}
		if !available {
			uc.logger.Warn("produto sem estoque", "product_id", item.ProductID)
			return nil, domain.ErrInsufficientStock
		}
	}

	// 3. Criar entidade de domínio
	order := &domain.Order{
		ID:        generateID(),
		UserID:    input.UserID,
		Items:     toDomainItems(input.Items),
		Status:    domain.OrderStatusPending,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}
	order.CalculateTotal()

	// 4. Validar regras de domínio
	if err := order.Validate(); err != nil {
		uc.logger.Error("validação de pedido falhou", "error", err)
		return nil, err
	}

	// 5. Reservar estoque
	if err := uc.inventorySvc.ReserveItems(ctx, order.Items); err != nil {
		uc.logger.Error("falha ao reservar estoque", "error", err)
		return nil, fmt.Errorf("erro ao reservar estoque: %w", err)
	}

	// 6. Persistir pedido
	if err := uc.orderRepo.Create(ctx, order); err != nil {
		// Rollback: liberar estoque
		uc.inventorySvc.ReleaseItems(ctx, order.Items)
		uc.logger.Error("falha ao salvar pedido", "error", err)
		return nil, fmt.Errorf("erro ao criar pedido: %w", err)
	}

	// 7. Enviar notificação (não bloqueante)
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := uc.notifier.SendOrderConfirmation(ctx, order); err != nil {
			uc.logger.Error("falha ao enviar notificação", "error", err)
		}
	}()

	uc.logger.Info("pedido criado com sucesso", "order_id", order.ID)
	output := toOutputOrder(order)
	return &output, nil
}

// PayOrder processa o pagamento de um pedido
func (uc *OrderUseCase) PayOrder(ctx context.Context, orderID string) (*OrderOutput, error) {
	// 1. Buscar pedido
	order, err := uc.orderRepo.GetByID(ctx, orderID)
	if err != nil {
		return nil, fmt.Errorf("pedido não encontrado: %w", err)
	}

	// 2. Validar transição de status
	if !order.CanTransitionTo(domain.OrderStatusPaid) {
		return nil, domain.ErrInvalidStatus
	}

	// 3. Processar pagamento
	paymentResult, err := uc.paymentSvc.ProcessPayment(ctx, orderID, order.Total)
	if err != nil {
		uc.logger.Error("falha no pagamento", "order_id", orderID, "error", err)
		return nil, fmt.Errorf("pagamento falhou: %w", err)
	}

	if paymentResult.Status != ports.PaymentStatusApproved {
		return nil, fmt.Errorf("pagamento não aprovado: %s", paymentResult.Status)
	}

	// 4. Atualizar domínio
	if err := order.Pay(); err != nil {
		return nil, err
	}

	// 5. Persistir
	if err := uc.orderRepo.Update(ctx, order); err != nil {
		return nil, fmt.Errorf("falha ao atualizar pedido: %w", err)
	}

	output := toOutputOrder(order)
	return &output, nil
}

// GetOrder busca um pedido
func (uc *OrderUseCase) GetOrder(ctx context.Context, orderID string) (*OrderOutput, error) {
	order, err := uc.orderRepo.GetByID(ctx, orderID)
	if err != nil {
		return nil, err
	}

	output := toOutputOrder(order)
	return &output, nil
}

// CancelOrder cancela um pedido
func (uc *OrderUseCase) CancelOrder(ctx context.Context, orderID string) error {
	order, err := uc.orderRepo.GetByID(ctx, orderID)
	if err != nil {
		return err
	}

	if err := order.Cancel(); err != nil {
		return err
	}

	// Liberar estoque
	if err := uc.inventorySvc.ReleaseItems(ctx, order.Items); err != nil {
		uc.logger.Error("falha ao liberar estoque", "error", err)
	}

	return uc.orderRepo.Update(ctx, order)
}

func generateID() string {
	// Implementação simples - em produção use UUID
	return fmt.Sprintf("ORD-%d", time.Now().UnixNano())
}

4. Infraestrutura (Implementações)

// internal/infrastructure/persistence/postgres/order_repository.go
package postgres

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"

	_ "github.com/lib/pq"

	"order-service/internal/domain"
	"order-service/internal/ports"
)

// OrderRepository implementa ports.OrderRepository para PostgreSQL
type OrderRepository struct {
	db *sql.DB
}

// Garante que implementa a interface
var _ ports.OrderRepository = (*OrderRepository)(nil)

// NewOrderRepository cria uma nova instância
func NewOrderRepository(db *sql.DB) *OrderRepository {
	return &OrderRepository{db: db}
}

func (r *OrderRepository) Create(ctx context.Context, order *domain.Order) error {
	// Serializar items para JSON
	itemsJSON, err := json.Marshal(order.Items)
	if err != nil {
		return fmt.Errorf("falha ao serializar items: %w", err)
	}

	query := `
		INSERT INTO orders (id, user_id, items, total_amount, total_currency, status, created_at, updated_at)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
	`

	_, err = r.db.ExecContext(ctx, query,
		order.ID,
		order.UserID,
		itemsJSON,
		order.Total.Amount,
		order.Total.Currency,
		order.Status,
		order.CreatedAt,
		order.UpdatedAt,
	)

	if err != nil {
		return fmt.Errorf("falha ao inserir pedido: %w", err)
	}

	return nil
}

func (r *OrderRepository) GetByID(ctx context.Context, id string) (*domain.Order, error) {
	query := `
		SELECT id, user_id, items, total_amount, total_currency, status, created_at, updated_at
		FROM orders
		WHERE id = $1
	`

	var order domain.Order
	var itemsJSON []byte

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&order.ID,
		&order.UserID,
		&itemsJSON,
		&order.Total.Amount,
		&order.Total.Currency,
		&order.Status,
		&order.CreatedAt,
		&order.UpdatedAt,
	)

	if err == sql.ErrNoRows {
		return nil, domain.ErrOrderNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("falha ao buscar pedido: %w", err)
	}

	// Desserializar items
	if err := json.Unmarshal(itemsJSON, &order.Items); err != nil {
		return nil, fmt.Errorf("falha ao desserializar items: %w", err)
	}

	return &order, nil
}

func (r *OrderRepository) GetByUserID(ctx context.Context, userID string, page, pageSize int) ([]*domain.Order, error) {
	offset := (page - 1) * pageSize

	query := `
		SELECT id, user_id, items, total_amount, total_currency, status, created_at, updated_at
		FROM orders
		WHERE user_id = $1
		ORDER BY created_at DESC
		LIMIT $2 OFFSET $3
	`

	rows, err := r.db.QueryContext(ctx, query, userID, pageSize, offset)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	return r.scanOrders(rows)
}

func (r *OrderRepository) Update(ctx context.Context, order *domain.Order) error {
	itemsJSON, _ := json.Marshal(order.Items)

	query := `
		UPDATE orders
		SET items = $1, total_amount = $2, status = $3, updated_at = $4
		WHERE id = $5
	`

	result, err := r.db.ExecContext(ctx, query,
		itemsJSON,
		order.Total.Amount,
		order.Status,
		order.UpdatedAt,
		order.ID,
	)
	if err != nil {
		return err
	}

	rows, _ := result.RowsAffected()
	if rows == 0 {
		return domain.ErrOrderNotFound
	}

	return nil
}

func (r *OrderRepository) Delete(ctx context.Context, id string) error {
	_, err := r.db.ExecContext(ctx, "DELETE FROM orders WHERE id = $1", id)
	return err
}

func (r *OrderRepository) UpdateStatus(ctx context.Context, id string, status domain.OrderStatus) error {
	_, err := r.db.ExecContext(ctx,
		"UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2",
		status, id,
	)
	return err
}

// Helper para scan múltiplas rows
func (r *OrderRepository) scanOrders(rows *sql.Rows) ([]*domain.Order, error) {
	var orders []*domain.Order

	for rows.Next() {
		var order domain.Order
		var itemsJSON []byte

		err := rows.Scan(
			&order.ID,
			&order.UserID,
			&itemsJSON,
			&order.Total.Amount,
			&order.Total.Currency,
			&order.Status,
			&order.CreatedAt,
			&order.UpdatedAt,
		)
		if err != nil {
			return nil, err
		}

		json.Unmarshal(itemsJSON, &order.Items)
		orders = append(orders, &order)
	}

	return orders, rows.Err()
}
// internal/infrastructure/http/handler.go
package http

import (
	"encoding/json"
	"net/http"

	"order-service/internal/application"
	"order-service/pkg/errors"
)

// OrderHandler lida com requisições HTTP de pedidos
type OrderHandler struct {
	useCase *application.OrderUseCase
}

func NewOrderHandler(useCase *application.OrderUseCase) *OrderHandler {
	return &OrderHandler{useCase: useCase}
}

func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	var input application.CreateOrderInput
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		h.respondError(w, http.StatusBadRequest, "JSON inválido")
		return
	}

	// Executar caso de uso
	output, err := h.useCase.CreateOrder(ctx, input)
	if err != nil {
		switch {
		case errors.Is(err, domain.ErrInsufficientStock):
			h.respondError(w, http.StatusConflict, "Estoque insuficiente")
		case errors.Is(err, domain.ErrInvalidAmount):
			h.respondError(w, http.StatusBadRequest, "Valor inválido")
		default:
			h.respondError(w, http.StatusInternalServerError, "Erro interno")
		}
		return
	}

	h.respondJSON(w, http.StatusCreated, output)
}

func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	orderID := r.URL.Query().Get("id")

	if orderID == "" {
		h.respondError(w, http.StatusBadRequest, "ID do pedido obrigatório")
		return
	}

	output, err := h.useCase.GetOrder(ctx, orderID)
	if err != nil {
		if errors.Is(err, domain.ErrOrderNotFound) {
			h.respondError(w, http.StatusNotFound, "Pedido não encontrado")
			return
		}
		h.respondError(w, http.StatusInternalServerError, "Erro interno")
		return
	}

	h.respondJSON(w, http.StatusOK, output)
}

func (h *OrderHandler) respondJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func (h *OrderHandler) respondError(w http.ResponseWriter, status int, message string) {
	h.respondJSON(w, status, map[string]string{"error": message})
}

5. Wire Everything (Dependency Injection)

// cmd/api/main.go
package main

import (
	"database/sql"
	"log"
	"net/http"

	_ "github.com/lib/pq"

	"order-service/internal/application"
	httphandler "order-service/internal/infrastructure/http"
	"order-service/internal/infrastructure/persistence/postgres"
	"order-service/pkg/logger"
)

func main() {
	// Configurações
	dbURL := "postgres://user:pass@localhost/orderdb?sslmode=disable"
	port := ":8080"

	// 1. Inicializar infraestrutura (fora -> dentro)
	log := logger.NewZapLogger()

	db, err := sql.Open("postgres", dbURL)
	if err != nil {
		log.Fatal("falha ao conectar no banco", "error", err)
	}
	defer db.Close()

	// 2. Criar repositórios
	orderRepo := postgres.NewOrderRepository(db)
	userRepo := postgres.NewUserRepository(db)

	// 3. Criar serviços externos (stubs para exemplo)
	paymentSvc := NewMockPaymentService()
	inventorySvc := NewMockInventoryService()
	notifier := NewMockNotificationService()

	// 4. Criar casos de uso (injetando dependências)
	orderUseCase := application.NewOrderUseCase(
		orderRepo,
		userRepo,
		paymentSvc,
		inventorySvc,
		notifier,
		log,
	)

	// 5. Criar handlers HTTP
	orderHandler := httphandler.NewOrderHandler(orderUseCase)

	// 6. Configurar rotas
	mux := http.NewServeMux()
	mux.HandleFunc("/orders", func(w http.ResponseWriter, r *http.Request) {
		switch r.Method {
		case http.MethodPost:
			orderHandler.CreateOrder(w, r)
		case http.MethodGet:
			orderHandler.GetOrder(w, r)
		default:
			w.WriteHeader(http.StatusMethodNotAllowed)
		}
	})

	log.Info("servidor iniciando", "port", port)
	if err := http.ListenAndServe(port, mux); err != nil {
		log.Fatal("falha no servidor", "error", err)
	}
}

Testes na Clean Architecture

Testando Casos de Uso (Mocks)

// internal/application/order_usecase_test.go
package application

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"

	"order-service/internal/domain"
	"order-service/internal/ports"
)

// Mock do repositório
type MockOrderRepository struct {
	mock.Mock
}

func (m *MockOrderRepository) Create(ctx context.Context, order *domain.Order) error {
	args := m.Called(ctx, order)
	return args.Error(0)
}

func (m *MockOrderRepository) GetByID(ctx context.Context, id string) (*domain.Order, error) {
	args := m.Called(ctx, id)
	return args.Get(0).(*domain.Order), args.Error(1)
}

// ... implementar outros métodos

func TestOrderUseCase_CreateOrder(t *testing.T) {
	// Arrange
	mockOrderRepo := new(MockOrderRepository)
	mockUserRepo := new(MockUserRepository)
	mockPaymentSvc := new(MockPaymentService)
	mockInventorySvc := new(MockInventoryService)
	mockNotifier := new(MockNotificationService)
	mockLogger := new(MockLogger)

	useCase := NewOrderUseCase(
		mockOrderRepo,
		mockUserRepo,
		mockPaymentSvc,
		mockInventorySvc,
		mockNotifier,
		mockLogger,
	)

	input := CreateOrderInput{
		UserID: "user-123",
		Items: []OrderItemInput{
			{ProductID: "prod-1", ProductName: "Produto 1", Quantity: 2, UnitPrice: 5000},
		},
	}

	// Configurar expectativas
	mockUserRepo.On("GetByID", mock.Anything, "user-123").
		Return(&domain.User{ID: "user-123", Active: true}, nil)
	
	mockInventorySvc.On("CheckAvailability", mock.Anything, "prod-1", 2).
		Return(true, nil)
	
	mockInventorySvc.On("ReserveItems", mock.Anything, mock.Anything).
		Return(nil)
	
	mockOrderRepo.On("Create", mock.Anything, mock.AnythingOfType("*domain.Order")).
		Return(nil)

	// Act
	output, err := useCase.CreateOrder(context.Background(), input)

	// Assert
	assert.NoError(t, err)
	assert.NotNil(t, output)
	assert.Equal(t, "user-123", output.UserID)
	assert.Equal(t, int64(10000), output.Total.Amount) // 2 * 5000

	// Verificar que métodos foram chamados
	mockOrderRepo.AssertExpectations(t)
	mockInventorySvc.AssertExpectations(t)
}

func TestOrderUseCase_CreateOrder_UserInactive(t *testing.T) {
	mockOrderRepo := new(MockOrderRepository)
	mockUserRepo := new(MockUserRepository)
	mockLogger := new(MockLogger)

	useCase := NewOrderUseCase(
		mockOrderRepo, mockUserRepo, nil, nil, nil, mockLogger,
	)

	input := CreateOrderInput{UserID: "user-123", Items: []OrderItemInput{{ProductID: "p1", Quantity: 1}}}

	// Usuário inativo
	mockUserRepo.On("GetByID", mock.Anything, "user-123").
		Return(&domain.User{ID: "user-123", Active: false}, nil)

	output, err := useCase.CreateOrder(context.Background(), input)

	assert.Error(t, err)
	assert.Equal(t, domain.ErrUserInactive, err)
	assert.Nil(t, output)
}
// Testando regras de domínio (sem mocks!)
func TestOrder_CalculateTotal(t *testing.T) {
	order := &domain.Order{
		Items: []domain.OrderItem{
			{Quantity: 2, UnitPrice: domain.Money{Amount: 1000, Currency: "BRL"}},
			{Quantity: 1, UnitPrice: domain.Money{Amount: 500, Currency: "BRL"}},
		},
	}

	order.CalculateTotal()

	assert.Equal(t, int64(2500), order.Total.Amount) // 2*1000 + 1*500
	assert.Equal(t, "BRL", order.Total.Currency)
}

func TestOrder_StatusTransitions(t *testing.T) {
	tests := []struct {
		name       string
		from       domain.OrderStatus
		to         domain.OrderStatus
		shouldWork bool
	}{
		{"Pending -> Paid", domain.OrderStatusPending, domain.OrderStatusPaid, true},
		{"Pending -> Shipped", domain.OrderStatusPending, domain.OrderStatusShipped, false},
		{"Paid -> Shipped", domain.OrderStatusPaid, domain.OrderStatusShipped, true},
		{"Shipped -> Paid", domain.OrderStatusShipped, domain.OrderStatusPaid, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			order := &domain.Order{Status: tt.from}
			can := order.CanTransitionTo(tt.to)
			assert.Equal(t, tt.shouldWork, can)
		})
	}
}

Resumo da Arquitetura

┌─────────────────────────────────────────────────────────────┐
│                    INFRASTRUCTURE                           │
│  (HTTP Handlers, Postgres, Stripe, Kafka, Redis)            │
│                                                             │
│  Depende de: Application                                    │
└─────────────────────────────────────────────────────────────┘
                            │ Usa interfaces (Ports)
┌─────────────────────────────────────────────────────────────┐
│                    APPLICATION                              │
│  (OrderUseCase, UserUseCase)                                │
│                                                             │
│  Depende de: Domain, Ports                                  │
└─────────────────────────────────────────────────────────────┘
                            │ Usa entidades
┌─────────────────────────────────────────────────────────────┐
│                    DOMAIN                                   │
│  (Order, User - regras de negócio puras)                    │
│                                                             │
│  Não depende de ninguém!                                    │
└─────────────────────────────────────────────────────────────┘

Checklist antes de adotar a arquitetura

Antes de copiar toda a árvore de diretórios, confirme se você consegue responder “sim” a pelo menos parte destas perguntas:

  • existem regras de negócio que devem ser testadas sem HTTP ou banco;
  • o serviço conversa com mais de um sistema externo;
  • trocar um adapter exige hoje mudanças espalhadas pela aplicação;
  • handlers conhecem SQL, detalhes de cache ou regras de domínio;
  • o projeto terá manutenção longa ou contribuição de vários times;
  • a composição manual de dependências já ficou difícil de visualizar.

Se quase todas as respostas forem “não”, mantenha o projeto menor. Se forem “sim”, implemente a separação gradualmente: primeiro extraia o caso de uso, depois defina a interface mínima de que ele precisa e, por último, mova o detalhe externo para um adapter. O guia de dependency injection em Go sem framework mostra essa composição passo a passo. Se o grafo realmente crescer, compare Google Wire e Uber Fx antes de adicionar uma ferramenta.

Próximos passos

Continue seu aprendizado:

  1. Clean Architecture sem overengineering — critérios e trade-offs para não criar camadas desnecessárias
  2. API REST em Go — transporte HTTP, JSON, validação e testes
  3. Dependency injection sem framework — composição explícita de dependências
  4. Go para Microserviços — arquitetura distribuída
  5. Testes em Go — testes unitários, integração e boas práticas

Clean Architecture em Go funciona melhor quando cada camada paga o próprio custo em clareza, testes ou liberdade para mudar.

Perguntas frequentes

O que é Clean Architecture em Go?

É uma forma de organizar o código para que regras de negócio não dependam de HTTP, banco de dados, filas ou frameworks. Em Go, isso costuma significar domínio e casos de uso no centro, interfaces pequenas definidas perto de quem as consome e adapters externos implementando esses contratos.

Qual estrutura de pastas usar em Clean Architecture com Go?

Uma estrutura prática usa cmd para os executáveis e internal para os pacotes da aplicação. Dentro de internal, separe por domínio ou recurso e mantenha handlers, casos de uso e adapters apenas quando essa divisão resolver um acoplamento real. Não existe uma árvore oficial obrigatória.

Todo projeto Go precisa de Clean Architecture?

Não. Um serviço pequeno, CRUD simples ou protótipo pode funcionar melhor com poucos pacotes e dependências explícitas. A arquitetura passa a valer o custo quando há regras de negócio importantes, vários adapters externos, necessidade forte de testes isolados ou evolução por mais de um time.

Onde devem ficar as interfaces em um projeto Go?

Prefira interfaces pequenas no pacote consumidor, normalmente junto ao caso de uso que precisa delas. O adapter de PostgreSQL, Redis ou API externa implementa o contrato implicitamente. Evite criar interfaces para toda struct antes de existir necessidade de substituição ou teste.

Preciso usar Wire ou outro framework de dependency injection?

Não. Na maioria dos serviços Go, constructors e composição explícita no main são suficientes. Wire ou Fx podem ajudar quando o grafo de dependências e o ciclo de vida da aplicação ficam grandes, mas adicionam ferramentas e convenções que precisam justificar seu custo.