Salta al contenuto principale

02 - Architettura MACH Commerce: Microservizi, API-First, Cloud-Native e Headless nell'E-commerce

MACH è un insieme di principi per comporre un negozio da servizi indipendenti. Vedi cosa chiede ogni lettera, quanto costa e come arrivarci per gradi.

Cosa ne pensi di questo articolo?

02 - Architettura MACH Commerce: Microservizi, API-First, Cloud-Native e Headless nell'E-commerce
HeadlessCommerceFoto:Brett SaylesPexels

In Breve

MACH è un insieme di principi per comporre un negozio da servizi indipendenti. Vedi cosa chiede ogni lettera, quanto costa e come arrivarci per gradi.

Headless Commerce 11 min

MACH (Microservices, API-first, Cloud-native, Headless) è un insieme di principi per comporre un e-commerce da servizi indipendenti. Qui vedi che cosa chiede ciascuna lettera, che cosa costa e come arrivarci senza riscrivere tutto.

MACH architecture composable commerce microservizi e-commerce API-first design commercetools

MACH: quattro principi per comporre un negozio da pezzi

MACH è un acronimo usato dalla MACH Alliance, un'organizzazione di fornitori di software: non è un prodotto né uno standard con una certificazione, è una famiglia di principi. Le lettere sono Microservices, API-first, Cloud-native e Headless. L'idea che le tiene insieme si chiama composable: invece di comprare una suite unica che fa tutto in modo mediocre, scegli per ogni funzione il servizio più adatto (ricerca, pagamenti, contenuti, gestione dell'anagrafica prodotti) e li colleghi tramite API.

Un paragone dalla cucina: una cucina componibile ha elementi con misure standard e puoi cambiare il forno senza rifare il mobile; una cucina monolitica è un blocco unico che costa meno finché non si rompe qualcosa. Ma nella cucina componibile qualcuno deve garantire che le misure coincidano, e nel software quel qualcuno sei tu. È il costo nascosto di MACH e ritornerà in ogni sezione.

Microservices: ogni servizio possiede i propri dati

Martin Fowler e James Lewis, nell'articolo del 2014 che ha reso popolare il termine, descrivono un microservizio come un servizio piccolo, con il proprio processo, costruito attorno a una capacità di business e rilasciabile in modo indipendente. In un negozio le capacità sono catalogo, prezzi, giacenze, carrello, ordini, pagamenti, spedizioni. Ciascuna ha un proprio confine (in linguaggio Domain-Driven Design, un bounded context): dentro il confine "prodotto" significa una cosa precisa, e fuori se ne usa solo una copia.

La conseguenza pratica più importante è che ogni servizio ha il proprio archivio dati. Il listato mostra perché conta: due archivi separati, uno dei prezzi e uno degli ordini, e l'ordine che copia il prezzo unitario nel momento dell'acquisto invece di rileggerlo dopo. Il servizio degli ordini non conosce il servizio dei prezzi, quindi non può interrogarne il database; se potesse, sarebbe tentato di ricalcolare il totale con il listino di oggi e un ordine passato cambierebbe importo.

// Ogni servizio ha il proprio archivio. Il servizio prezzi non sa nulla degli ordini, e viceversa.
const archivioPrezzi = new Map<string, number>([['moka-3', 2400]]); // centesimi

type RigaOrdine = { sku: string; quantita: number; prezzoUnitarioCents: number };
const archivioOrdini: { id: number; righe: RigaOrdine[] }[] = [];

function creaOrdine(sku: string, quantita: number) {
  // Il prezzo si COPIA nell'ordine al momento dell'acquisto: l'ordine non lo rilegge piu'.
  const righe = [{ sku, quantita, prezzoUnitarioCents: archivioPrezzi.get(sku)! }];
  archivioOrdini.push({ id: archivioOrdini.length + 1, righe });
}

const totale = (righe: RigaOrdine[]) => righe.reduce((s, r) => s + r.quantita * r.prezzoUnitarioCents, 0);

creaOrdine('moka-3', 2);
archivioPrezzi.set('moka-3', 2900); // il listino cambia il giorno dopo
creaOrdine('moka-3', 1);

for (const o of archivioOrdini) console.log(`ordine ${o.id}: ${totale(o.righe) / 100} EUR`);

// Output:
// ordine 1: 48 EUR
// ordine 2: 29 EUR

Il secondo ordine costa 29 euro e il primo resta a 48 (due unità da 24) anche se il listino è salito nel frattempo: la riga prezzoUnitarioCents: archivioPrezzi.get(sku)! copia il valore invece di conservare un riferimento. È l'accettazione di una duplicazione voluta, e ha un prezzo: se il prezzo di un prodotto viene corretto per errore, nessun ordine si aggiorna da solo e serve una procedura esplicita. Il listato è un modello in memoria, eseguito con Node 24, per mostrare il principio: un sistema reale lo implementa con archivi e transazioni distinti.

API-first: il contratto prima del codice

API-first vuol dire scrivere la descrizione dell'interfaccia prima di implementarla, così chi costruisce il frontend e chi costruisce il servizio possono lavorare in parallelo sullo stesso accordo. Lo strumento più diffuso per REST è la OpenAPI Specification (qui la 3.1.0, pubblicata il 15 febbraio 2021): un file YAML o JSON che elenca percorsi, parametri, risposte e schemi. Il listato descrive un servizio di catalogo con due operazioni: l'elenco paginato per cursore e il dettaglio di un prodotto, con la risposta 404 definita una volta in components e riusata con $ref.

openapi: 3.1.0
info:
  title: Catalog Service API
  version: 2.0.0
paths:
  /products:
    get:
      summary: Elenco prodotti, paginato per cursore
      parameters:
        - name: cursor
          in: query
          schema: { type: string }
        - name: category
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Una pagina di prodotti
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductPage' }
  /products/{id}:
    get:
      summary: Dettaglio di un prodotto
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Il prodotto
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Product' }
        '404':
          $ref: '#/components/responses/NotFound'
components:
  schemas:
    Product:
      type: object
      required: [id, title, priceCents, currency]
      properties:
        id: { type: string }
        title: { type: string }
        priceCents: { type: integer }
        currency: { type: string, minLength: 3, maxLength: 3 }
    ProductPage:
      type: object
      required: [items]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/Product' }
        nextCursor: { type: [string, 'null'] }
  responses:
    NotFound:
      description: Prodotto inesistente

# Output:
# openapi 3.1.0, 2 percorsi, problemi: 0

Il file l'ho controllato con un piccolo script che lo legge con js-yaml e verifica due regole: ogni $ref deve puntare a un nodo esistente, e ogni segnaposto come {id} nel percorso deve essere dichiarato come parametro in: path. È un controllo sintattico, non una validazione completa contro lo schema OpenAPI: per quella serve un validatore dedicato. Il secondo controllo non è pedanteria: togliendo la dichiarazione del parametro id lo script segnala /products/{id}: parametro {id} non dichiarato: un percorso con un segnaposto senza parametro è un contratto incoerente, che i generatori di client rifiutano o interpretano ciascuno a modo suo.

Un contratto vale solo se cambia con regole. La specifica Semantic Versioning dice di incrementare la versione maggiore quando le modifiche all'API sono incompatibili, la minore quando aggiungi funzioni in modo retrocompatibile e la patch per le correzioni. Dichiararlo nel campo info.version non basta, ma dà al team che usa l'API un segnale preciso su che cosa rischia aggiornando.

Cloud-native: costruito per ambienti elastici

Cloud-native non significa «gira su un cloud»: significa che il servizio assume di poter essere avviato e fermato in qualunque momento, in più copie, su macchine che non conosce. Ne derivano abitudini concrete: nessuno stato dentro il processo (la sessione sta in un archivio condiviso), configurazione presa dall'ambiente, rilasci automatizzati, e comunicazione asincrona tramite eventi quando una risposta immediata non serve. Per un negozio, un esempio chiaro: la conferma d'ordine non deve aspettare l'invio dell'email; l'ordine pubblica un evento e il servizio di notifica lo legge quando può.

Il costo da non sottovalutare è che ogni confine tra servizi è una rete, e la rete si guasta. Le richieste vanno fatte con scadenze e tentativi limitati, e va deciso in anticipo che cosa mostrare se il servizio dei consigliati non risponde (di solito: niente consigliati, ma la pagina funziona). Fowler e Lewis lo chiamano design for failure nello stesso articolo citato sopra.

Headless e il livello BFF: una richiesta, molti servizi

La lettera H l'hai vista nel primo articolo: il frontend consuma API e non è generato dal backend. In una architettura a molti servizi, però, la pagina di un catalogo richiede dati da più di uno: prodotti, filtri laterali, consigliati. Chiederli dal browser uno a uno significa tante richieste su rete mobile (l'effetto sulle metriche di velocità è nell'articolo su Core Web Vitals e SEO); per questo si inserisce un BFF (Backend for Frontend), un servizio piccolo, scritto per un singolo frontend, che raccoglie i dati dai servizi e restituisce quello che serve alla pagina.

Il listato simula tre servizi con ritardi di 80, 120 e 60 millisecondi e li chiama in due modi. In sequenza le attese si sommano (circa 260 ms); con Promise.all partono insieme e il tempo totale è quello del più lento (circa 120 ms). Le tre chiamate sono indipendenti tra loro, ed è per questo che si possono parallelizzare: se la seconda avesse bisogno del risultato della prima, non si potrebbe.

// Tre servizi finti: ciascuno risponde dopo un ritardo fisso, come farebbe una chiamata di rete.
const attesa = (ms: number) => new Promise((r) => setTimeout(r, ms));
const catalogo = async () => (await attesa(80), ['moka-3', 'macinacaffe']);
const faccette = async () => (await attesa(120), { tostatura: ['chiara', 'scura'] });
const consigliati = async () => (await attesa(60), ['filtro-v60']);

async function inSequenza() {
  const prodotti = await catalogo();
  const filtri = await faccette();
  const correlati = await consigliati();
  return { prodotti, filtri, correlati };
}

async function inParallelo() {
  const [prodotti, filtri, correlati] = await Promise.all([catalogo(), faccette(), consigliati()]);
  return { prodotti, filtri, correlati };
}

async function misura(nome: string, f: () => Promise<unknown>) {
  const t0 = performance.now();
  await f();
  console.log(`${nome}: ${Math.round(performance.now() - t0)} ms`);
}

await misura('in sequenza', inSequenza);
await misura('in parallelo', inParallelo);

// Output:
// in sequenza: 260 ms
// in parallelo: 120 ms

I numeri sono la somma e il massimo dei ritardi che ho scelto io, non una misura di rete: servono a vedere la regola, e cambiano di qualche millisecondo da un’esecuzione all'altra (in tre prove ho letto 260-261 e 120-121 ms). Nel mondo reale i ritardi sono variabili, e il caso peggiore è il servizio lento: Promise.all attende comunque il più lento, e se uno dei tre fallisce la promessa intera si rifiuta. Per i dati non essenziali, come i consigliati, conviene Promise.allSettled e una pagina degradata invece di un errore.

Dove MACH va storto

Gli errori ricorrenti nascono tutti dal frammentare troppo e troppo presto. Fowler, nel breve testo Monolith First, osserva che quasi tutte le storie di microservizi riuscite sono partite da un monolite diventato troppo grande e poi suddiviso, mentre i sistemi progettati a microservizi da zero hanno incontrato più spesso difficoltà. È un’osservazione di un singolo autore, non una legge, ma indica da dove conviene partire quando il negozio è piccolo.

  • Servizi prematuri: venti servizi per un negozio con pochi ordini sono venti sistemi da rilasciare e monitorare, per un guadagno che non esiste ancora.
  • Contratti senza versione: se un team cambia un campo senza dirlo, rompe quelli che lo consumano. Il file OpenAPI e la versione semantica sono la protezione.
  • Chiamate a cascata: ogni richiesta che ne scatena altre in sequenza somma le latenze e le probabilità di guasto.
  • Database condiviso: due servizi sullo stesso archivio sono in pratica un unico servizio con due codici, e non si possono più rilasciare separatamente.

L'ultimo è il più subdolo, perché all'inizio sembra un risparmio. Se per capire se un cambiamento è sicuro devi leggere il codice di un altro servizio, i confini non esistono: hai pagato il prezzo dei microservizi senza averne i vantaggi.

Arrivarci per gradi

Non si passa a MACH in una notte. Lo schema più citato per la migrazione graduale è lo Strangler Fig di Fowler: il nuovo sistema cresce attorno al vecchio e ne sostituisce una funzione alla volta, finché il vecchio può essere spento. Applicato a un negozio, il primo passo è mettere un frontend headless davanti al sistema esistente, usando le API che già espone. Il secondo è estrarre la funzione che ha l'esigenza più specifica, tipicamente la ricerca, in un servizio dedicato scelto sul mercato. Un altro candidato frequente sono i contenuti editoriali, di cui parla l'articolo sul content commerce. Poi si prosegue una funzione alla volta, ma solo finché ogni passo ha una ragione che sai misurare: se la ricerca andava bene com'era, lasciala dov'è.

Che cosa sai fare adesso

Sai distinguere le quattro lettere di MACH e il problema che ciascuna risolve, scrivere un contratto OpenAPI controllabile, spiegare perché un servizio non deve leggere il database di un altro, e usare un BFF con chiamate parallele per costruire una pagina dai dati di più servizi. Soprattutto sai che il confine dei servizi si traccia dove il negozio ha esigenze diverse, non dove sembra elegante. Nei prossimi capitoli applichiamo l'idea a piattaforme concrete, a partire da Shopify Hydrogen, e proseguiamo con le due piattaforme open source: Medusa e Saleor.

Fonti

Articolo precedente ← Headless Commerce: Cos'è
Prossimo nella serie Shopify Hydrogen e Oxygen →

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti