Skip to main content

04 - Medusa.js: Open-Source, Modular and Self-Hosted E-commerce with Node.js and TypeScript

Medusa is an open-source Node.js e-commerce backend with isolated modules. Write a module, see why they cannot read each other and use workflows with rollback.

What do you think about this article?

At a Glance

Medusa is an open-source Node.js e-commerce backend with isolated modules. Write a module, see why they cannot read each other and use workflows with rollback.

Headless Commerce 9 min

Medusa is an open-source e-commerce backend in Node.js and TypeScript built from isolated modules. Here you see how a module is defined, why modules cannot read each other and how workflows undo an operation halfway through.

medusa.js medusa ecommerce headless ecommerce nodejs medusa workflow open source shopify

Why Medusa: the backend code is yours

In the previous article the backend was Shopify: you use it, you do not modify it. Medusa is the opposite: a Node.js application that you install, extend with your own code and run wherever you want. The installation guide (consulted on 2026-11-12, documentation at version 2.22.6) requires Node.js v22.22.0 or later in an LTS release and a running PostgreSQL; the application consists of a Node.js server and an admin dashboard. The code is under the MIT licence, with the exception of the Enterprise Edition materials listed in the repository’s LICENSE file, which have a different licence: if a feature you need is in there, read that file before counting on it.

The price of control is that you run operations yourself: updates, database backups, monitoring. For a team that already has a server and knows how, it is often an advantage (predictable costs, data where you decide); for a team of two who just want to sell, it can be the cost that tips the balance toward a managed service like the one in the introductory article. Medusa also has a cloud service; I do not compare its prices here, because I have not verified them.

The central idea: modules that do not know each other

In Medusa each area of commerce (products, cart, orders, regions, taxes) is a module: a package with its own data models and its own service. The point to grasp immediately is isolation. According to the Module Isolation page, a module is not aware of the resources of other modules: it cannot use their service nor have direct relations from its data models to those of another. It is the same principle as the database per service seen in the article on MACH architecture, applied inside a single application.

To connect data from different modules the documentation provides two tools: Module Links, which create associations between models of different modules without breaking isolation, and functions that cross several modules, which are written as workflows. The advantage stated by the documentation is being able to replace a module with an implementation of your own or reuse it elsewhere; the drawback is that a query like “products with their brand” is not a simple SQL join.

A module in three files

The Brand module follows the official guide. A data model described with Medusa’s Data Model Language becomes a table; a service extending MedusaService receives the generated management methods (such as createBrands and retrieveBrand); an index.ts file declares the module with its name. The name matters: the BRAND_MODULE constant is the key with which the rest of the application asks for the service, and that is why the guide exports it. After registering the module in medusa-config.ts, two commands generate and run the migration that creates the table.

// 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 generates the management methods: 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, inside defineConfig({ ... })
//   modules: [{ resolve: './src/modules/brand' }],
//
// then, from the project root:
//   npx medusa db:generate brand
//   npx medusa db:migrate

This block was not run: it needs a Medusa project with PostgreSQL, and here it is a schema to adapt, recomposed from the guide’s three files (in the project they are separate files, and the repeated import lines must be merged). Two notes on what to avoid: do not give the module a different name in Module(...) and in the resolutions, because the error shows up only when you try to resolve the service; and do not forget db:migrate, without which the service exists but the table does not and the first write fails with a database error.

Workflows and compensation: undoing halfway through

Creating a product and linking it to a brand are two writes on different stores, and there is no database transaction covering both. If the second fails, the first stays and the catalogue contains an orphan product. The solution used by Medusa is the workflow: a sequence of steps, each with a compensation function which, as the documentation says, runs only if an error occurs in the workflow and undoes what the step had done. The following listing is not Medusa: it is a twenty-line runner that shows the rule, so you understand what happens before reading the real API.

// A mini workflow runner: each step has an action and, if needed, a compensation.
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(`ERROR    ${(errore as Error).message}`);
    // Undo in reverse order, only what was completed.
    for (const { passo, risultato } of completati.reverse()) {
      await passo.compensa?.(risultato);
      console.log(`undone   ${passo.nome}`);
    }
  }
}

const prodotti = new Set<string>();
const collegamenti = new Set<string>();

await eseguiWorkflow([
  {
    nome: 'create product',
    esegui: async () => (prodotti.add('prod_1'), 'prod_1'),
    compensa: async (id: string) => void prodotti.delete(id),
  },
  {
    nome: 'link brand',
    esegui: async () => {
      throw new Error('brand does not exist');
    },
    compensa: async (id: string) => void collegamenti.delete(id),
  },
]);

console.log(`products left: ${prodotti.size}, links left: ${collegamenti.size}`);

// Output:
// ok       create product
// ERROR    brand does not exist
// undone   create product
// products left: 0, links left: 0

The catch loop is the point that matters: it walks in reverse order only the completed steps (the step that failed has nothing to undo) and calls their compensation. The output is deterministic and was obtained with Node 24: after the error, products left: 0 shows that the compensation removed the product. The limit is important: a compensation is another write, not a magic rollback. If it fails too, or if the outside world has already seen the effect (an email that went out), an inconsistency remains to be handled by hand.

Now the same idea with Medusa’s API. createStep receives the action and, as its third argument, the compensation; the second argument of StepResponse is the data the compensation receives, here the identifier of the brand created. You compose the workflow with createWorkflow, and run it from an API route by passing it the request container.

// 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',
  // action
  async (input: { name: string }, { container }) => {
    const brandService: BrandModuleService = container.resolve(BRAND_MODULE);
    const brand = await brandService.createBrands(input);
    // the second argument of StepResponse is what the compensation receives
    return new StepResponse(brand, brand.id);
  },
  // compensation: runs only if a LATER step fails
  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: run it from a route with
//   const { result } = await createBrandWorkflow(req.scope).run({ input: { name: 'South Roastery' } });

This too is a schema that was not run. The line new StepResponse(brand, brand.id) is the easiest to forget: if you do not pass the second argument, the compensation does not know what to delete. The example workflow has only one step because the goal is to show the shape; the real value shows when there are two or more steps and they touch different modules, for example creating the brand and then synchronising it with an external service.

Regions, currencies and taxes are not the same thing

In version 2 international selling is divided into several modules. According to the Region Module documentation, a region has a currency and a set of countries, and to sell in several currencies you create several regions; tax settings live in a separate module, the Tax Module, with its own tax regions. The practical consequence is that an example such as “create a Europe region with 22% VAT” mixes two modules: a region has name and currency_code, the rate is configured in the tax module. If you follow a version 1 tutorial that puts the rate in the region, the field no longer exists where you expect it.

When Medusa yes, when no

Medusa is worth it when your store has rules that a managed service does not allow (a non-standard order flow, a checkout entirely your own (for payment, see the article on payments with Stripe), an integration with a management system), and when someone knows how to maintain a Node.js application and a database. It is not worth it when the problem is only the speed of the storefront: for that, improving the frontend is enough (an example is the storefront with Next.js), and a backend you run yourself does not speed it up; to find out whether the slowness lies there, measure first with the thresholds in the article on Core Web Vitals and SEO. The comparison with the other open-source platform in the series, Saleor, comes down to a question of language and style: Medusa for those who live in TypeScript and want REST and workflows, Saleor for those who prefer Python and an entirely GraphQL interface.

What you can do now

You know what Medusa requires to run and which parts of its code have a different licence, you explain why modules are isolated and how they are connected, you write a module with model, service and registration, and you use a workflow with compensation so as not to leave data half-done. In the next article we move on to Saleor.

Sources

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects