wandres.dev
COMPOSICIÓN Y TEST · dependencies, TestStore

Modularizar: la app como ensamblaje de features

Si el reducer es un valor componible, la consecuencia práctica es que cada feature puede vivir en su propio módulo de Swift Package Manager, compilarse y testearse en aislamiento, y previsualizarse sin arrancar la app entera. Esta lección lleva la composición del nivel anterior a su conclusión organizativa: un módulo por feature, dependencias inyectadas por interfaz y no importadas por implementación, y un módulo raíz delgado cuya única labor es ensamblar el árbol con `Scope`, `ifLet` y `forEach`. Explica por qué esta disciplina hunde los tiempos de compilación, multiplica las previews y convierte la palabra modular en algo verificable por el grafo de dependencias del paquete, no en una aspiración de arquitecto.

⏱ 17 min

La lección anterior demostró que un reducer es un valor que se compone. Esta extrae la consecuencia que de verdad cambia cómo trabajas: si el hijo ignora al padre, entonces el hijo puede vivir en otro módulo, compilarse solo, testearse solo y previsualizarse solo. La modularidad deja de ser una promesa de arquitecto —esas carpetas bien nombradas que en realidad dependen de todo— y pasa a ser una propiedad que el grafo de dependencias del paquete verifica por ti: si tu módulo de feature importara el módulo de otra feature, no compilaría. TCA no te pide que seas disciplinado; hace de la falta de disciplina un error del compilador.

🎯 Al terminar esta lección sabrás
  • Organizar cada feature como un módulo independiente de Swift Package Manager con su propio destino de tests y de previews.
  • Distinguir importar la interfaz de una dependencia de importar su implementación, y por qué solo la primera preserva el aislamiento.
  • Diseñar un módulo raíz delgado cuya única responsabilidad sea ensamblar features con los operadores de composición.
  • Entender cómo la modularidad recorta tiempos de compilación y habilita previews por feature sin arrancar la app.

Un módulo por feature, no una carpeta por feature

La diferencia entre una carpeta llamada Perfil y un módulo llamado Perfil es que el compilador solo hace cumplir la segunda. En una carpeta, cualquier archivo puede importar cualquier otro y el acoplamiento crece a escondidas hasta que tocar una feature recompila la app entera. En un módulo de SPM, las fronteras son físicas: Perfil solo ve lo que declara como dependencia en Package.swift, y si intenta usar un tipo de Ajustes sin declararlo, el build falla. Esa es la única definición de modular que no miente.

// Package.swift, recortado
let package = Package(
  name: "MiApp",
  products: [
    .library(name: "PerfilFeature", targets: ["PerfilFeature"]),
    .library(name: "AjustesFeature", targets: ["AjustesFeature"]),
    .library(name: "AppFeature", targets: ["AppFeature"]),
  ],
  dependencies: [
    .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.0.0"),
  ],
  targets: [
    .target(name: "PerfilFeature", dependencies: [.tca]),
    .target(name: "AjustesFeature", dependencies: [.tca]),
    .target(name: "AppFeature", dependencies: ["PerfilFeature", "AjustesFeature", .tca]),
    .testTarget(name: "PerfilFeatureTests", dependencies: ["PerfilFeature"]),
  ]
)

Lee el grafo de arriba: PerfilFeature y AjustesFeature no se conocen entre sí —no aparecen en las dependencias del otro— y solo AppFeature los conoce a ambos. Eso reproduce en el sistema de módulos la misma asimetría que viste en el reducer: el padre conoce a los hijos, los hijos se ignoran mutuamente. La modularidad de la app es la sombra, a escala de paquete, de la composición del reducer.

La interfaz de la dependencia, no su implementación

El aislamiento se rompe en el momento en que una feature importa la implementación de un servicio pesado —la capa de red real, la base de datos real— porque entonces compilar la feature exige compilar todo ese peso. TCA resuelve esto separando cada dependencia en dos: un módulo de interfaz, minúsculo, con solo el tipo del cliente y sus firmas; y un módulo de implementación, que provee el valor real. La feature importa solo la interfaz.

// Módulo APIClientInterface: liviano, sin red real
@DependencyClient
struct APIClient {
  var perfil: (_ id: Int) async throws -> Perfil
  var guardar: (_ perfil: Perfil) async throws -> Void
}

extension APIClient: TestDependencyKey {
  static let testValue = APIClient()   // falla si se llama sin sobrescribir
}

extension DependencyValues {
  var apiClient: APIClient {
    get { self[APIClient.self] }
    set { self[APIClient.self] = newValue }
  }
}

La macro @DependencyClient sintetiza un valor de prueba cuyos métodos, si se invocan sin sobrescribir, hacen fallar el test con un mensaje claro. PerfilFeature depende solo de APIClientInterface; la implementación viva —con URLSession y todo su peso— vive en APIClientLive, que únicamente el módulo raíz importa para inyectarla al arrancar. Así la feature compila sin arrastrar la red, y sus tests corren sin tocarla.

ℹ️
Por qué esta separación acelera la compilación

Los tiempos de compilación en un monolito crecen de forma superlineal: cada archivo puede depender de cualquier otro, así que el compilador debe reconsiderar demasiado ante cada cambio. Al cortar la app en módulos con fronteras explícitas, un cambio en PerfilFeature solo recompila PerfilFeature y lo que dependa de él, no AjustesFeature. Y al separar interfaz de implementación, editar la capa de red real no recompila ninguna feature, porque ninguna la importa. La modularidad no es solo higiene de diseño: es la palanca más directa sobre el bucle editar-compilar-probar, que es donde de verdad se gasta el día.

El módulo raíz: ensamblar y nada más

Si cada feature es un módulo autónomo, ¿qué queda para el módulo raíz? Solo el ensamblaje. AppFeature no contiene lógica de negocio propia; su reducer es casi todo composición: incrusta los estados y acciones de los hijos y los enchufa con los operadores del nivel anterior.

import PerfilFeature
import AjustesFeature
import ComposableArchitecture

@Reducer
public struct AppFeature {
  @ObservableState
  public struct State: Equatable {
    public var perfil = Perfil.State()
    public var ajustes = Ajustes.State()
    public init() {}
  }
  public enum Action {
    case perfil(Perfil.Action)
    case ajustes(Ajustes.Action)
  }
  public var body: some ReducerOf<Self> {
    Scope(state: \.perfil, action: \.perfil) { Perfil() }
    Scope(state: \.ajustes, action: \.ajustes) { Ajustes() }
    Reduce { state, action in
      // única lógica propia: coordinación entre features
      return .none
    }
  }
  public init() {}
}

Fíjate en los modificadores public: son necesarios porque el tipo cruza una frontera de módulo, y esa fricción es útil, no molesta. Cada public es una decisión consciente sobre qué expone la feature; todo lo demás queda internal y sellado. El módulo raíz, además, es el único que importa las implementaciones vivas y las inyecta al construir el store —el punto donde el árbol abstracto de features se enchufa al mundo real.

flowchart TD
Root[AppFeature modulo raiz] --> P[PerfilFeature]
Root --> A[AjustesFeature]
P --> IF[APIClientInterface]
A --> IF
Root --> Live[APIClientLive]
Live --> IF
P -. no importa .-x A
style IF fill:#a6e3a1,color:#11111b
style Live fill:#fab387,color:#11111b
Modular no es un adjetivo que reclamas: es un grafo que compila

El error histórico de la palabra modular es que casi siempre describe una intención, no un hecho: carpetas ordenadas, nombres bonitos y un diagrama en la wiki que jura que las capas están separadas, mientras el código real las cruza mil veces porque nada se lo impide. TCA convierte esa intención en una propiedad verificable al apoyar la modularidad de la app sobre la composición del reducer y ambas sobre el grafo de dependencias del paquete. La cadena es tensa y sin holguras: como el hijo ignora al padre, la feature puede vivir en su módulo; como vive en su módulo, solo ve lo que declara; como solo importa la interfaz de sus dependencias y nunca la implementación, su compilación y sus tests no arrastran el mundo; y como el módulo raíz es lo único que conoce a todos, el acoplamiento tiene un único lugar donde ocurrir y ese lugar es explícito. El día que alguien, con prisa, intente que PerfilFeature llame directamente a AjustesFeature, no habrá un revisor recordando la regla en un comentario: habrá un error de compilación, porque AjustesFeature no está en las dependencias de PerfilFeature y añadirlo obligaría a justificar por qué. Esa es la diferencia entre disciplina y arquitectura. La disciplina la sostienen personas cansadas; la arquitectura la sostiene el compilador, que nunca se cansa ni tiene prisa. Modularizar en TCA no es aspirar a algo: es dejar que la máquina haga cumplir lo que en otras arquitecturas solo prometías, y descubrir que, una vez la máquina lo exige, las features empiezan a comportarse de verdad como piezas separables —previsualizables, testeables y desplegables— y no como habitaciones de una casa donde todas las paredes son de cristal.

⚔️ Extrae una feature a su propio módulo
  1. Elige una feature real que hoy viva en el mismo destino que el resto de la app y que sospeches acoplada.
  2. Créale un módulo de SPM propio y muévela. Anota cada error de compilación que aparezca: cada uno es una dependencia oculta que la carpeta escondía.
  3. Para cada dependencia que la feature usaba, decide si necesita la interfaz o la implementación. Si solo llama a firmas, extrae una interfaz liviana con @DependencyClient y haz que la feature importe solo esa.
  4. Añade a la feature un destino de tests y una preview que arranque solo ese módulo. Comprueba que compilan sin el resto de la app.
  5. Deja en el módulo raíz únicamente el Scope que la enchufa y la inyección de su implementación viva. Mide el tiempo de compilación de la feature aislada frente al de la app entera.