← blog Read in English
IT 6 min read

Schema stitching: unire Sanity e Shopify in un'unica API GraphQL

Come ho unificato due API GraphQL distinte — Sanity come CMS e Shopify come storefront — in un singolo endpoint coerente, con type namespacing, executor custom e resolver cross-schema.

graphqlshopifysanitytypescriptecommerce

Un e-commerce CMS-driven ha tipicamente due source-of-truth: il CMS per i contenuti editoriali (descrizioni, pagine, guide all’acquisto) e la piattaforma commerce per i dati transazionali (prezzi, varianti, inventario). Tenerli separati lato client significa fare due chiamate distinte, gestire due formati diversi, e duplicare la logica di accesso ai dati.

La soluzione che ho adottato è lo schema stitching: unire due subschemi GraphQL — Sanity e Shopify — in un unico endpoint gestito da un BFF (Backend for Frontend) su Lambda. Il client fa una sola query e riceve dati da entrambe le sorgenti, normalizzati in un’unica risposta.

Il problema del namespace

Il primo ostacolo: Sanity e Shopify hanno entrambi un tipo Product. Se importi i due schemi direttamente, si sovrascrivono a vicenda.

La soluzione è aggiungere un prefisso a tutti i tipi Shopify prima di unirli:

import { RenameTypes, RenameRootFields, RenameRootTypes } from '@graphql-tools/wrap';

const shopifyTransforms = [
  new RenameTypes((name) => `Spf${name}`),          // Product → SpfProduct
  new RenameRootTypes((name) => `Spf${name}`),      // Query → SpfQuery
  new RenameRootFields((_op, name) => `spf_${name}`), // products → spf_products
];

Spf sta per “Shopify Storefront”. Tutti i tipi Shopify diventano SpfProduct, SpfCart, SpfVariant — nessun conflitto con i tipi Sanity, e il client può sempre distinguere da quale sorgente viene un campo.

Costruire lo schema unificato

Con i due subschemi configurati, stitchSchemas li unisce e permette di aggiungere typedefs e resolver custom sopra:

import { stitchSchemas } from '@graphql-tools/stitch';

const schema = stitchSchemas({
  subschemas: [
    { schema: sanitySchema, executor: sanityExecutor },
    { schema: shopifySchema, executor: shopifyExecutor, transforms: shopifyTransforms },
  ],
  typeDefs: /* GraphQL */ `
    extend type Product {
      sanity: SanityProduct
    }
    type Routing { ... }
    type Query {
      allRouting(where: RoutingWhereInput, limit: Int, offset: Int): [Routing]
    }
  `,
  resolvers: {
    Product: {
      sanity: {
        selectionSet: '{ id }',
        resolve(parent, _args, context, info) {
          const sanityId = createSanityProductId(extractIdFromGid(parent.id));
          return delegateToSchema({
            schema: sanitySubschema,
            operation: 'query',
            fieldName: 'Product',
            args: { id: sanityId },
            context,
            info,
          });
        },
      },
    },
    Query: {
      allRouting: allRoutingResolver,
    },
  },
});

Il campo Product.sanity è il punto più interessante: quando il client chiede spf_product { sanity { description } }, il resolver prende l’ID Shopify, lo converte nel formato ID di Sanity, e delega la query al subschema Sanity. Il client non sa che stanno parlando due sistemi diversi.

L’executor Shopify: iniettare il contesto locale

Shopify Storefront API supporta la localizzazione via direttiva @inContext(country: $country, language: $language). Il problema è che questa direttiva deve stare nella query, ma il prefisso Spf che applichiamo alle trasformazioni dei tipi non si applica alle variabili.

Ho scritto un executor custom che intercetta ogni richiesta prima di inviarla a Shopify:

