Fronteras entre slices: quién puede leer a quién
Un store combinado es un único objeto, de modo que cualquier selector puede alcanzar cualquier rama del estado y ninguna herramienta se queja. Esa permisividad convierte las fronteras entre slices en una disciplina voluntaria, y su incumplimiento produce el acoplamiento silencioso: dependencias reales entre dominios que no aparecen en ninguna importación y que solo se descubren cuando alguien cambia la forma de un estado ajeno. Esta lección distingue lectura de escritura entre dominios, ordena las tres vías legítimas de comunicación —selector exportado, acción como evento y selector transversal en una capa superior—, y fija la dirección de las dependencias para que el grafo entre funcionalidades siga siendo acíclico.
Una vez el código está partido en dominios, la pregunta que decide si esa partición sobrevive es qué le está permitido a un dominio saber de otro. Y aquí Redux juega en contra: el estado combinado es un solo objeto, así que cualquier función que reciba la raíz puede leer cualquier rama sin que nada lo impida, ni el compilador, ni el linter, ni la revisión de código, porque no hay nada anómalo que mirar. Un reducer está encerrado en su porción por construcción, pero un selector no lo está en absoluto. De esa asimetría nace la patología central de un Redux grande: dominios que dependen unos de otros sin que exista ninguna importación que lo declare, un acoplamiento que no se ve hasta que se rompe algo lejos de donde se tocó.
- Distinguir el aislamiento estructural del reducer de la permisividad total de la lectura sobre el estado raíz.
- Identificar el acoplamiento silencioso y las tres formas en que se cuela entre funcionalidades.
- Ordenar las vías legítimas de comunicación: selector exportado, acción como evento y selector transversal.
- Fijar la dirección de las dependencias y detectar ciclos entre dominios antes de que se consoliden.
La asimetría entre escribir y leer
Al escribir, las fronteras se cumplen solas. combineReducers entrega a cada reducer exclusivamente su porción del estado, de modo que el reducer de carrito no puede tocar el de usuarios ni aunque quiera: no lo recibe. Ese aislamiento es estructural, no disciplinar, y es la razón por la que las mutaciones cruzadas casi nunca son el problema en un store grande.
Al leer ocurre lo contrario. Un selector recibe la raíz completa, así que nada distingue técnicamente a state.carrito.lineas de state.usuarios.actual.plan dentro de un fichero del dominio de carrito. La lectura es la única operación que puede cruzar fronteras sin declararlo, y por eso las fronteras entre slices son, en la práctica, una disciplina de lectura. Es la continuación directa de lo que la lección del Nivel 5 sobre selectores planteó como frontera de lectura: allí el argumento era desacoplar los componentes de la forma del estado; aquí es desacoplar los dominios entre sí, y el mecanismo es el mismo.
// Acoplamiento silencioso: carrito conoce la forma interna de usuarios.
// Ninguna importacion lo delata; un cambio en usuarios lo rompe en silencio.
export const selectDescuento = (s: RootState) =>
s.usuarios.actual.plan === 'premium' ? 0.2 : 0
// Frontera respetada: carrito consume la superficie publica de usuarios.
import { selectPlan } from '../usuarios/usuariosSlice'
export const selectDescuentoOk = (s: RootState) =>
selectPlan(s) === 'premium' ? 0.2 : 0
Que el carrito dependa del plan del usuario puede ser perfectamente razonable; el negocio a veces es así. Lo que no es razonable es que esa dependencia no aparezca en ninguna parte salvo en un acceso por punto enterrado en un selector. Sin importación no hay grafo que analizar, ni herramienta que dibuje quién depende de quién, ni aviso cuando el dominio propietario cambie su forma. La versión con importación tiene exactamente el mismo acoplamiento conceptual y una virtud decisiva: se puede contar, revisar y prohibir.
Las tres vías legítimas
La comunicación entre dominios no debe prohibirse, debe canalizarse, y hay tres canales con propiedades distintas. El primero es el selector exportado: la funcionalidad propietaria publica funciones de lectura y las demás las consumen, sin conocer jamás la forma del estado ajeno. Es la vía por defecto para leer, y su virtud es que el propietario puede reorganizar su estado sin romper a nadie mientras mantenga la firma.
El segundo canal es la acción entendida como evento. Un slice puede reaccionar a acciones de otro dominio desde extraReducers, sin que el dominio emisor sepa que alguien lo escucha. Esta inversión es la propiedad más infravalorada del modelo Flux: la acción no es una orden dirigida a un reducer, es un hecho difundido que cualquiera puede interpretar a su manera. La dependencia queda del lado del oyente, que es donde debe estar, y añadir un tercer interesado no obliga a tocar al emisor.
import { createSlice } from '@reduxjs/toolkit'
import { sesionCerrada } from '../sesion/sesionSlice'
const carrito = createSlice({
name: 'carrito',
initialState: { lineas: [] as string[] },
reducers: { agregar: (s, a: { payload: string }) => { s.lineas.push(a.payload) } },
extraReducers: (builder) => {
// carrito escucha a sesion; sesion no sabe que carrito existe
builder.addCase(sesionCerrada, (s) => { s.lineas = [] })
},
})
La diferencia entre este canal y el anterior es de dirección, y es la razón por la que conviene preferirlo cuando ambos sirven. Si carrito importa un selector de usuarios, carrito depende de usuarios. Si carrito escucha una acción de usuarios, carrito sigue dependiendo de usuarios, pero solo de un tipo de acción, que es una superficie mucho más estrecha y mucho más estable que la forma de un estado. El emisor, en cambio, no adquiere ninguna dependencia en ninguno de los dos casos, y eso permite que un dominio muy consultado —la sesión, típicamente— tenga docenas de oyentes sin conocer a ninguno.
El tercer canal aparece cuando la lógica no pertenece a ninguno de los dos dominios sino a su cruce: el total de un pedido que combina líneas de carrito, descuentos de usuario e impuestos de facturación. Meter ese cálculo en cualquiera de los tres slices lo acopla a los otros dos; la salida limpia es un selector transversal en una capa superior, que compone selectores públicos de cada dominio y no vive dentro de ninguno. Es el equivalente de estado al patrón que en el mundo de servicios se llama orquestación: el que conoce a varios está por encima de todos ellos, nunca al lado.
// app/selectores/pedido.ts: vive por encima de los tres dominios
import { createSelector } from '@reduxjs/toolkit'
import { selectLineas } from '../../features/carrito/carritoSlice'
import { selectDescuento } from '../../features/usuarios/usuariosSlice'
import { selectTipoIva } from '../../features/facturacion/facturacionSlice'
export const selectTotalPedido = createSelector(
[selectLineas, selectDescuento, selectTipoIva],
(lineas, descuento, iva) => lineas.length * (1 - descuento) * (1 + iva),
)
Ninguno de los tres slices sabe que este selector existe, y esa ignorancia es el objetivo: la regla del cruce se puede cambiar sin tocar ningún dominio, y cualquiera de los tres se puede borrar sabiendo con exactitud qué se rompe, porque las importaciones lo declaran. Colocar el mismo cálculo dentro de carrito habría creado dos aristas nuevas que además serían invisibles para cualquier lectura superficial del dominio.
Selector exportado
Leer de otro dominio a través de su superficie pública. La forma interna sigue siendo privada.
Acción como evento
Reaccionar a lo que otro anuncia desde extraReducers. La dependencia la carga quien escucha.
Selector transversal
Lo que pertenece al cruce de varios dominios vive por encima de todos, no dentro de uno.
Acceso por punto ajeno
Alcanzar state.otro.campo desde dentro de un dominio. La única vía que hay que erradicar.
La dirección de las dependencias
Canalizar la comunicación no basta si el grafo resultante es un ovillo. La regla que ordena el conjunto es la del ciclo: entre dos dominios puede haber dependencia, pero no en ambos sentidos. Si carrito lee selectores de usuarios y usuarios lee selectores de carrito, tienes dos módulos que en realidad son uno, con el inconveniente de que están en carpetas distintas y la ilusión de que están separados. Es el principio de dependencias acíclicas, y su violación se paga en imposibilidad de razonar sobre cualquiera de los dos por separado, de probarlos aislados y de borrar uno sin tocar el otro.
flowchart TD A[carrito] -->|lee selector publico| B[usuarios] C[facturacion] -->|escucha accion| A D[selector transversal en app] --> A D --> B D --> C B -.->|dependencia inversa prohibida| A style D fill:#89b4fa,color:#11111b style B fill:#a6e3a1,color:#11111b
Cuando aparece un ciclo, hay tres salidas y conviene conocerlas en orden. La primera es invertir uno de los sentidos convirtiéndolo en evento: si usuarios necesitaba algo de carrito, casi siempre puede en su lugar reaccionar a una acción que carrito ya emite, y la flecha desaparece. La segunda es extraer lo compartido a un módulo del que ambos dependan, que es la materia de la lección siguiente. La tercera, la menos frecuente y la más honesta cuando toca, es admitir que los dos dominios eran uno y fusionarlos: dos funcionalidades que no pueden existir la una sin la otra no ganan nada viviendo en carpetas separadas.
El grafo de dependencias entre funcionalidades es medible en cualquier repositorio si el acceso cruzado pasa por importaciones. Genera el listado de qué carpeta importa de qué carpeta, cuenta las aristas y busca los ciclos: la conversación sobre fronteras cambia por completo cuando deja de ser una opinión sobre estilo y pasa a ser una lista concreta de aristas que alguien tiene que justificar o eliminar. Y es justamente por eso que el acceso directo a state.otro.campo resulta tan corrosivo: no aparece en ese listado, así que el grafo que mides es más limpio que el grafo que tienes.
La frontera en los efectos
Todo lo anterior habla de estado, pero la fuga más frecuente en una aplicación madura ocurre en los efectos. Un thunk recibe getState y con ello la raíz entera, así que puede leer cualquier dominio con la misma facilidad que un selector, y además puede despachar acciones de cualquier otro. Un thunk de carrito que consulta tres ramas ajenas y despacha en dos de ellas es, de hecho, un orquestador disfrazado de detalle de un dominio, y concentra más conocimiento del sistema que ningún componente.
// Sospechoso: un thunk de carrito que gobierna otros dos dominios
export const finalizar = createAsyncThunk('carrito/finalizar', async (_, api) => {
const s = api.getState() as RootState
if (s.usuarios.actual.plan === 'basico') api.dispatch(mostrarAviso())
api.dispatch(facturacionIniciada())
})
// Preferible: el listener vive por encima y reacciona al hecho
listener.startListening({
actionCreator: pedidoConfirmado,
effect: async (accion, api) => { api.dispatch(facturacionIniciada()) },
})
La regla que ordena esto es la misma de los selectores, aplicada al tiempo en vez de al espacio: quien conoce a varios dominios está por encima de ellos. El listener middleware es el lugar natural para esa coordinación porque no pertenece a ningún slice, se suscribe a hechos ya ocurridos y se puede registrar desde la capa de aplicación. El thunk sigue siendo la herramienta correcta para lo que ocurre dentro de un dominio; en cuanto empieza a decidir sobre otros, la lógica ha dejado de ser suya.
Una confusión habitual es leer estas reglas como si el objetivo fuera impedir que un dominio conozca los datos de otro. No lo es: los datos del store son públicos por diseño y cualquier componente puede pintar lo que quiera. Lo que la frontera protege es la libertad del propietario para cambiar la forma interna de su estado sin romper a nadie: normalizar una colección, renombrar un campo, dividir un slice en dos. Si esa reorganización solo obliga a tocar los selectores de la funcionalidad propietaria, la frontera cumple su función; si obliga a peinar toda la aplicación, no existía frontera, solo carpetas.
Toda la disciplina de esta lección descansa en una idea que trasciende Redux: el peligro de una dependencia no es proporcional a su fuerza sino a su invisibilidad. Un sistema con muchas dependencias explícitas es analizable, dibujable y refactorizable, porque cada relación deja un rastro que las herramientas pueden seguir; un sistema con pocas dependencias pero ocultas es intratable, porque no hay forma de saber qué se romperá antes de romperlo. El estado global es el disolvente universal de las fronteras justamente porque ofrece un canal de comunicación que no deja rastro: dos módulos que jamás se importan pueden estar tan acoplados como si compartieran memoria, y de hecho la comparten. Por eso la respuesta madura no es reducir el estado compartido a cero, que ni es posible ni es deseable, sino exigir que todo acoplamiento se declare en un lugar donde una máquina pueda contarlo. La importación es ese lugar, y su valor no está en lo que impide sino en lo que registra: convierte una relación invisible en un hecho verificable, y solo lo verificable se puede gobernar. De ahí la formulación general que vale para slices, para módulos y para servicios enteros: no persigas el acoplamiento bajo, persigue el acoplamiento explícito, porque el bajo sin ser explícito se degrada solo con el tiempo mientras que el explícito, aunque sea alto, se puede ver crecer y decidir a tiempo qué hacer con él.
- Busca en tu código todos los accesos a la raíz del estado que salgan del dominio donde están escritos. Cada uno es una arista invisible; anótalos todos.
- Convierte cada uno en una importación del selector público del dominio propietario, creando ese selector si no existía.
- Con las importaciones ya explícitas, genera el listado de qué carpeta depende de cuál y dibuja el grafo resultante.
- Localiza los ciclos. Para cada uno, decide cuál de las tres salidas aplica: invertir con un evento, extraer lo compartido o fusionar los dominios.
- Elige una dependencia de lectura y comprueba si podría ser un
extraReducersque escucha una acción, moviendo el peso al lado del oyente. - Identifica una lógica que combine dos dominios y muévela a un selector transversal por encima de ambos. Verifica que ningún slice quedó conociendo al otro.