Paquetes locales de SPM: sacar una feature del target de la app
Cómo se extrae una feature del target de la aplicación hacia un paquete local de Swift Package Manager: el manifiesto, los targets, la resolución del grafo y las patologías que convierten un grafo sano en una cascada de recompilaciones.
El paquete local es la herramienta que hace practicable todo lo demás. Antes de que Swift Package Manager aprendiera a vivir dentro de un proyecto de Xcode sin repositorio remoto, modularizar significaba crear framework en el navegador de proyectos, pelearse con los ajustes de build heredados, mantener listas de ficheros en un pbxproj binario y provocar conflictos de fusión que nadie sabía resolver. Un paquete local elimina toda esa capa: es una carpeta con un Package.swift, se arrastra al proyecto, Xcode lo resuelve y a partir de ahí el grafo de dependencias vive en un archivo de texto de treinta líneas que se lee, se revisa y se versiona como código. La cuestión ya no es si se puede, sino qué se extrae primero, dónde se corta y qué forma tiene el grafo resultante, porque un grafo mal formado te devuelve exactamente los tiempos de compilación de los que huías, con manifiestos añadidos.
- Escribir un
Package.swiftcon variostargety entender qué declara cada campo. - Ejecutar una extracción real desde el
targetde la app hacia un paquete local. - Reconocer las patologías de grafo: el módulo común hipertrofiado y el rombo profundo.
- Verificar el grafo resultante con las herramientas de línea de comandos de
swiftyxcodebuild.
El manifiesto es el grafo
Un paquete es un directorio con un Package.swift en la raíz. Ese archivo no es configuración declarativa inerte: es un programa Swift que se compila y se ejecuta para producir la descripción del paquete. De ahí que se pueda depurar, que los errores sean errores de compilación con posición real, y que la primera línea sea una directiva de versión de herramientas que determina qué API tienes disponible.
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "Modulos",
platforms: [.iOS(.v18)],
products: [
.library(name: "FeaturePerfil", targets: ["FeaturePerfil"]),
.library(name: "DisenoUI", targets: ["DisenoUI"]),
],
targets: [
.target(name: "Dominio"),
.target(name: "DisenoUI"),
.target(
name: "FeaturePerfil",
dependencies: ["Dominio", "DisenoUI"]
),
.testTarget(
name: "FeaturePerfilTests",
dependencies: ["FeaturePerfil"]
),
]
)
Conviene separar dos conceptos que se confunden todo el tiempo. Un target es la unidad de compilación real: un módulo de Swift, con su superficie public y su lista de dependencias. Un product es lo que el paquete exporta hacia fuera; solo lo declarado como producto puede ser consumido por el proyecto de Xcode o por otro paquete. Un target sin producto es interno al paquete y esa es una herramienta de diseño valiosa: te permite tener módulos que existen para el compilador pero que nadie de fuera puede importar.
La convención de rutas es rígida por defecto y ahorra mucha configuración: Sources/NombreDelTarget/ y Tests/NombreDelTargetTests/. Puedes desviarte con el parámetro path, pero cada desviación es una línea más que mantener y una sorpresa más para quien llegue después.
La extracción, paso a paso
Extraer una feature no es mover archivos: es descubrir, uno a uno, todos los hilos que la ataban al resto de la app. El proceso es mecánico y su valor está precisamente en que los hilos se hacen visibles.
mkdir -p Modulos/Sources/FeaturePerfil
cd Modulos && swift package init --type library --name FeaturePerfil
# Despues: arrastrar la carpeta Modulos al navegador de proyectos de Xcode
# y anadir el producto FeaturePerfil en Frameworks and Libraries del target de la app
Al mover el primer archivo aparecen los errores, y esos errores son el diagnóstico. Todo lo que la feature usaba y no ha venido con ella se convierte en un cannot find in scope. Cada uno de esos errores es una dependencia que existía y era invisible; ahora tienes que decidir conscientemente si sube al módulo, si baja a Dominio o si no debería haber estado nunca ahí.
El segundo bloque de errores es distinto y más instructivo. Todo lo que la app necesitaba de la feature deja de existir, porque en el módulo nuevo internal ya no alcanza al target de la app. Ahí es donde se decide la superficie pública, y la regla es la contraria a la intuición: no marques public todo lo que falla. Marca solo el punto de entrada —normalmente una vista raíz y su inicializador— y trata cada public adicional como una petición que hay que justificar.
// Sources/FeaturePerfil/PerfilView.swift
import SwiftUI
import Dominio
public struct PerfilView: View {
@State private var modelo: PerfilModelo
// Sin este init publico, el target de la app no puede construir la vista:
// los miembros sintetizados son internal al modulo.
public init(usuario: Usuario) {
_modelo = State(initialValue: PerfilModelo(usuario: usuario))
}
public var body: some View {
List { Text(modelo.nombre) }
}
}
Ese init explícito es el detalle que atrapa a todo el mundo la primera vez. El inicializador por miembros que Swift sintetiza para un struct es internal, siempre, sin excepción. Cruzar una frontera de módulo obliga a escribirlo a mano, y eso es una característica del diseño y no un fastidio: te fuerza a declarar cuál es el contrato de construcción de tu vista en vez de heredar el que el compilador dedujo de tus campos privados.
Grafos sanos y grafos enfermos
Un grafo sano es un orden parcial: ancho en la base, con dependencias que apuntan siempre en la misma dirección, y con una profundidad que no crece con el número de features. Dos patologías lo estropean, y ambas aparecen sin que nadie las decida.
flowchart TB subgraph sano[Grafo sano] A1[App] --> F1[FeaturePerfil] A1 --> F2[FeatureAjustes] F1 --> D1[Dominio] F2 --> D1 F1 --> U1[DisenoUI] F2 --> U1 end subgraph enfermo[Grafo enfermo] A2[App] --> F3[FeaturePerfil] F3 --> C[Comun] F3 --> F4[FeatureAjustes] F4 --> C C --> T[Todo lo demas] end style D1 fill:#a6e3a1,color:#11111b style U1 fill:#a6e3a1,color:#11111b style C fill:#f38ba8,color:#11111b style T fill:#f38ba8,color:#11111b
La primera patología es el módulo Comun, Core o Shared. Nace como cajón para lo que no encaja en ningún sitio y crece por acumulación hasta que todo el mundo depende de él. En ese punto has recreado el monolito con manifiestos: cualquier cambio en Comun recompila el proyecto entero, y la modularización deja de rendir. El síntoma es diagnóstico y no admite matices: si un módulo aparece en las dependencias de casi todos los demás, tu grafo tiene un cuello.
La segunda es la profundidad. Una cadena de seis módulos donde cada uno depende del siguiente serializa la compilación: el sexto no puede empezar hasta que el quinto termine, y todos los núcleos de la máquina se quedan mirando. Un grafo ancho y bajo paraleliza; uno estrecho y alto convierte una máquina de doce núcleos en una de uno. Prefiere siempre ensanchar antes que apilar.
Target contra product
El target es el módulo que compila; el product es lo que sale del paquete. Un target sin producto es privado y eso es una herramienta.
Los errores son el mapa
Cada cannot find in scope al extraer es una dependencia que existía y no se veía. Léelos como diagnóstico, no como obstáculo.
Public con parsimonia
Solo el punto de entrada. Cada public extra es superficie que tendrás que sostener y que impide cambiar por dentro.
Ancho, no alto
Un grafo bajo paraleliza en todos los núcleos. Una cadena larga serializa la build aunque tenga muchos módulos.
Verifica el resultado en vez de confiar en el diagrama mental. La herramienta de línea de comandos imprime el grafo real, que casi nunca coincide con el que crees tener.
swift package show-dependencies --format tree
swift package describe --type json | grep -A3 '"name"'
Lo más subestimado de un paquete local no es lo que hace el compilador con él, sino lo que hace el equipo. Antes de SPM, la estructura de dependencias de un proyecto de Xcode vivía dispersa entre ajustes de build, fases de enlazado y un pbxproj que ningún ser humano lee voluntariamente; añadir una dependencia entre dos partes del proyecto era una operación de interfaz gráfica que dejaba un rastro ilegible en el control de versiones, y por tanto una operación que nadie revisaba. Cuando el grafo se escribe en un Package.swift, añadir una dependencia se convierte en una línea de código en un diff. Alguien la ve. Alguien puede preguntar por qué Dominio necesita de pronto importar DisenoUI, y esa pregunta —que en el mundo del pbxproj era literalmente imposible de formular porque el cambio era invisible— es el mecanismo entero por el que una arquitectura se conserva. La restricción del compilador impide lo prohibido; el manifiesto legible hace discutible lo permitido. Son dos capas distintas de defensa y la segunda es la que evita la degradación lenta, esa en la que ninguna decisión individual fue mala y el resultado agregado es un grafo que nadie diseñó. Hay aquí un principio general que reaparece en la infraestructura como código, en las migraciones de base de datos versionadas y en los lockfile de dependencias: cuando el estado estructural de un sistema deja de ser un efecto lateral de acciones y pasa a ser un texto declarado, se vuelve auditable, comparable en el tiempo y reversible. Un Package.swift es la arquitectura de tu app en un formato que se puede leer en dos minutos, y ninguna otra representación —ni el diagrama, ni el documento, ni la charla del equipo— tiene la propiedad de estar sincronizada con la realidad por construcción.
Un paquete local es una carpeta con Package.swift donde cada target es un módulo y cada product es lo que sale hacia fuera. Extraer una feature revela sus dependencias ocultas en forma de errores de compilación y obliga a declarar una superficie pública mínima, incluido el init que Swift ya no sintetiza como accesible. Vigila las dos patologías del grafo: el módulo común hipertrofiado y la cadena profunda que serializa la build.
- Crea un paquete local con tres
targety declara producto solo para uno; comprueba qué ocurre al importar los otros desde la app. - Extrae la feature más aislada de tu proyecto y anota cada error de compilación como una dependencia que desconocías.
- Reduce la superficie
publicdel módulo extraído al mínimo que permita compilar la app y justifica cada símbolo que sobreviva. - Ejecuta
swift package show-dependenciesy compara el grafo real con el que dibujarías de memoria. - Busca en tu proyecto un candidato a módulo
Comuny propón en qué dos o tres módulos con nombre propio debería disolverse.