export const shopifyExecutor: Executor = async (request) => {
  // Rimuove il prefisso Spf dalle variabili di tipo
  // (SpfCountryCode → CountryCode, SpfLanguageCode → LanguageCode)
  const cleanedVariables = stripTypePrefix(request.variables, 'Spf');

  // Estrae country e language dalle direttive dell'operazione
  const country = extractDirectiveVariable(request.document, 'country');
  const language = extractDirectiveVariable(request.document, 'language');

  return fetch(shopifyStorefrontUrl, {
    method: 'POST',
    headers: { 'X-Shopify-Storefront-Access-Token': token },
    body: JSON.stringify({
      query: print(request.document),
      variables: {
        ...cleanedVariables,
        country,
        language,
      },
    }),
  }).then(res => res.json());
};

Questo significa che il client può inviare la direttiva @inContext nella query senza preoccuparsi del prefisso — l’executor si occupa di tutto prima che la richiesta raggiunga Shopify.

Resolver custom: routing, inventario, recensioni

Oltre ai dati dei due subschemi, il BFF aggiunge resolver custom per funzionalità che non appartengono né a Sanity né a Shopify:

Routing — Le rotte del sito sono pre-calcolate e salvate su DynamoDB. Il resolver allRouting interroga la tabella con filtri su slug, documentId, e paginazione via cursor:

const allRoutingResolver = async (_parent, args, context) => {
  const { where, limit, offset, first, after } = args;

  if (where?.slug?.eq) {
    return dynamoDB.query({ KeyConditionExpression: 'slug = :slug', ... });
  }
  if (where?.documentId?.in) {
    return dynamoDB.batchGet({ Keys: where.documentId.in.map(id => ({ documentId: id })) });
  }
  // ...
};

Inventario — Prende SKU e locale, interroga l’Admin API Shopify, filtra per country market. La quantità viene restituita con la policy di backorder (CONTINUE o DENY).

Recensioni verificate — Delega a un’API esterna, separata da entrambe le sorgenti principali. Il client la vede come parte dello stesso schema.

Stellate: cacheare l’intero BFF

Con schema stitching, ogni query passa per il BFF su Lambda — che a sua volta chiama Sanity, Shopify, e DynamoDB. Senza caching, la latenza è la somma di tutti gli hop.

Ho aggiunto Stellate come reverse proxy GraphQL davanti al BFF:

// stellate config
export default defineConfig({
  serviceName: 'my-storefront',
  rules: [
    { types: ['Query'], maxAge: 1800, swr: 172800 },
    { types: ['SpfCart', 'SpfCustomer'], maxAge: 0 }, // mai cachare dati utente
  ],
  keyFields: {
    Routing: ['slug'],
    SpfProduct: ['id'],
  },
  scopes: [
    { scope: 'AUTHENTICATED', representation: 'header:authorization' },
  ],
});

30 minuti di maxAge + 2 giorni di stale-while-revalidate per la maggior parte delle query. Cart e Customer hanno maxAge: 0 — non vengono mai cachati perché sono specifici per utente. Quando Sanity pubblica un aggiornamento, un webhook triggerisce la purge su Stellate via API.

Cosa non rifarei uguale

stitchSchemas non è leggero. La costruzione dello schema unificato richiede introspection di entrambe le sorgenti a cold start. L’ho mitigato cachando lo schema in memoria dopo il primo build, ma è un overhead che con federation (Apollo o schema modules) sarebbe distribuito diversamente.

Il prefisso Spf è verboso lato client. Ogni query Shopify deve usare spf_products, SpfProduct, ecc. È necessario per il namespace, ma rende le query meno leggibili. Avrei potuto usare un prefisso più corto o esporre solo i campi Shopify che servono davvero tramite typedefs custom, nascondendo il subschema grezzo.

Resolver cross-schema hanno costi nascosti. Product.sanity richiede di fare una query Sanity per ogni prodotto restituito — se la lista ha 20 prodotti, sono 20 chiamate. Ho risolto con DataLoader per fare batching, ma è qualcosa da considerare dall’inizio.


Schema stitching funziona bene quando hai due API GraphQL già esistenti che devi unire senza riscriverle. Non è la soluzione più leggera, ma permette di mantenere Sanity e Shopify indipendenti e aggiornabili, con un singolo punto di accesso per il client.