wandres.dev
MODULARIZAR · paquetes SPM

La estructura de una app grande: capas y grafo de dependencias sano

Cuarenta módulos sin una regla de ordenación son cuarenta problemas. Esta lección propone las cuatro capas que Point-Free usa en sus apps abiertas —dominio, clientes, features y raíz— con una única regla de dirección, y después da el instrumental para auditar si el grafo real la cumple: entrada y salida de dependencias por objetivo, la métrica de inestabilidad y el principio de depender siempre hacia lo estable. Termina con el caso difícil, dos features que se necesitan, y con las herramientas de `SPM` que dibujan el grafo que tienes en lugar del que crees tener.

⏱ 21 min

Modularizar sin un criterio de ordenación produce algo peor que un monolito: un monolito con fronteras, donde el acoplamiento sigue estando pero ahora además hay que declararlo en cuarenta sitios. La pregunta que cierra este nivel no es cómo se parte una app, que ya sabes, sino qué forma debe tener el resultado para que siga siendo manejable cuando haya cien módulos y tres equipos. La respuesta que la comunidad de TCA ha convergido —cuatro capas, una sola dirección de flechas y una raíz que solo ensambla— es sorprendentemente simple, y lo interesante es que no se justifica por gusto arquitectónico sino por una propiedad medible del grafo: quién puede cambiar sin obligar a cambiar a otros.

🎯 Al terminar esta lección sabrás
  • Situar cada objetivo en una de las cuatro capas —dominio, clientes, features y raíz— y aplicar la regla de que las flechas apuntan siempre hacia abajo.
  • Medir la salud del grafo con la entrada y la salida de dependencias de cada objetivo, y con la inestabilidad que se deduce de ambas.
  • Aplicar el principio de dependencias estables: la responsabilidad de un módulo debe ser proporcional a cuánta gente depende de él.
  • Resolver el caso difícil de dos features que se necesitan sin abrir una puerta lateral, y auditar el grafo real con las herramientas de SPM.

Cuatro capas y una sola dirección

La capa de dominio contiene los tipos que describen el negocio: valores puros, Codable, Equatable, sin dependencias más allá de la biblioteca estándar. La capa de clientes contiene las interfaces de todo lo que toca el mundo —red, disco, sensores, notificaciones— y, en objetivos aparte, sus implementaciones vivas. La capa de features contiene los reducers y las vistas. La capa raíz contiene el objetivo que ensambla el árbol y el objetivo de app, que debe ser tan delgado que quepa en una pantalla.

targets: [
  // Dominio: no depende de nada
  .target(name: "Modelos"),

  // Clientes: interfaz e implementacion separadas
  .target(name: "APIClient",     dependencies: ["Modelos", .tca]),
  .target(name: "APIClientLive", dependencies: ["APIClient"]),
  .target(name: "Almacen",       dependencies: ["Modelos", .tca]),
  .target(name: "AlmacenLive",   dependencies: ["Almacen"]),

  // Features: dependen de dominio e interfaces, nunca entre si
  .target(name: "PerfilFeature",  dependencies: ["APIClient", "Modelos", .tca]),
  .target(name: "AjustesFeature", dependencies: ["Almacen", "Modelos", .tca]),

  // Raiz: lo unico que conoce a todos
  .target(
    name: "AppFeature",
    dependencies: ["PerfilFeature", "AjustesFeature", "APIClientLive", "AlmacenLive", .tca]
  ),
]

La regla única es que las flechas apuntan hacia abajo: la raíz conoce features, las features conocen clientes y dominio, los clientes conocen dominio, y el dominio no conoce a nadie. Todo lo demás —qué se llama cada objetivo, cuántas features hay, si los clientes vivos van en un objetivo o en diez— es negociable. La dirección no lo es, porque es la que garantiza que el grafo sea acíclico y que exista un orden de compilación estable.

Los síntomas de un grafo enfermo

Dos números por objetivo bastan para auditar la salud del grafo. La entrada es cuántos objetivos dependen de este; la salida, de cuántos depende él. La entrada mide responsabilidad, porque cada cambio en la interfaz se propaga a todos ellos. La salida mide fragilidad, porque cualquiera de esos cambios puede romperte. De ambos se deduce la inestabilidad, que es la salida dividida entre la suma de entrada y salida: vale cero cuando nadie te obliga a cambiar y todos dependen de ti, y vale uno cuando dependes de todos y nadie depende de ti.

