Clean architecture em Go: organizando por camadas

1. Introdução à Clean Architecture no Contexto Go

A Clean Architecture, proposta por Robert C. Martin, encontra em Go um terreno fértil para sua implementação. A simplicidade da linguagem, seu sistema de pacotes bem definido e as interfaces implícitas criam um ambiente natural para aplicar os princípios de separação por camadas.

Em Go, não precisamos de frameworks complexos para implementar inversão de dependência. Uma interface com dois métodos e um construtor que recebe essa interface já estabelece o contrato necessário. Essa simplicidade reduz o boilerplate e mantém o foco no que realmente importa: as regras de negócio.

Os princípios fundamentais adaptados ao ecossistema Go são:
- Entidades (camada mais interna): structs de domínio sem dependências externas
- Casos de Uso: orquestração de regras de negócio com interfaces definidas pelo domínio
- Adaptadores de Interface: conversão entre o mundo externo e o domínio
- Frameworks & Drivers: configurações de infraestrutura na periferia

A regra de ouro: dependências apontam para dentro. Camadas internas nunca sabem sobre camadas externas.

2. Camada de Entidades (Entities / Domain)

As entidades são o coração do sistema. Em Go, representamos entidades como structs com métodos de negócio. Esta camada não importa nada — zero dependências externas.

package domain

import (
    "errors"
    "time"
)

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

func NewUser(name, email string) (*User, error) {
    if name == "" {
        return nil, errors.New("name cannot be empty")
    }
    if email == "" {
        return nil, errors.New("email cannot be empty")
    }
    return &User{
        ID:        generateUUID(),
        Name:      name,
        Email:     email,
        CreatedAt: time.Now(),
    }, nil
}

func (u *User) UpdateEmail(newEmail string) error {
    if newEmail == "" {
        return errors.New("email cannot be empty")
    }
    u.Email = newEmail
    return nil
}
package domain

import (
    "errors"
    "time"
)

type OrderStatus string

const (
    OrderPending   OrderStatus = "pending"
    OrderConfirmed OrderStatus = "confirmed"
    OrderShipped   OrderStatus = "shipped"
)

type Order struct {
    ID         string
    UserID     string
    Items      []OrderItem
    Status     OrderStatus
    Total      float64
    CreatedAt  time.Time
}

type OrderItem struct {
    ProductID string
    Quantity  int
    Price     float64
}

func NewOrder(userID string, items []OrderItem) (*Order, error) {
    if userID == "" {
        return nil, errors.New("user ID is required")
    }
    if len(items) == 0 {
        return nil, errors.New("order must have at least one item")
    }

    var total float64
    for _, item := range items {
        if item.Quantity <= 0 {
            return nil, errors.New("item quantity must be positive")
        }
        total += item.Price * float64(item.Quantity)
    }

    return &Order{
        ID:        generateUUID(),
        UserID:    userID,
        Items:     items,
        Status:    OrderPending,
        Total:     total,
        CreatedAt: time.Now(),
    }, nil
}

func (o *Order) Confirm() error {
    if o.Status != OrderPending {
        return errors.New("only pending orders can be confirmed")
    }
    o.Status = OrderConfirmed
    return nil
}

3. Camada de Casos de Uso (Use Cases / Application)

Os casos de uso orquestram as regras de negócio. Eles definem interfaces que serão implementadas pelos adaptadores, mantendo o domínio puro.

package application

import (
    "context"
    "your-project/domain"
)

// Interfaces definidas pelo domínio
type UserRepository interface {
    Save(ctx context.Context, user *domain.User) error
    FindByID(ctx context.Context, id string) (*domain.User, error)
    FindByEmail(ctx context.Context, email string) (*domain.User, error)
}

type OrderRepository interface {
    Save(ctx context.Context, order *domain.Order) error
    FindByUserID(ctx context.Context, userID string) ([]*domain.Order, error)
}

