The Architecture Decoder3 axes / 10 words

A field guide to software architecture

Ten architecture words.Three real questions.

Monorepo. Modular monolith. Micro-frontends. Hexagonal, Clean and Onion Architecture. Event-driven, pipeline, layered, distributed. Every architecture discussion drags in these ten terms as though you have to pick a side.

You do not. They answer three unrelated questions, and most of them were never alternatives to each other. Learn which question each word belongs to, and the confusion stops for good.

Every concept below comes with a plain-language explanation, three depth levels from beginner to advanced, code examples, and an interactive demo of the problem it solves.

Orientation

The three axes

Read this once. Everything below hangs off it.

Picture building a business park. Three separate decisions, and answering one does not answer the others:

  • The filing cabinet where blueprints are stored: one cabinet or several? That is your repo question.
  • The buildings you actually construct: one big building, or five connected by paths? That is your deployment question.
  • How rooms are arranged inside any given building, where the plumbing runs, which room opens into which. That is your internal structure question.

You can keep blueprints for five buildings in one cabinet. You can spread one building's blueprints across five cabinets. The cabinets have nothing to do with the buildings. That is why "monorepo" and "monolith" are unrelated despite sharing a prefix.

AXIS 1where code lives Monorepo Polyrepo AXIS 2what you deploy Modular monolith 1 deploy unit Build-time MFEs 1 deploy unit Runtime MFEs many deploy units AXIS 3code inside a unit Hexagonal / Clean Layered / Tiers Events / Pipeline
You answer each row. "Modular monolith in a monorepo with hexagonal modules" is one coherent sentence, not three competing choices.

Imagine your toys.

Question one: do you keep all your toys in one big box, or a separate box for each kind of toy? That is the repo question. It is only about storage.

Question two: when you build a castle, is it one giant castle, or five small castles standing apart? That is the deployment question. It is about how many separate things exist when you actually play.

Question three: inside one castle, where do you put the kitchen and the throne room? That is the internal structure question.

Here is the part grown-ups get wrong: keeping all toys in one box does not mean you build one castle. They are separate choices. Big box, five castles is perfectly fine.

The one rule

Before you compare two architecture words, ask which axis each belongs to. If they are on different axes, they are not alternatives and comparing them is a category error.

One more distinction: style, pattern, tool

The three axes tell you which question a word answers. This tells you what kind of thing the word is, and it prevents a second, very common category error.

LevelWhat it isExamples on this page
StyleThe overall shape of a system or of a unit inside itmodular monolith, microservices, runtime micro-frontends, layered, event-driven, pipeline, Hexagonal Architecture
PatternA reusable solution to a recurring problem within a styleports and adapters, saga, transactional outbox, circuit breaker, BFF, reducer, dead-letter queue
ToolA concrete product that implements a style or a patternNx, Turborepo, Module Federation, Kafka, BullMQ, Prisma, Redux

Arguments get stuck when people compare across levels without noticing. "Should we use microservices or Kafka?" is not a question with an answer, because one is a style and the other is a tool you could use in either style. Neither is "hexagonal or Prisma".

The trap runs the other way too. Tools get spoken about as though they were architectures, usually because the tool arrived first and the vocabulary followed. Module Federation is the clearest case: it is a bundler feature, not a way of designing software. The style is runtime micro-frontends, and you could implement that style with import maps, single-spa, web components or plain iframes instead.

What this model is not

Three axes is a teaching frame, not a complete taxonomy. Some concepts genuinely straddle it. Event-driven is the clearest case: as an in-process bus it is purely an axis 3 choice about how modules talk, but swap in Kafka and it becomes an axis 2 choice too, because you have just created deploy units. Pipeline does the same thing when its stages become separate workers.

Treat the axes as dimensions you can vary independently, not as boxes every word drops neatly into. The frame earns its keep by stopping the most common category error, and it does not have to be exhaustive to do that.

Two questions, not one

Before comparing two terms, ask which axis each belongs to, then ask which level each sits at. If either answer differs, they were never competing.

Axis 1 · where code lives

Monorepo vs polyrepo

The cheapest decision on the page. Answer it in ten seconds.

Why

Why this exists at all

A product is rarely one program. You have an API, a website, an admin console, some shared code, some infrastructure config. Those files have to live somewhere in version control, and someone has to decide whether they share a repository or get their own.

Get it wrong toward polyrepo and a one-line change to a shared type becomes a four-repository, four-pull-request afternoon. Get it wrong toward monorepo without tooling and your CI runs the whole test suite for a typo in the README.

What it does

What each choice actually gives you

A monorepo is one repository holding many projects. One clone gets you everything. Changes that touch several projects land in a single atomic commit, so the codebase is never in a half-updated state.

A polyrepo (also called multi-repo) gives each project its own repository. Shared code becomes a published package that others install by version number.

Neither one says anything about how many servers you run. Google uses a monorepo and runs thousands of separate services. Netflix uses polyrepo. All four combinations exist in production somewhere.

How

How you actually set it up

For a monorepo you need a workspace tool so that builds and tests only run for what changed. Pick one: Nx, Turborepo, Bazel, or plain pnpm workspaces for small setups. Then add a CODEOWNERS file so the right people review the right folders.

For polyrepo you need a package registry (npm, Artifactory, GitHub Packages) and a versioning discipline, because now every shared change is a version bump followed by upgrades in each consumer.

You and three friends are drawing a comic book together.

Monorepo is one shared notebook. Everyone draws in the same notebook. If you change a character's hat on page 2, you can fix it on page 9 at the same time, in one go. But everyone has to be careful not to scribble on each other's pages.

Polyrepo is a notebook each. Very tidy, nobody scribbles on anyone. But if the hat changes, you have to walk to each friend and ask them to update their notebook, and some of them will forget.

Most small teams are happier with one shared notebook.

Level 1: just look at what is on disk.

Forget theory. Run git clone and look at the folder. That tells you which one you have.

Monorepo: one clone, everything inside:

opsvo/              ← a single git repository
  apps/
    api/
    storefront/
    admin-console/
  packages/
    ui/
    shared-types/

Polyrepo: four separate clones:

opsvo-api/          ← repo 1
opsvo-storefront/   ← repo 2
opsvo-admin/        ← repo 3
opsvo-ui/           ← repo 4, published to npm

That is the whole distinction. Notice you cannot tell from either picture how many servers run in production, because the repo layout does not decide that.

Level 2: the workspace wiring that makes a monorepo bearable.

In a monorepo, apps reference shared packages by name, and the workspace tool links them on disk instead of downloading from a registry. No version numbers, no publishing.

// pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

// apps/api/package.json
{
  "dependencies": {
    "@opsvo/shared-types": "workspace:*"   ← linked locally, not downloaded
  }
}

The payoff is the atomic commit. Change a type and its consumer together, and CI verifies the whole thing at once:

$ git commit -m "add currency to Order"
  modified: packages/shared-types/order.ts
  modified: apps/api/src/orders/create.ts
  modified: apps/storefront/src/cart.tsx
# one commit. one review. never half-migrated.

In polyrepo that same change is three pull requests plus a version bump, and there is a window where the published package and its consumers disagree.

Level 3: what breaks at scale, and how teams fix it.

Monorepos fail on CI time. The fix is an affected graph: the tool computes which projects depend on the changed files and only builds those.

$ npx nx affected --target=test --base=origin/main

  ✓ shared-types  (changed)
  ✓ api           (depends on shared-types)
  - storefront     skipped, not affected
  - admin-console  skipped, not affected

# 2 of 4 projects built. this is why the tooling exists.

Three more things serious monorepos add:

  • Remote build cache: a task with identical inputs is never rebuilt, on any machine. CI restores the previous output instead.
  • Module boundary rules: a lint rule that fails the build when a project imports something it should not. This is what stops a monorepo becoming a mud ball.
  • Sparse checkout: at Google scale nobody clones everything; you materialise only the paths you touch.

Here is a boundary rule, which matters far more than it looks. It is the same idea that will make modular monoliths work later:

// .eslintrc: nx enforce-module-boundaries
{
  "sourceTag": "scope:storefront",
  "onlyDependOnLibsWithTags": ["scope:shared"]
}

// storefront importing from admin now fails CI:
import { AuditLog } from '@opsvo/admin/internals'
   ✗ scope:storefront cannot depend on scope:admin

The polyrepo failure mode is the mirror image: no CI problem, but a "diamond dependency" mess where two services need incompatible versions of the same shared library, and nobody can upgrade without coordinating a release train.

Jargon used in this section

monorepo
One version-control repository holding many projects. Storage only: it says nothing about deployment.
polyrepo / multi-repo
One repository per project. Shared code becomes a published, versioned package.
atomic commit
A single commit that changes several projects together, so the codebase is never half-updated.
affected graph
The tool's calculation of which projects depend on your changed files, so CI builds only those.
diamond dependency
Two things you depend on need conflicting versions of the same third thing. The classic polyrepo pain.
Common mix-up

Monorepo does not mean monolith. Monorepo is a storage choice; monolith is a deployment choice. If you remember nothing else from this section, remember that they share a prefix and nothing else.

Axis 2 · what you deploy

Modular monolith

One deployable thing. Many enforced compartments inside it.

Why

Why not just a plain monolith

A plain monolith works fine until it does not. What kills it is not size, it is entanglement: the orders code reads the inventory tables directly, the billing code imports a helper from shipping, and after two years every change touches everything. Nobody can reason about one part in isolation, and nobody can extract one part later.

A modular monolith keeps the operational simplicity of one deployment while refusing that entanglement. It buys you the ability to split later without committing to distribution now.

Interactive: change one thing
4modules broken
11files to change
Daysrealistic effort

      

An online store with five modules. Flip the toggle and pick a change to see how far it travels. File counts are illustrative, the failure shape is not.
What it does

What it actually is

One process, one deployable artifact. Inside it, modules that each own their data and expose an explicit, narrow interface. Modules talk through those interfaces or through in-process events, never by reaching into each other's tables or internal files.

Everything ships together. Fix a checkout bug, rebuild the one image, redeploy. Checkout, orders and catalog all restart, because they are the same process.

How

How you enforce the boundaries

This is the whole discipline, and it is mechanical. Folder structure alone always decays, so you need three things:

  • A single public entry file per module. Everything else in that folder is private.
  • A lint rule in CI that fails on deep imports past that entry file.
  • Data ownership: each module's tables are touched only by that module. No cross-module joins.

Think of a big house with rooms. Everyone lives in the same house: one front door, one address.

A messy monolith is a house with no walls. Someone's socks end up in the kitchen. Change one thing and you trip over something in another room.

A modular monolith is the same house, but each room has a door, and there is a rule: you knock, you do not climb through the window. You also do not take things out of someone's cupboard: you ask them and they hand it to you.

It is still one house. But because everyone knocks, you could one day move a room into its own little cottage without the whole house falling down.

Level 1: the folder shape and the one rule.

src/
  modules/
    orders/
      index.ts        ← the ONLY file others may import
      service.ts      ← private
      repository.ts   ← private
    inventory/
      index.ts
      service.ts

Legal:

import { placeOrder } from '../orders'          ✓ goes through index.ts

Illegal:

import { OrderRepo } from '../orders/repository'  ✗ reaches inside

That is it. One public door per module, and everything else is behind it.

Level 2: making the rule real instead of aspirational.

A rule nobody checks is a suggestion. Put it in CI with dependency-cruiser or an ESLint boundaries plugin:

// .dependency-cruiser.js
{
  name: 'no-deep-module-imports',
  severity: 'error',
  from: { path: '^src/modules/([^/]+)/' },
  to:   {
    path: '^src/modules/([^/]+)/.+',
    pathNot: '^src/modules/$1/',      own folder is fine
    pathNot: '^src/modules/[^/]+/index.ts$'  public door is fine
  }
}

Then the data half, which people skip and later regret. Each module gets its own schema, so a cross-module join is not merely discouraged: it is visible in the SQL:

// prisma/schema.prisma
model Order {
  id     String @id
  @@schema("orders")
}
model StockItem {
  id     String @id
  @@schema("inventory")
}

-- this now looks obviously wrong in a code review:
SELECT * FROM orders.order o JOIN inventory.stock_item s ...

Modules ask each other in code, not in SQL:

// orders/service.ts
const ok = await inventory.reserve(items)   ← a normal function call

Level 3: designing for the extraction you may never do.

The advanced move is to make every cross-module call look exactly like a network call would, without being one. Define the contract as an interface, inject the implementation:

// orders/ports.ts: orders declares what it NEEDS
export interface InventoryPort {
  reserve(items: LineItem[]): Promise<Reservation>
}

// wiring/container.ts, today it is in-process
const inventoryPort: InventoryPort = new LocalInventoryAdapter(inventoryService)

// tomorrow, if you ever extract it: one line changes
const inventoryPort: InventoryPort = new HttpInventoryAdapter('https://inventory.internal')

Because the call was always async and always went through an interface, extraction becomes configuration rather than a rewrite. But note carefully what that one line silently introduces:

  • The call can now time out, and you will not know whether the reservation happened.
  • You need a retry policy, and therefore an idempotency key so a retry does not double-reserve.
  • You lose the shared database transaction that used to make order-plus-reservation atomic.

Which is why the honest advice is: build the seam, then do not use it until something forces you. Keep a checklist of what a module must satisfy before it earns extraction: owns its tables, no shared transactions with others, communicates only through its port, has its own metrics.

The transactional point is the one that bites hardest. In-process you get this for free:

await db.$transaction(async (tx) => {
  await orders.create(tx, order)
  await inventory.reserve(tx, items)   both commit or neither does
})

Split them across services and that guarantee is gone. You replace it with a saga: reserve first, create the order, and if the order fails, issue a compensating action that releases the reservation. That is real work, and it is the true price of the split.

Jargon used in this section

modular monolith
One deployable process containing modules with enforced boundaries and their own data.
deployable artifact
The single built thing you ship: a container image, a jar, a zip. Counting these is how you count deploy units.
module boundary
The rule that code outside a module may only use its public entry point, checked by CI rather than trusted.
idempotency key
A unique ID sent with a request so that retrying it does not perform the action twice.
saga
A sequence of local steps with compensating undo actions, used to replace a transaction once you no longer have one.
compensating action
The undo step in a saga: release the reservation, refund the charge.
Axis 2 · what you deploy

Distributed architecture

Not a separate option: the umbrella name for the whole right side of axis 2.

Why

Why anyone accepts the pain

Distributed architecture is not a maturity level you graduate to. It is a trade: you accept an entire category of new failures in exchange for something specific. If you cannot name the something, do not trade.

The reasons that come up most often:

  • Divergent scaling: image processing needs 16 GB and a GPU, your API needs 512 MB. Scaling them together burns money.
  • Divergent runtime: the ML piece is Python, the rest is Node.
  • Team release independence: two teams whose deploy cadences genuinely conflict, after you already tried one pipeline and it failed.
  • Isolation: a regulated or untrusted workload that must not share a process.
  • Availability asymmetry: checkout must stay up while reporting is being redeployed.

That list is the common ground, not a closed set. Others are real but rarer: data residency rules forcing EU data onto EU infrastructure, systems you acquired rather than built, a boundary around code you do not control, and strangler-fig migrations where the split is a transitional state rather than the target.

Notice what is missing from all of them: "it scales better", "it is cleaner", "it is what real companies do". A modular monolith scales horizontally perfectly well: you run more copies of the same container. Scaling and splitting are different things.

What it does

What the word actually covers

A distributed architecture is any system whose parts run in separate processes and communicate over a network. Microservices are distributed. Runtime micro-frontends are distributed. A Kafka-based event system is distributed. A modular monolith is not, no matter how many modules it has.

The moment a call crosses a process boundary, it stops being a function call and becomes a message that may vanish.

IN ONE PROCESS Order module direct function call Payment module one failure mode: it throws. same memory, same clock. ACROSS THE NETWORK Order service call over a network Payment service timeouts, retries, partial writes, version skew, network splits.
Only the middle box changed. That is the entire difference between a monolith and a distributed system.

You want a cookie. Your sister is in the same room. You ask her, she hands you a cookie, or she says no. Two outcomes. Easy.

Now your sister is at her friend's house and you send her a letter asking for a cookie. You wait. Nothing comes back.

Did the letter get lost? Did she get it and the cookie is still in the post? Did she never read it? You cannot tell. And if you send a second letter, you might end up with two cookies, which sounds good until it is two charges on someone's credit card.

That "I do not know what happened" feeling is the whole difficulty of distributed systems. Everything hard about them (retries, timeouts, undo steps) exists to deal with that one feeling.

Level 1: count your deploy units.

Ask one question: I fixed a bug in checkout. What must I redeploy, and what keeps running untouched?

Modular monolith
  rebuild 1 image → redeploy → everything restarts together
  deploy units: 1   → not distributed

Microservices
  rebuild checkout-service → redeploy it alone
  orders-service never restarts and does not notice
  deploy units: many → distributed

Same code split, wildly different operational reality. The word "distributed" just means the second picture.

Level 2: the timeout that has no answer.

Here is the failure that does not exist in a monolith:

try {
  await paymentService.charge(orderId, amount)   HTTP call
} catch (e) {
  // e.code === 'ETIMEDOUT'
  // Did the customer get charged? UNKNOWABLE from here.
}

You cannot fix this by retrying blindly, because a retry might charge twice. You fix it by making the operation idempotent: the caller sends a key, the callee remembers it:

// caller: same key on every retry of the same logical charge
await payments.charge({
  idempotencyKey: `order-${orderId}-charge`,
  amount
})

// callee: seen this key? return the old result, do nothing new
const prior = await db.charges.findUnique({ where: { key } })
if (prior) return prior.result

Every distributed system ends up building this. It is not optional, and it is pure overhead you did not need in-process.

Level 3: the tax bill in full.

Once you are distributed, these stop being nice-to-haves:

  • Correlation IDs and distributed tracing: a stack trace no longer spans the request. You need OpenTelemetry to reassemble one user action from five services' logs.
  • Circuit breakers and timeouts everywhere: a slow dependency will otherwise exhaust your connection pool and take you down with it.
  • Backpressure: a fast producer will drown a slow consumer unless something pushes back.
  • Contract versioning: you cannot deploy both sides at once, so every change must be backward compatible for at least one release. Expand, migrate, contract.
  • Eventual consistency: different services briefly disagree about the truth, and the UI has to be honest about that.

The backward-compatible rollout is the one people underestimate. You cannot rename a field:

// ✗ deploys are not simultaneous: this breaks in the gap
- { total: number }
+ { totalAmount: number }

// ✓ expand → migrate → contract, over three releases
release 1: { total: number, totalAmount: number }  write both
release 2: all consumers read totalAmount
release 3: { totalAmount: number }                 drop the old field

And the deepest one: you cannot have consistency, availability and partition tolerance all at once. Partitions happen whether you like it or not, so every distributed design is really choosing between staying consistent and staying available when the network splits. In a monolith that question never arises.

Jargon used in this section

distributed architecture
Any system whose parts run in separate processes talking over a network. The umbrella term.
microservices
The backend form of distributed architecture: many independently deployed services. Independent deployability is the defining property, not size.
eventual consistency
Parts of the system are briefly out of sync and converge later, rather than all agreeing instantly.
circuit breaker
A wrapper that stops calling a failing dependency for a while, so its failure does not exhaust your resources.
backpressure
A signal from a slow consumer telling the producer to slow down instead of dropping work.
distributed tracing
Tagging a request with an ID that travels across services so you can reconstruct one user action from many logs.
blast radius
How much of the system a single failure or deploy can damage.
Common mix-up

Distributed is about process boundaries, not about code organisation. You can have a beautifully modular monolith that is not distributed, and a hideous ball of mud spread across forty services that is. Splitting does not clean anything up: it just adds a network between your existing mess.

Axis 2 · what you deploy

Build-time micro-frontends

Code ownership without deploy independence. Usually the right answer.

Why

Why split a frontend at all

Because several teams working in one React app step on each other: shared routing, shared state, merge conflicts in the same files, and a review queue where nobody knows who owns what. Splitting the frontend into owned pieces fixes the ownership problem.

The question is whether you also need each piece to deploy on its own. Build-time integration says no, and that "no" saves you an enormous amount of grief.

What it does

What actually happens

Each so-called micro-frontend is a package in your workspace. The shell app imports them, and the bundler compiles everything into one output. The browser downloads a single application.

So the cart team owns their code, gets their own folder, their own reviews, their own tests. But when they fix a bug, someone must rebuild and redeploy the shell. They cannot ship without the shell shipping.

Which is the honest description: this is a modular monolith written in React. That is not an insult: it is the correct default.

How

How it is wired

Workspace packages, ordinary imports, one build. There is no special runtime machinery at all: that is precisely the point.

Your class is making one big poster for the science fair.

Each group draws their own piece on their own sheet of paper: the volcano group, the planets group, the dinosaur group. Everyone owns their sheet and nobody scribbles on anyone else's.

But at the end, the teacher glues all the sheets onto one big board, and that board goes to the fair.

So if the dinosaur group wants to fix a wonky tail after the board is already glued, the whole board has to come back and be re-glued. Everyone's work goes back on the wall together, every time.

That is build-time. Separate sheets, one board.

Level 1: what the browser downloads.

This is the fastest way to tell build-time from runtime. Open the network tab.

GET /assets/main-a3f2b1.js
     ↑ shell + cart + search + checkout, all in one file

# one request. one file. one deploy produced it.

And the code is completely ordinary:

// apps/shell/src/App.tsx
import { CartWidget } from '@opsvo/cart'
import { SearchBar }  from '@opsvo/search'

// just imports. nothing clever happening.

Level 2: ownership without a network, plus lazy loading.

You get real boundaries from CODEOWNERS and lint rules, exactly like the modular monolith on the backend:

# .github/CODEOWNERS
/packages/cart/     @opsvo/team-cart
/packages/search/   @opsvo/team-search
/apps/shell/        @opsvo/team-platform

People often confuse code splitting with micro-frontends. It is not the same thing. Lazy loading splits the bundle, but it is still one deploy:

const Checkout = React.lazy(() => import('@opsvo/checkout'))

// browser now fetches:
GET /assets/main-a3f2b1.js
GET /assets/checkout-7d21c9.js   ← separate FILE...

// ...but produced by the SAME build, from the SAME deploy.
// still one deploy unit. still not distributed.

Deploy units are counted by "how many things can I ship separately", not by "how many files does the browser fetch".

Level 3: scaling this to many teams before reaching for runtime.

Most teams that think they need runtime micro-frontends actually need a better build-time setup. Three techniques buy you a long runway:

  • Affected builds: CI only rebuilds and retests the packages a change touched, so a shared deploy is still fast.
  • Contract-tested boundaries: each package exports a typed public API, and a type test fails CI if it changes incompatibly.
  • Trunk-based deploys with feature flags: teams merge continuously and control their own release timing by flag, not by deploy. This is the key move: it gives you release independence without deploy independence.

That last one deserves the code, because it solves the actual problem people buy Module Federation for:

// the cart team ships code every day, dark, behind a flag
export function CartWidget() {
  const flags = useFlags()
  return flags.cartRedesign
    ? <CartV2 />
    : <CartV1 />
}

// release = flip a flag in the dashboard, no deploy at all.
// rollback = flip it back, in seconds, not a revert + rebuild.

Compare the rollback stories honestly. Flag flip: seconds, no build. Runtime micro-frontend rollback: republish the previous remote bundle and hope no other remote depended on the new one. Feature flags are strictly better for most teams, and cost a fraction as much.

Jargon used in this section

build-time micro-frontends
Frontend packages composed into one application by the bundler. One deploy unit.
shell / host app
The application that owns routing and layout and hosts the other pieces.
bundler
The tool (Vite, webpack, esbuild) that compiles your source and dependencies into files the browser can load.
code splitting
Producing several bundle files from one build so the browser loads them on demand. Still one deploy unit.
feature flag
A runtime switch that turns a feature on or off without deploying, giving release independence cheaply.
trunk-based development
Everyone merges to main continuously, keeping unfinished work behind flags rather than in long branches.
Axis 2 · what you deploy

Runtime micro-frontends

The style. Module Federation is one tool that implements it.

Why

Why anyone does this

The dominant reason is organisational rather than technical: you have several frontend teams that genuinely cannot share a release train. Team A must ship at 3pm Tuesday without waiting for Team B's failing test to be fixed.

There is one other legitimate reason, and it is structural: you do not have the code at build time. Plugin and marketplace architectures where third parties ship UI into your shell cannot bundle what has not been written yet. Framework migrations hit the same wall, which is why an Angular shell hosting React remotes is a real pattern rather than a mistake.

Outside those two, the case is thin. There is no performance reason, no scalability reason, no cleanliness reason. If neither independent deploy nor unknown-at-build-time code is what you need, this is all cost.

What it does

What actually happens

Each team builds and deploys its own bundle to its own URL. The shell fetches those bundles at runtime, in the browser, and stitches them into one running application. Nobody rebuilds anybody.

This is the same trade as microservices, moved to the client tier. The browser is now assembling a distributed system on every page load, from parts that were built at different times against possibly different dependency versions.

How

How the pieces find each other

The shell is configured with the URLs of remote entry files. Each remote publishes a runtime container rather than a static manifest: remoteEntry.js is executable JavaScript exposing init and get. At load time the host calls init to negotiate a single shared copy of React and friends, then get to pull the requested component.

Module Federation is the most common way to do this, but it is a bundler feature rather than the architecture itself. The same style can be built with browser-native import maps, with single-spa as an orchestrator, with web components as the composition boundary, or with plain iframes when hard isolation matters more than a seamless feel. Angular users will meet Native Federation, which is the same idea on web standards rather than webpack internals.

Worth separating those two things when you evaluate this. Deciding to adopt runtime micro-frontends is an architecture decision with the costs listed below. Deciding to use Module Federation rather than import maps is a tooling decision you can revisit far more cheaply.

Back to the science fair poster, but this time nobody glues anything.

Each group pins their own sheet to the board with their own pin. The dinosaur group can take their sheet down, fix the tail, and pin it back up whenever they want. Nobody else's sheet moves. That is the good part.

Here is the bad part. Everyone has to share one box of crayons. If the volcano group brings the new crayons and the planets group only knows how to use the old ones, someone's picture comes out wrong, and you only find out when people are already looking at the board.

Also, if one group forgets to pin their sheet back up, there is just a hole in the middle of the poster, and visitors see it.

Separate sheets, no glue, shared crayons, occasional holes.

Level 1: the network tab tells you immediately.

GET /assets/shell-9c1d.js       ← shell team's deploy
GET /cart/remoteEntry.js        ← cart team's deploy
GET /search/remoteEntry.js      ← search team's deploy

# three files from three separate deployments,
# assembled in the browser at load time.

Compare with build-time, which was one file from one deployment. That difference is the entire concept.

Level 2: the configuration, and the line that matters most.

// cart/vite.config.ts, the REMOTE (producer)
federation({
  name: 'cart',
  filename: 'remoteEntry.js',
  exposes: { './CartWidget': './src/CartWidget' },
  shared: { react: { singleton: true, requiredVersion: '^18.2.0' } }
})

// shell/vite.config.ts, the HOST (consumer)
federation({
  name: 'shell',
  remotes: { cart: 'https://cdn.opsvo.in/cart/remoteEntry.js' },
  shared: { react: { singleton: true } }
})

Consuming it looks almost normal, except the import target does not exist on your disk:

const CartWidget = React.lazy(() => import('cart/CartWidget'))
// resolved over HTTP at runtime, from someone else's deploy

singleton: true is the line that keeps you alive. Without it two copies of React load, hooks break in ways that make no sense, and context providers silently stop reaching their consumers.

Level 3: everything that will actually go wrong.

1. Version skew is a runtime failure your CI cannot see. The remote was built against React 18.3; the shell negotiated 18.2. Nothing fails at build time on either side, because they were never built together.

// what you see in production, with no useful stack trace:
Uncaught TypeError: Cannot read properties of null (reading 'useState')
// meaning: two React copies. hooks resolved against the wrong one.

2. A broken remote takes down the shell unless you defend every mount point:

<ErrorBoundary fallback={<CartUnavailable />}>
  <Suspense fallback={<Skeleton />}>
    <CartWidget />
  </Suspense>
</ErrorBoundary>

// without this, one 404 on remoteEntry.js = white screen.

3. Types do not cross the boundary for free. TypeScript cannot see into a URL, so you need generated declaration packages published by each remote, which reintroduces exactly the version coordination you were trying to escape.

4. Everything else gets harder: end-to-end tests need every remote deployed somewhere; CSS leaks between independently built bundles unless scoped; tracing a bug means correlating three deploy histories; and the design system becomes a shared dependency that drifts per remote.

The honest threshold

There is no magic team count. The real question is whether independent release governance is worth this much complexity to you, and for most organisations it is not. Before reaching for this, try feature flags on a build-time setup: they deliver release independence, which is what you actually wanted, at a fraction of the price.

Jargon used in this section

runtime micro-frontends
Separately deployed frontend bundles fetched and combined in the browser at load time. Many deploy units: distributed.
Module Federation
The webpack and Vite mechanism that implements this: remotes expose modules, hosts import them over HTTP.
remote
A separately deployed bundle that exposes components for others to load.
remoteEntry.js
The runtime container entry a remote publishes. Not a static manifest: it is executable JavaScript exposing a container with init and get, which the host calls to negotiate shared dependencies and pull modules.
singleton (shared dependency)
A configuration forcing exactly one copy of a library like React across all bundles. Omitting it breaks hooks.
version skew
Independently deployed parts holding incompatible versions of a shared dependency: a runtime-only failure.
error boundary
A React component that catches a rendering failure below it, so one broken remote does not blank the page.
Axis 3 · code inside a unit

Hexagonal, Onion, Clean

One family of ideas, published three times, with three vocabularies and three emphases.

Why

Why this pattern exists

Because business rules outlive frameworks. Your rule "orders over ₹50,000 need manual approval" will be true in ten years. Prisma, Fastify and Stripe will not necessarily still be your choices. When the rule is welded to the tools, three things go wrong: you cannot test the rule without booting a database, you cannot swap a vendor without rewriting logic, and you cannot read the rules without wading through query code.

What it does

The single rule underneath all three names

Learn this sentence

Infrastructure imports the domain. The domain never imports infrastructure.

That is the entire content. The domain declares interfaces describing what it needs; infrastructure provides classes implementing them. Dependencies point inward, toward the business logic.

They are not identical, and the differences are real if small. Hexagonal prescribes no internal layering at all; Onion adds rings; Clean additionally specifies how control crosses the boundary. Hexagonal (Cockburn, 2005) calls the interfaces ports and the implementations adapters, and draws driving adapters on one side and driven adapters on the other. Onion (Palermo, 2008) draws the same thing as concentric rings and adds an application-services ring. Clean (Martin, 2012) synthesises both and names the artifacts: entities, use cases, controllers, presenters.

On capitalisation

Hexagonal, Clean and Onion Architecture are capitalised throughout this page because they are proper names, coined in specific articles by specific people. Event-driven, layered, pipeline and distributed stay lowercase because they describe a shape rather than name someone's work. If you ever wonder whether a term is a coined pattern or a plain description, that is the test: can you name the article?

How

How to check you have done it

The distinctions above are worth knowing, but they are not what determines whether you have the benefit. This is:

$ grep -r "prisma\|stripe\|fastify" src/domain/

(no results)   ← you have it. whatever you call it.

Think about a lamp and a wall socket.

The lamp does not care where electricity comes from. It just knows the shape of the plug. Solar panel, coal plant, a battery in the shed: the lamp works the same, because everyone agrees on the shape of the socket.

Your business rules are the lamp. The database and the payment company are the power stations. The plug shape is the interface: you write down "I need something that can save an order" without saying who does it.

Bad code glues the lamp directly to the coal plant. Then you can never test the lamp without firing up a coal plant, and you can never switch to solar.

Hexagonal, Onion and Clean are three people who each drew a picture of "use a plug, not glue". Same advice. Different drawings.

Level 1: the before and after.

Before: business rules welded to tools:

// order.ts
import { prisma } from '../db'
import Stripe from 'stripe'

export async function placeOrder(userId, items) {
  const order = await prisma.order.create({ data: {...} })
  await new Stripe(KEY).charges.create({ amount: order.total })
}

To test "orders over ₹50,000 need approval" you must boot Postgres and mock the Stripe SDK. To switch to Razorpay you edit order logic.

After: the domain states what it needs:

// domain/ports.ts, the plug shapes
export interface OrderRepository { save(o: Order): Promise<void> }
export interface PaymentGateway  { charge(a: Money): Promise<Receipt> }

// domain/place-order.ts, imports NOTHING from prisma/stripe
export async function placeOrder(
  deps: { orders: OrderRepository, payments: PaymentGateway },
  cmd: PlaceOrderCommand
) { /* pure business rules */ }

// infrastructure/prisma-order-repository.ts
import { OrderRepository } from '../domain/ports'   ← arrow points INWARD
export class PrismaOrderRepository implements OrderRepository { ... }

Level 2: why the tests get dramatically better.

This is the payoff you feel daily. The business rule test needs no database, no network, no mocking library:

// a fake that is 4 lines, not a mock framework
class InMemoryOrders implements OrderRepository {
  saved: Order[] = []
  async save(o: Order) { this.saved.push(o) }
}

test('orders over 50k need approval', async () => {
  const orders = new InMemoryOrders()
  await placeOrder({ orders, payments: new FakePayments() }, bigOrder)
  expect(orders.saved[0].status).toBe('PENDING_APPROVAL')
})

// runs in 3ms. no docker. no test database. no flakiness.

Then wiring, which is where the real implementations get plugged in, in one place at startup:

// wiring/container.ts, the only file that knows about BOTH sides
export const deps = {
  orders:   new PrismaOrderRepository(prisma),
  payments: new RazorpayGateway(process.env.RAZORPAY_KEY)
}

Swapping Stripe for Razorpay is now a one-line change in one file, because no domain code ever mentioned either.

The vocabulary map, so you can read any of the three literatures:

ConceptHexagonalOnionClean
Business rulesthe coredomain modelentities
Orchestration(unnamed)application servicesuse cases / interactors
The interfaceport(interface)boundary / gateway
The implementationadapterinfrastructureinterface adapters
The rulepoint inwardpoint inwardthe Dependency Rule

Level 3: the mistakes that look like the pattern but are not.

Leaking the ORM through the port. The most common failure. This looks hexagonal and is not:

// ✗ the "domain" now depends on Prisma's generated types
import type { Order } from '@prisma/client'
export interface OrderRepository { save(o: Order): Promise<void> }

// ✓ the domain owns its own type; infrastructure maps to it
export class Order { constructor(readonly id: OrderId, ...) {} }
export interface OrderRepository { save(o: Order): Promise<void> }

If your grep for prisma in src/domain/ hits a type import, you have not done it.

Anemic domain. If your entities are bare data bags and every rule lives in a service, you have layered architecture with extra folders, not hexagonal. Rules belong on the objects that own the data.

