wandres.dev
@SHARED · estado compartido

Cuándo compartir y cuándo no: el singleton disfrazado

`@Shared` resuelve un problema real y, usado sin criterio, resucita el que la arquitectura llevaba veintitrés niveles evitando. Esta lección cierra el nivel con la parte que ninguna documentación de una herramienta cuenta de sí misma: las dos maneras radicalmente distintas de obtener una caja compartida —recibirla de quien la posee o invocarla por una clave global— y por qué solo la segunda convierte el mecanismo en una variable global con mejor sintaxis. Después, una batería de preguntas para decidir antes de escribir la anotación, un inventario honesto de lo que se pierde al compartir aunque lo hagas bien, y las cuatro contenciones que mantienen ese coste acotado en una aplicación que va a durar años.

⏱ 21 min

Toda herramienta que resuelve una fricción real acaba usándose para eliminar fricciones que no eran problemas sino avisos. Pasar un dato por cuatro niveles es incómodo; escribir una acción delegate para que el padre decida es más largo que tocar el campo directamente; declarar quién posee qué exige pensarlo antes. @Shared hace desaparecer las tres incomodidades de un plumazo, y ahí está el peligro, porque dos de ellas eran el precio de propiedades que querías. La pregunta que decide el destino de una base de código no es si usar estado compartido, sino cuál de sus dos formas de uso adoptas por defecto: la que mantiene el grafo de propiedad explícito, o la que permite a cualquier feature del proyecto conjurar el dato con una cadena de texto y quedarse con él sin pedir permiso a nadie.

🎯 Al terminar esta lección sabrás
  • Distinguir la entrega explícita de una caja compartida de la invocación por clave, y valorar el coste arquitectónico de cada una.
  • Aplicar una batería de preguntas de decisión que descarte los casos que se resuelven mejor con scope, delegate o una dependencia.
  • Inventariar con precisión qué garantías se debilitan al introducir estado compartido, incluso cuando la decisión es correcta.
  • Contener ese coste con cuatro reglas: declaración en la raíz, un solo escritor, entrega explícita y clave global solo por necesidad.

Dos maneras de conseguir una caja

La forma que has usado durante todo el nivel es la entrega explícita: alguien crea la caja con Shared(value:) y se la pasa a quien deba compartirla. La otra forma es la invocación por clave, en la que la caja se identifica por una cadena y se materializa allí donde alguien la nombre.

// Entrega explícita: hay un dueño y hay una cadena de custodia
let sesion = Shared(value: Sesion())
state.perfil = Perfil.State(sesion: sesion)

// Invocación por clave: cualquiera, desde cualquier sitio, sin permiso
@Shared(.inMemory("sesion")) var sesion: Sesion = Sesion()

Las dos líneas producen un valor del mismo tipo con el mismo comportamiento en tiempo de ejecución, y su diferencia arquitectónica es abismal. Merece la pena detenerse en que la segunda ni siquiera necesita un padre: una feature puede declararla en su propio State, con un valor por defecto, y quedar conectada al resto del sistema sin que nadie más en el proyecto haya escrito una línea para conectarla. En la primera, para que una feature acceda al dato alguien tuvo que dárselo, de modo que el grafo de quién comparte qué está escrito en los inicializadores y se puede leer siguiendo la construcción del estado. En la segunda, el acceso no requiere que nadie participe: basta con conocer una cadena, que además no está comprobada por nada. La consecuencia es que el grafo de propiedad deja de existir como estructura y se convierte en un conjunto de coincidencias textuales repartidas por el proyecto. Eso —no la mutabilidad, no la referencia, no la persistencia— es la definición operativa de una variable global, y es exactamente lo que la arquitectura llevaba veintitrés niveles evitando.

Se puede afinar el diagnóstico un poco más. Lo que la clave elimina no es la propiedad del dato —sigue habiendo un solo dato— sino la cadena de custodia: el rastro de quién se lo entregó a quién. Y la cadena de custodia es justamente lo que convierte un grafo en algo navegable, porque permite responder a las dos preguntas que uno hace al llegar a un módulo desconocido: de dónde sale esto y quién más lo tiene. Sin ella, ambas preguntas se contestan con una búsqueda de texto, que es decir que no se contestan.

Conviene ser justo con las claves, porque tienen su lugar legítimo. Cuando el dato debe sobrevivir al proceso, la clave no es un atajo sino la identidad de ese dato en un almacén externo, y no hay manera de evitarla:

// Legítimo: la clave identifica el dato en un almacén que existe fuera del proceso
@Shared(.appStorage("temaOscuro")) var temaOscuro = false
@Shared(.fileStorage(.documentsDirectory.appending(component: "borrador.json")))
var borrador = Borrador()

La distinción práctica que hay que sostener es entre usar la clave porque el dato vive fuera y usar la clave porque pasarlo era pesado. La primera es un requisito del dominio; la segunda es una decisión de ergonomía que se paga en trazabilidad, y casi siempre se toma sin advertir que se está tomando. Un test útil para distinguirlas en el momento de escribir la línea: pregúntate qué debería ocurrir si la aplicación se cierra y se vuelve a abrir. Si la respuesta es el valor sigue ahí, la clave está justificada. Si la respuesta es empieza de cero, lo que necesitabas era una caja entregada, y la clave solo te ha ahorrado escribir un parámetro a cambio de borrar el mapa.

Incluso cuando la persistencia es legítima, queda una decisión que sigue siendo tuya y que la mayoría de la gente no toma: dónde se declara la clave. Declararla una vez en la raíz y repartir el resultado mantiene la cadena de custodia intacta y aprovecha la persistencia; repetir la misma anotación con la misma cadena en cinco features la disuelve otra vez. Las dos versiones funcionan idénticamente en tiempo de ejecución y solo una deja escrito quién participa.

Propiedad Entrega explícita Invocación por clave
Quién puede acceder solo quien lo recibe cualquiera que sepa la cadena
Dónde está escrito el grafo en los inicializadores en ningún sitio
Comprobación del vínculo el compilador | el tipo coincidencia de texto
Qué puede hacer un test fabricar la caja y sostenerla confiar en el almacén de fondo
Uso apropiado el caso por defecto persistencia real fuera del proceso

La fila que más conviene mirar de esa tabla es la cuarta, porque es la que se paga en horas. Un test que puede fabricar la caja y sostenerla controla por completo las condiciones iniciales de lo que está probando; un test que depende del almacén de fondo depende de un estado que otro test pudo dejar sucio, de un orden de ejecución que nadie fijó y de un mecanismo de limpieza que hay que recordar. La diferencia entre las dos formas de obtener el dato es, en la práctica cotidiana, la diferencia entre una suite determinista y una suite en la que de vez en cuando algo falla y se vuelve a lanzar.

Y una advertencia sobre la disciplina personal como estrategia: no funciona. Mientras exista la posibilidad de conjurar el dato con una cadena, alguien con prisa lo hará el martes que viene, y no será por descuido sino porque en ese momento era objetivamente lo más rápido y nada en el código señalaba el coste. Lo que sostiene la cadena de custodia no es la buena voluntad del equipo sino la ausencia de una alternativa cómoda: si la caja se crea en la raíz y no hay clave asociada, no hay atajo que tomar.

Preguntas antes de escribir la anotación

Antes de las preguntas conviene fijar la postura por defecto, porque una batería de criterios sin postura acaba justificando cualquier cosa. La postura es que @Shared no se usa hasta que las otras tres herramientas del catálogo han fallado explícitamente: el estado en el ancestro con scope, la acción delegate y la dependencia. No porque compartir sea malo, sino porque las tres alternativas dejan una huella más clara en el código y una de ellas resuelve la mayoría de los casos que la gente lleva a @Shared por comodidad. Solo cuando puedas nombrar por qué falla cada una en tu caso concreto has ganado el derecho a escribir la anotación.

La primera pregunta es cuántos son y qué parentesco tienen. Si el dato lo necesitan dos features hermanas con un padre inmediato, la respuesta es el padre y scope: compartir aquí no arregla nada que estuviera roto y añade una arista al grafo de escritura. @Shared empieza a ganar cuando la distancia obliga a atravesar features que no usan el dato, y gana con claridad cuando los interesados están en subárboles sin ancestro común cercano.

La segunda es si lo que hace falta transportar es un dato o un evento. Que la feature B deba mostrar el nombre nuevo es un dato y se comparte. Que la feature B deba lanzar una petición cuando el usuario cambia de plan es un evento, y su vehículo es una acción delegate, porque el estado compartido invalida vistas pero no ejecuta reducers. Un campo compartido que existe para que otro lo vea cambiar es una notificación disfrazada de dato, y se reconoce por el nombre que acaba teniendo:

