Skip to main content

02 - MACH Commerce Architecture: Microservices, API-First, Cloud-Native and Headless in E-commerce

MACH is a set of principles for composing a store from independent services. See what each letter asks for, what it costs and how to get there step by step.

What do you think about this article?

At a Glance

MACH is a set of principles for composing a store from independent services. See what each letter asks for, what it costs and how to get there step by step.

Headless Commerce 11 min

MACH (Microservices, API-first, Cloud-native, Headless) is a set of principles for composing an e-commerce store from independent services. Here you see what each letter asks for, what it costs and how to get there without rewriting everything.

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

MACH: four principles for composing a store from parts

MACH is an acronym used by the MACH Alliance, an organisation of software vendors: it is neither a product nor a standard with a certification, it is a family of principles. The letters stand for Microservices, API-first, Cloud-native and Headless. The idea that holds them together is called composable: instead of buying a single suite that does everything in a mediocre way, you choose for each function the most suitable service (search, payments, content, product master data) and connect them through APIs.

A comparison from the kitchen: a modular kitchen has units with standard sizes and you can change the oven without rebuilding the cabinet; a monolithic kitchen is a single block that costs less until something breaks. But in the modular kitchen someone has to guarantee that the sizes match, and in software that someone is you. It is the hidden cost of MACH and it will come back in every section.

Microservices: each service owns its data

Martin Fowler and James Lewis, in the 2014 article that made the term popular, describe a microservice as a small service, running in its own process, built around a business capability and independently deployable. In a store the capabilities are catalogue, prices, stock, cart, orders, payments, shipping. Each has its own boundary (in Domain-Driven Design language, a bounded context): inside the boundary "product" means one precise thing, and outside only a copy of it is used.

The most important practical consequence is that each service has its own data store. The listing shows why it matters: two separate stores, one for prices and one for orders, and the order that copies the unit price at the moment of purchase instead of rereading it later. The order service does not know the price service, so it cannot query its database; if it could, it would be tempted to recalculate the total with today’s price list and a past order would change its amount.

// Each service has its own store. The price service knows nothing about orders, and vice versa.
const archivioPrezzi = new Map<string, number>([['moka-3', 2400]]); // cents

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

function creaOrdine(sku: string, quantita: number) {
  // The price is COPIED into the order at purchase time: the order never rereads it.
  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); // the price list changes the next day
creaOrdine('moka-3', 1);

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

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

The second order costs 29 euros and the first stays at 48 (two units at 24) even though the price list went up in the meantime: the line prezzoUnitarioCents: archivioPrezzi.get(sku)! copies the value instead of keeping a reference. It is the acceptance of a deliberate duplication, and it has a price: if a product’s price is corrected by mistake, no order updates itself and an explicit procedure is needed. The listing is an in-memory model, run with Node 24, to show the principle: a real system implements it with separate stores and transactions.

API-first: the contract before the code

API-first means writing the description of the interface before implementing it, so that whoever builds the frontend and whoever builds the service can work in parallel on the same agreement. The most widespread tool for REST is the OpenAPI Specification (here 3.1.0, published on 15 February 2021): a YAML or JSON file listing paths, parameters, responses and schemas. The listing describes a catalogue service with two operations: the cursor-paginated list and the detail of a product, with the 404 response defined once in components and reused with $ref.

openapi: 3.1.0
info:
  title: Catalog Service API
  version: 2.0.0
paths:
  /products:
    get:
      summary: Product list, cursor-paginated
      parameters:
        - name: cursor
          in: query
          schema: { type: string }
        - name: category
          in: query
          schema: { type: string }
      responses:
        '200':
          description: A page of products
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductPage' }
  /products/{id}:
    get:
      summary: Detail of a product
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The product
          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: Product does not exist

# Output:
# openapi 3.1.0, 2 paths, problems: 0

I checked the file with a small script that reads it with js-yaml and verifies two rules: every $ref must point to an existing node, and every placeholder such as {id} in the path must be declared as an in: path parameter. It is a syntactic check, not a full validation against the OpenAPI schema: for that you need a dedicated validator. The second check is not pedantry: remove the declaration of the id parameter and the script reports /products/{id}: path parameter {id} not declared: a path with a placeholder and no parameter is an inconsistent contract, which client generators either reject or each interpret in their own way.

A contract is worth something only if it changes by rules. The Semantic Versioning specification says to increment the major version when API changes are incompatible, the minor version when you add functionality in a backward-compatible way and the patch for fixes. Declaring it in the info.version field is not enough, but it gives the team using the API a precise signal about what they risk by upgrading.

Cloud-native: built for elastic environments

Cloud-native does not mean “runs on a cloud”: it means that the service assumes it can be started and stopped at any moment, in several copies, on machines it does not know. Concrete habits follow: no state inside the process (the session lives in a shared store), configuration taken from the environment, automated releases, and asynchronous communication through events when an immediate answer is not needed. For a store, a clear example: the order confirmation must not wait for the email to be sent; the order publishes an event and the notification service reads it when it can.

The cost not to be underestimated is that every boundary between services is a network, and the network fails. Requests must be made with deadlines and a limited number of retries, and you must decide in advance what to show if the recommendations service does not answer (usually: no recommendations, but the page works). Fowler and Lewis call it design for failure in the article cited above.

Headless and the BFF layer: one request, many services

You saw the letter H in the first article: the frontend consumes APIs and is not generated by the backend. In an architecture with many services, however, a catalogue page needs data from more than one: products, side filters, recommendations. Asking for them from the browser one by one means many requests on a mobile network (the effect on speed metrics is in the article on Core Web Vitals and SEO); that is why a BFF (Backend for Frontend) is inserted, a small service written for a single frontend, which collects the data from the services and returns what the page needs.

The listing simulates three services with delays of 80, 120 and 60 milliseconds and calls them in two ways. Sequentially the waits add up (about 260 ms); with Promise.all they start together and the total time is that of the slowest (about 120 ms). The three calls are independent of one another, and that is why they can be parallelised: if the second needed the result of the first, it would not be possible.

// Three fake services: each answers after a fixed delay, as a network call would.
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: ['light', 'dark'] });
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('sequential', inSequenza);
await misura('parallel', inParallelo);

// Output:
// sequential: 260 ms
// parallel: 120 ms

The numbers are the sum and the maximum of the delays I chose, not a network measurement: they serve to see the rule, and they change by a few milliseconds from one run to another (in three trials I read 260-261 and 120-121 ms). In the real world delays are variable, and the worst case is the slow service: Promise.all still waits for the slowest, and if one of the three fails the whole promise is rejected. For non-essential data, such as recommendations, Promise.allSettled and a degraded page are preferable to an error.

Where MACH goes wrong

The recurring mistakes all come from fragmenting too much and too early. Fowler, in the short text Monolith First, observes that almost all successful microservice stories started with a monolith that became too big and was then split, while systems designed as microservices from scratch have more often run into difficulties. It is one author’s observation, not a law, but it indicates where it is better to start when the store is small.

  • Premature services: twenty services for a store with few orders are twenty systems to release and monitor, for a gain that does not exist yet.
  • Unversioned contracts: if a team changes a field without saying so, it breaks the ones consuming it. The OpenAPI file and semantic versioning are the protection.
  • Cascading calls: every request that triggers others in sequence adds up the latencies and the probabilities of failure.
  • Shared database: two services on the same store are in practice a single service with two codebases, and can no longer be released separately.

The last one is the most insidious, because at first it looks like a saving. If to know whether a change is safe you have to read another service’s code, the boundaries do not exist: you have paid the price of microservices without getting their benefits.

Getting there step by step

You do not move to MACH overnight. The most quoted pattern for gradual migration is Fowler’s Strangler Fig: the new system grows around the old one and replaces one function at a time, until the old one can be switched off. Applied to a store, the first step is to put a headless frontend in front of the existing system, using the APIs it already exposes. The second is to extract the function with the most specific need, typically search, into a dedicated service chosen on the market. Another frequent candidate is editorial content, covered in the article on content commerce. Then you continue one function at a time, but only as long as each step has a reason you can measure: if search was fine as it was, leave it where it is.

What you can do now

You can tell the four letters of MACH apart and the problem each solves, write a checkable OpenAPI contract, explain why a service must not read another’s database, and use a BFF with parallel calls to build a page from the data of several services. Above all you know that the boundary of services is drawn where the store has different needs, not where it looks elegant. In the next chapters we apply the idea to concrete platforms, starting with Shopify Hydrogen, and continue with the two open-source platforms: Medusa and 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