Módulo 7 · Lección 13

Arquitectura hexagonal (sin complicarla)

api-pedidos funciona, pero sus reglas de negocio están mezcladas con HTTP y con los nombres de los roles de Keycloak. En esta lección la reorganizas en arquitectura hexagonal: un dominio que no sabe nada de tokens, y Keycloak como un adaptador más. Desde fuera, nada cambia.

≈ 50 min Carpeta: tienda/pasos/paso-13 Mismo realm que el paso 12

En este capítulo

Trabajas en
api-pedidos, por dentro. tienda-web, facturacion y Keycloak no cambian.
Carpeta
tienda/pasos/paso-13 · el realm es el del paso 12: si vienes de él, no hace falta reimportar
Archivos
Nuevos: internal/pedidos/ (actor.go, puertos.go, servicio.go) e internal/adaptadores/ (rest, keycloak, memoria); desaparecen internal/api/ y el almacén de pedidos.go · ver todos los cambios del paso.
En marcha
Keycloak del paso 12 (o 13), api, web y facturacion.
Comprueba
go -C tools/comprobar run . -paso 13 -servicios (desde course/): el mismo resultado que en el paso 12, porque el comportamiento no cambia.

Al terminar sabrás

  • Qué son dominio, puertos y adaptadores, con el código de la tienda y no con teoría abstracta.
  • Dónde va cada comprobación de seguridad: token, scope, rol y regla de negocio.
  • Por qué Keycloak debe ser un adaptador y no estar repartido por los handlers.
  • Hasta dónde llevar el patrón para que ayude sin volverse una carga.

1. Por qué ahora

Mira internal/api/api.go del paso 12. Cada handler hace tres cosas a la vez: leer HTTP, decidir quién puede (con nombres de roles de Keycloak) y aplicar reglas de la tienda. Funciona, pero tiene costes concretos:

  • Para comprobar la regla «un cliente solo ve sus pedidos» necesitas un JWT firmado y un servidor HTTP.
  • Si el equipo de identidad renombra admin a tienda-admin, o los permisos pasan a venir de Entra ID (lección 12), tocas handlers repartidos por la API.
  • Cambiar el almacén en memoria por PostgreSQL obliga a reescribir código que también decide permisos.

La arquitectura hexagonal (también llamada puertos y adaptadores) separa esas tres cosas. Aquí la aplicamos solo a api-pedidos, que es donde viven las reglas de negocio, y con el mínimo de piezas.

2. La idea en un dibujo

El dominio en el centro; los adaptadores REST, Keycloak y memoria alrededor, conectados por puertos; main conecta las piezas dominio: pedidos Order · Actor · permisos Service (casos de uso) sin HTTP · sin JWT · sin roles puerto: Repository adaptadores/rest HTTP ↔ casos de uso adaptadores/keycloak token → Actor con permisos puerto: Identity adaptadores/memoria implementa Repository cmd/api/main.go crea y conecta las piezas Las dependencias apuntan hacia dentro: los adaptadores conocen el dominio; el dominio no conoce a ninguno.
Entrada a la izquierda (quién llama y cómo), salida a la derecha (dónde se guarda).
PiezaQué esEn la tienda
DominioLas reglas del negocio, en Go puro.internal/pedidos: Order, Actor, Permission, Service.
PuertoUna interfaz en el borde del dominio.pedidos.Repository (salida) y rest.Identity (lo que REST necesita saber de quién llama).
AdaptadorConecta un puerto con una tecnología.rest (HTTP), keycloak (tokens), memoria (almacén).
ComposiciónEl único sitio que conoce todo.cmd/api/main.go.

3. El dominio: permisos de la tienda, no roles de Keycloak

El paso clave es dejar de preguntar «¿tiene el rol admin?» dentro de la lógica, y preguntar «¿puede gestionar pedidos?». Los permisos son palabras de la tienda; quién los recibe lo decide el adaptador de identidad.

package pedidos

import "slices"

// Permission es algo que el dominio permite hacer. Son conceptos de la
// tienda, no de Keycloak: el adaptador de identidad decide qué roles o
// claims del token dan cada permiso.
type Permission string

const (
	PermBuy     Permission = "comprar"   // ver y crear pedidos propios
	PermManage  Permission = "gestionar" // ver todos los pedidos y cambiar su estado
	PermInvoice Permission = "facturar"  // listar pedidos de todos para facturarlos
)

// Actor es quien pide algo al dominio: una persona o un servicio.
type Actor struct {
	ID          string // identificador estable (el «sub»)
	Name        string
	Permissions []Permission
}

// Can indica si el actor tiene el permiso p.
func (a Actor) Can(p Permission) bool { return slices.Contains(a.Permissions, p) }

El almacenamiento es un puerto: una interfaz pequeña que el dominio necesita y que otro implementa.

package pedidos

import "context"

// Repository es el puerto de salida donde se guardan los pedidos. El dominio
// solo conoce esta interfaz; la implementación (en memoria, PostgreSQL…)
// vive en internal/adaptadores.
type Repository interface {
	// Get devuelve un pedido o ErrNotFound.
	Get(ctx context.Context, id int) (Order, error)
	// List devuelve los pedidos que cumplen el filtro, ordenados por número.
	List(ctx context.Context, f Filter) ([]Order, error)
	// Add guarda un pedido nuevo y le asigna número.
	Add(ctx context.Context, o Order) (Order, error)
	// Update guarda los cambios de un pedido existente (o ErrNotFound).
	Update(ctx context.Context, o Order) error
}

// Filter elige pedidos. Un campo vacío no filtra.
type Filter struct {
	Owner  string
	Status string
}

Los casos de uso reciben al Actor y aplican todas las reglas. Compara Get con el handler del paso 12: la regla «si es de otro, di que no existe» es la misma, pero ahora vive donde se puede probar sin HTTP ni tokens (lección 14).

// Get devuelve un pedido si es del actor o si el actor gestiona pedidos. Si
// es de otra persona, responde ErrNotFound, no ErrForbidden: así no revela
// qué pedidos existen.
func (s *Service) Get(ctx context.Context, a Actor, id int) (Order, error) {
	if !a.Can(PermBuy) && !a.Can(PermManage) {
		return Order{}, ErrForbidden
	}
	o, err := s.repo.Get(ctx, id)
	if err != nil {
		return Order{}, err
	}
	if o.Owner != a.ID && !a.Can(PermManage) {
		return Order{}, ErrNotFound
	}
	return o, nil
}
// Create registra un pedido a nombre del actor. El precio sale del catálogo:
// nunca lo decide quien compra.
func (s *Service) Create(ctx context.Context, a Actor, productID string, qty int) (Order, error) {
	if !a.Can(PermBuy) {
		return Order{}, ErrForbidden
	}
	p, ok := FindProduct(productID)
	if !ok {
		return Order{}, fmt.Errorf("%w: producto desconocido %q", ErrInvalid, productID)
	}
	if qty < 1 || qty > 10 {
		return Order{}, fmt.Errorf("%w: la cantidad debe estar entre 1 y 10", ErrInvalid)
	}
	return s.repo.Add(ctx, Order{
		Owner:     a.ID,
		Items:     fmt.Sprintf("%s ×%d", p.Name, qty),
		Total:     p.Price * float64(qty),
		Status:    "Pendiente",
		CreatedAt: s.now(),
	})
}

Los errores también son del dominio (ErrNotFound, ErrForbidden, ErrInvalid). El dominio no sabe que existen el 404 o el 403.

4. Keycloak como adaptador

Todo lo que sabe api-pedidos de Keycloak cabe ahora en un archivo. Valida el token con el apiauth de la lección 5 (no cambia) y traduce roles a permisos:

// Package keycloak es el adaptador de identidad de api-pedidos: valida el
// access token (con apiauth) y traduce lo que dice Keycloak (roles de realm,
// roles de client, scopes) a lo que entiende el dominio (pedidos.Actor).
//
// Es el único sitio de api-pedidos que conoce los nombres de los roles. Si
// mañana el rol «admin» se llama «tienda-admin», o los permisos vienen de
// otro proveedor, solo cambia este archivo.
package keycloak

import (
	"net/http"

	"tienda/internal/apiauth"
	"tienda/internal/pedidos"
)

// Identity implementa rest.Identity con access tokens de Keycloak.
type Identity struct {
	Verifier *apiauth.Verifier
}

// Middleware exige un access token válido (401 si no lo hay).
func (i Identity) Middleware(next http.Handler) http.Handler { return i.Verifier.Middleware(next) }

// RequireScope exige un scope en el token (403 insufficient_scope si falta).
func (Identity) RequireScope(scope string) func(http.Handler) http.Handler {
	return apiauth.RequireScope(scope)
}

// Actor devuelve quién llama, ya en términos del dominio.
func (Identity) Actor(r *http.Request) pedidos.Actor {
	p := apiauth.FromContext(r.Context())
	if p == nil {
		return pedidos.Actor{}
	}
	return ActorFrom(p)
}

// ActorFrom traduce un token verificado a un Actor del dominio.
func ActorFrom(p *apiauth.Principal) pedidos.Actor {
	a := pedidos.Actor{ID: p.Subject, Name: p.Username}
	if p.HasRole("cliente") || p.HasRole("admin") {
		a.Permissions = append(a.Permissions, pedidos.PermBuy)
	}
	if p.HasRole("admin") {
		a.Permissions = append(a.Permissions, pedidos.PermManage)
	}
	for _, r := range p.APIRoles { // roles de client de api-pedidos (servicios)
		if r == "facturar" {
			a.Permissions = append(a.Permissions, pedidos.PermInvoice)
		}
	}
	return a
}
La traducción es una capa anticorrupción

ActorFrom es una tabla: rol cliente o admin → comprar; admin → gestionar; rol de API facturar → facturar. Si los usuarios de Entra ID (lección 12) llegaran con otros roles, o mañana cambiaras de proveedor de identidad, solo cambiaría esta función.

5. El adaptador REST

REST define qué necesita saber de quien llama (su puerto Identity), registra las rutas y traduce errores del dominio a HTTP:

// Register añade las rutas de la API. Aquí solo se exige un token válido y,
// donde toca, el scope (lo que la APLICACIÓN puede hacer en nombre del
// usuario). Lo que la PERSONA puede hacer lo decide el dominio.
func Register(mux *http.ServeMux, svc *pedidos.Service, id Identity) {
	h := &handlers{svc: svc, id: id}
	escribir := id.RequireScope("pedidos:escribir")
	auth := func(f http.HandlerFunc) http.Handler { return id.Middleware(f) }

	mux.Handle("GET /pedidos", auth(h.list))
	mux.Handle("GET /pedidos/{id}", auth(h.get))
	mux.Handle("POST /pedidos", id.Middleware(escribir(http.HandlerFunc(h.create))))
	mux.Handle("GET /admin/pedidos", auth(h.listAll))
	mux.Handle("PATCH /admin/pedidos/{id}", id.Middleware(escribir(http.HandlerFunc(h.setStatus))))
	mux.Handle("GET /facturacion/pedidos", auth(h.listByStatus))
}
// writeError traduce los errores del dominio a respuestas HTTP.
func writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, pedidos.ErrNotFound):
		jsonhttp.Error(w, http.StatusNotFound, "not_found", "pedido no encontrado")
	case errors.Is(err, pedidos.ErrForbidden):
		jsonhttp.Error(w, http.StatusForbidden, "forbidden", "no tienes permiso para esto")
	case errors.Is(err, pedidos.ErrInvalid):
		jsonhttp.Error(w, http.StatusBadRequest, "invalid_request", err.Error())
	default:
		jsonhttp.Error(w, http.StatusInternalServerError, "server_error", "error interno")
	}
}

El dominio ya no lleva etiquetas JSON: el formato de la respuesta (orderJSON) es del adaptador. Es el mismo que antes, campo por campo, porque tienda-web y facturacion dependen de él.

Dónde va cada comprobación

PreguntaQuién respondeSi falla
¿El token es válido? (firma, iss, aud, exp, typ)Adaptador keycloak (apiauth)401
¿La aplicación puede escribir en nombre del usuario? (scope pedidos:escribir)Adaptador REST, en la ruta403 insufficient_scope
¿Qué puede hacer esta persona o servicio? (roles → permisos)Adaptador keycloak (ActorFrom)—
¿Puede hacer esto, con este pedido?Dominio403 o 404 (lo traduce REST)

¿Por qué el scope no entra en el dominio? Porque no habla de la persona sino de la aplicación que actúa en su nombre (lección 6): es un asunto del protocolo OAuth, y su respuesta correcta (WWW-Authenticate con insufficient_scope) también. Se queda en el borde.

6. main conecta las piezas

func main() {
	issuer := env("OIDC_ISSUER", "http://localhost:8080/realms/tienda")
	audience := env("API_AUDIENCE", "api-pedidos") // client ID de la API en Keycloak
	addr := env("ADDR", ":8081")

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	verifier, err := apiauth.NewVerifier(ctx, issuer, audience)
	if err != nil {
		log.Fatal(err) // ¿Keycloak está arrancado?
	}

	// Conectar las piezas: repositorio (salida) → dominio → REST (entrada),
	// con Keycloak como adaptador de identidad.
	svc := pedidos.NewService(memoria.NewWithSamples())
	mux := http.NewServeMux()
	rest.Register(mux, svc, keycloak.Identity{Verifier: verifier})

	srv := &http.Server{
		Addr:              addr,
		Handler:           logRequests(mux),
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Printf("api-pedidos escuchando en http://%s (issuer %s, audiencia %s)", listenHost(addr), issuer, audience)
	log.Fatal(srv.ListenAndServe())
}

Cambiar el almacén por PostgreSQL sería escribir adaptadores/postgres (que implemente Repository) y cambiar una línea aquí. Ni el dominio ni REST se enteran.

7. Probarlo: desde fuera, nada cambia

Es un refactor: el comportamiento debe ser idéntico. Arranca todo como en el paso 12 (desde tienda/pasos/paso-13) y ejecuta el comprobador: debe dar lo mismo que antes.

go -C tools/comprobar run . -paso 13 -servicios

Las dos diferencias visibles son mensajes: un cliente que entra en /admin/pedidos recibe "no tienes permiso para esto" en lugar de "hace falta uno de estos roles: [admin]", y ya no le contamos qué rol falta. Los códigos de estado son los mismos.

8. Lo que no hicimos (a propósito)

La arquitectura hexagonal se puede llevar mucho más lejos. Aquí no lo hacemos, y conviene saber por qué:

  • No hay interfaz para el servicio. REST usa *pedidos.Service directamente. Una interfaz solo haría falta si hubiera dos implementaciones; para los tests basta con un repositorio en memoria (lección 14).
  • tienda-web y facturacion siguen como estaban. Son casi solo adaptadores (pantallas y llamadas a otros servicios): tienen poca lógica que proteger.
  • Sin capas «application», «infrastructure», DTO por caso de uso… Tres carpetas de adaptadores y un paquete de dominio bastan para este tamaño. Si el dominio crece mucho, ya habrá tiempo de dividirlo.

Ejercicios

1. El rol informar de la lección 10 · fácil

El client informes tiene el rol de API informar. Haz que pueda listar todos los pedidos (solo lectura) sin poder cambiar estados. ¿Qué archivos tocas?

Ver solución

Dos, y ninguno de HTTP: un permiso nuevo en el dominio y su traducción en el adaptador.

// internal/pedidos/actor.go
PermReport Permission = "informar" // ver todos los pedidos, sin cambiarlos

// internal/pedidos/servicio.go — All
if !a.Can(PermManage) && !a.Can(PermReport) {
	return nil, ErrForbidden
}

// internal/adaptadores/keycloak/keycloak.go — ActorFrom, dentro del bucle de APIRoles
if r == "informar" {
	a.Permissions = append(a.Permissions, pedidos.PermReport)
}

GET /admin/pedidos ya existe y no cambia. Compáralo con el ejercicio 1 de la lección 10, donde había que añadir una ruta con su propio RequireAPIRole.

2. ¿Dónde pondrías esta regla? · fácil

Nuevas peticiones: (a) los pedidos Cancelado no pueden volver a otro estado; (b) aceptar también el token en la cabecera X-Token; (c) las cuentas que entran por la empresa (lección 12) no pueden comprar más de 3 unidades.

Ver solución
  • (a) Dominio: es una regla de negocio, en SetStatus.
  • (b) Adaptador de identidad (o mejor, no hacerlo: RFC 6750 define dónde va el token).
  • (c) Las dos cosas: el adaptador de identidad decide si el usuario viene de la empresa (por ejemplo, un claim identity_provider) y lo expresa como algo del dominio (un campo en Actor o un límite); la regla «máximo 3» va en Create.

3. facturacion en hexagonal · media

¿Qué piezas tendría facturacion? No hace falta escribirlo: dibuja dominio, puertos y adaptadores.

Ver solución

Dominio: Invoice y las reglas («solo se facturan pedidos enviados o entregados», «un pedido, una factura»). Puertos de salida: el almacén de facturas y «consultar un pedido en nombre del usuario». Adaptadores: REST de entrada, el worker periódico (otro adaptador de entrada: un temporizador en lugar de HTTP), el cliente de api-pedidos y el Token Exchange de la lección 8 como implementación de ese puerto de pedidos. El intercambio de tokens queda fuera del dominio, igual que aquí la validación del JWT.

Errores comunes

import cycle not allowed

El dominio importó un adaptador (por ejemplo, pedidos → adaptadores/memoria). Las dependencias van siempre hacia dentro. Si un test del dominio necesita el repositorio en memoria, escríbelo en el paquete pedidos_test, como en la lección 14.

403 para todos Nadie puede hacer nada tras el refactor

Falta traducir algún rol en ActorFrom, o no se registró keycloak.Identity en main. Un test de tabla de ActorFrom (lección 14) lo detecta al momento.

500 server_error

El dominio devolvió un error que writeError no reconoce. Envuelve los errores con %w y uno de los errores del dominio (fmt.Errorf("%w: …", ErrInvalid)) para que errors.Is los encuentre.

tienda-web muestra campos vacíos

Cambió el JSON de la API. Al quitar las etiquetas del dominio hay que mantener el mismo formato en el adaptador (orderJSON). El test de formato de la lección 14 lo protege.

Resumen

El dominio dice qué se puede hacer, con permisos propios. Keycloak es un adaptador que traduce tokens a un Actor. REST traduce HTTP. main conecta las piezas. Con eso, las reglas se prueban sin tokens y cambiar de proveedor de identidad toca un archivo. En la lección 14 lo aprovechas para escribir los tests.