// ANTIPATRÓN: un canal de notificación con forma de estado
@Shared(.inMemory("debeRefrescar")) var debeRefrescar = false
@Shared(.inMemory("ultimoEventoDeSincronizacion")) var ultimoEvento: Date?

Ninguno de esos dos campos describe algo que sea verdad del dominio: describen una tarea pendiente entre dos features que no supieron cómo hablarse. Y heredan todos los defectos de la comunicación por variable global —sin orden garantizado, sin registro en el log de acciones, sin nada que afirmar en un test— con el agravante de que ahora también hay que acordarse de volver a poner el booleano en falso, que es la clase de detalle que se olvida exactamente una vez y produce un bucle infinito de refrescos.

📝
La pregunta del dato y el evento tiene una versión rápida

Si al describir en voz alta lo que quieres conseguir usas un verbo en presente —que ajustes muestre el nombre nuevo, que la cabecera refleje el plan— estás hablando de un dato y compartir es correcto. Si usas un verbo que denota un instante —que ajustes recargue cuando el plan cambie, que se registre el evento al entrar— estás hablando de un hecho, y los hechos viajan como acciones. La gramática de la frase con la que describes el requisito acierta más veces que la intuición sobre la herramienta.

La tercera es si el dato es realmente de la aplicación o de un sistema externo. La caché de la red, el estado de la conexión, la cola de sincronización: eso no es estado de la interfaz, es el interior de un subsistema, y su forma correcta es una dependencia que lo encapsule y exponga una API. Meterlo en el estado compartido para poder mirarlo cómodamente desde tres pantallas es sacar las tripas de un componente a la sala de estar.

El matiz que hace difícil esta tercera pregunta es que a veces la respuesta correcta es las dos cosas a la vez: el subsistema queda encapsulado tras una dependencia y expone hacia la interfaz una proyección de solo lectura, que es exactamente la forma de la lección anterior. Que haya conexión es un hecho del subsistema y su mecánica no interesa a nadie; que la interfaz deba mostrar un indicador cuando no la hay es un dato observable y le corresponde entrar como lector. Separar el interior de la proyección evita el falso dilema entre esconderlo todo y exponerlo todo.

La cuarta es cuántos van a escribir. Si la respuesta honesta es más de uno, vuelve a la lección anterior antes de continuar: casi siempre existe una feature que posee el dominio del dato y las demás deberían pedirle las transiciones en lugar de ejecutarlas. Un dato compartido con un escritor es una fuente única de verdad; con cuatro es un recurso en disputa. Y conviene responder a esta pregunta contando features, no contando pantallas: tres pantallas que ofrecen un botón de cerrar sesión no son tres escritores si las tres emiten un hecho que recoge la misma dueña.

Conviene notar que esta cuarta pregunta es la única de las cinco cuya respuesta cambia con el tiempo sin que nadie lo decida. Un dato nace con un escritor y va acumulando escritores conforme se añaden pantallas, uno por uno, cada uno con una justificación razonable en el momento. Por eso la respuesta útil no es la de hoy sino la de dentro de un año, y por eso la contención práctica es estructural y no de criterio: si solo una feature sostiene la caja escribible, la respuesta no puede empeorar sola.

Y la quinta, que resume a las anteriores, es si el dato tiene identidad única en el dominio. La sesión, la configuración del usuario, el carrito: hay uno y solo uno, y compartir es representar fielmente esa unicidad. El resultado de una búsqueda, el borrador de un formulario, la selección de una lista: puede haber varios simultáneos y el hecho de que hoy solo haya uno es una circunstancia de la interfaz, no una verdad del dominio. Compartir lo segundo es una limitación futura escrita hoy, y suele cobrarse la deuda en el peor momento: el día que el diseño pide dos paneles lado a lado, o una vista dividida en iPad, o una segunda ventana en Mac. La pregunta que anticipa ese día es sencilla y conviene hacérsela siempre: si mañana hubiera dos de estas pantallas abiertas a la vez, ¿deberían ver lo mismo? Si la respuesta es que sí sin dudarlo, es identidad; si hay que pensarlo, no lo es.

Un detalle sobre el orden en que se aplican las cinco. No son un filtro que descarte casos hasta dejar los que merecen @Shared, sino cinco maneras distintas de nombrar la misma pregunta de modelado: qué es este dato en el dominio, con independencia de qué pantallas existan hoy. Cuando las cinco respuestas apuntan al mismo sitio, la decisión es fácil y estable durante años. Cuando se contradicen —el dato parece único pero lo escriben tres, o parece de la app pero en realidad lo posee el servidor— la contradicción no está en las preguntas: está en un modelo que todavía no ha decidido qué representa, y ninguna anotación va a decidirlo por ti.

Lo que se pierde y cómo acotarlo

Aun cuando la decisión sea correcta, compartir cuesta, y conviene tener el inventario a la vista. Se pierde la implicación de que una feature solo cambia al procesar sus acciones: ahora un campo suyo puede cambiar sin que le llegue nada. Se pierde parte de la explicación del registro de acciones: la escritura sí queda ligada a la acción que la provocó en la feature que la hizo, pero la lista de acciones de la feature afectada ya no cuenta toda su historia. Se pierde autonomía en el test: una prueba puede pasar apoyándose en un valor compartido que ninguna otra parte de la prueba escribió, y esa dependencia implícita no aparece en el código del test. Y se pierde independencia modular: dos features que comparten una caja ya no son piezas Lego sueltas, sino piezas que solo encajan en un tablero donde ese dato existe.

Esta última pérdida es la que más cuesta ver a tiempo y la que más caro sale, porque no se manifiesta hasta el día que intentas mover una feature a otro sitio: a un módulo SPM propio, a una app de vitrina, a un objetivo de pruebas de interfaz, a un widget. En ese momento descubres que la feature no es autónoma —necesita que alguien le entregue una sesión que no sabe construir— y que su superficie pública ya no es solo su State y su Action, sino también el conjunto de cajas que espera recibir. No es un defecto fatal ni una razón para no compartir; es información que conviene tener cuando decides, porque la diferencia entre una feature con cero cajas y una con dos es la diferencia entre poder reutilizarla mañana y tener que negociar su contexto cada vez.

Cuatro contenciones mantienen ese coste dentro de lo razonable. La primera es declarar arriba: la caja se crea en la raíz o en el ancestro que de verdad posee el dominio del dato, no en la primera feature que resultó necesitarla. La segunda es un solo escritor, con las demás sosteniendo SharedReader y pidiendo transiciones por delegate; y conviene aplicarla desde el primer día, porque quitar escritura a diez features que ya la tenían es un refactor de una tarde, mientras que no dárselas nunca no cuesta nada. La tercera es entregar en lugar de invocar: pasar el valor proyectado siempre que el dato no viva fuera del proceso, y reservar las claves para persistencia real. Y la cuarta es contar: si un proyecto acumula más de un puñado de datos compartidos, el problema ya no son las cajas sino un modelado del dominio que está pidiendo ser reorganizado.

Hay una pérdida más, específica del testing, que conviene mirar de frente porque es la que muerde más en silencio. Un dato compartido por clave global se resuelve contra un almacén que el sistema de dependencias controla, y en contexto de test ese almacén es efímero, de modo que las pruebas no se contaminan entre sí mientras respetes el sistema de dependencias. Respetarlo significa no leer ni escribir la clave por fuera, no cachear el valor en una propiedad estática y no dar por hecho que el almacén está vacío sin haberlo establecido en el propio test. Basta con saltarse una de las tres para que aparezca la peor especie de fallo intermitente: el test que pasa solo, falla en la suite y cambia de comportamiento según el orden de ejecución.

⚠️
La señal de que la herramienta se ha vuelto contra ti

Hay un momento concreto en el que una base de código cruza la línea, y se reconoce sin ambigüedad: cuando alguien añade un campo a un tipo compartido y no es capaz de enumerar quién lo lee. En ese punto la caja ha dejado de ser una arista declarada del grafo de estado y se ha convertido en un depósito común donde las features dejan cosas por si otra las necesita. La reparación no es técnica sino de modelado: dividir esa caja en varias con dueños identificables, o devolver al ancestro lo que nunca debió salir de él.

flowchart TD
Q1[Dos features necesitan el mismo dato] --> Q2[Hay ancestro comun cercano]
Q2 -->|si| S1[Estado en el padre y scope]
Q2 -->|no| Q3[Es dato o es evento]
Q3 -->|evento| S2[Accion delegate hacia el ancestro]
Q3 -->|dato| Q4[Pertenece a un sistema externo]
Q4 -->|si| S3[Dependencia con su propia API]
Q4 -->|no| Q5[Tiene identidad unica en el dominio]
Q5 -->|no| S4[Modelar por separado sin compartir]
Q5 -->|si| S5[Shared en la raiz con un solo escritor]
style S5 fill:#a6e3a1,color:#11111b
style S4 fill:#f9e2af,color:#11111b
🏠

Declarar arriba

La caja nace donde vive el dominio del dato, no donde alguien la necesitó primero. Bajarla después es mucho más caro.

✍️

Un solo escritor

La dueña escribe y recibe acciones. Los demás sostienen lectores y piden. Una historia posible en lugar de muchas.

🤝

Entregar, no invocar

Pasar el valor proyectado mantiene el grafo escrito en los inicializadores. La clave global lo disuelve en cadenas de texto.

🔢

Contar las cajas

Un puñado de datos compartidos describe un dominio. Una veintena describe un modelado que hay que rehacer.

Compartir no es el pecado; el pecado es que el reparto deje de estar escrito en alguna parte

Cerrar este nivel exige responder a la objeción que lo ha sobrevolado desde la primera lección, y que un ingeniero con memoria formula así: los últimos treinta años de arquitectura de software han consistido en gran medida en aprender a no tener estado global, y aquí hay un framework célebre por su rigor añadiendo una manera cómoda de tenerlo. La objeción parece decisiva y no lo es, porque identifica mal al culpable. Lo que hace daño en una variable global no es que sea única, ni que sea mutable, ni siquiera que muchos la lean; una base de datos es única, mutable y leída por todos, y nadie la considera una deuda de diseño. Lo que hace daño es que el acceso no deja rastro: cualquier componente puede alcanzarla sin que ninguna declaración lo registre, cualquiera puede escribirla sin que el tipo lo delate, y en consecuencia el conjunto de componentes que participan en la historia de ese valor no está escrito en ningún sitio del programa —hay que reconstruirlo cada vez, buscando por el repositorio, y esa reconstrucción es exactamente el trabajo que ninguna arquitectura debería exigir dos veces. Vista bajo esa luz, la contribución de @Shared no es haber legalizado el estado global, sino haber hecho el trabajo que un singleton nunca hizo: instrumentar la referencia para que conserve historia y el TestStore pueda afirmar sobre ella, marcar la escritura con una sintaxis que no se confunde con la de un campo propio, y ofrecer un tipo lector con el que el reparto de capacidad de escritura queda declarado y comprobado por el compilador. Con las tres piezas puestas, el estado compartido deja de ser una zona ciega y pasa a ser una parte más del programa que se puede leer, y ese es todo el asunto. Pero las tres piezas solo funcionan si tú sostienes la cuarta, que ninguna API puede sostener por ti: que el reparto siga estando escrito. La entrega explícita lo escribe en los inicializadores; la clave global lo borra y devuelve el sistema al punto de partida con una anotación más bonita. Por eso la regla con la que conviene salir de aquí no es una regla sobre estado compartido sino sobre diseño en general, y se aplicará igual en los niveles de persistencia y modularización que vienen: la pregunta que decide si una estructura envejece bien no es cuántas partes se comunican, sino si el mapa de quién habla con quién existe en el código o solo en la memoria del equipo. @Shared te da los materiales para dibujar ese mapa; usarlo por su clave y no por su cadena de custodia es tirarlos a la basura y quedarse con la comodidad.

⚔️ Audita tus datos compartidos y justifica cada uno por escrito
  1. Enumera todos los @Shared de tu proyecto, agrupados por dato. Para cada uno anota quiénes lo leen, quiénes lo escriben y cómo obtuvo cada feature su acceso: entrega explícita o clave.
  2. Pasa cada dato por las cinco preguntas de la segunda sección. Marca los que no superen alguna y escribe en una frase cuál habría sido la herramienta correcta: scope, delegate o dependencia.
  3. Elige el que peor puntúe y migra: si era un evento, conviértelo en acción delegate; si era un subsistema, enciérralo en una dependencia; si eran dos hermanas, súbelo al padre. Comprueba que el comportamiento observable no cambia.
  4. Toma un dato que se obtenga por clave global sin necesitar persistencia y conviértelo a entrega explícita desde la raíz. Anota cuántos inicializadores has tenido que tocar: ese número es el grafo que antes no estaba escrito.
  5. Escribe un párrafo de justificación para cada caja que sobreviva a la auditoría, nombrando su dueño, su único escritor y por qué su identidad es única en el dominio. Si alguna no admite ese párrafo, no era estado compartido: era una prisa.