Ports defined by the provider, not the need. A port named StripePort with methods mirroring Stripe's API is an adapter wearing an interface. The domain should declare PaymentGateway.charge(Money) because that is what the business needs: the shape comes from the inside, not the vendor.

Enforce it mechanically, or it decays within two sprints:

// .dependency-cruiser.js
{
  name: 'domain-stays-pure',
  severity: 'error',
  from: { path: '^src/domain' },
  to:   { path: 'node_modules|^src/infrastructure|^src/http' }
}

And the payoff that connects back to axis 2: a module whose domain imports nothing can be lifted into its own service by writing one new adapter. A module welded to the framework is a rewrite. Hexagonal architecture is what buys you the option to go distributed without forcing you to.

Jargon used in this section

port
An interface the domain declares, describing something it needs. Named for the need, not the vendor.
adapter
A class in infrastructure that implements a port using a real tool: Prisma, Stripe, S3.
dependency inversion
High-level code depends on an interface it owns; low-level code implements it. This is what makes the arrows point inward.
domain
The code expressing business rules, free of frameworks and storage concerns.
infrastructure
The code that talks to the outside world: databases, HTTP, queues, third-party APIs.
use case / interactor
Clean Architecture's name for one application operation, orchestrating entities through ports.
anemic domain model
Entities reduced to data with no behaviour, with all rules pushed into services. A common false positive.
composition root
The single startup file where real adapters are constructed and injected. The only place both sides meet.
Axis 3 · code inside a unit

Layered, multi-tier and GUI architectures

The oldest answer on axis 3, plus its frontend cousins.

Why

Why group code by technical role

Because it is the obvious first move and it works for a long time. Put the HTTP handling together, put the business logic together, put the database code together. Anyone joining the codebase knows where to look.

The reason hexagonal exists is that layered tends to leak: the business layer imports the persistence layer's entities, and now your domain knows about your ORM after all.

What it does

Layer versus tier: the distinction worth having

A layer is logical: a folder, a package, a namespace. A tier is a runtime deployment unit: something that can be deployed and scaled on its own, and that you reach across a process or network boundary. Classic three-tier is browser, application server, database. Note that tiers do not require separate machines: all three run on your laptop in development and it is still three-tier, because what makes them tiers is the boundary, not the hardware. Your backend might have four layers inside the middle tier.

Layered says "group by what kind of code this is". Hexagonal says "group by what the business does, and push technical concerns to the edge". Same axis, competing opinions.

How

GUI architectures are the same question for UI code

Every GUI pattern solves one problem: separating what is on screen from what is true.

  • MVC: controller takes input, updates the model, view renders it.
  • MVP: a presenter mediates everything and the view is passive and dumb, which makes it testable.
  • MVVM: the view binds declaratively to a view-model and the framework syncs them. WPF, Android, Vue.
  • Flux / Redux / MVU: strictly one direction. Action → reducer → new state → re-render. The React world.

Layers are like a sandwich. Bread, filling, bread. Each layer only touches the one below it. You do not reach through the bread to grab the filling from underneath.

Tiers are different: that is whether the sandwich is all on one plate, or the bread is on your plate and the filling is on a plate in the next room. Layers are about arrangement. Tiers are about how many plates.

Now the GUI part. Imagine a puppet show. The puppet is what people see. Somewhere backstage there is a list of what is really happening: who is angry, who has the treasure.

All those scary names (MVC, MVP, MVVM, Redux) are just different rules about who is allowed to move the puppet, and how the puppet finds out what changed backstage. That is honestly all they are.

Level 1: the shape you have probably already written.

src/
  controllers/    HTTP in, HTTP out
  services/       business logic
  repositories/   database access
  models/         data shapes

// the rule: each layer calls only downward.
controller → service → repository → database

And the three tiers your web app already is:

[ browser ]  ←HTTP→  [ app server ]  ←SQL→  [ database ]
   tier 1              tier 2                 tier 3

Layers live inside tier 2. Two different words, two different scales.

Level 2: the leak, and Redux as one concrete GUI pattern.

Here is how layered quietly turns into a mess:

// services/order-service.ts, the "business" layer
import type { Order } from '@prisma/client'   ← the leak

export async function approve(order: Order) { ... }

// business logic now depends on the database schema.
// rename a column → business layer changes. this is what
// hexagonal was invented to stop.

On the UI side, the unidirectional pattern is the one you will actually meet:

// 1. something happens → an ACTION describes it
dispatch({ type: 'cart/itemAdded', payload: { sku, qty } })

// 2. a REDUCER computes the new truth. pure function.
function cart(state, action) {
  if (action.type === 'cart/itemAdded')
    return { ...state, items: [...state.items, action.payload] }
  return state
}

// 3. the VIEW re-renders from the new truth
// data flows ONE way. the view never mutates state directly.

Notice that reducer is a pure business rule with no framework in it: the same instinct as hexagonal, arrived at from the UI side.

Level 3: choosing between the GUI patterns, and how tiers stack.

PatternWho updates the viewBest when
MVCView observes the modelServer-rendered apps; simple flows
MVPPresenter pushes to a passive viewYou need the view fully unit-testable
MVVMFramework data-binding syncs bothThe framework provides binding: WPF, Vue, Android
Flux / MVUState change re-renders everythingComplex shared state; you want time-travel debugging

The useful realisation: a full system runs several axis-3 answers at once, one per tier, and that is completely normal.

tier 1: browser
   Flux/MVU inside          (actions → reducers → render)

tier 2: application server
   Hexagonal inside         (ports, adapters, pure domain)
   + a BFF layer shaping responses for this specific UI

tier 3: database
   schema per module        (from the modular monolith rules)

One more thing worth knowing, because it explains a lot of real systems: the BFF (backend for frontend) is a thin tier-2 layer that exists purely to shape data for one client. Mobile and web get different BFFs over the same domain, so neither client has to make six round trips or receive fields it will not use.

Finally, the honest verdict on layered versus hexagonal: layered is easier to teach and fine for CRUD. The moment your business rules become interesting, the leak in the middle layer starts costing you, and that is your signal to invert the dependency.

Jargon used in this section

layer
A logical grouping of code by technical role: controllers, services, repositories. A folder.
tier
A runtime deployment unit you reach across a process or network boundary: browser, app server, database. Not necessarily a separate machine.
MVC / MVP / MVVM
GUI patterns differing in who updates the view and how it learns about state changes.
Flux / MVU / Redux
Strictly unidirectional UI flow: action → reducer → new state → re-render.
reducer
A pure function taking current state and an action and returning the next state.
BFF (backend for frontend)
A thin server layer shaping data for one specific client, so mobile and web can differ without changing the domain.
CRUD
Create, read, update, delete: applications that are mostly forms over a database, with few real rules.
Axis 3 · code inside a unit

Event-driven

Announce what happened. Let others decide whether they care.

Why

Why stop calling things directly

Because the caller accumulates dependencies. Placing an order needs to reserve stock, send a confirmation email, update analytics, notify the warehouse, award loyalty points. Call each one directly and the order module now imports five modules and must be edited every time a sixth thing wants to react.

Invert it: the order module announces that an order was placed, and anything that cares subscribes. Adding a sixth reaction touches zero existing code.

What it does

The trade you are making

You gain extensibility: adding a consumer touches no existing code. You lose the ability to read the flow, because no single file tells you everything that happens when an order is placed and you have to go find the subscribers.

Be precise about what you are actually buying, though. Publish-and-subscribe decouples who knows whom. It does not by itself decouple when things run or what happens when one fails. The in-process bus below awaits each handler in turn, so a slow subscriber still blocks the publisher and a throwing subscriber still surfaces to it. Buffering, retries and resilience to a slow consumer come from putting a queue or broker in the middle, which is a separate decision with its own cost.

Critical point

Event-driven is not the same as distributed. The bus can be an in-memory emitter inside your monolith, or it can be Kafka. Same pattern, wildly different operational cost. In-process domain events give you most of the decoupling benefit for almost none of the price.

How

How the coupling shapes differ

Compare three ways the same work gets triggered. Only the wiring changes:

DIRECT CALLS: producer knows every consumer Order module Inventory Email EVENT-DRIVEN: producer knows nobody Order module Event bus Inventory Email PIPELINE: each stage knows only the next Fetch Parse Enrich Store
None of these three is distributed as drawn. Every arrow could be an ordinary function call. Turn one arrow into a network hop and then you have a distributed system.

Two ways to tell people dinner is ready.

Direct calls: you walk to each person's room and tell them individually. You have to know exactly who lives in the house. When a new cousin moves in, you have to remember to add them to your walking route, or they never eat.

Event-driven: you ring a bell in the hallway and shout "DINNER". You do not know or care who is home. Anyone who wants dinner comes. A new cousin just needs to know that the bell means dinner: you change nothing.

The catch: after ringing the bell, you do not know if anyone heard you. With the walking route you at least saw each person's face. That uncertainty is the price of the bell.

Level 1: the two versions side by side.

// DIRECT: order module now depends on three modules
await inventory.reserve(items)
await email.sendConfirmation(order)
await analytics.track(order)

// EVENT-DRIVEN: order module depends on nothing
await bus.publish(new OrderPlaced(order))

And the listeners live wherever they belong:

// inventory/subscribers.ts
bus.on('OrderPlaced', e => reserve(e.items))

// email/subscribers.ts
bus.on('OrderPlaced', e => sendConfirmation(e.orderId))

Want loyalty points too? Add a new file. The order module is never opened.

Level 2: in-process events, which is where you should start.

You do not need Kafka to be event-driven. This is a complete, useful implementation:

// shared/bus.ts, the entire "infrastructure"
type Handler = (e: any) => Promise<void>
const handlers = new Map<string, Handler[]>()

export const bus = {
  on(type: string, h: Handler) {
    handlers.set(type, [...(handlers.get(type) ?? []), h])
  },
  async publish(e: { type: string }) {
    for (const h of handlers.get(e.type) ?? []) await h(e)
  }
}

Now the important design decision: should the event be part of the caller's transaction?

// SYNCHRONOUS in-process: subscribers run inside the same
// transaction. if email fails, the ORDER rolls back. usually wrong.

// better: commit first, then publish
await db.$transaction(tx => orders.save(tx, order))
await bus.publish(new OrderPlaced(order))   ← after commit

Event names matter more than people expect. Name them as facts in the past tense: OrderPlaced, PaymentCaptured. If you find yourself writing SendEmailCommand, that is a command, not an event, and it belongs as a direct call.

Level 3: what changes when the bus becomes a real broker.

Swap the in-memory bus for Kafka or SNS and you have crossed onto axis 2: you are now distributed, with every cost from that section. Three problems appear immediately.

1. The dual-write problem. This code is broken and it looks fine:

await db.orders.save(order)          ✓ committed
await kafka.publish(orderPlaced)    ✗ process dies here

// order exists. nobody was ever told. permanently inconsistent.

The fix is the transactional outbox: write the event into your own database in the same transaction, and let a separate process publish it.

await db.$transaction(async tx => {
  await tx.orders.create({ data: order })
  await tx.outbox.create({ data: { type: 'OrderPlaced', payload } })
})
// both commit or neither. a relay polls outbox → publishes → marks sent.

2. Delivery is at-least-once, so handlers must be idempotent. Your email subscriber will eventually receive the same event twice. Deduplicate on event ID.

3. Choreography drifts. With enough events reacting to events, no one can answer "what happens when an order is placed" without reading the whole system. The counterweight is orchestration: a single explicit workflow that calls steps in order and owns the compensations. Rule of thumb: choreography for notifications, orchestration for anything with money in it.

One more distinction worth carrying, because it decides your payload design:

StylePayloadTrade-off
Event notificationJust the IDTiny; consumers must call back for details
Event-carried stateFull snapshotNo callback needed; payloads grow and go stale
Event sourcingEvents are the databasePerfect audit and replay; genuinely hard, rarely needed

Jargon used in this section

event-driven
Components announce facts; interested parties subscribe. The producer does not know its consumers.
event bus / broker
The thing in the middle. In-memory inside one process, or Kafka/SNS across the network.
publish / subscribe
Publishing announces an event; subscribing registers interest in one.
transactional outbox
Writing events to your own database in the same transaction as the data, then relaying them, so a crash cannot lose them.
dual-write problem
Writing to a database and a broker separately, where a crash between the two leaves them permanently disagreeing.
at-least-once delivery
The broker guarantees an event arrives, possibly more than once, so handlers must be idempotent.
choreography vs orchestration
Choreography: everyone reacts independently. Orchestration: one workflow explicitly directs the steps.
event sourcing
Storing the sequence of events as the source of truth and deriving current state by replaying them.
Axis 3 · code inside a unit

Pipeline (pipes and filters)

Data flows one way through independent stages. You have already built one.

Why

Why arrange code as a sequence

Because some work genuinely is a sequence of transformations, and modelling it as anything else makes it worse. Reading a file, parsing it, enriching each record, storing the result: there is a natural order, each step is independent, and nothing needs to talk backwards.

When the shape fits, you get properties that are hard to buy any other way: each stage is testable alone, and stages can be reordered or inserted without touching neighbours.

Independent scaling per stage is often listed here too, but it is not a property of the shape. An in-process pipeline runs every stage in the same process at the same concurrency. You only get per-stage scaling once each stage sits behind its own queue with its own workers, which is the level 3 version below and which makes the pipeline distributed.

What it does

What defines it

Each stage, traditionally called a filter, receives data, transforms it, and passes it on. A stage knows the shape of the data and nothing about its neighbours. The connections between them are the pipes.

You already use pipelines constantly: cat log | grep error | sort | uniq -c, compilers (lex → parse → optimise → emit), ETL jobs, Fastify's hook chain, and any queue-based job flow that moves records through stages.

How

When to reach for it, and when not

Reach for it when the flow is one-directional and each stage is close to a pure transformation. Avoid it when stages need to negotiate back and forth, share mutable state, or when the flow branches heavily. At that point you are fighting the shape and should use events or a plain orchestrator instead.

Think of a car wash.

The car rolls in dirty. First station sprays water. Next station scrubs. Next rinses. Next blows it dry. Car rolls out clean.

Each station does one job and does not know or care what the other stations do. The scrubber does not know a dryer exists: it just scrubs and pushes the car forward.

