Medusa è un backend e-commerce open source in Node.js e TypeScript costruito a moduli isolati. Qui vedi come si definisce un modulo, perché i moduli non possono leggersi a vicenda e come i workflow annullano un'operazione a metà.
Perché Medusa: il codice del backend è tuo
Nell'articolo precedente il backend era Shopify: lo usi, non lo modifichi. Medusa è l'opposto: un'applicazione Node.js che installi, estendi con codice tuo e fai girare dove vuoi. La guida di installazione (consultata il 2026-11-12, documentazione alla versione 2.22.6) richiede Node.js v22.22.0 o successivo in versione LTS e un PostgreSQL in funzione; l'applicazione è composta da un server Node.js e da una dashboard di amministrazione. Il codice è sotto licenza MIT, con l'eccezione dei materiali della Enterprise Edition elencati nel file LICENSE del repository, che hanno una licenza diversa: se una funzione che ti serve è lì, leggi quel file prima di contarci.
Il prezzo del controllo è che gestisci tu l'esercizio: aggiornamenti, backup del database, monitoraggio. Per un team che ha già un server e sa farlo, è spesso un vantaggio (costi prevedibili, dati dove li decidi); per un team di due persone che vuole solo vendere, può essere il costo che fa pendere la bilancia verso un servizio gestito come quello dell'articolo introduttivo. Esiste anche un servizio cloud di Medusa; qui non ne confronto i prezzi, che non ho verificato.
L'idea centrale: moduli che non si conoscono
In Medusa ogni area del commercio (prodotti, carrello, ordini, regioni, tasse) è un modulo: un pacchetto con i propri modelli di dati e il proprio servizio. Il punto da capire subito è l'isolamento. Secondo la pagina sul Module Isolation, un modulo non conosce le risorse di altri moduli: non può usare il loro servizio né avere relazioni dirette dai suoi modelli di dati verso quelli di un altro. È lo stesso principio del database per servizio visto nell'articolo sull'architettura MACH, applicato dentro un'unica applicazione.
Per collegare dati di moduli diversi la documentazione prevede due strumenti: i Module Links, che creano associazioni tra modelli di moduli diversi senza rompere l'isolamento, e le funzioni che attraversano più moduli, che si scrivono come workflow. Il vantaggio dichiarato dalla documentazione è poter sostituire un modulo con un'implementazione tua o riusarlo altrove; lo svantaggio è che una query «prodotti con il loro brand» non è una semplice join SQL.
Un modulo in tre file
Il modulo Brand segue la guida ufficiale. Un modello di dati descritto con il Data Model Language di Medusa diventa una tabella; un servizio che estende MedusaService riceve i metodi di gestione generati
(come createBrands e retrieveBrand); un file index.ts dichiara il modulo con il suo nome. Il nome conta: la costante BRAND_MODULE è la chiave con cui il resto dell'applicazione chiede il servizio, ed è per questo che la guida la esporta.
Dopo aver registrato il modulo in medusa-config.ts, due comandi generano ed eseguono la migrazione che crea la tabella.
// src/modules/brand/models/brand.ts
import { model } from '@medusajs/framework/utils';
export const Brand = model.define('brand', {
id: model.id().primaryKey(),
name: model.text(),
});
// src/modules/brand/service.ts
import { MedusaService } from '@medusajs/framework/utils';
import { Brand } from './models/brand';
// MedusaService genera i metodi di gestione: createBrands, retrieveBrand, listBrands...
class BrandModuleService extends MedusaService({ Brand }) {}
export default BrandModuleService;
// src/modules/brand/index.ts
import { Module } from '@medusajs/framework/utils';
export const BRAND_MODULE = 'brand';
export default Module(BRAND_MODULE, { service: BrandModuleService });
// medusa-config.ts, dentro defineConfig({ ... })
// modules: [{ resolve: './src/modules/brand' }],
//
// poi, dalla radice del progetto:
// npx medusa db:generate brand
// npx medusa db:migrate
Questo blocco non è stato eseguito: serve un progetto Medusa con PostgreSQL, e qui è uno schema da adattare, ricomposto dai tre file della guida (nel progetto sono file distinti, e gli import ripetuti vanno uniti). Due precisazioni su cosa evitare:
non dare al modulo un nome diverso nel Module(...) e nelle risoluzioni, perché l'errore si manifesta solo quando provi a risolvere il servizio; e non dimenticare db:migrate, senza il quale il servizio esiste ma la tabella no e la prima scrittura fallisce con un errore di database.
Workflow e compensazione: annullare a metà
Creare un prodotto e collegarlo a un brand sono due scritture su archivi diversi, e non esiste una transazione di database che le copra entrambe. Se la seconda fallisce, la prima resta e il catalogo contiene un prodotto orfano. La soluzione usata da Medusa è il workflow: una sequenza di passi, ciascuno con una funzione di compensazione che, come dice la documentazione, gira solo se si verifica un errore nel workflow e disfa ciò che il passo aveva fatto. Il listato seguente non è Medusa: è un esecutore di venti righe che mostra la regola, per capire che cosa accade prima di leggere l'API vera.
// Un mini esecutore di workflow: ogni passo ha un'azione e, se serve, una compensazione.
type Passo<T> = {
nome: string;
esegui: () => Promise<T>;
compensa?: (risultato: T) => Promise<void>;
};
async function eseguiWorkflow(passi: Passo<any>[]) {
const completati: { passo: Passo<any>; risultato: unknown }[] = [];
try {
for (const passo of passi) {
const risultato = await passo.esegui();
console.log(`ok ${passo.nome}`);
completati.push({ passo, risultato });
}
} catch (errore) {
console.log(`ERRORE ${(errore as Error).message}`);
// Si annulla in ordine inverso, solo cio' che e' stato completato.
for (const { passo, risultato } of completati.reverse()) {
await passo.compensa?.(risultato);
console.log(`annullato ${passo.nome}`);
}
}
}
const prodotti = new Set<string>();
const collegamenti = new Set<string>();
await eseguiWorkflow([
{
nome: 'crea prodotto',
esegui: async () => (prodotti.add('prod_1'), 'prod_1'),
compensa: async (id: string) => void prodotti.delete(id),
},
{
nome: 'collega brand',
esegui: async () => {
throw new Error('brand inesistente');
},
compensa: async (id: string) => void collegamenti.delete(id),
},
]);
console.log(`prodotti rimasti: ${prodotti.size}, collegamenti rimasti: ${collegamenti.size}`);
// Output:
// ok crea prodotto
// ERRORE brand inesistente
// annullato crea prodotto
// prodotti rimasti: 0, collegamenti rimasti: 0
Il ciclo catch è il punto che conta: percorre in ordine inverso i soli passi completati (il passo che ha fallito non ha nulla da disfare) e chiama la loro compensazione. L'uscita è deterministica ed è stata ottenuta con Node 24:
dopo l'errore, prodotti rimasti: 0 mostra che la compensazione ha tolto il prodotto. Il limite è importante: una compensazione è un'altra scrittura, non un rollback magico. Se anche lei fallisce, o se il mondo esterno ha già visto l'effetto (un'email partita), resta un'incoerenza da gestire a mano.
Ora la stessa idea con l'API di Medusa. createStep riceve l'azione e, come terzo argomento, la compensazione; il secondo argomento di StepResponse è il dato che la compensazione riceve, qui l'identificativo del brand creato.
Il workflow lo componi con createWorkflow, e lo esegui da una rotta API passandogli il contenitore della richiesta.
// src/workflows/create-brand.ts
import { createStep, createWorkflow, StepResponse, WorkflowResponse } from '@medusajs/framework/workflows-sdk';
import { BRAND_MODULE } from '../modules/brand';
import BrandModuleService from '../modules/brand/service';
const createBrandStep = createStep(
'create-brand-step',
// azione
async (input: { name: string }, { container }) => {
const brandService: BrandModuleService = container.resolve(BRAND_MODULE);
const brand = await brandService.createBrands(input);
// il secondo argomento di StepResponse e' cio' che riceve la compensazione
return new StepResponse(brand, brand.id);
},
// compensazione: gira solo se un passo SUCCESSIVO fallisce
async (brandId: string, { container }) => {
const brandService: BrandModuleService = container.resolve(BRAND_MODULE);
await brandService.deleteBrands([brandId]);
},
);
export const createBrandWorkflow = createWorkflow('create-brand', (input: { name: string }) => {
const brand = createBrandStep(input);
return new WorkflowResponse(brand);
});
// src/api/admin/brands/route.ts: si esegue da una rotta con
// const { result } = await createBrandWorkflow(req.scope).run({ input: { name: 'Tostatura Sud' } });
Anche questo è uno schema non eseguito. La riga new StepResponse(brand, brand.id) è la più facile da dimenticare: se non passi il secondo argomento, la compensazione non sa che cosa cancellare. Il workflow di esempio ha un passo solo perché l'obiettivo è mostrare la forma;
il valore reale si vede quando i passi diventano due o più e toccano moduli diversi, per esempio creare il brand e poi sincronizzarlo con un servizio esterno.
Regioni, valute e tasse non sono la stessa cosa
Nella versione 2 la vendita internazionale si divide in più moduli. Secondo la documentazione del Region Module, una regione ha una valuta e un insieme di paesi, e per vendere in più valute si creano più regioni;
le impostazioni fiscali vivono in un modulo a parte, il Tax Module, con le proprie tax region. La conseguenza pratica è che un esempio come «crea una regione Europa con IVA al 22%» mescola due moduli: una regione ha name e currency_code,
l'aliquota si configura nel modulo tasse. Se segui un tutorial della versione 1 che mette l'aliquota nella regione, il campo non esiste più dove te lo aspetti.
Quando Medusa sì, quando no
Medusa conviene quando il tuo negozio ha regole che un servizio gestito non permette (un flusso d'ordine non standard, un checkout tutto tuo (per il pagamento, vedi l'articolo sui pagamenti con Stripe), un'integrazione con un gestionale), e quando c'è chi sa mantenere una applicazione Node.js e un database. Non conviene quando il problema è soltanto la velocità della vetrina: per quello basta migliorare il frontend (un esempio è lo storefront con Next.js), e un backend che gestisci tu non lo accelera; per capire se la lentezza sta lì, misura prima con le soglie dell'articolo su Core Web Vitals e SEO. Il confronto con l'altra piattaforma open source della serie, Saleor, si riduce a una questione di linguaggio e di stile: Medusa per chi vive in TypeScript e vuole REST e workflow, Saleor per chi preferisce Python e un'interfaccia tutta GraphQL.
Che cosa sai fare adesso
Sai che cosa richiede Medusa per girare e quali parti del suo codice hanno una licenza diversa, spieghi perché i moduli sono isolati e come si collegano, scrivi un modulo con modello, servizio e registrazione, e usi un workflow con compensazione per non lasciare dati a metà. Nel prossimo articolo passiamo a Saleor.
Fonti
- Medusa: Installation - requisiti (Node.js v22.22.0+ LTS, PostgreSQL), struttura dell'applicazione; consultata il 2026-11-12
- Medusa: Guide, Implement Brand Module - modello, servizio, definizione del modulo, registrazione e migrazioni; consultata il 2026-11-12
- Medusa: Module Isolation - isolamento, Module Links, workflow tra moduli; consultata il 2026-11-12
- Medusa: Compensation Function - comportamento della compensazione e dato passato da StepResponse; consultata il 2026-11-12
- Medusa: Region Module - regioni, valute, paesi; consultata il 2026-11-12
- Medusa: LICENSE - licenza MIT ed eccezione per i materiali Enterprise Edition; consultata il 2026-11-12

Commenti
Caricamento commenti...
Accedi per commentare