// DTOs de entrada e saída
type CreateUserInput struct {
    Name  string
    Email string
}

type CreateUserOutput struct {
    ID    string
    Name  string
    Email string
}

// Caso de uso
type CreateUserUseCase struct {
    userRepo UserRepository
}

func NewCreateUserUseCase(userRepo UserRepository) *CreateUserUseCase {
    return &CreateUserUseCase{userRepo: userRepo}
}

func (uc *CreateUserUseCase) Execute(ctx context.Context, input CreateUserInput) (*CreateUserOutput, error) {
    // Verificar se email já existe
    existing, _ := uc.userRepo.FindByEmail(ctx, input.Email)
    if existing != nil {
        return nil, ErrEmailAlreadyExists
    }

    // Criar entidade de domínio
    user, err := domain.NewUser(input.Name, input.Email)
    if err != nil {
        return nil, err
    }

    // Persistir
    if err := uc.userRepo.Save(ctx, user); err != nil {
        return nil, err
    }

    return &CreateUserOutput{
        ID:    user.ID,
        Name:  user.Name,
        Email: user.Email,
    }, nil
}
package application

import (
    "context"
    "your-project/domain"
)

type ListOrdersInput struct {
    UserID string
}

type ListOrdersOutput struct {
    Orders []OrderOutput
}

type OrderOutput struct {
    ID        string
    Status    string
    Total     float64
    CreatedAt string
}

type ListOrdersUseCase struct {
    orderRepo OrderRepository
}

func NewListOrdersUseCase(orderRepo OrderRepository) *ListOrdersUseCase {
    return &ListOrdersUseCase{orderRepo: orderRepo}
}

func (uc *ListOrdersUseCase) Execute(ctx context.Context, input ListOrdersInput) (*ListOrdersOutput, error) {
    orders, err := uc.orderRepo.FindByUserID(ctx, input.UserID)
    if err != nil {
        return nil, err
    }

    var output ListOrdersOutput
    for _, order := range orders {
        output.Orders = append(output.Orders, OrderOutput{
            ID:        order.ID,
            Status:    string(order.Status),
            Total:     order.Total,
            CreatedAt: order.CreatedAt.Format("2006-01-02T15:04:05Z"),
        })
    }

    return &output, nil
}

4. Camada de Adaptadores de Interface (Interface Adapters)

Os adaptadores convertem dados entre o mundo externo e o domínio. Handlers HTTP recebem requisições e chamam casos de uso. Repositórios concretos implementam as interfaces definidas pelo domínio.

package handlers

import (
    "encoding/json"
    "net/http"
    "your-project/application"
)

type UserHandler struct {
    createUserUseCase *application.CreateUserUseCase
    listOrdersUseCase *application.ListOrdersUseCase
}

func NewUserHandler(
    createUser *application.CreateUserUseCase,
    listOrders *application.ListOrdersUseCase,
) *UserHandler {
    return &UserHandler{
        createUserUseCase: createUser,
        listOrdersUseCase: listOrders,
    }
}

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var input application.CreateUserInput
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }

    output, err := h.createUserUseCase.Execute(r.Context(), input)
    if err != nil {
        http.Error(w, err.Error(), http.StatusUnprocessableEntity)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(output)
}
package repositories

import (
    "context"
    "database/sql"
    "your-project/application"
    "your-project/domain"
)

type PostgresUserRepository struct {
    db *sql.DB
}

func NewPostgresUserRepository(db *sql.DB) *PostgresUserRepository {
    return &PostgresUserRepository{db: db}
}

func (r *PostgresUserRepository) Save(ctx context.Context, user *domain.User) error {
    query := `INSERT INTO users (id, name, email, created_at) VALUES ($1, $2, $3, $4)`
    _, err := r.db.ExecContext(ctx, query, user.ID, user.Name, user.Email, user.CreatedAt)
    return err
}

