---
title: "Architecture Patterns: The Six Shapes a System Can Take (and Why They Are Not a Menu)"
description: "Event-driven, layered, monolithic, microservices, MVC, primary-replica — teams compare these six as if they were alternatives, which is why the meeting never ends. They are not alternatives. They sit on three different levels: the deploy unit, the code organisation, and the data topology. Here is the sort, proved on a real 246-line FastAPI service where four of the six are true at the same moment, with the price of each level measured rather than argued."
keywords: "architecture patterns, layered architecture, MVC, event driven architecture, monolith vs microservices, primary replica, software architecture, deploy unit, data topology, FastAPI architecture, clean architecture, system design, fat controller"
created_at: "2026-08-21T09:30:00"
post_type: "anonym_post"
content_type: "technical_article"
---

![Banner](./banner.webp)

<div style="display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; padding: 1rem 1.25rem; margin: 1.5rem 0 2rem; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border: 1px solid #e2e8f0; border-radius: 12px;">
  <span style="font-size: 0.95rem; font-weight: 600; color: #475569; margin-right: 0.25rem;">Prefer to watch?</span>
  <a href="https://youtu.be/TNzcHkoRnCA" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #ff0000; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">▶ The full 16-minute episode</a>
  <a href="https://youtube.com/shorts/j48x2rKPlx8" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #0f172a; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">⚡ The 2-minute version</a>
  <a href="https://t.me/SoftwareEngineerBlog" target="_blank" rel="noopener noreferrer" style="display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.55rem 1rem; border-radius: 8px; background: #229ed9; color: #ffffff; font-size: 0.875rem; font-weight: 600; text-decoration: none;">✈ Telegram</a>
</div>

There is a meeting that cannot end. Someone says *"we should go event-driven."* Someone else says *"no, layered is fine."* A third person says *"honestly this should just be microservices."* Everyone is annoyed, nobody is wrong, and the whiteboard slowly fills with six words:

**event-driven · layered · monolithic · microservices · MVC · primary-replica**

The reason that meeting cannot end is that it is being run as a vote between six options. It is not a vote. Those six words are **not alternatives**, and the question *"which architecture pattern should we use?"* has no answer — the same way *"should I drive or should I go fast?"* has no answer.

The one-line mental model:

- The six names sit on **three different levels** of the system.
- A real system answers **all three at once**.
- So the useful question is never *which pattern* — it is *which level am I deciding on right now*.

---

## The three levels

Sort the six words and the argument dissolves:

<table>
  <thead>
    <tr>
      <th align="left">Level</th>
      <th align="left">The question it answers</th>
      <th align="left">The names that live here</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Deploy unit</strong></td>
      <td>How many independently deployable processes is this?</td>
      <td>monolithic, microservices</td>
    </tr>
    <tr>
      <td><strong>Code organisation</strong></td>
      <td>How is the code inside one unit arranged, and who may call whom?</td>
      <td>layered, MVC, event-driven</td>
    </tr>
    <tr>
      <td><strong>Data topology</strong></td>
      <td>How many copies of the data are there, and who may write?</td>
      <td>primary-replica</td>
    </tr>
  </tbody>
</table>

Those are three genuinely independent axes. You can be a monolith with an event-driven interior. You can be microservices whose services are each internally layered. You can move any one of them without touching the other two. That independence is the whole point — and it is why comparing "layered vs microservices" is a category error, not a trade-off.

---

## The proof: four of six, on one small program

Arguments about architecture are cheap, so let's not have one. Take a single small service — a bookshop API written in FastAPI: **12 files, 246 lines, one `uvicorn` process, one socket on `127.0.0.1`.** A browser talks to routes, routes call a service layer, the service calls a repository, the repository talks to SQLite.

Now hold each of the six names up against that one program:

<table>
  <thead>
    <tr>
      <th align="left">Name</th>
      <th align="left">True of this program?</th>
      <th align="left">Why</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>monolithic</td>
      <td><strong>Yes</strong></td>
      <td>One deployable unit, one process, one socket.</td>
    </tr>
    <tr>
      <td>layered</td>
      <td><strong>Yes</strong></td>
      <td>routes → services → repo → data, and imports only ever point downward.</td>
    </tr>
    <tr>
      <td>MVC</td>
      <td><strong>Yes</strong></td>
      <td>The three roles are all present and separated.</td>
    </tr>
    <tr>
      <td>event-driven</td>
      <td><strong>Yes</strong></td>
      <td>Checkout publishes an event; two subscribers react without being called.</td>
    </tr>
    <tr>
      <td>microservices</td>
      <td>No</td>
      <td>There is exactly one deploy unit. This is the axis it answers <em>differently</em>.</td>
    </tr>
    <tr>
      <td>primary-replica</td>
      <td>Not decided</td>
      <td>One SQLite file. The data-topology question simply has not been asked yet.</td>
    </tr>
  </tbody>
</table>

**Four of the six names are simultaneously true of the same 246 lines.** That is the fact that kills the menu framing. If they were alternatives, a program could only be one of them.

One honesty note that matters: the folders in this app are literally `routes/`, `services/`, `repo/` — not `model/`, `view/`, `controller/`. The MVC label reads the **roles**, not the filenames. A pattern that only applies when you name your directories after it is not a pattern, it is a naming convention.

---

## Level one: the deploy unit, and what the boundary costs

Moving a call across a deploy boundary is the most expensive edit in this entire article, and it is usually the one made most casually.

Take the exact same service call and run it two ways on the same machine: in-process, and over loopback HTTP.

<table>
  <thead>
    <tr>
      <th align="left">The same call</th>
      <th align="left">Cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>In-process function call</td>
      <td><strong>0.11 ms</strong></td>
    </tr>
    <tr>
      <td>Over loopback HTTP</td>
      <td><strong>1.67 ms</strong></td>
    </tr>
    <tr>
      <td>The boundary itself</td>
      <td><strong>+1.55 ms ≈ 15×</strong></td>
    </tr>
  </tbody>
</table>

Fifteen times, for changing nothing but *where the code lives*. And that is the **friendly** number: this is loopback on one six-core laptop, with no network, no TLS, no load balancer, no other tenant. A real network hop is worse.

Two things worth being precise about, because sloppy versions of this measurement circulate widely:

- Of that 0.11 ms in-process call, about **98 µs is SQLite open/close** — real work, not overhead. The pure business rule with no I/O at all runs in **0.33 µs**. So the honest same-work comparison is 15×, not the ~1500× you get by comparing a bare function against a full HTTP round trip.
- A **fresh TCP connection costs only +93.5 µs (1.06×)**, not the 20–300× a naive first run suggests. That large early number turned out to be `httpx` *client construction*, not networking. Measure the thing you mean.

None of this says "don't split." It says the split buys you independent deployment and independent scaling, and it charges you a millisecond and a distributed system. Pay it deliberately. (The full monolith-versus-microservices trade-off deserves its own treatment — here the only claim is which *level* that decision lives on.)

---

## Level two: code organisation

This is the level where most day-to-day design decisions actually happen, and it holds three of the six names.

### Layered — and the fact that it is checkable

"Layered" sounds like a diagram, but it is really a single enforceable rule: **imports point one way only.** Routes may import services; services may import the repository; nothing ever imports upward.

That rule is a *property of the source*, not a philosophy, which means a program can verify it. An **80-line import-direction checker** walks the app, builds the edge list, and fails on any upward edge. On the clean app it reports **11 intra-app edges, 0 upward — PASS**. Add a single upward import and it exits non-zero and prints the exact offending line.

This is the most under-used idea in architecture: if your layering is real, you can put it in CI, and it stops being a thing people erode one pull request at a time.

### MVC — and the fat-controller bill

MVC separates three roles: the model (data and rules), the view (presentation), the controller (request handling). The failure mode has a name — the **fat controller**, where business logic creeps into the route handler because that is where the request already is.

The bill, measured:

- The fat route is **21 lines instead of 5**.
- A unit test of the pricing rule exercises **17 lines of the service and 0 lines of the fat route**.
- Testing that rule through the service takes **0.11 ms**; testing it through HTTP takes **1.41 ms** — **12×**, and at 100 cases it stretches to **53×**.

The logic in a fat controller is not *untested by accident*. It is **unreachable** by anything cheaper than a full HTTP request. That is the cost, and it compounds every time the suite runs.

### Event-driven — which does not mean Kafka

The single biggest misconception about event-driven design is that it requires a broker. It does not. In this app, checkout publishes an event and two subscribers handle it — and the measurement says the publisher and **both subscribers run on the same process id, with zero new network connections and zero brokers.**

Event-driven is a **code-organisation** decision. Whether the bus is an in-process list of callbacks or a Kafka cluster is a *deploy-unit* decision. Conflating those two is exactly the confusion this whole article is about.

And it has a real price, which shows up in the traceback. When the handler raises:

<table>
  <thead>
    <tr>
      <th align="left"></th>
      <th align="left">Direct call</th>
      <th align="left">Through the bus</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Traceback depth</td>
      <td>4 frames</td>
      <td>3 frames</td>
    </tr>
    <tr>
      <td>Contains the request handler?</td>
      <td><strong>Yes</strong></td>
      <td><strong>No</strong></td>
    </tr>
    <tr>
      <td>What the caller saw</td>
      <td>HTTP 500</td>
      <td>HTTP 200, already returned in 3.32 ms</td>
    </tr>
  </tbody>
</table>

That is the trade in one table. You bought decoupling; you paid with a stack trace that no longer contains the thing that caused the failure, and a client that was told everything went fine. This is why event-driven systems live or die on their observability, not on their broker.

(For completeness: the bus itself hands the event over in **0.133 ms**. A larger figure you might see quoted for "the event path" is the subscriber's own SQLite commit — that is the subscriber's work, not bus overhead.)

---

## Level three: the data topology

**Primary-replica** answers a question the other five never touch: how many copies of the data exist, and which of them may accept writes. One primary takes writes; replicas take reads and follow along.

The point here is not how replication works — it is **which level the decision belongs to**. You can add replicas to a monolith. You can run microservices against a single database. The data topology is a genuinely separate dial, and treating it as an alternative to "layered" or "MVC" is what produced the six-item whiteboard in the first place.

---

## The reframe: the same three levels in an LLM system

This sort is not a legacy-backend concern. Point it at an AI serving stack and the three levels are immediately recognisable — which is useful, because "what is the architecture of our LLM app?" is currently the same unanswerable meeting.

<table>
  <thead>
    <tr>
      <th align="left">Level</th>
      <th align="left">The LLM-system decision</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Deploy unit</strong></td>
      <td>Is the model in-process, behind your own vLLM/TGI server, or a third-party API? Each step out is another boundary — and the 1.55 ms you measured on loopback becomes tens or hundreds of milliseconds against a hosted endpoint.</td>
    </tr>
    <tr>
      <td><strong>Code organisation</strong></td>
      <td>Is retrieval → prompt build → inference → post-process a <em>layered</em> pipeline with one-way dependencies? Are your evals, logging and cost tracking wired as <em>event</em> subscribers rather than stuffed into the request handler — the RAG equivalent of a fat controller?</td>
    </tr>
    <tr>
      <td><strong>Data topology</strong></td>
      <td>How many copies of the vector index exist, which one accepts writes during re-embedding, and do readers see the old index or the new one?</td>
    </tr>
  </tbody>
</table>

An agent framework that "does everything" is usually a fat controller with a nicer name: tool selection, retrieval, prompt assembly and business rules all in the handler, reachable only by running the whole thing. The fix is the same fix as 2005 — push the rules into a layer a unit test can call in 0.11 ms.

---

## The verdict

**Stop asking which pattern. Ask which level you are deciding on.**

When the next meeting opens with *"should we be event-driven?"*, the productive move is a question, not an opinion: *are we talking about how the code inside this service is organised, or about whether this becomes a second deploy unit?* Those are different decisions, with different costs, made by different people, at different times. Half the architecture arguments in the industry are two people confidently answering two different questions.

The three things worth taking away:

1. **The six names are not a menu** — four of them were true of one 246-line program at once.
2. **Each level has a measurable price**, so you can stop arguing and go measure: 15× for a deploy boundary, 12× on test cost for a fat controller, a stack trace that loses the culprit for an in-process bus.
3. **Layering is checkable in CI.** Eighty lines of import-direction checker is the cheapest architecture enforcement you will ever write.

Everything quoted here was executed on a real running service before it was written down, which is why a couple of the "obvious" numbers ended up smaller than the internet's version of them.

---

**Watch the reel:** the [2-minute version](https://youtube.com/shorts/j48x2rKPlx8) frames the problem, and the [full 16-minute episode](https://youtu.be/TNzcHkoRnCA) builds the bookshop service and runs every measurement on screen.
