wandres.dev
MODULARIZACIÓN · paquetes y fronteras

Capas y reglas: quién importa a quién y cómo se rompe un ciclo

El grafo de módulos es un orden parcial y Swift lo impone: no admite ciclos. Cómo se ordenan las capas, por qué separar la interfaz de la implementación desacopla la compilación, y las tres técnicas para romper una dependencia circular.

⏱ 18 min

Hay un momento, en toda modularización que avanza, en el que el compilador se planta y dice que hay una dependencia circular entre dos módulos. La reacción instintiva es tratarlo como un obstáculo burocrático y buscar la forma de esquivarlo: fusionar los dos módulos, mover una clase al cajón común, marcar algo más como público. Las tres funcionan y las tres son un error, porque el ciclo no es un problema del sistema de módulos: es un diagnóstico que el sistema de módulos te está entregando gratis. Dos componentes que se necesitan mutuamente son, a efectos de razonamiento, de prueba y de reutilización, un solo componente escrito en dos sitios. Swift no admite ciclos entre módulos y esa restricción —que en otros ecosistemas es opcional y por tanto se ignora— convierte cada intento de acoplamiento bidireccional en un error de compilación que obliga a mirar el diseño. Esta lección trata de cómo se ordenan las capas para que eso ocurra pocas veces, y de qué hacer cuando ocurre.

🎯 Al terminar esta lección sabrás
  • Formular la regla de dirección de dependencias entre capas y sus excepciones.
  • Explicar por qué una dependencia circular entre módulos es imposible en Swift y qué revela.
  • Separar el módulo de interfaz del de implementación y medir qué desacopla eso realmente.
  • Aplicar las tres técnicas de ruptura de ciclos: inversión, extracción y mediación.

La dirección de las flechas

Una arquitectura por capas se reduce a una sola afirmación: las dependencias apuntan hacia lo estable. Lo estable es lo que cambia poco y no sabe nada de sus consumidores; lo inestable es lo que cambia con cada requisito nuevo y conoce a todos los demás. En una app iOS eso produce un orden que casi siempre es el mismo.

En la base viven los tipos del dominio y las reglas de negocio: Usuario, Pedido, el cálculo de un precio con impuestos. No importan SwiftUI, no importan la red, no saben si existe una pantalla. Encima, los contratos de acceso a datos, expresados como protocol. Encima, las features, cada una con sus vistas y su estado. Y en la cima, el target de la app, que es el único sitio del proyecto que conoce todas las features a la vez y que existe casi exclusivamente para componer: instanciar las implementaciones reales, inyectarlas y decidir la navegación entre features.

La regla operativa que se deriva es corta y se aplica sin excepciones: una feature nunca importa otra feature. Si FeaturePerfil necesita abrir una pantalla de FeatureAjustes, no la importa; emite una intención —un enum de destino, una clausura de navegación— y el target de la app, que sí conoce a las dos, la resuelve. En cuanto una feature importa a otra, el grafo empieza a colapsar hacia el monolito con manifiestos: la dependencia se propaga transitivamente y la próxima feature heredará las dos.

// Mal: FeaturePerfil sabe de la existencia de FeatureAjustes
import FeatureAjustes
NavigationLink { AjustesView() } label: { Text("Ajustes") }

// Bien: FeaturePerfil emite una intencion, el compositor la resuelve
public enum DestinoPerfil: Hashable { case ajustes, cerrarSesion }

public struct PerfilView: View {
    let alDestino: (DestinoPerfil) -> Void
    public var body: some View {
        Button("Ajustes") { alDestino(.ajustes) }
    }
}

La regla tiene una excepción aparente que conviene desactivar antes de que alguien la use como coartada: dos features que comparten un tipo no necesitan importarse, necesitan que ese tipo baje al dominio. Si FeaturePedidos y FeatureFacturas manejan ambas un Cupon, el Cupon no pertenece a ninguna de las dos; vive abajo, donde ambas pueden verlo sin conocerse. Cada vez que alguien argumenta que la regla no aplica a su caso, lo que casi siempre está describiendo es un concepto de dominio que quedó alojado por accidente en la primera feature que lo necesitó.

Interfaz e implementación en módulos distintos

La separación que más rinde en tiempo de compilación no es la de features: es la de contrato frente a realización. El patrón consiste en publicar dos módulos donde antes había uno.

ClienteAPIInterface contiene el protocol, los tipos que aparecen en su firma y nada más. Es diminuto, no depende de casi nada y su firma cambia raras veces. ClienteAPIEnvivo contiene la implementación real, con URLSession, decodificadores, reintentos y todo lo que eso arrastra. Las features importan solo la interfaz. La app importa las dos y hace la unión.

flowchart TB
App[Target de la app] --> FP[FeaturePerfil]
App --> FA[FeatureAjustes]
App --> Vivo[ClienteAPIEnvivo]
FP --> Iface[ClienteAPIInterface]
FA --> Iface
Vivo --> Iface
Iface --> Dom[Dominio]
style Iface fill:#a6e3a1,color:#11111b
style Dom fill:#a6e3a1,color:#11111b
style Vivo fill:#89b4fa,color:#11111b
style App fill:#cba6f7,color:#11111b

El efecto sobre la compilación es el punto entero del ejercicio. Cuando cambias el cuerpo de un método de ClienteAPIEnvivo, ninguna feature se recompila: su contrato, que es lo único que conocen, no ha cambiado. Solo se recompila la implementación y se vuelve a enlazar. En un módulo único, ese mismo cambio invalidaba todo lo que tocaba el cliente. Además obtienes un segundo beneficio que se nota al probar: los tests de una feature dependen de la interfaz y de un doble, nunca de la red, y por tanto ni siquiera compilan la implementación real.

// Modulo ClienteAPIInterface: minusculo y estable
public protocol ClienteAPI: Sendable {
    func perfil(id: Usuario.ID) async throws -> Perfil
}

// Modulo ClienteAPIEnvivo: cambia a menudo, nadie lo importa salvo la app
public struct ClienteAPIEnvivo: ClienteAPI {
    public init() {}
    public func perfil(id: Usuario.ID) async throws -> Perfil { /* red */ }
}

Advertencia importante: este patrón duplica el número de módulos de la capa que lo aplique. No lo apliques a todo. Reserva la separación para las dependencias que cumplen las dos condiciones a la vez —implementación cara de compilar y muchos consumidores—, porque solo ahí el desacoplamiento paga el manifiesto extra.

Romper un ciclo

Cuando el ciclo aparece, hay tres respuestas legítimas y la elección depende de por qué existe.

Inversión. El caso más común: Autenticacion necesita mostrar la pantalla de login que vive en FeatureLogin, y FeatureLogin necesita a Autenticacion para validar. La solución no es que se importen: es que la capa baja declare un protocol con lo que necesita y que la capa alta lo conforme. La dependencia deja de ser mutua y pasa a apuntar, las dos veces, hacia el módulo estable que declara el contrato.

Extracción. A veces el ciclo señala que hay un tercer concepto atrapado entre los dos. Pedidos y Facturacion se importan mutuamente porque ambos manipulan un Importe con divisa y redondeo. Ese Importe no pertenece a ninguno: pertenece a un módulo nuevo del que dependen ambos. El ciclo desaparece porque el concepto compartido dejó de vivir arbitrariamente en uno de los lados.

// Inversion: el modulo estable declara lo que necesita
// Modulo Autenticacion
public protocol PresentadorDeLogin: Sendable {
    @MainActor func presentarLogin() async -> Credenciales?
}

public actor Sesion {
    private let presentador: any PresentadorDeLogin
    public init(presentador: any PresentadorDeLogin) { self.presentador = presentador }
}

// Modulo FeatureLogin: conforma el protocolo, la flecha apunta hacia abajo
// El target de la app une las dos piezas y nadie importa hacia arriba.

Mediación. Cuando el acoplamiento es de eventos y no de datos —Reproductor quiere avisar de que terminó una pista y Biblioteca quiere reaccionar—, ninguna dependencia directa es correcta en ninguna dirección. Ambos dependen de un módulo de eventos que no conoce a nadie, y la app conecta los cables. Esta es la técnica más potente y la más fácil de abusar: cada evento intermediado es una relación que el compilador ya no puede verificar y que solo existe en tiempo de ejecución.

⬇️

Hacia lo estable

Las flechas apuntan a lo que cambia poco y no conoce a nadie. Si una capa baja importa una alta, el orden está invertido.

🔀

Ninguna feature importa otra

Una feature emite intenciones; el compositor las resuelve. Sin esta regla el grafo colapsa transitivamente.

🪶

Interfaz separada

Cambiar la implementación no recompila a sus consumidores si el contrato vive en su propio módulo.

⛓️

El ciclo es un diagnóstico

Swift no admite ciclos entre módulos. Cada uno señala un concepto mal ubicado, no una limitación de la herramienta.

Hacer cumplir el orden

La regla que solo vive en la cabeza del equipo se erosiona; conviene que algo la compruebe. Una parte la comprueba ya el compilador, gratis: si Dominio no declara dependencia sobre SwiftUI, el import SwiftUI no compila y no hay conversación posible. Para lo que el compilador no ve —una feature que importa otra cuando ambas están declaradas en el mismo paquete— basta una comprobación de texto en integración continua.

# Ninguna feature debe importar otra feature
if grep -rn "^import Feature" Modulos/Sources/Feature*/ ; then
  echo "Violacion de capas: una feature importa otra"
  exit 1
fi

La comprobación es tosca a propósito. Su valor no está en la sofisticación sino en que convierte una discusión recurrente de revisión de código en un fallo de build, es decir, en algo que no requiere que nadie esté prestando atención.

Un sistema de módulos sin ciclos no es una limitación técnica: es una teoría sobre lo que se puede entender

La prohibición de ciclos parece una consecuencia práctica de cómo funciona un enlazador, y no lo es. Es una decisión sobre qué clase de sistemas se pueden razonar. Un grafo dirigido acíclico admite un orden topológico, y ese orden es exactamente lo que hace posible entender un componente sin entenderlo todo: puedes leer Dominio sin haber visto una sola vista, probarlo sin arrancar nada, sustituirlo sabiendo que nada por debajo se rompe. En cuanto hay un ciclo, ese orden desaparece y con él la posibilidad de comprensión parcial: para entender A hay que entender B, y para entender B hay que volver a A. Deja de haber un punto por donde empezar. Lo mismo ocurre con la prueba, que necesita poder construir el sujeto sin construir el universo; con la compilación, que necesita un orden en el que emitir; y con la reutilización, porque un componente en un ciclo no se puede llevar a otro sitio sin llevarse a su pareja. Es revelador que los sistemas que sí permiten ciclos —los ficheros de un mismo módulo en Swift, las clases en un jar de Java, los ficheros de un paquete de Python— acaben todos desarrollando herramientas externas para detectarlos y prohibirlos, porque la libertad resulta no ser deseable una vez que el sistema crece. Swift toma la decisión al revés y la mete en el lenguaje: no puedes crear el problema. El coste es que a veces tienes que pararte a rediseñar cuando querías avanzar. El beneficio es que la propiedad estructural que permite razonar por partes está garantizada por construcción y no depende de que alguien ejecute una herramienta de auditoría que lleva ocho meses en rojo y que todo el mundo ha aprendido a ignorar.

📝
Lo esencial de esta lección

Las dependencias apuntan hacia lo estable y ninguna feature importa otra: el compositor resuelve las intenciones. Separar la interfaz de la implementación en módulos distintos evita que cambiar el cuerpo de una dependencia recompile a todos sus consumidores. Un ciclo es imposible en Swift y siempre señala un concepto mal ubicado: se rompe invirtiendo la dependencia, extrayendo el concepto compartido o mediando con eventos.

⚔️ Ordena y verifica tu grafo
  1. Dibuja el grafo real de tu proyecto y marca cada flecha que apunte de una capa estable a una inestable.
  2. Convierte una navegación entre features en un enum de destinos resuelto por el compositor.
  3. Separa una dependencia cara en interfaz e implementación y mide la compilación incremental antes y después.
  4. Provoca un ciclo a propósito entre dos módulos, lee el error del compilador y rómpelo con las tres técnicas para comparar resultados.
  5. Añade a tu integración continua una comprobación que falle si una feature importa otra.