Capa Entrada Salida Inestabilidad Qué se espera
Dominio Alta Cero Cerca de cero Cambia poco, con cuidado y con revisión
Clientes interfaz Alta Baja Baja API estable, firmas mínimas
Features Baja Media Alta Cambian a diario sin molestar a nadie
Raíz Cero Muy alta Uno Se rompe siempre que algo cambia, y está bien
💎

Dominio inmóvil

Valores puros sin dependencias. Entrada máxima, salida cero, y por eso cambia con revisión.

🔗

Clientes en dos mitades

Interfaz liviana arriba del dominio, implementación viva colgando solo de la raíz.

🧩

Features autónomas

Nadie depende de ellas, así que se reescriben sin coordinar con nadie. Ahí debe vivir casi todo el código.

🪶

Raíz delgada

Ensambla con Scope, ifLet y forEach, inyecta lo vivo, y no contiene lógica de negocio propia.

El principio que ordena la tabla es el de dependencias estables: se debe depender siempre en dirección a lo más estable. Un módulo con mucha entrada y mucha salida es la patología clásica, porque acumula la responsabilidad del primero con la fragilidad del segundo; suele llamarse Comun, Utilidades o Shared, suele haber nacido con buenas intenciones y suele ser el objetivo que aparece en todas las recompilaciones. La cura no es dividirlo por temas sino por dirección: lo que muchos usan baja al dominio y se estabiliza; lo que usa muchas cosas sube a features o a la raíz.

⚠️
El módulo cajón de sastre no se arregla renombrándolo

Si tu objetivo compartido depende de cuatro clientes y además la mitad del grafo depende de él, no tienes una capa común: tienes un segundo objetivo raíz colocado por debajo de todo. El síntoma inequívoco es que cualquier cambio en cualquier sitio recompila la app entera pasando por él. Divídelo mirando la salida de cada archivo que contiene, no su nombre: los que no importan nada bajan al dominio, los que importan clientes son en realidad lógica de feature disfrazada.

Cuando dos features se necesitan

Antes o después aparece la situación incómoda: Perfil necesita algo que vive en Ajustes. La respuesta correcta depende de qué es ese algo, y solo hay tres respuestas legítimas.

Si es un tipo de datos o una función pura, no era de Ajustes: baja al dominio y ambas dependen de él. Si es un comportamiento con efectos —guardar, consultar, notificar—, no era de Ajustes tampoco: es un cliente, y se extrae a un objetivo de interfaz del que ambas dependan. Y si lo que se quiere es coordinación en tiempo de ejecución, entonces no es una dependencia de compilación en absoluto: es una acción delegate que la feature emite hacia arriba y que el padre traduce en lo que corresponda.

// En PerfilFeature: hablo hacia arriba, no hacia el lado
public enum Action {
  case delegate(Delegate)
  public enum Delegate: Equatable { case pidioCambiarTema(Tema) }
}

// En AppFeature: la unica pieza que conoce a las dos
case .perfil(.delegate(.pidioCambiarTema(let tema))):
  state.ajustes.tema = tema
  return .none

Esta es la misma asimetría de la composición de reducers vista a escala de paquete, y la razón por la que el grafo puede permanecer acíclico indefinidamente: la comunicación lateral no se prohíbe, se redirige por el vértice común. La cuarta respuesta —añadir la dependencia entre hermanas— es la única que compila hoy y hace imposible la separación mañana.

Ver el grafo que de verdad tienes

Ninguna de estas reglas sirve si el grafo real solo existe en la cabeza de alguien. SPM sabe describir el paquete y esa descripción se puede leer, contar y dibujar.

# Dependencias externas, en arbol
swift package show-dependencies

# El paquete completo en JSON: objetivos, tipo y dependencias internas
swift package describe --type json

# El manifiesto evaluado, util para verificar ajustes por objetivo
swift package dump-package

Con la descripción en JSON se calculan en un guion corto la entrada y la salida de cada objetivo, se detecta el módulo cajón de sastre por su producto de ambas y se puede generar el diagrama que acompaña a la documentación sin dibujarlo a mano. La ventaja de derivarlo así es la misma de todo el nivel: el diagrama pasa a ser una consecuencia del código y no una promesa sobre él.

El paso siguiente, cuando el equipo crece, es convertir la auditoría en una comprobación automática. Basta un guion en integración continua que lea esa descripción y falle si alguna feature aparece en las dependencias de otra, si algún objetivo de feature depende de un módulo cuyo nombre termina en vivo, o si la profundidad del grafo supera el número que hayáis acordado. No hace falta ninguna herramienta especializada: la información está publicada y la regla se escribe en veinte líneas.

# Toda dependencia de una feature hacia otra feature es un fallo de arquitectura
swift package describe --type json \
  | jq -r '.targets[] | select(.name | endswith("Feature")) | .name as $f
           | .target_dependencies[]? | select(endswith("Feature")) | "\($f) -> \(.)"'

Que la comprobación exista importa menos por los fallos que atrapa que por la conversación que provoca. Cuando alguien la hace saltar, la discusión deja de ser sobre estilo y pasa a ser sobre en qué capa vive esa responsabilidad, que es exactamente la conversación que un equipo grande necesita tener de vez en cuando y que sin un disparador concreto no ocurre nunca.

flowchart TD
APP[Objetivo de app] --> ROOT[AppFeature]
ROOT --> PF[PerfilFeature]
ROOT --> AF[AjustesFeature]
ROOT --> LIVE[Clientes vivos]
PF --> CI[Interfaces de clientes]
AF --> CI
LIVE --> CI
CI --> DOM[Modelos de dominio]
PF --> DOM
AF --> DOM
PF -. delegate .-> ROOT
style DOM fill:#a6e3a1,color:#11111b
style ROOT fill:#89b4fa,color:#11111b
style LIVE fill:#fab387,color:#11111b
La forma del grafo decide quién puede trabajar sin pedir permiso

Una arquitectura por capas se defiende habitualmente en términos de limpieza, separación de intereses y otras virtudes que suenan bien y no se miden. Hay una defensa mucho más concreta y es la única que resiste a un equipo grande: la forma del grafo determina la distribución del poder de cambio. Un objetivo con entrada cero puede reescribirse entero un martes por la tarde sin hablar con nadie, porque nadie depende de él; un objetivo con entrada alta no puede cambiar su firma sin coordinar a media empresa. Esto significa que colocar código en una capa u otra no es una decisión estética sino una decisión sobre cuánta autonomía tendrá quien lo mantenga, y explica por qué las features deben ser el sitio donde más código vive: son la capa donde el cambio es barato porque su inestabilidad es máxima y su responsabilidad es nula. La inversión simétrica también se sigue: cuanto más abajo está algo, más caro es cambiarlo, luego más pequeño y más pensado debe ser. Un dominio de treinta tipos de valor sin comportamiento es sano; un dominio que ha ido acumulando conveniencias es una hipoteca que paga todo el mundo en cada compilación y en cada revisión. Vista así, la regla de que las flechas apuntan hacia abajo deja de ser una convención heredada de otras plataformas y se convierte en una consecuencia aritmética de querer que la mayor parte del trabajo diario ocurra en la zona del grafo donde equivocarse es reversible. Y hay un último corolario que cierra el nivel entero: como el grafo es un artefacto ejecutable y no un documento, esta distribución del poder de cambio es auditable, discutible en una revisión concreta y modificable a propósito. Es, probablemente, la única parte de la cultura de un equipo de software que se puede escribir en un archivo y verificar en cada compilación.

⚔️ Audita el grafo real de tu app y corrige una capa
  1. Genera la descripción del paquete en JSON y construye una tabla con la entrada y la salida de cada objetivo.
  2. Ordena por el producto de ambas. El primero de la lista es tu candidato a cajón de sastre; ábrelo y clasifica cada archivo por lo que importa, no por su nombre.
  3. Baja al dominio todo lo que no importe nada y sube a features lo que importe clientes. Vuelve a calcular la tabla y comprueba que el producto máximo ha caído.
  4. Busca cualquier dependencia declarada entre dos features hermanas. Para cada una decide si es dato, cliente o coordinación, y aplícale la respuesta que corresponda.
  5. Comprueba que el objetivo de app cabe en una pantalla y que es el único sitio donde se inyectan implementaciones vivas. Si aparece lógica ahí, es una feature que nunca se extrajo.