Because of that, you can add a wax station in the middle without rebuilding the car wash. And if drying is the slow bit, you can put in two dryers.

But a car wash only works because cars go one way. If a car had to roll backwards to get re-scrubbed after drying, the whole design falls apart. That is when a pipeline is the wrong shape.

Level 1: the shape, in the shell and in code.

$ cat access.log | grep ERROR | cut -d' ' -f4 | sort | uniq -c

# four stages. none knows about the others.
# each reads text, writes text, and stops thinking.

The same thing in application code:

type Stage<A, B> = (input: A) => Promise<B>

const fetch:  Stage<Url, Raw>      = url => http.get(url)
const parse:  Stage<Raw, Record[]> = raw => xml.parse(raw)
const enrich: Stage<Record[], Record[]> = rs => rs.map(addMeta)
const store:  Stage<Record[], void> = rs => db.insertMany(rs)

// composed:
await store(await enrich(await parse(await fetch(url))))

Level 2: composition, and why streaming matters.

Nested calls get ugly fast, so pipelines are usually composed:

const pipeline = compose(fetch, parse, enrich, store)
await pipeline(url)

// adding a stage is now a one-line edit, no neighbours touched:
const pipeline = compose(fetch, parse, validate, enrich, store)

The failure that catches everyone: loading everything into memory between stages. With 34 million records this dies.

// ✗ each stage materialises the FULL dataset
const all = await parse(raw)      // 34M records in RAM

// ✓ stream: records flow through one at a time
await pipeline(
  fs.createReadStream('dump.xml'),
  parseStream(),
  enrichStream(),
  batchInsertStream(1000)
)
// constant memory, regardless of input size.
// backpressure is automatic: a slow writer slows the reader.

Level 3: durable pipelines and where stages become distributed.

An in-process pipeline dies with the process. For long jobs you make each stage a queue, which gives you retries, persistence and independent scaling per stage:

// each stage = its own queue and worker
new Worker('fetch',  async job => {
  const raw = await http.get(job.data.url)
  await parseQueue.add('parse', { raw })       hand off
})

new Worker('parse',  async job => { ... }, { concurrency: 4  })
new Worker('enrich', async job => { ... }, { concurrency: 20 })  ← the slow one
new Worker('store',  async job => { ... }, { concurrency: 2  })

Now you have independently tunable stages, and you have also quietly become distributed, so every rule from that section applies: stages must be idempotent, because a worker crash means the job is retried.

Two design points that separate a real pipeline from a fragile one:

  • Failure routing. One bad record must not stop 34 million good ones. Bad records go to a dead-letter queue for inspection; the pipeline continues.
  • Per-stage observability. Measure throughput and depth at each stage. The stage whose queue grows without bound is your bottleneck, and this is the only easy way to find it.

Finally, note how the patterns compose rather than compete. A realistic request in a well-built system is all of them at once:

An HTTP adapter (hexagonal) in the app tier (multi-tier)
invokes a use case that publishes OrderPlaced (event-driven),
which a worker picks up and runs through
fetch → validate → enrich → persist (pipeline).

Four axis-3 patterns, one request, no conflict. Choose per module by what that module's work actually looks like: transformation work wants a pipeline, fan-out work wants events, business-rule work wants ports and adapters.

Jargon used in this section

pipeline / pipes and filters
Data flows one way through independent stages, each transforming and passing along.
filter / stage
One step in a pipeline. Knows the data shape, knows nothing about its neighbours.
streaming
Passing records through the pipeline a few at a time instead of materialising the whole dataset, giving constant memory use.
backpressure
A slow stage automatically slowing the stages feeding it, instead of dropping work or exhausting memory.
dead-letter queue
Where records that repeatedly fail get parked, so one bad item cannot halt the whole run.
ETL
Extract, transform, load: the classic data pipeline shape.
Decide

Pick yours

Three questions. One sentence out the other end.

Every architecture choice on this page collapses into a single sentence with three slots: one repo layout, one deployment shape, one internal structure. Answer these and read yours below.

1. How many teams have genuinely separate release governance?

2. Can you name a forcing function for splitting a backend module out?

3. How many independent frontend teams ship to the same app?

Answer all three and your architecture sentence appears here.

The complete map

TermAxisWhat it actually isReach for it when
Monorepo1 · repoOne repository holding many projectsAlmost always: the cheap default
Polyrepo1 · repoOne repository per projectSeparately governed teams, or open source
Modular monolith2 · deployOne deployable process, enforced internal modulesDefault, until a forcing function appears
Distributed architecture2 · deployUmbrella: parts in separate processes over a networkOnly with a named forcing function
Build-time MFEs2 · deployFrontend packages bundled into one appSeveral frontend teams, one release train
Runtime MFEs2 · deploySeparately deployed bundles stitched in the browserIndependent release governance is worth the cost
Hexagonal3 · insidePorts and adapters; domain imports nothingRules are complex, vendors may change, or the system must last
Onion3 · insideSame rule, concentric rings, an application-services ringSame conditions; pick the vocabulary your team knows
Clean3 · insideSame rule, plus named use cases and presentersSame conditions; pick the vocabulary your team knows
Layered / multi-tier3 · insideGroup by technical role; layers are logical, tiers are deployedCRUD-ish work; the easy starting point
Event-driven3 · insideProducers announce, consumers subscribeFan-out work; many reactions to one fact
Pipeline3 · insideOne-way flow through independent stagesTransformation work; ETL, parsing, batch jobs
The default answer for most teams

Monorepo. One deployed service with enforced internal modules that own their data. Ports and adapters inside each module. Frontend composed at build time. Nothing distributed until something specific forces it.

Retain

Test yourself

Five questions targeting the exact places people get confused.

Reference

Jargon glossary

Every dotted-underlined term on this page, in one place.

Terms marked with a dotted underline anywhere above are defined here, and hovering or tapping one shows its definition on the spot. The underline comes back each time a term resurfaces later in the page, not just at its first mention. Repetition is the point.

Further reading

Where these ideas come from

Primary sources, grouped by the axis they belong to.

Almost every term on this page traces back to one specific article by one specific person. Reading the original is usually faster than reading the hundred blog posts that summarise it, and it tells you what the author actually claimed as opposed to what the internet decided they meant.

Axis 1: where code lives

Axis 2: what you deploy

Axis 3: how code is arranged inside

Books worth the time

If you only read one, read the first

  • Fundamentals of Software Architecture, Mark Richards and Neal Ford. The catalogue this page is a subset of: layered, pipeline, microkernel, service-based, event-driven, space-based, microservices, each with a characteristics rating. It is the book that makes "architectural style" a useful category rather than a vague one.
  • Designing Data-Intensive Applications, Martin Kleppmann. The definitive treatment of everything in the distributed section: replication, partitioning, consistency, and exactly why that timeout has no answer.
  • Building Microservices, Sam Newman. Second edition. Practical and notably honest about the costs, including a chapter on when not to.
  • Microservices Patterns, Chris Richardson. The book form of microservices.io, with worked Java examples for saga, outbox and API composition.
  • Micro Frontends in Action, Michael Geers. Covers every composition technique, not just Module Federation: iframes, web components, server-side includes, and build-time.