Arquitectura limpia en Go: hasta dónde tiene sentido aplicarla

Clean architecture y hexagonal en Go: capas, interfaces, trade-offs y cuándo la arquitectura ayuda vs cuándo mata la simplicidad de Go.

Cover for Arquitectura limpia en Go: hasta dónde tiene sentido aplicarla

Mi primer proyecto en Go parecía una aplicación Spring Boot sin Spring. Cuatro capas, interfaces por todas partes, un contenedor de inyección de dependencias y carpetas anidadas que necesitaban cinco niveles de profundidad para llegar al handler. Compilaba rápido, sí. Pero navegar el código era una pesadilla. Cada cambio mínimo requería tocar tres archivos y dos interfaces. Había traído mis hábitos de Java/Spring a un lenguaje que fue diseñado con una filosofía radicalmente diferente.

El problema no era la clean architecture en sí. Era que la estaba aplicando sin cuestionar si cada capa, cada abstracción y cada interfaz tenían sentido en el contexto de Go. Y no lo tenían.

Desde entonces he reescrito proyectos, he trabajado con equipos que cometen el mismo error y he llegado a un punto donde creo que tengo bastante claro cuándo la arquitectura ayuda y cuándo mata exactamente lo que hace atractivo a Go. Aunque, siendo honestos, sigo descubriendo matices.


Qué es la clean architecture (sin la definición de libro)

Robert C. Martin la formalizó. El concepto es simple: separar el software en capas concéntricas donde las dependencias apuntan siempre hacia dentro, hacia las reglas de negocio. La capa exterior (frameworks, bases de datos, HTTP) depende de la interior (casos de uso, entidades), nunca al revés.

La arquitectura hexagonal (ports and adapters) de Alistair Cockburn llega a lo mismo por otro camino: tu lógica de negocio define puertos (interfaces) y el mundo exterior proporciona adaptadores que los implementan. Base de datos, API HTTP, cola de mensajes… son adaptadores intercambiables.

En la práctica, ambas convergen en lo mismo:

  • Entidades / dominio: las reglas de negocio puras.
  • Casos de uso / application: la orquestación de esas reglas.
  • Adaptadores / infrastructure: la implementación concreta de persistencia, HTTP, etc.
  • Frameworks / drivers: el pegamento con el mundo exterior.

En Java o C# esto tiene sentido porque los lenguajes te empujan hacia ahí. Tienes un framework pesado (Spring, ASP.NET) que necesitas aislar. Tienes un contenedor de inyección de dependencias que resuelve grafos complejos de objetos. Tienes herencia y polimorfismo explícito que hacen que las interfaces sean baratas de mantener.

Go no funciona así. Y ese es el punto de fricción.


Por qué la gente trae clean architecture a Go

La respuesta corta: porque venimos de Java, C# o algún lenguaje con frameworks pesados. Y no es que esté mal tener esa experiencia, al contrario. El problema es otro.

Cuando llevas años trabajando con Spring Boot, tu cerebro tiene un molde. Controlador, servicio, repositorio, DTO, mapper, interfaz para cada dependencia, contenedor de DI. Lo haces en piloto automático. Cuando empiezas con Go, el primer impulso es replicar ese molde. Y Go te lo permite, porque es lo suficientemente flexible. Pero que puedas hacerlo no significa que debas.

He visto proyectos Go con esta estructura:

project/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── domain/
│   │   ├── entity/
│   │   │   └── user.go
│   │   ├── repository/
│   │   │   └── user_repository.go     // interfaz
│   │   └── service/
│   │       └── user_service.go        // interfaz
│   ├── application/
│   │   └── usecase/
│   │       └── create_user.go
│   ├── infrastructure/
│   │   ├── persistence/
│   │   │   └── postgres_user_repo.go
│   │   ├── http/
│   │   │   └── user_handler.go
│   │   └── di/
│   │       └── container.go
│   └── adapter/
│       └── dto/
│           └── user_dto.go
└── pkg/

Si vienes de Spring Boot, esto te resulta familiar. Si llevas tiempo con Go, probablemente te provoca una reacción visceral. No porque la separación sea mala en sí misma, sino porque la granularidad es excesiva para lo que Go necesita.

Un handler que llama a un caso de uso que llama a un servicio que llama a un repositorio… para guardar un usuario en PostgreSQL. Cuatro niveles de indirección. En Go, donde la navegación por código es una de las mejores experiencias que existen gracias a gopls, acabas saltando entre archivos sin ganar nada a cambio.


Lo que Go ya te da: paquetes como fronteras

Pero antes de meter capas, creo que conviene pararse un momento a entender qué mecanismos de organización te da Go de serie. Porque a veces la respuesta ya está ahí.

Paquetes como límites de módulo

En Go, un paquete es un límite real. Lo que empieza con mayúscula es público; lo que no, es privado al paquete. No necesitas interfaces para ocultar implementación. El sistema de visibilidad del paquete ya lo hace.

// internal/user/store.go
package user

// store es privado al paquete. Nadie fuera puede usarlo directamente.
type store struct {
    db *sql.DB
}

func (s *store) Create(ctx context.Context, u User) error {
    _, err := s.db.ExecContext(ctx,
        "INSERT INTO users (name, email) VALUES ($1, $2)",
        u.Name, u.Email,
    )
    return err
}

El paquete user expone lo que decide exponer. No necesitas una interfaz UserRepository en otro paquete para lograr encapsulación. El paquete es la frontera.

Interfaces implícitas

Y aquí está la diferencia fundamental con Java o C#. En Go, una interfaz se satisface implícitamente. No declaras implements. Si tu struct tiene los métodos, cumple la interfaz. Punto.

Esto cambia, de una forma que al principio no es obvia, dónde y cuándo defines interfaces. En Java defines la interfaz donde vive la implementación (o en el módulo de dominio). En Go, la convención idiomática es definir la interfaz donde se consume, no donde se implementa.

// internal/order/service.go
package order

// El servicio de pedidos necesita buscar usuarios.
// Define la interfaz aquí, donde la consume.
type UserFinder interface {
    FindByID(ctx context.Context, id string) (User, error)
}

type Service struct {
    users UserFinder
}

El paquete user ni siquiera sabe que esta interfaz existe. Simplemente tiene un método FindByID en alguna de sus structs, y eso basta. Esta es la forma Go de hacer las cosas, y es mucho más potente que el modelo explícito de Java porque reduce el acoplamiento a cero: el consumidor define lo que necesita, el productor no tiene que saber quién lo consume.

Si vienes de interfaces en Go, esto ya lo tienes claro. Si no, vale la pena profundizar porque cambia completamente cómo diseñas la arquitectura.


Una arquitectura pragmática para Go: handlers, services, repositories

Después de varios proyectos, he ido convergiendo en una estructura que, al menos en mi experiencia, funciona para la mayoría de APIs y servicios. No tiene nombre elegante. Es simplemente lo mínimo que mantiene el código organizado sin añadir capas innecesarias.

project/
├── cmd/
│   └── server/
│       └── main.go          // composición, wiring, arranque
├── internal/
│   ├── user/
│   │   ├── handler.go       // HTTP handlers
│   │   ├── service.go       // lógica de negocio
│   │   ├── store.go         // acceso a datos
│   │   └── user.go          // tipos y modelos
│   ├── order/
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── store.go
│   │   └── order.go
│   └── platform/
│       ├── database/
│       │   └── postgres.go  // conexión a DB
│       └── server/
│           └── http.go       // setup del servidor HTTP
└── go.mod

Tres niveles de responsabilidad por dominio. No cuatro. No cinco. Tres:

Handler: recibe la petición HTTP, valida input, llama al servicio, devuelve respuesta. No conoce la base de datos.

Service: lógica de negocio. Orquesta operaciones, aplica reglas, coordina entre stores si hace falta. No conoce HTTP.

Store: acceso a datos. SQL, Redis, llamadas a APIs externas. No conoce la lógica de negocio.

// internal/user/handler.go
package user

import (
    "encoding/json"
    "net/http"
)

type Handler struct {
    svc *Service
}

func NewHandler(svc *Service) *Handler {
    return &Handler{svc: svc}
}

func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
    var req CreateRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }

    u, err := h.svc.Create(r.Context(), req.Name, req.Email)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(u)
}
// internal/user/service.go
package user

import (
    "context"
    "fmt"
)

type Service struct {
    store *Store
}

func NewService(store *Store) *Service {
    return &Service{store: store}
}

func (s *Service) Create(ctx context.Context, name, email string) (User, error) {
    if name == "" {
        return User{}, fmt.Errorf("name is required")
    }

    existing, err := s.store.FindByEmail(ctx, email)
    if err != nil {
        return User{}, fmt.Errorf("checking existing user: %w", err)
    }
    if existing != nil {
        return User{}, fmt.Errorf("email already registered")
    }

    return s.store.Create(ctx, User{Name: name, Email: email})
}
// internal/user/store.go
package user

import (
    "context"
    "database/sql"
)

type Store struct {
    db *sql.DB
}

func NewStore(db *sql.DB) *Store {
    return &Store{db: db}
}

func (s *Store) Create(ctx context.Context, u User) (User, error) {
    err := s.db.QueryRowContext(ctx,
        "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, created_at",
        u.Name, u.Email,
    ).Scan(&u.ID, &u.CreatedAt)
    return u, err
}

func (s *Store) FindByEmail(ctx context.Context, email string) (*User, error) {
    var u User
    err := s.db.QueryRowContext(ctx,
        "SELECT id, name, email, created_at FROM users WHERE email = $1",
        email,
    ).Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt)
    if err == sql.ErrNoRows {
        return nil, nil
    }
    return &u, err
}

Fíjate: no hay interfaces todavía. El Service depende directamente del Store concreto. El Handler depende directamente del Service concreto. Y eso está bien para la mayoría de proyectos.

Para más detalles sobre cómo organizar esto a nivel de carpetas, tengo un artículo dedicado a la estructura de proyecto Go.


Cuándo añadir capas: el umbral de complejidad

Entonces, si la estructura simple funciona, la pregunta natural es: ¿cuándo tiene sentido añadir capas y abstracciones? La regla que he ido destilando es bastante directa: cuando el dolor es real, no anticipado.

Señales de que necesitas más estructura

Necesitas mockear dependencias externas en tests. Si tu service llama directamente a un store que depende de PostgreSQL, y quieres tests unitarios rápidos sin base de datos, necesitas una interfaz.

// internal/order/service.go
package order

import "context"

// Ahora sí, definimos interfaces. Porque las necesitamos para testing.
type ProductStore interface {
    FindByID(ctx context.Context, id string) (Product, error)
    UpdateStock(ctx context.Context, id string, delta int) error
}

type PaymentGateway interface {
    Charge(ctx context.Context, amount Money, method PaymentMethod) (PaymentResult, error)
}

type Service struct {
    products ProductStore
    payments PaymentGateway
}

Tienes múltiples implementaciones reales. Si tu servicio de notificaciones puede enviar por email, SMS o push, esa abstracción tiene sentido real, no teórico.

Equipos distintos trabajan en capas distintas. Si un equipo mantiene la lógica de negocio y otro la integración con infraestructura, la separación formal ayuda a definir contratos.

El dominio es complejo. Si tienes reglas de negocio con invariantes, estados, transiciones y validaciones complejas, aislar el dominio de la infraestructura vale la pena.

Señales de que estás sobre-arquitecturando

  • Tienes interfaces con una sola implementación y no las usas en tests.
  • Necesitas mappers entre DTOs, modelos de dominio y entidades de persistencia que son básicamente el mismo struct.
  • Cambiar un campo en la base de datos requiere tocar más de tres archivos.
  • Los nuevos miembros del equipo tardan más de un día en entender dónde va cada cosa.

Si te identificas más con el segundo grupo, probablemente vale la pena simplificar. Si te identificas más con el primero, añade estructura. La respuesta no tiene por qué ser la misma para todos los proyectos.


Interfaces en arquitectura Go: defínelas donde se consumen

Y aquí es donde creo que más errores veo en proyectos Go que intentan hacer clean architecture. Gente que define interfaces en el paquete de dominio, como haría en Java:

// ❌ Anti-patrón: interfaces en el paquete de dominio
// internal/domain/repository/user.go
package repository

type UserRepository interface {
    Create(ctx context.Context, u entity.User) error
    FindByID(ctx context.Context, id string) (entity.User, error)
    FindByEmail(ctx context.Context, email string) (entity.User, error)
    Update(ctx context.Context, u entity.User) error
    Delete(ctx context.Context, id string) error
}

Esto es Java con sintaxis de Go. La interfaz es enorme, tiene todos los métodos posibles, y vive en un paquete que no la usa. Todo consumidor tiene que importar esa interfaz aunque solo necesite un método.

La forma Go:

// ✅ Interfaz en el consumidor, mínima
// internal/notification/service.go
package notification

type UserEmailFinder interface {
    FindEmail(ctx context.Context, userID string) (string, error)
}

type Service struct {
    users UserEmailFinder
    // ...
}

El servicio de notificaciones no necesita crear, actualizar ni borrar usuarios. Solo necesita encontrar un email. Define una interfaz con un solo método. Esto es el principio de segregación de interfaces llevado a su máxima expresión, y en Go sale natural gracias a las interfaces implícitas.

Interfaces pequeñas, compuestas si hace falta

Si un servicio necesita más métodos, puedes componer:

type UserReader interface {
    FindByID(ctx context.Context, id string) (User, error)
}

type UserWriter interface {
    Create(ctx context.Context, u User) (User, error)
    Update(ctx context.Context, u User) error
}

// Solo si alguien necesita ambas
type UserStore interface {
    UserReader
    UserWriter
}

La librería estándar de Go está llena de ejemplos: io.Reader, io.Writer, io.ReadWriter. Interfaces de uno o dos métodos que se componen. Ese es el modelo a seguir.


Inyección de dependencias sin contenedor

En Spring tienes @Autowired o inyección por constructor con un contenedor que resuelve todo el grafo. En Go no necesitas nada de eso. Tu main.go es tu contenedor de DI.

// cmd/server/main.go
package main

import (
    "database/sql"
    "log"
    "net/http"

    "myapp/internal/order"
    "myapp/internal/platform/database"
    "myapp/internal/user"
)

func main() {
    // Infraestructura
    db, err := database.Connect("postgres://localhost/myapp?sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // Stores
    userStore := user.NewStore(db)
    orderStore := order.NewStore(db)

    // Services
    userService := user.NewService(userStore)
    orderService := order.NewService(orderStore, userService)

    // Handlers
    userHandler := user.NewHandler(userService)
    orderHandler := order.NewHandler(orderService)

    // Router
    mux := http.NewServeMux()
    mux.HandleFunc("POST /users", userHandler.Create)
    mux.HandleFunc("GET /users/{id}", userHandler.Get)
    mux.HandleFunc("POST /orders", orderHandler.Create)
    mux.HandleFunc("GET /orders/{id}", orderHandler.Get)

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

Todo el wiring es explícito. Puedes leer main.go y ver exactamente qué depende de qué. No hay magia. No hay reflexión. No hay un contenedor que resuelva dependencias en runtime.

Si el grafo de dependencias se vuelve complejo, lo máximo que hago es extraer funciones de setup:

func setupUserDomain(db *sql.DB) *user.Handler {
    store := user.NewStore(db)
    svc := user.NewService(store)
    return user.NewHandler(svc)
}

func setupOrderDomain(db *sql.DB, userSvc *user.Service) *order.Handler {
    store := order.NewStore(db)
    svc := order.NewService(store, userSvc)
    return order.NewHandler(svc)
}

Hay librerías como wire de Google o fx de Uber para inyección de dependencias en Go. Las he probado. Para la mayoría de proyectos, el main.go explícito es mejor. Cuando main.go se vuelve inmanejable (más de 200 líneas de wiring), puede tener sentido introducir una de estas herramientas. Pero ese umbral llega mucho más tarde de lo que la gente cree.


La trampa del over-engineering: demasiadas abstracciones matan la simplicidad de Go

Creo que el mayor problema que veo en proyectos Go no es la falta de arquitectura. Es el exceso. Y lo digo habiendo caído en esa trampa yo mismo.

El coste real de cada abstracción

Cada interfaz que añades es una indirección que alguien tiene que seguir al leer el código. Cada capa es un salto entre archivos. Cada mapper es un lugar donde puedes tener bugs de conversión. Cada paquete adicional es un import más que gestionar.

En Java, el IDE te oculta gran parte de este coste. IntelliJ genera implementaciones, navega a través de interfaces, autocompleta mocks. El coste de las abstracciones está parcialmente subsidiado por las herramientas.

En Go, aunque gopls es excelente, la filosofía del lenguaje es que el código sea legible directamente. go doc muestra lo que hay. grep funciona para encontrar usos. Añadir capas de abstracción innecesarias rompe esta experiencia.

Un ejemplo de lo que NO hay que hacer

He visto esto en un proyecto real (nombres cambiados):

// internal/domain/entity/task.go
type Task struct {
    ID          string
    Title       string
    Description string
    Status      TaskStatus
    CreatedAt   time.Time
}

// internal/domain/repository/task_repository.go
type TaskRepository interface {
    Save(ctx context.Context, task entity.Task) error
    FindByID(ctx context.Context, id string) (entity.Task, error)
}

// internal/application/dto/task_dto.go
type CreateTaskDTO struct {
    Title       string `json:"title"`
    Description string `json:"description"`
}

type TaskResponseDTO struct {
    ID          string `json:"id"`
    Title       string `json:"title"`
    Description string `json:"description"`
    Status      string `json:"status"`
    CreatedAt   string `json:"created_at"`
}

// internal/application/mapper/task_mapper.go
func ToEntity(dto CreateTaskDTO) entity.Task {
    return entity.Task{
        Title:       dto.Title,
        Description: dto.Description,
        Status:      entity.StatusPending,
    }
}

func ToDTO(task entity.Task) TaskResponseDTO {
    return TaskResponseDTO{
        ID:          task.ID,
        Title:       task.Title,
        Description: task.Description,
        Status:      string(task.Status),
        CreatedAt:   task.CreatedAt.Format(time.RFC3339),
    }
}

// internal/application/usecase/create_task.go
type CreateTaskUseCase struct {
    repo repository.TaskRepository
}

func (uc *CreateTaskUseCase) Execute(ctx context.Context, dto CreateTaskDTO) (TaskResponseDTO, error) {
    task := mapper.ToEntity(dto)
    if err := uc.repo.Save(ctx, task); err != nil {
        return TaskResponseDTO{}, err
    }
    return mapper.ToDTO(task), nil
}

// internal/infrastructure/persistence/postgres_task_repo.go
type PostgresTaskRepo struct {
    db *sql.DB
}

func (r *PostgresTaskRepo) Save(ctx context.Context, task entity.Task) error {
    // ... SQL
}

// internal/infrastructure/http/task_handler.go
type TaskHandler struct {
    createTask *usecase.CreateTaskUseCase
}

Siete archivos. Cuatro paquetes. Un mapper que copia campos de un struct a otro casi idéntico. Un use case que es un wrapper de una línea sobre un repositorio. Y al final, lo único que hace es guardar una tarea en PostgreSQL. Técnicamente no estaba equivocado quien lo escribió. Pero el coste de navegación era desproporcionado para lo que hacía.


Lo mismo, en versión pragmática

// internal/task/task.go
package task

import "time"

type Task struct {
    ID          string    `json:"id"`
    Title       string    `json:"title"`
    Description string    `json:"description"`
    Status      Status    `json:"status"`
    CreatedAt   time.Time `json:"created_at"`
}

type Status string

const (
    StatusPending  Status = "pending"
    StatusDone     Status = "done"
)

type CreateRequest struct {
    Title       string `json:"title"`
    Description string `json:"description"`
}
// internal/task/store.go
package task

import (
    "context"
    "database/sql"
    "time"

    "github.com/google/uuid"
)

type Store struct {
    db *sql.DB
}

func NewStore(db *sql.DB) *Store {
    return &Store{db: db}
}

func (s *Store) Create(ctx context.Context, title, description string) (Task, error) {
    t := Task{
        ID:          uuid.NewString(),
        Title:       title,
        Description: description,
        Status:      StatusPending,
        CreatedAt:   time.Now(),
    }

    _, err := s.db.ExecContext(ctx,
        "INSERT INTO tasks (id, title, description, status, created_at) VALUES ($1, $2, $3, $4, $5)",
        t.ID, t.Title, t.Description, t.Status, t.CreatedAt,
    )
    return t, err
}
// internal/task/handler.go
package task

import (
    "encoding/json"
    "net/http"
)

type Handler struct {
    store *Store
}

func NewHandler(store *Store) *Handler {
    return &Handler{store: store}
}

func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
    var req CreateRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    if req.Title == "" {
        http.Error(w, "title is required", http.StatusBadRequest)
        return
    }

    t, err := h.store.Create(r.Context(), req.Title, req.Description)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

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

Tres archivos. Un paquete. Sin interfaces, sin mappers, sin use cases vacíos. Hace exactamente lo mismo. Si mañana necesito tests unitarios del handler sin base de datos, entonces extraigo una interfaz del store. No antes.

¿Es menos “correcta” arquitecturalmente? Depende de a quién preguntes. Yo creo que es más correcta para Go, porque respeta la filosofía del lenguaje: claridad, simplicidad, el mínimo necesario. Pero entiendo que alguien con un background diferente pueda verlo de otra forma.


Cuándo la clean architecture completa sí tiene sentido

No quiero que este artículo se lea como “nunca hagas clean architecture en Go”. Sería demasiado simplista. Hay contextos donde tiene sentido pleno:

Dominios complejos con reglas de negocio ricas

Si estás construyendo un sistema de facturación con reglas fiscales de múltiples países, estados de facturas con transiciones controladas, validaciones de negocio complejas y cálculos financieros que no pueden tener bugs… aislar el dominio del framework y la base de datos es una inversión que se paga.

// internal/domain/invoice/invoice.go
package invoice

import "errors"

type Invoice struct {
    id        string
    lines     []Line
    status    Status
    taxRules  TaxRuleSet
}

// Lógica de dominio pura, sin dependencias de infraestructura
func (i *Invoice) AddLine(product string, quantity int, unitPrice Money) error {
    if i.status != StatusDraft {
        return errors.New("cannot modify a non-draft invoice")
    }
    if quantity <= 0 {
        return errors.New("quantity must be positive")
    }

    line := Line{
        Product:   product,
        Quantity:  quantity,
        UnitPrice: unitPrice,
        Tax:       i.taxRules.Calculate(product, unitPrice),
    }
    i.lines = append(i.lines, line)
    return nil
}

func (i *Invoice) Finalize() error {
    if i.status != StatusDraft {
        return errors.New("can only finalize draft invoices")
    }
    if len(i.lines) == 0 {
        return errors.New("cannot finalize empty invoice")
    }
    i.status = StatusFinalized
    return nil
}

Aquí la separación del dominio te permite testear toda la lógica fiscal sin base de datos ni HTTP. Eso vale su peso en oro.

Equipos grandes con fronteras claras

Con 15-20 desarrolladores trabajando en el mismo servicio, las convenciones de paquetes de Go no bastan para organizar el trabajo. Necesitas contratos formales entre equipos, y eso son interfaces y capas bien definidas.

Múltiples puertos de entrada y salida

Si tu servicio recibe peticiones por HTTP, gRPC y colas de mensajes, y persiste en PostgreSQL, Redis y S3, la abstracción de puertos y adaptadores deja de ser teórica. Tienes adaptadores reales para cada puerto.

// El mismo servicio sirve tráfico HTTP, gRPC y consume de Kafka
func main() {
    svc := order.NewService(store, paymentGW, notifier)

    httpHandler := httpport.NewOrderHandler(svc)
    grpcHandler := grpcport.NewOrderServer(svc)
    consumer := kafkaport.NewOrderConsumer(svc)

    // ...
}

Requisitos de testabilidad estrictos

Si tu pipeline de CI exige cobertura alta con tests rápidos (sin contenedores Docker para la base de datos), necesitas interfaces para mockear dependencias externas. Eso es legítimo.


Mis reglas para arquitectura en Go

Después de varios proyectos y bastantes errores, estas son las reglas a las que he ido llegando. No pretendo que sean universales, pero me funcionan:

1. Empieza plano, refactoriza cuando duela

El primer diseño no necesita ser el definitivo. Y creo que eso cuesta aceptarlo, especialmente si vienes de entornos donde refactorizar es caro. Go compila tan rápido y los refactors son tan seguros (gracias al sistema de tipos y gorename/gopls) que puedes empezar con la estructura más simple posible y añadir capas cuando la complejidad lo justifique.

2. Un paquete por dominio, no por capa técnica

Organiza por lo que hace tu código, no por su rol técnico:

// ❌ Por capa técnica (Java-style)
internal/handlers/
internal/services/
internal/repositories/

// ✅ Por dominio (Go-style)
internal/user/
internal/order/
internal/payment/

Esto mantiene relacionado lo que cambia junto. Si necesitas modificar la feature de pedidos, todo está en internal/order/.

3. Interfaces solo cuando hay polimorfismo real o necesitas tests

No crees una interfaz “por si acaso”. Crea una interfaz cuando:

  • Tienes dos o más implementaciones reales.
  • Necesitas un mock para un test unitario.
  • Un paquete necesita usar algo de otro paquete sin depender de su implementación concreta.

4. Define interfaces en el consumidor

Siempre. Sin excepciones. Si el paquete order necesita algo del paquete user, la interfaz se define en order. Si eso te resulta extraño, es porque vienes de lenguajes con interfaces explícitas. En Go, este es el camino.

5. El main.go es tu contenedor de DI

Todo el wiring explícito en main.go. Si crece demasiado, extrae funciones setup*. Solo considera librerías de DI cuando tengas un grafo de dependencias genuinamente complejo (más de 30-40 componentes).

6. No necesitas DTOs separados si tus structs ya tienen tags JSON

En Java necesitas DTOs porque tus entidades JPA tienen anotaciones de Hibernate que no quieres exponer. En Go, un struct con tags json y tags db puede servir tanto para HTTP como para persistencia. Solo sepáralos cuando la representación sea genuinamente diferente.

// Esto es válido y pragmático
type User struct {
    ID        string    `json:"id" db:"id"`
    Name      string    `json:"name" db:"name"`
    Email     string    `json:"email" db:"email"`
    Password  string    `json:"-" db:"password_hash"`  // No se expone en JSON
    CreatedAt time.Time `json:"created_at" db:"created_at"`
}

El tag json:"-" oculta el campo en las respuestas HTTP. No necesitas un UserDTO separado para eso.

7. Mide la complejidad por el coste de cambio

La pregunta interesante no es “¿esto sigue el patrón clean architecture?”. Es otra: “Si necesito cambiar la base de datos de PostgreSQL a MongoDB, ¿cuántos archivos toco?”. Si la respuesta es “unos pocos archivos en el store”, tu arquitectura está bien. No necesitas una capa de abstracción adicional para hacer ese cambio más fácil si el cambio ya es manejable.

8. Revisa la arquitectura cada 6 meses

Lo que funcionaba con 3 desarrolladores y 10 endpoints puede no funcionar con 12 desarrolladores y 80 endpoints. Programa revisiones periódicas de la estructura del proyecto. Añade capas cuando el dolor sea real, no cuando lo anticipes.


El test de la silla vacía

Una heurística que uso: si un nuevo desarrollador junior se incorpora al equipo, ¿puede encontrar dónde vive la lógica de “crear un pedido” en menos de 30 segundos? Si tiene que navegar por domain/entity, application/usecase, infrastructure/persistence y adapter/http antes de entender el flujo, la arquitectura no te está ayudando. Te está estorbando.

En la estructura plana, busca internal/order/ y tiene todo ahí. Handler, servicio, store, tipos. Un paquete, un dominio, todo junto.

Esto no significa que todo proyecto deba ser plano. Significa que la complejidad de la estructura debe ser proporcional a la complejidad del dominio. Un CRUD con cinco entidades no necesita la misma arquitectura que un sistema de trading en tiempo real.

Si quieres profundizar en cómo se testa una arquitectura así, te recomiendo el artículo sobre testing en Go. Si necesitas montar una API REST con Go, ahí tienes la guía práctica.


Conclusión: la arquitectura debe servir al código

La clean architecture no es mala. La hexagonal tampoco. Son herramientas, y como tales, la pregunta no es si son buenas o malas en abstracto. Es si el contexto las justifica.

Go fue diseñado con una filosofía clara: simplicidad, legibilidad, composición sobre herencia, el mínimo necesario para hacer el trabajo. Cuando importas los patrones de Java o C# sin adaptarlos, estás luchando contra el lenguaje.

Mi enfoque es empezar simple. Paquetes por dominio, tres niveles de responsabilidad (handler, service, store), interfaces solo cuando hay necesidad real. A medida que el proyecto crece y la complejidad aparece, añado estructura. No antes.

Los mejores proyectos Go que he visto no son los que tienen la arquitectura más sofisticada. Son los que tienen la arquitectura justa para su nivel de complejidad. Ni más, ni menos.

Y al final, creo que la arquitectura es un medio, no un fin. Si tu estructura te ayuda a entregar más rápido, a tener menos bugs y a que el equipo entienda el código, es buena. Si te ralentiza, te obliga a tocar cinco archivos para un cambio trivial y confunde a los nuevos, es mala. Da igual cómo la llames.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados