Hexagonal architecture com ports and adapters

1. Introdução à Arquitetura Hexagonal

A Arquitetura Hexagonal, também conhecida como Ports and Adapters, foi proposta por Alistair Cockburn em 2005 como uma forma de criar sistemas isolados do mundo exterior. O princípio fundamental é manter o núcleo de negócio (domínio) completamente independente de tecnologias externas como bancos de dados, frameworks web ou sistemas de mensageria.

A motivação principal é o desacoplamento: o código de negócio não deve saber se está sendo executado via HTTP, gRPC ou CLI. Da mesma forma, não deve conhecer detalhes de persistência ou serviços externos. Isso é alcançado através de:

  • Domínio: regras de negócio puras, sem dependências externas
  • Portas (interfaces): contratos que definem como o domínio se comunica com o mundo externo
  • Adaptadores: implementações concretas das portas para tecnologias específicas

2. Estrutura de Diretórios e Organização do Projeto

A organização típica de um projeto Go seguindo arquitetura hexagonal é:

meu-projeto/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── core/
│   │   ├── entity.go
│   │   └── service.go
│   ├── ports/
│   │   ├── in/
│   │   │   └── user_usecase.go
│   │   └── out/
│   │       └── user_repository.go
│   └── adapters/
│       ├── in/
│       │   ├── http/
│       │   │   └── user_handler.go
│       │   └── grpc/
│       │       └── user_server.go
│       └── out/
│           ├── postgres/
│           │   └── user_repository.go
│           └── cache/
│               └── redis_cache.go
├── go.mod
└── go.sum

Cada diretório tem responsabilidades claras:
- core: entidades e serviços de domínio sem dependências externas
- ports: interfaces que definem contratos
- adapters: implementações concretas que conectam o core ao mundo externo

3. Definindo o Core Domain

O core domain contém as regras de negócio puras. Vamos criar um exemplo de sistema de usuários:

// internal/core/entity.go
package core

import "errors"

type User struct {
    ID    string
    Name  string
    Email string
}

func NewUser(id, name, email string) (*User, error) {
    if id == "" || name == "" || email == "" {
        return nil, errors.New("all fields are required")
    }
    return &User{ID: id, Name: name, Email: email}, nil
}
// internal/core/service.go
package core

import "context"

type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) CreateUser(ctx context.Context, id, name, email string) (*User, error) {
    user, err := NewUser(id, name, email)
    if err != nil {
        return nil, err
    }
    return s.repo.Save(ctx, user)
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
    return s.repo.FindByID(ctx, id)
}

Note que UserService depende de UserRepository, que é uma interface. O serviço não conhece a implementação concreta.

4. Portas (Interfaces) de Entrada e Saída

As portas são interfaces que definem contratos. Dividimos em portas de entrada (casos de uso) e portas de saída (repositórios, serviços externos).

// internal/ports/in/user_usecase.go
package ports_in

import (
    "context"
    "meu-projeto/internal/core"
)

type UserUsecase interface {
    CreateUser(ctx context.Context, id, name, email string) (*core.User, error)
    GetUser(ctx context.Context, id string) (*core.User, error)
}
// internal/ports/out/user_repository.go
package ports_out

import (
    "context"
    "meu-projeto/internal/core"
)

type UserRepository interface {
    Save(ctx context.Context, user *core.User) (*core.User, error)
    FindByID(ctx context.Context, id string) (*core.User, error)
}

5. Adaptadores de Entrada (Drivers)

Adaptadores de entrada recebem requisições do mundo externo e as traduzem para chamadas às portas de entrada.

// internal/adapters/in/http/user_handler.go
package http

import (
    "encoding/json"
    "net/http"
    "meu-projeto/internal/ports/in"
)

type UserHandler struct {
    usecase ports_in.UserUsecase
}

func NewUserHandler(usecase ports_in.UserUsecase) *UserHandler {
    return &UserHandler{usecase: usecase}
}

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    var req struct {
        ID    string `json:"id"`
        Name  string `json:"name"`
        Email string `json:"email"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    user, err := h.usecase.CreateUser(r.Context(), req.ID, req.Name, req.Email)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

6. Adaptadores de Saída (Driven)

Adaptadores de saída implementam as interfaces definidas nas portas de saída.

// internal/adapters/out/postgres/user_repository.go
package postgres

import (
    "context"
    "database/sql"
    "meu-projeto/internal/core"
    "meu-projeto/internal/ports/out"
)

type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Save(ctx context.Context, user *core.User) (*core.User, error) {
    _, err := r.db.ExecContext(ctx, 
        "INSERT INTO users (id, name, email) VALUES ($1, $2, $3)",
        user.ID, user.Name, user.Email)
    if err != nil {
        return nil, err
    }
    return user, nil
}

func (r *UserRepository) FindByID(ctx context.Context, id string) (*core.User, error) {
    row := r.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)
    user := &core.User{}
    if err := row.Scan(&user.ID, &user.Name, &user.Email); err != nil {
        return nil, err
    }
    return user, nil
}

7. Injeção de Dependência e Inicialização

No main.go, conectamos todas as peças através de injeção de dependência manual:

// cmd/server/main.go
package main

import (
    "database/sql"
    "log"
    "net/http"
    "meu-projeto/internal/adapters/in/http"
    "meu-projeto/internal/adapters/out/postgres"
    "meu-projeto/internal/core"
)

func main() {
    // Conexão com banco de dados
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/mydb")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Adaptador de saída
    userRepo := postgres.NewUserRepository(db)

    // Core domain
    userService := core.NewUserService(userRepo)

    // Adaptador de entrada
    userHandler := http.NewUserHandler(userService)

    // Configuração HTTP
    http.HandleFunc("/users", userHandler.CreateUser)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

8. Testabilidade e Benefícios Práticos

A arquitetura hexagonal torna os testes muito mais fáceis. Podemos testar o core domain com mocks:

// internal/core/service_test.go
package core_test

import (
    "context"
    "testing"
    "meu-projeto/internal/core"
)

type mockUserRepository struct {
    users map[string]*core.User
}

func (m *mockUserRepository) Save(ctx context.Context, user *core.User) (*core.User, error) {
    m.users[user.ID] = user
    return user, nil
}

func (m *mockUserRepository) FindByID(ctx context.Context, id string) (*core.User, error) {
    return m.users[id], nil
}

func TestCreateUser(t *testing.T) {
    repo := &mockUserRepository{users: make(map[string]*core.User)}
    service := core.NewUserService(repo)

    user, err := service.CreateUser(context.Background(), "1", "João", "joao@email.com")
    if err != nil {
        t.Fatal(err)
    }
    if user.Name != "João" {
        t.Errorf("expected João, got %s", user.Name)
    }
}

Os benefícios práticos são evidentes:
- Substituição de infraestrutura: trocar PostgreSQL por MongoDB requer apenas um novo adaptador
- Testes isolados: o core domain é testado sem dependências externas
- Manutenção: alterações em frameworks ou bibliotecas não afetam as regras de negócio

A arquitetura hexagonal em Go é uma escolha natural, já que a linguagem promove o uso de interfaces e composição. A combinação de portas (interfaces) e adaptadores (implementações) cria um sistema modular, testável e resiliente a mudanças tecnológicas.

Referências