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
- Alistair Cockburn - Hexagonal Architecture — Artigo original do criador da arquitetura hexagonal explicando os conceitos fundamentais
- Go Documentation - Effective Go — Guia oficial de boas práticas Go, incluindo uso de interfaces e composição
- Matt Boldt - Hexagonal Architecture in Go — Tutorial prático com exemplos completos de implementação em Go
- Domain-Driven Design with Go — Série de artigos sobre DDD em Go com exemplos de arquitetura hexagonal
- Testcontainers for Go — Biblioteca para testes de integração com containers Docker, útil para testar adaptadores de saída