Una feature, un módulo: por qué Point-Free defiende paquetes pequeños
Point-Free no propone modularizar por elegancia sino porque en Swift el módulo es la única frontera que el compilador hace cumplir. Esta lección define con precisión qué es un módulo —unidad de compilación, unidad de `internal`, unidad de invalidación incremental— y por qué una carpeta llamada `Perfil` no aporta ninguna de las tres cosas. Recorre los cuatro dividendos que produce partir la app en `target` pequeños de `SPM`, el precio real que se paga en `public` y en optimización entre módulos, el modificador `package` que Swift añadió para abaratarlo, y la decisión que casi nadie discute: un paquete con muchos objetivos frente a muchos paquetes con uno.
Llevas veinticinco niveles construyendo una arquitectura cuya tesis es que el todo tiene la misma forma que las partes. Este nivel cobra la factura de esa tesis: si de verdad el hijo ignora al padre, entonces el hijo puede compilarse sin el padre, y si puede compilarse sin el padre, no hay ninguna razón para que viva en el mismo binario. Point-Free ha defendido esta conclusión con una insistencia que a muchos les parece obsesiva —paquetes de SPM diminutos, uno por feature, a veces cuarenta en una sola app— y conviene entender que el argumento no es estético. Es que en Swift el módulo es la única unidad donde el control de acceso, la compilación incremental y el aislamiento significan algo simultáneamente. Todo lo demás son buenas intenciones escritas en nombres de carpeta.
- Definir el módulo de Swift como unidad de compilación, de control de acceso y de invalidación incremental, y ver por qué una carpeta no es ninguna de las tres.
- Enumerar los cuatro dividendos concretos de un objetivo por feature y saber cuál de ellos justifica por sí solo el trabajo.
- Reconocer el precio real de la frontera: ceremonia de
public, inicializadores explícitos y pérdida de optimización entre módulos, y cómopackagee@inlinablelo mitigan. - Decidir entre un paquete con muchos objetivos y muchos paquetes independientes según quién consuma el código y con qué cadencia se versiona.
La carpeta miente; el módulo no
En Swift, un módulo es tres cosas a la vez y esa coincidencia es la que hace todo el trabajo. Primero, es la unidad de compilación: el compilador procesa los archivos de un objetivo juntos y emite un .swiftmodule que resume su interfaz pública. Segundo, es la unidad de internal, el nivel de acceso por defecto: un símbolo internal es visible en todo su módulo y en ninguno más. Tercero, es la unidad de invalidación entre objetivos: cambiar el cuerpo de una función internal no altera la interfaz emitida, así que los módulos que dependen de este no se recompilan.
Una carpeta no participa de ninguna de las tres. En un objetivo único que contiene la app entera, internal significa literalmente toda la aplicación, de modo que el nivel de acceso por defecto de Swift, que en un proyecto modular es una jaula útil, se convierte en el equivalente exacto de no tener control de acceso. Es la razón por la que la disciplina de carpetas se degrada siempre: no hay ningún momento en que el sistema te diga que no.
// En un objetivo monolítico esto compila sin protestar
struct AjustesState { var tema: Tema } // internal, o sea: visible en toda la app
// ...y trescientos archivos más allá, en otra "carpeta"
func pintarPerfil(ajustes: AjustesState) { } // acoplamiento invisible, cero errores
Mueve AjustesState a un objetivo AjustesFeature y el segundo archivo deja de compilar hasta que alguien declare la dependencia en el manifiesto y marque el tipo como public. Ese error no es un obstáculo: es el sistema informándote de que estabas cruzando una frontera que solo existía en el diagrama.
La tercera propiedad, la de invalidación, es la que menos se conoce y la que más rendimiento produce. Al compilar un objetivo, Swift emite un archivo binario con su interfaz: los símbolos visibles hacia fuera y sus firmas. Un módulo que depende de él no lee su código fuente, lee esa interfaz. Por tanto, si cambias el cuerpo de una función sin tocar su firma, la interfaz emitida no cambia y los dependientes no tienen ninguna razón para rehacerse. Esa frontera no existe dentro de un objetivo, donde el sistema debe considerar mucho más ante cada edición, y es la razón mecánica de que la siguiente lección pueda prometer números y no impresiones.
Cuatro dividendos, y ninguno es estético
El primero es el bucle de edición. Un cambio dentro de PerfilFeature recompila PerfilFeature y sus dependientes, no AjustesFeature, ni la capa de red, ni los tres SDK de terceros. El segundo es el arranque en aislamiento: una feature que solo depende de interfaces livianas se puede previsualizar y ejecutar en una app de demo minúscula, sin autenticación ni migraciones. El tercero es el testing: los tests de la feature enlazan la feature, no la app, y por eso pasan de minutos a segundos. El cuarto, el más subestimado, es la legibilidad del acoplamiento: el manifiesto se convierte en el único documento donde el diseño del sistema está escrito de forma que no puede mentir.
Bucle corto
Editar, compilar y ver el resultado dentro de un objetivo pequeño. Es el dividendo que se cobra cien veces al día.
Tests que enlazan poco
El objetivo de tests depende de la feature y de las interfaces, nunca de las implementaciones vivas.
Previews que sí dibujan
Sin red real ni base de datos, el canvas arranca porque no hay nada pesado que inicializar.
Acoplamiento legible
El grafo de dependencias es el diseño. Si dos features no deben conocerse, se ve de un vistazo.
Hay un quinto efecto que no cabe en una tarjeta porque no es técnico. Un objetivo con fronteras explícitas se puede dar a alguien entero: quien lo mantiene ve de un vistazo todo lo que puede afectarle, porque está en la primera pantalla del manifiesto. En un objetivo único, incorporar a alguien nuevo consiste en explicarle qué partes del código no debe tocar, que es una forma de conocimiento que no se documenta y se pierde. La modularización convierte esa transmisión oral en una lista de dependencias.
Conviene ordenarlos con honestidad: el cuarto es el que sostiene a los otros tres en el tiempo. Los tres primeros se pueden conseguir puntualmente con trucos —compilaciones parciales, mocks, targets de prueba— pero se pierden en cuanto alguien añade un import inocente. El cuarto es el único que se defiende solo, porque el acoplamiento indebido deja de ser una discusión de revisión de código y pasa a ser un fallo de compilación.
El precio: public, inicializadores y la optimización que cruza
Nada de esto es gratis. Cruzar una frontera de módulo obliga a marcar public cada tipo, propiedad y método que el otro lado necesite, y —el detalle que más sorprende— a escribir a mano los inicializadores, porque el inicializador sintetizado de una struct es internal. En una feature de TCA eso significa public en State, en Action, en el body, en el tipo de la vista y un public init en cada uno.
@Reducer
public struct Perfil {
@ObservableState
public struct State: Equatable {
public var nombre = ""
public init() {} // sin esto, el padre no puede construirlo
}
public enum Action: ViewAction {
case view(View)
public enum View { case aparecio }
}
public init() {}
public var body: some ReducerOf<Self> { Reduce { _, _ in .none } }
}
El segundo coste es de rendimiento del binario y casi nadie lo mide: por defecto el compilador no puede especializar ni incorporar en línea el código que vive al otro lado de una frontera, porque solo ve la interfaz. Swift ofrece tres salidas de distinto calibre: @inlinable publica el cuerpo en el .swiftmodule para que el consumidor pueda incorporarlo, @usableFromInline acompaña a los símbolos internos que ese cuerpo necesita, y la optimización entre módulos completa se activa por bandera cuando de verdad hace falta. La tercera salida, y la más importante para el día a día, es el nivel de acceso package, que Swift introdujo justamente para este problema: expone un símbolo a todos los objetivos del mismo paquete sin volverlo parte de la API pública que ve el mundo exterior.
// Visible en todos los objetivos del paquete, invisible fuera de él
package struct FormateadorDeFecha {
package init() {}
package func corta(_ fecha: Date) -> String { ... }
}
Antes de que existiera, compartir un helper entre dos features del mismo paquete obligaba a hacerlo public, lo que lo convertía en superficie de API para siempre y en una promesa implícita de compatibilidad. Con package, el símbolo cruza objetivos hermanos pero no sale del paquete, y SPM pasa el nombre de paquete al compilador sin que tengas que configurar nada. Es la herramienta correcta para el código de infraestructura compartida que no quieres publicar y tampoco quieres duplicar.
Merece la pena convertir esta ceremonia en un hábito de diseño en lugar de sufrirla. Cada vez que escribes public estás decidiendo que ese símbolo forma parte del contrato de la feature, y la pregunta correcta no es si compilaría sin él, sino si querrías que alguien lo usara desde fuera dentro de un año. La respuesta suele ser que no para casi todo, y por eso una feature bien delimitada expone tres cosas: su State, su Action y su vista. Cuando la lista crece hasta diez o quince símbolos, el módulo no es una feature: es una biblioteca que aún no ha decidido qué quiere ser.
Un paquete con muchos objetivos, no muchos paquetes
La confusión más común de quien empieza es equiparar módulo con paquete. No lo son: un paquete es una unidad de distribución y versionado; un módulo es un objetivo dentro de él. Para una app, la respuesta casi siempre correcta es un único paquete local con treinta o cuarenta objetivos, porque la resolución de dependencias externas ocurre una sola vez, no hay que etiquetar versiones para mover código entre features y refactorizar a través de fronteras es una edición del manifiesto en lugar de una danza de publicaciones.
# El paquete local vive junto al proyecto y se referencia como dependencia local
MiApp/
MiApp.xcodeproj # objetivo de app: delgado, solo arranque
MiAppKit/ # un unico paquete
Package.swift # aqui viven los 40 objetivos
Sources/PerfilFeature/
Sources/AjustesFeature/
Sources/APIClient/ # interfaz
Sources/APIClientLive/ # implementacion
Muchos paquetes se justifican cuando el consumidor es externo —una librería que publicas—, cuando el ciclo de versionado difiere de verdad, o cuando distintos equipos con permisos distintos poseen el código. Fuera de esos casos, multiplicar paquetes solo multiplica manifiestos y archivos de resolución.
| Decisión | Un paquete, muchos objetivos | Muchos paquetes |
|---|---|---|
| Resolución de dependencias | Una sola vez | Una por paquete, y hay que alinearlas |
| Mover código entre módulos | Editar el manifiesto | Publicar versión y actualizar consumidores |
| Refactor entre fronteras | Un commit | Varios repositorios coordinados |
| Cuándo compensa | Apps y monorepos | Librerías públicas y equipos separados |
Conviene además no confundir el objetivo de app del proyecto de Xcode con el objetivo raíz del paquete. El primero debe contener poco más que el punto de entrada, los recursos y la configuración de firma; toda la lógica de ensamblaje vive en el paquete, donde se puede compilar y testear sin abrir Xcode. Esa separación es lo que permite ejecutar la suite entera desde la línea de órdenes y en integración continua a la velocidad del paquete y no a la del proyecto.
flowchart LR C[Carpeta Perfil] --> C1[internal alcanza a toda la app] C --> C2[Cualquier archivo puede importar cualquier archivo] C --> C3[Un cambio invalida el objetivo entero] M[Objetivo PerfilFeature] --> M1[internal alcanza solo a la feature] M --> M2[Solo ve lo declarado en el manifiesto] M --> M3[Un cambio interno no cruza la frontera] style M fill:#a6e3a1,color:#11111b style C fill:#f38ba8,color:#11111b
Toda arquitectura es un conjunto de reglas sobre qué puede hablar con qué, y toda arquitectura fracasa por el mismo mecanismo: las reglas viven en la cabeza de las personas y las personas tienen prisa, se van de la empresa y contratan a alguien que no leyó la wiki. La pregunta interesante no es cuál es el diseño correcto, sino dónde reside la autoridad que lo hace cumplir. En una app de un solo objetivo esa autoridad es social: revisiones de código, convenciones, un lint que alguien mantiene los viernes. En una app modularizada la autoridad es el enlazador, que sencillamente no encuentra el símbolo. La diferencia entre ambos regímenes no es de grado sino de naturaleza, porque el primero se degrada monótonamente con el tamaño del equipo y del tiempo, y el segundo no se degrada en absoluto. Ese es el argumento entero de Point-Free, y por eso su ejemplo abierto de referencia lleva las fronteras hasta un extremo que parece exagerado hasta que lo trabajas: cada feature, cada cliente, cada interfaz y cada implementación en su propio objetivo. El corolario que casi nadie extrae es que la modularización no es una fase posterior de limpieza, sino el mecanismo por el que la composición del reducer —esa asimetría en la que el hijo ignora al padre— se vuelve auditable. Un reducer hijo que no puede compilar sin su padre nunca fue independiente; simplemente nadie lo había comprobado. Modularizar es, literalmente, comprobarlo, y lo notable es que la comprobación es continua, gratuita y ocurre cada vez que alguien pulsa compilar.
- Cuenta cuántos objetivos tiene tu proyecto actual y cuántos tipos declara. El cociente te dice cuánta gente vive dentro del alcance de
internal. - Elige la feature que sospeches más acoplada y lista, sin abrirla, con qué otras partes crees que habla. Después búscalo de verdad y compara la lista imaginada con la real.
- Crea un paquete local con un único objetivo y mueve allí esa feature. Anota cada error: los de símbolo no encontrado son dependencias ocultas, los de
publicausente son decisiones de API que nunca tomaste. - Marca como
publicsolo lo mínimo para que el padre la ensamble, y prueba a bajar apackagecualquier helper que dos objetivos hermanos compartan. - Compila el objetivo aislado y la app entera, anota ambos tiempos y guarda el número: es la línea base contra la que medirás las tres lecciones siguientes.