Arquitectura limpia en Go: hexagonal, ports & adapters y organización de paquetes a escala

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

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP