Dos backends alimentando una única caché de cliente compartida entre módulos federados

¿Dos backends, un cliente? RTK Query vs Apollo en React Native

El post 9 terminó con una promesa: el stack se queda igual y el backend se parte en dos, REST para la lista, GraphQL para las insignias de tipo, un cliente y una caché. Este post lo construye.

El signo de interrogación del título es deliberado. Durante la transición, con un backend GraphQL recién llegado y REST todavía en marcha, gana tener un solo cliente, casi da igual cuál, y si tu app ya usa RTK Query, eso lo decide. En el destino, con GraphQL en todas partes, la respuesta depende de dos cosas que puedes comprobar contra tu propia app: si tienes una restricción de federación, y si tu esquema es lo bastante relacional como para que una caché normalizada se gane el sitio. Una caché normalizada guarda cada entidad una sola vez, un Pokémon en una única entrada, de modo que cada vista que apunta a ella lee la misma copia. Esta serie tiene federación, así que aquí RTK Query sigue siendo la elección. Cambia el contexto y cambia la respuesta, y este post dice dónde.

La forma de la izquierda es la construcción: REST y GraphQL alimentan una sola caché de baseApi bajo un único grafo de tags, con el botón de Refresh del host intacto. La de la derecha son dos clientes con dos cachés y sin ninguna arista entre ellas. Esa arista que falta es de lo que va realmente este post.

Llega un segundo backend

El equipo de API monta GraphQL. Para las pantallas que necesitan datos relacionados puede sustituir varios viajes de ida y vuelta por uno, que es de donde sale la mejora y no del protocolo en sí, y es hacia donde va el backend. REST no va a desaparecer este trimestre, ni el que viene. Durante un tiempo, a menudo largo, los dos están vivos, y a cada pantalla la sirve uno o el otro.

El peligro es ejecutar dos clientes de datos a la vez, sean cuales sean los dos. Dos clientes mantienen dos cachés, y las dos cachés no se hablan. Una mutación que sale por GraphQL actualiza la caché de GraphQL, y la actualización nunca llega a la caché REST que guarda el mismo registro. El usuario edita un Pokémon en la pantalla servida por GraphQL, cambia a la lista servida por REST, y ve el valor antiguo. No salta ninguna excepción, no falla ninguna petición, no hay ningún aviso. Solo una pantalla que se queda mal, en silencio, hasta que algo fuerza un refetch. A mitad de la transición, cuando los dos backends más se solapan, es exactamente cuando más daño hace.

Así que la pregunta de la transición es cuántas cachés hacen falta, y la respuesta más barata es una.

Un slice, dos protocolos

RTK Query hace que una sola caché sirva a los dos protocolos, en el slice que esta serie ejecuta desde el post 6. Empieza desde el tag del post 8:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-08-client-state

La lista ya pide sus filas por REST. Ahora le surge una segunda necesidad de estado de servidor, una insignia de tipo en cada fila, servida por GraphQL. Primero, dos dependencias, solo en el remote list. graphql se queda en la 16 porque graphql-request declara un rango de peers de 14 a 16 mientras que la última en npm es la 17:

( cd apps/list && npm install graphql@16.14.2 graphql-request@7.4.0 )

Después el endpoint, en el mismo slice de api:

const typesApi = baseApi.injectEndpoints({
  endpoints: build => ({
    getPokemonTypes: build.query<Record<number, string[]>, void>({
      async queryFn() {
        try {
          const raw = await request(GRAPHQL_URL, POKEMON_TYPES);
          return { data: parsePokemonTypes(raw) };
        } catch (err) {
          return {
            error: {
              status: 'CUSTOM_ERROR',
              error: err instanceof Error ? err.message : 'Invalid GraphQL response',
            },
          };
        }
      },
      providesTags: ['PokemonList'],
    }),
  }),
});

export const { useGetPokemonTypesQuery } = typesApi;

Un slice de api tiene exactamente un baseQuery, y el de la lista es fetchBaseQuery sobre la base REST. Un segundo protocolo contra otra URL sale de un queryFn, que la propia documentación de RTK recomienda para «one-off queries that use a different base URL». graphql-request envía la query, y ese mismo archivo lleva lo que el extracto deja fuera: la query de GraphQL acotada y ordenada, el esquema de Zod que vigila los ids y los nombres de tipo, parsePokemonTypes y la integración con la pantalla. En vez de imprimirlo todo, deja tu árbol en el estado final:

npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-10-two-backends /tmp/pokedex-ref-10
cp -R /tmp/pokedex-ref-10/. .

La respuesta se valida con su propio esquema de Zod en la frontera que la lista REST ya protege, que se mantiene igual para que la comparación cambie solo el protocolo. El esquema vive en la app list y no en el paquete de contratos, porque una definición de datos viaja con el dominio que la posee (la regla del post 7).

El GraphQL de PokéAPI vive en graphql.pokeapi.co/v1beta2. El esquema antiguo v1beta está retirado, y con él el prefijo de campos pokemon_v2_ que llena todos los tutoriales anteriores a 2025. Pedir pokemon_v2_pokemon hoy devuelve field 'pokemon_v2_pokemon' not found in type: 'query_root'. La forma que funciona es la que va sin prefijo, comprobada en vivo contra el endpoint al escribir este post.

Dos cosas faltan a propósito. No hay nueva versión de contracts: el endpoint se inyecta en el baseApi existente y declara el tag que ya es de la lista. Y graphql-request se queda fuera de las entradas shared de Module Federation, exactamente igual que Zod. Solo exporta valores, no tiene una identidad de instancia que compartir, así que viaja como dependencia propia del remote list. Qué protocolo sirvió cada fila es asunto privado del remote, invisible para el host y para todos los demás remotes.

El GraphQL de PokéAPI tiene un límite de 100 llamadas por hora y por IP, mientras que REST solo pide un uso razonable. La caché de RTK Query lo absorbe con un uso normal. Una tanda de recargas en frío todavía puede agotarlo, y un 429 mientras construyes es el límite, no un bug.

Un tag para toda la transición

El botón de Refresh del host despacha invalidateTags(['PokemonList']) desde el post 6, y no guarda ninguna referencia a ninguno de los dos endpoints. Tanto la lista REST como la query de tipos por GraphQL proveen 'PokemonList', así que una sola pulsación vuelve a pedir las dos. El panel Network de las DevTools de React Native lo muestra en directo: un toque lanza las dos peticiones juntas, y la vista previa de v1beta2 enseña los datos de tipos llegando por GraphQL.

El panel Network de las DevTools de React Native: un registro vacío, luego un toque en Refresh lanza dos peticiones juntas, la llamada GraphQL a v1beta2 y la llamada REST a la lista de pokémon, y la vista previa de la fila GraphQL muestra los datos de pokemontypes llegando

Esa es la tesis de la transición en un solo tag: te quedes con el cliente que te quedes, quédate con uno. Nada cruza de forma automática, eso sí. Una mutación limpia la caché del otro protocolo cuando nombra el tag que ambos proveen, y solo entonces; el grafo compartido es lo que lo hace posible, no lo que lo ejecuta.

La misma carrera se ve en el dispositivo: en un arranque en frío las filas REST aterrizan primero, y las insignias de GraphQL llegan un momento después, a medida que cada query va llenando la caché compartida:

La lista del Pokédex en iOS durante un arranque en frío: un spinner de carga, luego las filas y los nombres servidos por REST, luego las insignias de tipo servidas por GraphQL llegando un momento después, y al final los sprites completando la lista ya estable

Las insignias también se degradan en silencio. Apunta el endpoint GraphQL a un host inalcanzable y las filas se renderizan igual, las insignias simplemente no están, y nada se mueve en la pantalla.

Las insignias también le ponen números a la promesa del propio GraphQL. Los tipos por REST serían 151 llamadas de detalle, una por fila, porque el endpoint de lista devuelve solo nombres y URLs. Por GraphQL es una única query. Llámalo por su nombre, fan-out de peticiones desde el cliente y no N+1: el problema clásico describe un backend que repite un acceso a datos por fila, mientras que aquí es un cliente el que hace una petición por fila porque el endpoint no le da otra cosa. La ganancia son menos viajes de ida y vuelta, y es un punto para el protocolo, no para ninguno de los dos clientes.

Lo que Apollo hace y esto no

RTK Query trata una respuesta GraphQL como datos que cachear por endpoint, igual que cualquier payload REST. Apollo hace algo que RTK Query ni intenta: con su InMemoryCache por defecto, normaliza. Cada objeto con un __typename y un id recibe una única entrada en la caché, y cada query que lo referencia lee esa entrada. Una mutación cuya respuesta lleva el mismo tipo y el mismo id actualiza la entidad en todos los sitios donde aparece, sin ningún refetch.

Eso es real, y es el motivo honesto para elegir Apollo. También es más acotado que la promesa. La consistencia automática cubre modificar una entidad que ya está en la caché. No cubre hacer crecer una lista. En palabras de Apollo, «a newly cached object isn’t automatically added to any list fields that should now include that object», así que una fila nueva sigue necesitando una función de actualización o un refetch.

El resto del caso de Apollo se sostiene por méritos propios. La colocación de fragments deja que un componente declare los campos que necesita y los componga hacia arriba por simple interpolación de template literals, sin paso de build; la generación de código es opcional, y se gana el sitio cuando quieres resultados tipados. Los cache redirects pueden servir una vista de detalle directamente desde datos que una lista ya pidió, pero solo cuando cada campo que pide la query de detalle ya está en la caché. Un campo de más y la query entera va a la red. Las suscripciones y @defer tienen soporte, y @defer necesita un handler de entrega incremental más un polyfill de fetch en streaming en React Native, así que es «con soporte, previa configuración», no gratis.

Y el esquema importa más que todo esto. La propia forma de PokéAPI, Pokémon que referencian tipos, habilidades, especies y cadenas de evolución, con las mismas entidades reutilizadas en muchas queries, es exactamente la forma relacional donde la normalización se amortiza. Con estos datos, la caché de Apollo encaja mejor, y fingir lo contrario sería el hombre de paja que esta serie evita. (Relay está en la misma familia. urql solo entra en ella cuando le añades Graphcache: su caché de documentos por defecto guarda respuestas enteras por query, más cerca del modelo de RTK Query que del de Apollo. Las contrapartidas de Elegir van de la familia normalizada, no de una librería concreta.)

Lo que la federación le hace a la elección

Empieza por la parte que sorprende. Apollo no tiene injectEndpoints, y no lo necesita. Una operación es un documento que se ejecuta contra un cliente, no un artefacto registrado en un store, así que cualquier remote puede traer sus propias queries contra un cliente compartido sin ninguna maquinaria de registro. Un remote puede incluso ampliar la caché al montarse mediante cache.policies.addTypePolicies, que es API pública documentada. No hay guía oficial de micro-frontends (un hilo sin respuesta en la comunidad y un caso de éxito son todo el historial), pero la API de la caché da la casualidad de que lo permite. En el eje de extensibilidad en runtime que le importaba al post 9, Apollo no va por detrás.

Tampoco comparte el problema de contexto de TanStack. Apollo cachea un único contexto de React sobre la propia instancia de React, guardado bajo un symbol conocido, precisamente para que dos copias de la librería no puedan entregarte contextos divergentes. El error de «no client in context» solo salta cuando el propio React está duplicado. El caso singleton de @apollo/client bajo federación es una sola instancia de caché y ninguna divergencia de versiones entre el cliente y sus hooks, no la división del contexto.

La federación todavía inclina la elección en dos sitios: el grafo de tags y la transición. El único tag que vuelve a pedir datos entre remotes, y entre dos protocolos, es coordinación que RTK Query tiene por construcción y que Apollo deja a la convención. Y adoptar Apollo a mitad de la transición significa levantar una segunda caché al lado de la de RTK Query que ya ejecutas, exactamente la forma de dos cachés del principio de este post, durante toda la migración. Apollo Client 4 además convierte rxjs en un peer obligatorio y mueve sus exports de React, así que el bundle suma una dependencia nueva en vez de cambiar una por otra.

RTK Query

  • Modelo de caché: por endpoint, refetch al invalidar
  • Invalidación entre protocolos: un grafo de tags compartido, de serie
  • Añadir queries desde un remote: injectEndpoints en runtime
  • Esquema relacional con entidades reutilizadas: vuelve a pedir lo que ya tenía

Apollo

  • Modelo de caché: normalizada por __typename + id, parche en el sitio
  • Invalidación entre protocolos: por cliente; una segunda caché que coordinar
  • Añadir queries desde un remote: documentos contra el cliente; nada que registrar
  • Esquema relacional con entidades reutilizadas: la caché se amortiza

Elegir

No hay ganador absoluto, así que baja hasta el caso que encaje con tu app.

Transición, los dos backends vivos: ejecuta un solo cliente. Si la app ya está en RTK Query, y esta serie lo está, eso lo decide. Una caché, un tag, sin datos obsoletos de dos cachés que vigilar, y es el caso común.

Destino, GraphQL en todas partes, con una restricción de federación y estado compartido coordinado: RTK Query se queda. El precio honesto es vivir sin normalización: la invalidación por tags vuelve a pedir donde Apollo parchearía en el sitio, y updateQueryData cubre los pocos puntos donde un parche a mano merece la pena. Cambias algunos viajes de ida y vuelta por una caché y un grafo de tags entre remotes publicados de forma independiente.

Destino, GraphQL en todas partes, un esquema relacional cargado de entidades y ninguna restricción de federación: Apollo, sin más. La caché normalizada es la razón de ser de un cliente GraphQL, y sin una federación tirando en contra, ese es el motivo para adoptarlo. Sin matices.

Las señales que hay que vigilar son dos días concretos. El día que notes que la mayoría de tus invalidaciones vuelven a pedir datos que la caché ya tenía: la normalización te habría ahorrado esos viajes. Y el día que el segundo backend entre en producción y dos cachés se contradigan en pantalla sin ningún error que lo explique. El primero es un empujón hacia Apollo. El segundo es la razón para asegurarte de que solo ejecutaste un cliente desde el principio.

Lo siguiente: el design system se convierte en un singleton federado, un solo paquete de UI compartido en runtime, para que cada remote renderice los mismos componentes sin cargar con su propia copia.

Fuentes

Warren de Leon
Warren de Leon

Software Engineering Manager. Recientemente lideré el equipo de Mobile Platform en Hargreaves Lansdown. Escribo sobre liderazgo técnico, React Native y cómo construir buenos equipos.

Ver perfil