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.
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.
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.
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.
| Level | What it is | Examples on this page |
|---|---|---|
| Style | The overall shape of a system or of a unit inside it | modular monolith, microservices, runtime micro-frontends, layered, event-driven, pipeline, Hexagonal Architecture |
| Pattern | A reusable solution to a recurring problem within a style | ports and adapters, saga, transactional outbox, circuit breaker, BFF, reducer, dead-letter queue |
| Tool | A concrete product that implements a style or a pattern | Nx, 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.
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.
Monorepo vs polyrepo
The cheapest decision on the page. Answer it in ten seconds.
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 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 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.
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.
Modular monolith
One deployable thing. Many enforced compartments inside it.
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.
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 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.
Distributed architecture
Not a separate option: the umbrella name for the whole right side of axis 2.
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 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.
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.
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.
Build-time micro-frontends
Code ownership without deploy independence. Usually the right answer.
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 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 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.
Runtime micro-frontends
The style. Module Federation is one tool that implements it.
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 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 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.
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
initandget, 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.
Hexagonal, Onion, Clean
One family of ideas, published three times, with three vocabularies and three emphases.
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.
The single rule underneath all three names
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.
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 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.tsimport { prisma } from '../db'import Stripe from 'stripe'export async function placeOrder(userId, items) { const order = awaitprisma.order.create({ data: {...} }) awaitnew 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:
| Concept | Hexagonal | Onion | Clean |
|---|---|---|---|
| Business rules | the core | domain model | entities |
| Orchestration | (unnamed) | application services | use cases / interactors |
| The interface | port | (interface) | boundary / gateway |
| The implementation | adapter | infrastructure | interface adapters |
| The rule | point inward | point inward | the 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 typesimport 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.
Layered, multi-tier and GUI architectures
The oldest answer on axis 3, plus its frontend cousins.
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.
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.
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" layerimport 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.
| Pattern | Who updates the view | Best when |
|---|---|---|
| MVC | View observes the model | Server-rendered apps; simple flows |
| MVP | Presenter pushes to a passive view | You need the view fully unit-testable |
| MVVM | Framework data-binding syncs both | The framework provides binding: WPF, Vue, Android |
| Flux / MVU | State change re-renders everything | Complex 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.
Event-driven
Announce what happened. Let others decide whether they care.
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.
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.
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 the coupling shapes differ
Compare three ways the same work gets triggered. Only the wiring changes:
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 modulesawait 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) ✓ committedawait 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:
| Style | Payload | Trade-off |
|---|---|---|
| Event notification | Just the ID | Tiny; consumers must call back for details |
| Event-carried state | Full snapshot | No callback needed; payloads grow and go stale |
| Event sourcing | Events are the database | Perfect 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.
Pipeline (pipes and filters)
Data flows one way through independent stages. You have already built one.
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 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.
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 datasetconst 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.
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?
The complete map
| Term | Axis | What it actually is | Reach for it when |
|---|---|---|---|
| Monorepo | 1 · repo | One repository holding many projects | Almost always: the cheap default |
| Polyrepo | 1 · repo | One repository per project | Separately governed teams, or open source |
| Modular monolith | 2 · deploy | One deployable process, enforced internal modules | Default, until a forcing function appears |
| Distributed architecture | 2 · deploy | Umbrella: parts in separate processes over a network | Only with a named forcing function |
| Build-time MFEs | 2 · deploy | Frontend packages bundled into one app | Several frontend teams, one release train |
| Runtime MFEs | 2 · deploy | Separately deployed bundles stitched in the browser | Independent release governance is worth the cost |
| Hexagonal | 3 · inside | Ports and adapters; domain imports nothing | Rules are complex, vendors may change, or the system must last |
| Onion | 3 · inside | Same rule, concentric rings, an application-services ring | Same conditions; pick the vocabulary your team knows |
| Clean | 3 · inside | Same rule, plus named use cases and presenters | Same conditions; pick the vocabulary your team knows |
| Layered / multi-tier | 3 · inside | Group by technical role; layers are logical, tiers are deployed | CRUD-ish work; the easy starting point |
| Event-driven | 3 · inside | Producers announce, consumers subscribe | Fan-out work; many reactions to one fact |
| Pipeline | 3 · inside | One-way flow through independent stages | Transformation work; ETL, parsing, batch jobs |
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.
Test yourself
Five questions targeting the exact places people get confused.
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.
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.