wandres.dev
MODULARIZAR · paquetes SPM

Package.swift: declarar objetivos, dependencias y fronteras

El manifiesto de un paquete de Swift no es un archivo de configuración: es la especificación ejecutable de tu arquitectura. Esta lección desarma `Package.swift` pieza a pieza —`platforms`, `products`, `dependencies`, `targets`— y muestra cómo se declara un objetivo por feature, cómo se enchufan las dependencias externas con `.product`, y por qué la lista de dependencias de cada `target` es la frontera real: lo que no está declarado no se puede importar, los ciclos son rechazados por el resolutor y el corte entre interfaz e implementación deja de ser una convención para volverse una propiedad del grafo. Cierra con los ajustes por objetivo que conviene fijar desde el primer día.

⏱ 20 min

El manifiesto es el único archivo del proyecto que no puede mentir sobre la arquitectura, porque el sistema de compilación lo obedece literalmente. Un diagrama en la wiki describe intenciones; Package.swift describe hechos, y esos hechos se comprueban en cada compilación. La consecuencia práctica es que aprender a leer un manifiesto ajeno es aprender a leer el diseño de esa app en dos minutos, y que escribir el tuyo con cuidado equivale a escribir las reglas de acoplamiento en un lenguaje que la máquina entiende. Esta lección lo recorre entero, no como referencia de sintaxis, sino como el sitio donde se toman las decisiones que las lecciones anteriores solo podían recomendar.

🎯 Al terminar esta lección sabrás
  • Leer y escribir las cuatro secciones del manifiesto y saber qué decide cada una: plataformas, productos publicados, dependencias externas y objetivos internos.
  • Declarar un objetivo por feature con sus dependencias mínimas, y distinguir un objetivo de biblioteca de uno de tests y de uno ejecutable.
  • Usar extensiones sobre Target.Dependency para que el grafo se lea de un vistazo y para que renombrar un paquete externo sea un cambio de una línea.
  • Fijar ajustes por objetivo —modo de lenguaje y funcionalidades futuras— y entender por qué conviene hacerlo desde el primer día y no al migrar.

Las cuatro secciones y qué decide cada una

Un manifiesto es código Swift que se ejecuta para producir una descripción del paquete. platforms fija la versión mínima de cada sistema y determina qué API puedes usar sin comprobaciones de disponibilidad. products declara qué bibliotecas ve el mundo exterior, es decir, qué puede importar el objetivo de app del proyecto de Xcode. dependencies lista los paquetes externos con su rango de versiones. Y targets describe el grafo interno, que es donde vive la arquitectura de verdad.

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
  name: "MiAppKit",
  platforms: [.iOS(.v17), .macOS(.v14)],
  products: [
    .library(name: "AppFeature", targets: ["AppFeature"]),
    .library(name: "PerfilFeature", targets: ["PerfilFeature"]),
  ],
  dependencies: [
    .package(
      url: "https://github.com/pointfreeco/swift-composable-architecture",
      from: "1.15.0"
    ),
  ],
  targets: [
    .target(name: "Modelos"),
    .target(name: "APIClient", dependencies: ["Modelos", .tca]),
    .target(name: "APIClientLive", dependencies: ["APIClient"]),
    .target(name: "PerfilFeature", dependencies: ["APIClient", "Modelos", .tca]),
    .target(name: "AjustesFeature", dependencies: ["Modelos", .tca]),
    .target(
      name: "AppFeature",
      dependencies: ["PerfilFeature", "AjustesFeature", "APIClientLive", .tca]
    ),
    .testTarget(name: "PerfilFeatureTests", dependencies: ["PerfilFeature"]),
  ]
)

Dos cosas merecen atención inmediata. La primera es que solo declaro como producto lo que el proyecto de Xcode necesita importar; los objetivos internos no publicados siguen siendo perfectamente utilizables entre ellos, y no publicarlos evita que alguien los importe desde fuera por accidente. La segunda es que PerfilFeature depende de APIClient y no de APIClientLive: esa única línea es la que impide que compilar la feature arrastre URLSession, credenciales, reintentos y el resto de la implementación real.

Tipo de objetivo Se enlaza en Uso típico en TCA
.target Otros objetivos y la app Features, clientes, modelos
.testTarget Solo la suite de tests Un objetivo de tests por feature
.executableTarget Un binario propio Apps de demo y herramientas de línea de comandos
.binaryTarget Otros objetivos SDK precompilado que no quieres recompilar

Azúcar para que el grafo se lea de un vistazo

Escribir la forma larga de una dependencia externa en cada objetivo hace el manifiesto ilegible justo donde más importa que se lea. La convención que Point-Free popularizó es declarar una extensión al final del archivo y usar nombres cortos arriba.

extension Target.Dependency {
  static var tca: Self {
    .product(name: "ComposableArchitecture", package: "swift-composable-architecture")
  }
  static var dependencias: Self {
    .product(name: "Dependencies", package: "swift-dependencies")
  }
}

El beneficio inmediato es cosmético, pero el importante es de mantenimiento: cuando un paquete externo cambia de nombre de repositorio o de módulo, editas una línea en lugar de cuarenta. El beneficio real, sin embargo, es que la sección targets queda tan compacta que el grafo se percibe como una figura. Si al mirarla no puedes decir en diez segundos quién depende de quién, el problema no es el formato: es que el grafo está mal.

Lo que la lista de dependencias hace imposible

Aquí está el núcleo de la lección. La lista de dependencias de un objetivo define exactamente el conjunto de módulos que sus archivos pueden importar; cualquier otro import produce un error de módulo no encontrado. Eso convierte tres reglas de arquitectura, que en un monolito solo podías pedir por favor, en propiedades verificadas.

La primera es la no vecindad entre features hermanas. PerfilFeature no lista AjustesFeature, luego no puede importarla, luego no puede llamarla, luego cualquier coordinación entre ambas tiene que subir al padre en forma de acción delegate. La segunda es el corte entre interfaz e implementación: como ninguna feature lista APIClientLive, ninguna feature puede construir el cliente real ni siquiera por accidente, y la inyección queda forzosamente en el objetivo raíz. La tercera es la ausencia de ciclos: el resolutor de SPM rechaza un grafo cíclico con un error explícito, de modo que la clase de acoplamiento más tóxica —dos módulos que se necesitan mutuamente— es directamente inexpresable.

ℹ️
El error de módulo no encontrado es una funcionalidad

Cuando alguien añade import AjustesFeature dentro de PerfilFeature y el build falla, el sistema no está estorbando: está pidiendo que la decisión se tome en el sitio correcto. Solo hay dos respuestas legítimas. O el código que se quería reutilizar pertenece a una capa inferior compartida, y entonces se extrae a un objetivo del que ambas dependan; o lo que se quería era comunicación entre features, y entonces se modela como acción delegate que el padre traduce. Añadir la dependencia para que compile es la tercera respuesta, y es la que borra la frontera de forma silenciosa y permanente.

🧱

Objetivo por feature

Una feature, un objetivo, un objetivo de tests. Si dos features siempre cambian juntas, quizá eran una.

🔀

Interfaz y vivo separados

El cliente se parte en dos objetivos. Solo la raíz depende del que trae el peso real.

🚧

Sin ciclos, por construcción

El resolutor rechaza un grafo cíclico. La peor forma de acoplamiento es inexpresable.

🎚️

Ajustes graduales

El modo de lenguaje se fija por objetivo, así que la migración avanza módulo a módulo.

Vale la pena insistir en la asimetría que produce el corte del cliente, porque es el patrón que más se copia mal. La interfaz debe ser tan liviana que dependa solo del dominio y de la librería de dependencias: un struct de closures, sus valores de test y de preview y nada más. La implementación viva puede ser tan pesada como haga falta, porque solo la enlaza quien construye la app. Cuando alguien mete en la interfaz una constante que necesita el SDK real, o un tipo que viene del framework de red, la frontera sigue declarada pero ya no separa nada, y el síntoma aparece semanas después en forma de previews que tardan un minuto.

Ajustes por objetivo, y por qué desde el primer día

Cada objetivo acepta swiftSettings, y hay dos ajustes que conviene fijar antes de que el código crezca. El modo de lenguaje determina si la concurrencia estricta es un aviso o un error, y las funcionalidades futuras permiten adoptar de forma gradual comportamientos que serán obligatorios en versiones posteriores.

extension SwiftSetting {
  static let estricto: [SwiftSetting] = [
    .swiftLanguageMode(.v6),
    .enableUpcomingFeature("ExistentialAny"),
  ]
}

.target(name: "PerfilFeature", dependencies: ["APIClient", .tca], swiftSettings: .estricto)

Que el ajuste sea por objetivo es exactamente lo que hace viable una migración: puedes poner en modo estricto los módulos nuevos y las capas bajas, que casi no tienen concurrencia, y dejar en modo permisivo los objetivos heredados mientras los conviertes uno a uno. En un monolito esa gradualidad no existe; el cambio es global y por eso se pospone para siempre.

flowchart TD
App[AppFeature] --> P[PerfilFeature]
App --> A[AjustesFeature]
App --> L[APIClientLive]
P --> I[APIClient interfaz]
A --> M[Modelos]
P --> M
I --> M
L --> I
P -. no declarada .-x A
style I fill:#a6e3a1,color:#11111b
style L fill:#fab387,color:#11111b
El manifiesto es la única documentación de arquitectura que no puede desactualizarse

Todo equipo que ha mantenido un sistema durante años conoce la misma decepción: el documento de arquitectura describe un sistema que dejó de existir hace dieciocho meses, y nadie sabe exactamente cuándo empezó a mentir. El fenómeno tiene una causa estructural y no moral: la documentación es un artefacto pasivo, separado del sistema que describe, y cualquier artefacto pasivo diverge de su referente en cuanto el referente cambia más rápido de lo que alguien tiene tiempo de reescribir. Package.swift pertenece a otra categoría porque no describe el sistema, lo constituye. No hay ninguna manera de que el grafo declarado difiera del grafo real, porque el grafo real se deriva del declarado; si alguien viola la arquitectura, no es que el documento quede obsoleto, es que el proyecto no compila. Esta propiedad —que la especificación y la implementación sean el mismo objeto— es rarísima en ingeniería de software y es la razón por la que merece la pena invertir tiempo en que el manifiesto se lea bien: los nombres de los objetivos, el orden en que los listas y las extensiones que definas no son estilo, son la interfaz de lectura del diseño. Un manifiesto de cuarenta objetivos que se entiende en un minuto vale más que treinta páginas de diagramas, entre otras cosas porque el minuto se invierte una vez y el diagrama hay que desconfiar de él cada vez. Y hay un efecto secundario que solo se aprecia con el tiempo: cuando la arquitectura vive en un archivo que se edita en las mismas revisiones de código que el resto, las decisiones de acoplamiento dejan de ser conversaciones abstractas de pizarra y se convierten en diferencias concretas que alguien aprueba o rechaza, con nombre, fecha y motivo.

⚔️ Convierte tu diagrama en un manifiesto que lo verifique
  1. Dibuja en papel el grafo de dependencias que crees tener y anota, para cada flecha, si es realmente necesaria o solo es histórica.
  2. Escribe el manifiesto correspondiente declarando un objetivo por feature y uno por cliente, con la lista de dependencias más corta que se te ocurra.
  3. Compila. Cada error de módulo no encontrado es una flecha que el papel no tenía. Decide para cada una si extraes a una capa inferior o si conviertes la llamada en acción delegate.
  4. Parte el cliente más pesado en interfaz e implementación, y verifica que ninguna feature lista la implementación en sus dependencias.
  5. Añade swiftSettings con modo de lenguaje seis a los objetivos de modelos y clientes, comprueba qué se rompe y anota cuánto de eso habrías descubierto en una migración global.