func (r *PostgresUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
    query := `SELECT id, name, email, created_at FROM users WHERE id = $1`
    row := r.db.QueryRowContext(ctx, query, id)

    var user domain.User
    err := row.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt)
    if err != nil {
        return nil, err
    }
    return &user, nil
}

func (r *PostgresUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
    query := `SELECT id, name, email, created_at FROM users WHERE email = $1`
    row := r.db.QueryRowContext(ctx, query, email)

    var user domain.User
    err := row.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt)
    if err != nil {
        return nil, err
    }
    return &user, nil
}

5. Camada de Frameworks e Drivers (Infrastructure)

Esta é a camada mais externa, onde configuramos banco de dados, servidores HTTP e frameworks. Aqui aplicamos a inversão de dependência: injetamos implementações concretas nas abstrações definidas pelo domínio.

package main

import (
    "database/sql"
    "log"
    "net/http"
    "your-project/application"
    "your-project/handlers"
    "your-project/repositories"
)

func main() {
    // Configurar banco de dados
    db, err := sql.Open("postgres", "postgres://user:password@localhost/mydb?sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Instanciar repositórios (adaptadores de saída)
    userRepo := repositories.NewPostgresUserRepository(db)
    orderRepo := repositories.NewPostgresOrderRepository(db)

    // Instanciar casos de uso
    createUserUseCase := application.NewCreateUserUseCase(userRepo)
    listOrdersUseCase := application.NewListOrdersUseCase(orderRepo)

    // Instanciar handlers (adaptadores de entrada)
    userHandler := handlers.NewUserHandler(createUserUseCase, listOrdersUseCase)

    // Configurar rotas HTTP
    mux := http.NewServeMux()
    mux.HandleFunc("POST /users", userHandler.CreateUser)
    mux.HandleFunc("GET /users/{id}/orders", userHandler.ListOrders)

    // Iniciar servidor
    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

6. Fluxo de Requisição Através das Camadas

Quando uma requisição HTTP chega, o fluxo percorre as camadas de fora para dentro e depois retorna:

[HTTP Request]
    ↓
[Handler] (Interface Adapter) - Converte JSON para DTO
    ↓
[Use Case] (Application) - Orquestra regras de negócio
    ↓
[Repository Interface] (definida no domínio)
    ↓
[PostgresRepository] (Interface Adapter) - Acessa banco
    ↓
[Database] (Framework/Driver)

Cada camada tem responsabilidades claras e pode ser testada isoladamente. Os handlers testam serialização/deserialização. Os casos de uso testam lógica de negócio com mocks de repositórios. Os repositórios testam queries com testcontainers.

Dependências apontam para dentro:

Frameworks → Interface Adapters → Application → Domain
   (infra)      (handlers, repos)   (use cases)   (entities)

7. Testes e Manutenibilidade

A organização por camadas torna os testes muito mais simples. Casos de uso podem ser testados com mocks das interfaces de repositório:

func TestCreateUserUseCase(t *testing.T) {
    mockRepo := new(MockUserRepository)
    mockRepo.On("FindByEmail", mock.Anything, "test@test.com").Return(nil, nil)
    mockRepo.On("Save", mock.Anything, mock.Anything).Return(nil)

    useCase := application.NewCreateUserUseCase(mockRepo)
    input := application.CreateUserInput{Name: "John", Email: "test@test.com"}

    output, err := useCase.Execute(context.Background(), input)
    assert.NoError(t, err)
    assert.NotEmpty(t, output.ID)
    assert.Equal(t, "John", output.Name)
}

Os benefícios são claros:
- Testabilidade: cada camada é testável independentemente
- Manutenibilidade: mudar de banco de dados? Só trocar o repositório
- Evolução: adicionar novos casos de uso sem impactar camadas existentes
- Clareza: a estrutura do código reflete a arquitetura

A Clean Architecture em Go não é sobre ferramentas ou frameworks — é sobre disciplina na organização do código. Comece pequeno, mantenha as camadas separadas e veja seu sistema crescer de forma sustentável.

Referências