Stellate: come ho cachato un'API GraphQL senza toccare il backend
Un BFF GraphQL su Lambda non ha stato, ogni query ripassa per Sanity e Shopify. Ho risolto con Stellate come reverse proxy CDN — configurazione, scope per utenti autenticati, e invalidazione selettiva via webhook.
Un BFF (Backend for Frontend) su Lambda è stateless per definizione. Ogni query GraphQL che arriva deve passare per Sanity, Shopify, e DynamoDB — tre hop di rete, anche se il dato non è cambiato da ore. Su un e-commerce con traffico reale, questo si traduce in latenza alta e costi di compute che crescono linearmente con le visite.
La soluzione standard sarebbe un layer Redis davanti all’origin. Ma gestire Redis significa gestire un’altra infrastruttura: sizing, failover, invalidazione manuale. Ho scelto Stellate, un CDN specifico per GraphQL che agisce da reverse proxy: le query passano prima da Stellate, che risponde dalla cache se il dato è fresco, oppure inoltra all’origin e cachea la risposta.
Come si posiziona nell’architettura
Client (Next.js)
↓
Stellate CDN ←── cache hit → risposta immediata
↓ cache miss
BFF (Lambda)
↓
Sanity + Shopify + DynamoDB
Il client non cambia nulla: continua a puntare allo stesso endpoint GraphQL. Stellate si frappone in trasparenza. La configurazione sta tutta in un file TypeScript che viene deployato sulla piattaforma Stellate, separato dal codice del BFF.
La configurazione del caching
export default defineConfig({
serviceName: 'my-storefront',
schema: process.env.STELLATE_SCHEMA,
originUrl: process.env.STELLATE_ORIGIN_URL,
rules: [
// Default: 30 minuti + 2 giorni stale-while-revalidate
{
types: ['Query'],
maxAge: 1800,
swr: 172800,
},
// Dati utente: mai cachare
{
types: ['SpfCart', 'SpfCustomer', 'SpfAdminInventory'],
maxAge: 0,
},
// Schema: cache lunga con SWR
{
types: ['__Schema'],
maxAge: 3780,
swr: 86400,
},
],
keyFields: {
Routing: ['slug'],
SpfProduct: ['id'],
},
scopes: [
{
scope: 'AUTHENTICATED',
representation: 'header:authorization',
},
],
});
Tre decisioni qui che meritano una spiegazione.
maxAge + SWR invece di solo maxAge. maxAge: 1800 significa che per 30 minuti Stellate risponde dalla cache senza toccare l’origin. swr: 172800 aggiunge 2 giorni di stale-while-revalidate: passati i 30 minuti, Stellate continua a rispondere con il dato stale mentre aggiorna la cache in background. Il client non aspetta mai l’origin — al massimo riceve un dato vecchio di qualche secondo.
Tipi non cacheable. SpfCart e SpfCustomer hanno maxAge: 0 — Stellate li bypassa completamente e va sempre all’origin. Cart e customer sono specifici per utente: cachearli significherebbe rischiare di mostrare il carrello di qualcun altro.
Key fields per invalidazione granulare. Routing: ['slug'] dice a Stellate come identificare univocamente un record di quel tipo. Serve per la purge selettiva — lo vediamo tra poco.
Separare la cache per utenti autenticati
Lo scope AUTHENTICATED con representation: 'header:authorization' istruisce Stellate a mantenere due cache separate: una per le richieste senza header di autorizzazione (utenti anonimi), una per quelle con.
Senza questo, un utente autenticato vedrebbe la cache di un utente anonimo e viceversa. In pratica, la maggior parte delle query di prodotto e categoria può essere condivisa tra tutti — ma qualsiasi query che tocca dati personalizzati (wishlist, prezzi B2B, disponibilità per mercato) va nella cache autenticata.
Il ciclo di invalidazione
Il problema del caching è sempre la stale data. Quando un editor pubblica una modifica in Sanity, la cache Stellate contiene ancora il vecchio dato — fino a quando maxAge non scade. Per un e-commerce, 30 minuti di ritardo su un cambio prezzo o disponibilità è inaccettabile.
Ho costruito un ciclo di invalidazione event-driven:
Editor pubblica in Sanity
↓
Sanity invia webhook POST (con firma HMAC)
↓
Lambda valida la firma e mette il documento in SQS FIFO
↓
SQS attende 45 secondi (aggrega burst di pubblicazioni)
↓
Lambda consumer processa fino a 10 messaggi
↓
Query routing table → ricava slug e typename del documento
↓
Promise.all([
stellatePurgeType(typename, documentId),
stellatePurgeType('Routing', slug),
storefrontISRRevalidate(slugs),
])
Il delay di 45 secondi sulla coda è intenzionale: se un editor salva un prodotto 5 volte in rapida successione, un solo consumer Lambda processa tutti i messaggi in batch invece di triggerare 5 purge separate.
Purge selettivo per tipo
Stellate espone una Purge API GraphQL. Anziché fare _purgeAll() che invalida l’intera cache, uso _purgeType() con keyFields:
async function stellatePurgeType(
type: string,
keyFields: { name: string; value: string }[],
soft = false
): Promise<void> {
await fetch(process.env.STELLATE_PURGE_ENDPOINT!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'stellate-token': process.env.STELLATE_PURGE_TOKEN!,
},
body: JSON.stringify({
query: `
mutation PurgeType($type: String!, $keyFields: [KeyFieldInput!], $soft: Boolean) {
_purgeType(type: $type, keyFields: $keyFields, soft: $soft)
}
`,
variables: { type, keyFields, soft },
}),
});
}
Quando un prodotto cambia, purgo solo quel prodotto:
await Promise.all([
// Purga il prodotto per ID
stellatePurgeType('SpfProduct', [{ name: 'id', value: shopifyProductId }]),
// Purga la route associata per slug
stellatePurgeType('Routing', [{ name: 'slug', value: productSlug }]),
]);
Il soft flag nella mutation corrisponde al comportamento SWR: con soft: true, il record viene marcato come stale ma rimane servibile mentre Stellate aggiorna in background. Con soft: false (default), il record viene rimosso e la prossima richiesta va obbligatoriamente all’origin.
Ho usato soft purge per la maggior parte dei casi — preferisco servire un dato di 30 secondi stale piuttosto che far aspettare il prossimo client mentre il Lambda fetcha da Sanity e Shopify.
Quello che ho imparato
Il gain in latenza è immediato e misurabile. Le query che prima richiedevano 300-800ms (round trip Lambda + Sanity + Shopify) scendono a 20-50ms sulla cache edge. Il trade-off è accettare che il dato possa essere stale per la finestra SWR.
La configurazione dei tipi non cacheable è critica. Dimenticare maxAge: 0 su SpfCart significa che due utenti potrebbero vedere lo stesso carrello. Ho scoperto il problema in staging guardando le risposte: il cartId nel response era identico per richieste diverse.
La purge selettiva richiede una routing table. Per sapere quale slug invalida a fronte di un documento Sanity, ho bisogno di una tabella che mappi documentId → slug. Senza quella, l’unica opzione è _purgeAll(), che è efficace ma brutale — invalida tutta la cache incluso quello che non è cambiato.
SQS con delay risolve i burst da CMS. Senza il delay di 45 secondi, una sessione di editing con 10 salvataggi rapidi generava 10 purge API call in sequenza. Con il delay, il consumer Lambda vede un batch e fa una sola purge multi-document.