La arquitectura hexagonal, también conocida como ports & adapters, organiza el código en capas con dependencias que siempre apuntan hacia el centro: el dominio. El código de negocio no sabe nada de bases de datos, HTTP ni librerías externas. Lo que cambia con el tiempo (proveedores, frameworks, protocolos) vive en la periferia.
Estructura de directorios
miapp/
??? cmd/
? ??? server/
? ??? main.go // punto de entrada, wire
??? internal/
? ??? domain/ // entidades y lógica de negocio
? ? ??? usuario.go
? ? ??? pedido.go
? ??? ports/ // interfaces (contracts)
? ? ??? repositories.go
? ? ??? services.go
? ??? application/ // casos de uso
? ? ??? crear_usuario.go
? ? ??? realizar_pedido.go
? ??? adapters/ // implementaciones concretas
? ??? postgres/
? ? ??? usuario_repo.go
? ??? smtp/
? ??? email_service.go
??? pkg/ // código reutilizable sin dependencias internas
??? pagination/
??? pagination.go
El dominio: sin dependencias externas
// internal/domain/usuario.go
package domain
import (
"errors"
"time"
)
type Usuario struct {
ID int
Nombre string
Email string
Activo bool
CreadoEn time.Time
}
var (
ErrEmailVacio = errors.New("el email no puede estar vacío")
ErrNombreVacio = errors.New("el nombre no puede estar vacío")
ErrUsuarioNoExiste = errors.New("usuario no encontrado")
)
func NuevoUsuario(nombre, email string) (*Usuario, error) {
if nombre == "" {
return nil, ErrNombreVacio
}
if email == "" {
return nil, ErrEmailVacio
}
return &Usuario{
Nombre: nombre,
Email: email,
Activo: true,
CreadoEn: time.Now(),
}, nil
}
func (u *Usuario) Desactivar() {
u.Activo = false
}
Ports: interfaces que define el dominio
// internal/ports/repositories.go
package ports
import "tuapp/internal/domain"
type UsuarioRepository interface {
Guardar(u *domain.Usuario) error
BuscarPorID(id int) (*domain.Usuario, error)
BuscarPorEmail(email string) (*domain.Usuario, error)
Listar(pagina, tamano int) ([]*domain.Usuario, int, error)
}
// internal/ports/services.go
type EmailService interface {
EnviarBienvenida(email, nombre string) error
EnviarRecuperacion(email, token string) error
}
Application: casos de uso que orquestan el dominio
// internal/application/crear_usuario.go
package application
import (
"tuapp/internal/domain"
"tuapp/internal/ports"
)
type CrearUsuarioInput struct {
Nombre string
Email string
}
type CrearUsuarioOutput struct {
ID int
Email string
}
type CrearUsuarioUseCase struct {
repo ports.UsuarioRepository
email ports.EmailService
}
func NewCrearUsuarioUseCase(
repo ports.UsuarioRepository,
email ports.EmailService,
) *CrearUsuarioUseCase {
return &CrearUsuarioUseCase{repo: repo, email: email}
}
func (uc *CrearUsuarioUseCase) Ejecutar(input CrearUsuarioInput) (*CrearUsuarioOutput, error) {
u, err := domain.NuevoUsuario(input.Nombre, input.Email)
if err != nil {
return nil, err
}
existente, _ := uc.repo.BuscarPorEmail(input.Email)
if existente != nil {
return nil, errors.New("el email ya está registrado")
}
if err := uc.repo.Guardar(u); err != nil {
return nil, fmt.Errorf("guardando usuario: %w", err)
}
// Efecto secundario: no bloquea la respuesta
go uc.email.EnviarBienvenida(u.Email, u.Nombre)
return &CrearUsuarioOutput{ID: u.ID, Email: u.Email}, nil
}
Adapters: implementaciones concretas en la periferia
// internal/adapters/postgres/usuario_repo.go
package postgres
import (
"database/sql"
"tuapp/internal/domain"
"tuapp/internal/ports"
)
type usuarioRepoDB struct {
db *sql.DB
}
func NewUsuarioRepo(db *sql.DB) ports.UsuarioRepository {
return &usuarioRepoDB{db: db}
}
func (r *usuarioRepoDB) Guardar(u *domain.Usuario) error {
row := r.db.QueryRow(
"INSERT INTO usuarios (nombre, email, activo, creado_en) VALUES (?,?,?,?) RETURNING id",
u.Nombre, u.Email, u.Activo, u.CreadoEn,
)
return row.Scan(&u.ID)
}
Antipatrón: interfaces con un solo implementador
El error más común en proyectos Go con arquitectura hexagonal es crear interfaces para todo, incluidas clases que solo tienen una implementación y nunca tendrán otra. Esto añade indirección sin beneficio:
// MAL: interfaz para algo que nunca va a variar
type LoggerService interface {
Log(msg string)
}
// BIEN: si solo hay una implementación y no la testeas con mock, usa el tipo directamente
type Logger struct {
level string
}
func (l *Logger) Log(msg string) { ... }
Feature-first vs. layer-first
La estructura layer-first (domain/, ports/, application/, adapters/) funciona bien en proyectos medianos. Para proyectos grandes con muchos features independientes, la organización feature-first (usuarios/, pedidos/, facturacion/) escala mejor porque mantiene el código relacionado junto:
internal/
??? usuarios/
? ??? domain.go
? ??? repository.go // interfaz
? ??? service.go // caso de uso
? ??? postgres_repo.go // implementación
??? pedidos/
??? domain.go
??? repository.go
??? service.go
