← back to blog
·90 min read·by Super Admin

Software Architecture Has No Rigor, and AI Feeds on It

Every serious engineering field can verify a design before it is built. Software architecture cannot, and an AI generating against an unverifiable spec will hallucinate, duplicate, and reorder with impunity. Here is the method I use at Guliel: model software as what it is made of (data, transformations, and a place to run) in a diagram with rules you can check.

Every other engineering field can check its work

Design a circuit and you can interrogate it. Kirchhoff's laws either hold or they don't; the design is a formal object, and "correct" is computed before a single component is soldered. Structural engineering has its load calculations. Network design has its capacity and routing proofs. Each of those fields can tell you a design is wrong on paper, by asking the design itself.

Software architecture has nothing of the kind.

I spent years feeling this before I could name it. Every time I designed with boxes and arrows (UML, an architecture diagram, a PRD full of rectangles) something felt off. My reasoning about the system never fit on the diagram: this composes with that, this would be redundant, this boundary is going to leak. The diagram could record a conclusion. It could not record the deduction that produced it, so it could never tell me a conclusion was wrong.

So I went looking. I read the academic software-architecture literature hoping for something rigorous and found dozens of definitions of "architecture," "component," and "connector," each slightly different, most unusable, and across all of them no deduction rules: nothing that lets you start from a design and derive a property. You draw, you ship, and you discover what should have been refactored once the code exists and the mistake has teeth.

Then I started modelling systems as categories, and the missing thing appeared.

A category, in the plainest words I can manage, is a collection of things plus arrows between them, with three rules. Arrows chain: an arrow from A to B followed by an arrow from B to C gives you an arrow from A to C. Grouping does not matter: A to B to C to D is the same arrow whether you join the first pair or the last pair first. And every thing has a do-nothing arrow to itself, which changes nothing when you chain it on. That is the whole definition. A field is an arrow (invoice.customer takes an Invoice to a Customer), and if you have ever written invoice.customer.organization you have chained two of them. Mathematicians call an arrow a morphism and chaining composition; I will mostly say arrow and chain.

A design adds one thing on top of those rules: it names the pairs of routes that must agree. Each pair starts at the same thing and ends at the same thing, and declaring them equal is a claim you can test. When a declared pair agrees, that part of the diagram commutes. When it does not, some arrow in the design is lying, and the diagram tells you which one.

graph LR
    subgraph Sketch["Boxes and arrows: nothing to check"]
        A1["Invoice"] --> B1["Customer"]
        B1 --> C1["Organization"]
        A1 --> C1
    end
    subgraph Cat["Category: the two routes must agree"]
        A2["Invoice"] -->|"customer"| B2["Customer"]
        B2 -->|"organization"| C2["Organization"]
        A2 -->|"counterparty"| C2
        Q["Check: is counterparty equal to<br/>customer, then organization,<br/>for every invoice?"]
    end
    style A1 fill:#9a9a9a,color:#fff
    style B1 fill:#9a9a9a,color:#fff
    style C1 fill:#9a9a9a,color:#fff
    style A2 fill:#4f8cf7,color:#fff
    style B2 fill:#4f8cf7,color:#fff
    style C2 fill:#f77f7f,color:#fff
    style Q fill:#f7c04f,color:#000

In the boxes-and-arrows sketch, the arrow straight from Invoice to Organization means whatever the person who drew it meant. In the category it is a claim: following customer and then organization must land on the same organization as counterparty, for every invoice. You can check that claim against the data, and against the code that writes it. An architecture stops being a sketch you defend in a meeting and becomes an object you can be wrong about, on paper, before the code exists.

There is also a reason this stopped being a craftsman's preference and became urgent. We now hand these specs to AI. An unverifiable spec was a slow tax when humans wrote all the code. Handed to a machine that generates against it at scale, it is something else, and I will come back to it, because it is the sharpest argument for everything here.

How I design, from the outside in

The workflow first, because the parts only make sense in the order you meet them. I start from the human and the database comes last, which is the reverse of how most of us were taught.

  1. Requirements. What does the user need to accomplish?

  2. Interaction surface. How will they touch it: a screen, a phone, an API call, an email? This is a physical decision and it comes early, because it constrains everything after it.

  3. Result data. Given that interaction, what data structure makes it feel effortless? This is the shape the screen wants, designed before, and independently of, how that data gets produced.

  4. Source data and transformations. What do I need to store, and what algorithms turn the stored shape into the result shape?

  5. Locations. Where does each of those things physically live and run: which server, which thread, the browser, the database?

  6. Components. Finally, bundle all of that into modules a team can own.

flowchart TD
    R["1. Requirements<br/>what the user needs to do"] --> S["2. Interaction surface<br/>screen, phone, API call, email"]
    S --> D["3. Result data<br/>the shape the surface wants"]
    D --> T["4. Source data + transformations<br/>what to store, what computes the result"]
    T --> L["5. Locations<br/>browser, server, database"]
    L --> C["6. Components<br/>bundles a team owns"]
    style R fill:#f7c04f,color:#000
    style S fill:#f77f7f,color:#fff
    style D fill:#4f8cf7,color:#fff
    style T fill:#7fc47f,color:#000
    style L fill:#f77f7f,color:#fff
    style C fill:#cf7fcf,color:#fff

Step 3 comes before step 4 on purpose. The interaction pulls the result shape out of the surface; the stored data and the code are pushed to produce it. A list screen wants the counterparty's name on every row, so the result shape has it there, and only then do you ask what to store and what join produces it. Designing the database first and hoping a nice screen falls out is how you get screens that fight their own data.

That workflow kept producing good systems, so eventually I asked what it was made of. Strip the process away and there are only four things underneath.

The four atoms

A running program does two things. It holds and transforms data, and it moves that data between physical places. That gives four atomic parts, in two pairs, and I treat each one as a category: a collection of things with arrows between them.

The logical pair is what the software means:

  • Data (Dat). Its things are the data types; its arrows are the stored relations between them, a field or a foreign key. invoice.customer is an arrow from Invoice to Customer.

  • Transformations (Trn). Its things are the algorithms that rewrite data: validate, total, render. Pure meaning, with no notion of where.

The physical pair is what the software occupies:

  • Locations (Loc). The physical sites where code runs and data rests: a thread, a core, a region of RAM, a disk, a network card, the browser.

  • Transmissions (Trm). Moving one piece of data across a boundary, from one location to another: an HTTP response, a queue message, a row written to disk.

The logical pair is the software in a vacuum. The physical pair drags it into the world, and software in a vacuum shows nothing, not even a screen. An interface exists only because data is transmitted to a physical boundary a human can see or touch. That is half of what an architecture is, and it is the half that boxes-and-arrows diagrams leave out.

graph LR
    subgraph Logical["Logical: what it means"]
        Data["Dat: data"]
        Transformations["Trn: transformations"]
    end
    subgraph Physical["Physical: what it occupies"]
        Locations["Loc: locations"]
        Transmissions["Trm: transmissions"]
    end
    Transformations -->|"reads"| Data
    Transformations -->|"writes"| Data
    Transmissions -->|"from"| Locations
    Transmissions -->|"to"| Locations
    Transmissions -.->|"carries"| Data

    style Data fill:#4f8cf7,color:#fff
    style Transformations fill:#7fc47f,color:#000
    style Locations fill:#f77f7f,color:#fff
    style Transmissions fill:#7fc4c4,color:#000

The categorical machinery

Those four names, plus the two that complete the method (placement and component), are each a category, and the links between them are arrows too, so every one of them can be chained and checked. Pinning that down is what lets the rest of this article say "arrow," "commutes," or "this reduces to that" and mean something you can check.

You do not need the formal version to follow the rest of the post. The collapsible holds it: each definition with a software example, the four atoms as categories, placement, how components join, the six coherence laws by number and name, and the worked reduction. Read it now, or skip it and come back when a term bites.

Deep dive: the categorical machinery

The main post uses a handful of loose words: arrow, chain, commutes, translation, same shape, optional arrow. Each has an exact counterpart in category theory, and the exact version is worth a page because it turns "good architecture" from taste into something you can check. Every style in the gallery below reads in the notation fixed here.

The definitions, each with a software example

A category is a collection of things plus arrows between them, with three rules. Arrows chain: an arrow A → B followed by an arrow B → C gives an arrow A → C. Chaining is associative: chain three arrows and it does not matter which pair you chain first. And every thing has a do-nothing arrow to itself that changes nothing when chained. That is the entire definition. Mathematicians call the things objects and the arrows morphisms. A database schema is a category: tables are the things, foreign keys are the arrows, and following two foreign keys in a row is chaining.

graph LR
    Inv["Invoice"] -->|"customer"| Cus["Customer"]
    Cus -->|"organization"| Org["Organization"]
    Inv -->|"counterparty"| Org
    Inv -.->|"id (the do-nothing arrow)"| Inv
    style Inv fill:#4f8cf7,color:#fff
    style Cus fill:#4f8cf7,color:#fff
    style Org fill:#f77f7f,color:#fff

In that diagram there are two routes from Invoice to Organization. The definition does not make them agree. The design declares that they do, for every invoice, and you can check that claim against the data, which a sketch of boxes never lets you do.

Term

Plain meaning

Software example

How it is written

Arrow (formal: morphism)

a relationship from one thing to another

the field invoice.customer

customer : Invoice → Customer, read "customer takes an Invoice to a Customer"

Chain (formal: composition)

follow one arrow, then the next

invoice.customer.organization

organization ∘ customer, read right to left: "customer, then organization". The style files write a chain as a path, Invoice → Customer → Organization

Commutes

two routes the design declares equal really do give the same result

invoice.counterparty equals invoice.customer.organization for every invoice

organization ∘ customer = counterparty

Translation (formal: functor)

a map from one diagram to another that sends things to things and arrows to arrows and keeps every chain intact

mapping the Expense model onto the Invoice model, field by field

F : ExpenseModel → InvoiceModel

Same shape (formal: isomorphic)

an arrow each way between two things, where both round trips change nothing

Customer and Supplier with identical columns, each customer row converting to a supplier row and back unchanged, and each supplier row likewise

Customer ≅ Supplier

Optional arrow (formal: partial morphism)

an arrow defined for only some items

a nullable column category

category? : Document → Category

Log of X (formal: free monoid)

an append-only list of X

an events table ordered by time

Event*

Natural transformation

the same wrapper applied at every thing, with every square commuting

auth or logging middleware applied uniformly to every route

used in the gallery only for cross-cutting concerns

The four atoms are four categories

A running system holds and transforms data, and moves it between places. Each of those four jobs is a category.

Data (Dat). The things are the data types: entities such as Invoice, plus primitives (numbers, strings, booleans, dates). The arrows are the stored relations between them: a field, a foreign key. A row with several columns is a tuple of its column types; a tagged union is an either; a history is a log (Invoice* is the append-only list of invoices).

Transformations (Trn). A transformation is an algorithm: validate, total, render. It looks like an arrow (a function from one type to another), and it is treated as a thing in its own category anyway, because the next step places it somewhere, and you place things, not arrows. Each transformation carries two arrows into Dat saying what it reads and what it writes:

Name

From → To

What it means

t_from

Trn → Dat

the input type the transformation consumes

t_to

Trn → Dat

the output type it produces

Algorithms still chain (the output type of one is the input type of the next), so nothing is lost by making them things. A transformation that writes somewhere besides its output (appends to a log, sends an email) has a side effect, and the style files say so in words: "side effect: appends to the log."

Locations (Loc). The things are the physical sites where code runs and data rests: a thread, a core, a RAM region, a disk, a network card, and named composites of those (Browser, RouteHandler, Postgres). The arrows are adjacency: "can hand off directly to."

Transmissions (Trm). A transmission is one piece of data crossing a location boundary: an HTTP response, a queue message, a disk write. Like a transformation it is a thing, with three arrows out of it:

Name

From → To

What it means

c_from

Trm → Loc

the location the data leaves

c_to

Trm → Loc

the location the data arrives at

carries

Trm → Dat

the kind of data on the wire; there is no untyped transmission

Dat and Trn are the logical pair, what the software means. Loc and Trm are the physical pair, what it occupies. An architecture is the logical pair applied onto the physical pair, and the next section says exactly what "applied" means.

Placement is a relation

When I first formalised the application I wrote "every transformation has a location": a function runsAt : Trn → Loc, one location per transformation. That is false. validate runs in the Browser and on the RouteHandler. A render runs server-side and client-side. One transformation, many locations, and sometimes none (a transformation nobody has deployed).

So "where does T run" is a relation, and the fix is to make each pairing its own record, a placement, with arrows out to the things it relates:

Placement

Its arrows

What it means

TrnLoc (a code placement)

tl_trn : TrnLoc → Trn, tl_loc : TrnLoc → Loc, tl_cmp : TrnLoc → Cmp

this transformation, deployed at this location, inside this component

DataLoc (a data placement)

dl_data : DataLoc → Dat, dl_loc : DataLoc → Loc, dl_cmp : DataLoc → Cmp

this kind of data, stored at this location, inside this component

TrmCmp (a transmission in use)

ts_trm : TrmCmp → Trm, ts_cmp : TrmCmp → Cmp

this transmission, used by this component

Each placement is a record with several arrows out of it (the formal word is a span: a tip with legs down to the things it relates). Because a placement is its own thing, one transformation can be the target of many TrnLoc records, one per place it runs.

graph TB
    TrnLoc["TrnLoc<br/>(a code placement)"]
    DataLoc["DataLoc<br/>(a data placement)"]
    Trn["Trn"]
    Dat["Dat"]
    Loc["Loc"]
    Cmp["Component"]

    TrnLoc -->|"tl_trn"| Trn
    TrnLoc -->|"tl_loc"| Loc
    TrnLoc -->|"tl_cmp"| Cmp
    DataLoc -->|"dl_data"| Dat
    DataLoc -->|"dl_loc"| Loc
    DataLoc -->|"dl_cmp"| Cmp

    style TrnLoc fill:#f7c04f,color:#000
    style DataLoc fill:#f7c04f,color:#000
    style Trn fill:#7fc47f,color:#000
    style Dat fill:#4f8cf7,color:#fff
    style Loc fill:#f77f7f,color:#fff
    style Cmp fill:#cf7fcf,color:#fff

"Where does T run" is then the set of placements whose tl_trn is T, read off through tl_loc. (Mathematicians call that set the fibre over T.) It may be empty, one location, or many. runsAt was never a function.

Components join

A component (Cmp) is a bundle of placements that share one component value: the thing a team owns. What a component owns is deduced from its placements, so nothing is stored twice. data(c) is the set of data placements with dl_cmp = c, behaviour(c) the code placements with tl_cmp = c, and locs(c) the locations those placements occupy.

Components join. Two of them glue along a shared transmission: a port of one and a port of the other name the same Trm (a port is a transmission a component exposes at its edge). In the joined component that transmission becomes internal.

graph LR
    A["WebAppCmp<br/>runs: validate, render<br/>at: Browser"]
    B["ApiCmp<br/>runs: validate, total<br/>stores: documents<br/>at: RouteHandler, Postgres"]
    AB["InvoicingCmp (joined)<br/>data = data(A) + data(B)<br/>behaviour = behaviour(A) + behaviour(B)<br/>locs = locs(A) + locs(B)<br/>t_submit is now internal"]
    A -->|"t_submit : FormPayload<br/>(a port of each side)"| B
    A -.->|"glue"| AB
    B -.->|"glue"| AB
    style A fill:#cf7fcf,color:#fff
    style B fill:#cf7fcf,color:#fff
    style AB fill:#cf7fcf,color:#fff

Gluing is associative and has an identity (the empty pass-through component), so components themselves form a category: the things are interfaces (sets of ports), the arrows are components, and chaining is port matching. A joined service's data, behaviour and locations are computed from its parts, which is what "a system is made of services" means once it is precise.

The six coherence laws

Because every relationship is an arrow that must respect chaining, "the architecture is well-formed" becomes six checkable statements. A failed law names the missing piece, which is the whole reason to bother.

  1. Placement honesty. Code may only read data that is stored where it runs or delivered there by a transmission. Formally: for every code placement of T at L, the input type t_from(T) has a data placement at L, or there is a transmission with carries = t_from(T) and c_to = L. If the server-side validate reads a form payload and no transmission carries that payload to RouteHandler, Law 1 fails and names the missing wire.

  2. Typed transmissions. Every transmission carries a named kind of data and really crosses a boundary (start ≠ end). Formally: c_from, c_to and carries are all defined, c_from ≠ c_to, and the carried type has a data placement at both ends. Data flow inside one location is a transformation, never a transmission.

  3. Complete placements. Every placement names its code or data, its location and its component. A transformation with no location, or a placement with no component, is not a placement.

  4. Mediated dependencies. When a component depends on one at another location, a transmission connects them; nothing reaches across a boundary directly. The browser component never reads Postgres; it depends on the API component through an HTTP transmission, and that transmission is in the diagram.

  5. Sound composition. A joined component's data, code and locations are exactly the union of its parts', and each glued port becomes an internal transmission of the joined component. A composite's views are deduced from its parts, never redescribed.

  6. Many places. A transformation can run at zero, one or many locations; a single "runs at" column is wrong. Encode placement as TrnLoc rows.

Checking Law 1 on one placement is a short procedure:

flowchart TD
    S["1. Take one code placement:<br/>validate at RouteHandler"] --> I["2. Read its input type:<br/>t_from(validate) = FormPayload"]
    I --> Q1["3. Is there a data placement of<br/>FormPayload at RouteHandler?"]
    Q1 -->|"yes"| OK["Law 1 holds for this placement"]
    Q1 -->|"no"| Q2["4. Is there a transmission with<br/>carries = FormPayload and c_to = RouteHandler?"]
    Q2 -->|"yes"| OK
    Q2 -->|"no"| Fail["Law 1 fails: a data placement<br/>or a transmission is missing"]
    style S fill:#f7c04f,color:#000
    style I fill:#4f8cf7,color:#fff
    style Q1 fill:#f7c04f,color:#000
    style Q2 fill:#f7c04f,color:#000
    style OK fill:#7fc47f,color:#000
    style Fail fill:#f77f7f,color:#fff

A design that satisfies all six has no teleported data, no untyped wire, no module that straddles locations without saying so, and an honest answer to "where does this run."

A worked reduction, step by step

This is the move the method exists for, in the formal language. The article runs it on Invoice and Expense; the five steps never change, and they apply to any two things you suspect are one.

Step 1. Write the naive model down, rigorously. Start where intuition starts, with Invoice and Expense as two things in Dat, and write every arrow out of each, with its target:

Invoice arrow

target

Expense arrow

target

issuer

Organization

payer

Organization

customer

Organization

supplier

Organization

lines

LineItem* (a log of LineItem)

lines

LineItem*

issuedAt

Date

incurredAt

Date

category

Category

total is left out of the table on purpose. It is computed from lines, so it lives in Trn with the other transformations, and it comes back in Step 4. The same goes for categorize: it is the transformation that fills the stored category arrow.

The instant you write the targets, something the noun never showed surfaces: every arrow except the expense's category lands in a target both things share: Organization, Date, LineItem*. They share a centre.

Step 2. Map one onto the other. Propose a translation F from the Expense model, minus category, to the Invoice model. On things it is forced: Expense goes to Invoice, every shared thing to itself. On arrows, pair by target: supplier to customer, payer to issuer, lines to lines, incurredAt to issuedAt.

Step 3. Check that each square commutes. A mapping means nothing unless it respects the structure, and for each paired arrow that is one concrete question. For supplier and customer: do they point at the same organization, the other party to the transaction? They do. For payer and issuer: both point at the owning tenant. For incurredAt and issuedAt: both are the document's date, and lines is the same list on both sides. Every square over shared structure commutes. (The equation behind the first question is customer(F(e)) = supplier(e) for every expense e: translate an expense and take its customer, or take the expense's supplier directly, and you reach the same organization.)

Step 4. Read the verdict. Leave category for Step 5. On everything else, every square commuted and F matches things and arrows one to one, so the reverse pairing is a translation too and both round trips change nothing. A translation with that inverse is the identity in disguise. There was only ever one thing: Invoice and Expense are the same type in Dat, and the squares record the reason: each pair of arrows plays the same role, counterparty or owner, on both sides. With one thing there is one total, the single transformation that sums lines.

Step 5. What had no partner is the other half of the answer. category : Expense → Category matched nothing on the invoice side, and the direction of the money is a real difference. Those are the true distinctions, and the deduction hands them to you by name. category is kept as an optional arrow on the unified thing, and direction is the discriminator the merge adds (a field saying which side a document came from).

graph LR
    Doc["Document"]
    Org["Organization"]
    Lines["log of LineItem"]
    DateO["Date"]
    Dir["{ INCOMING, OUTGOING }"]
    Cat["Category"]
    Doc -->|"counterparty"| Org
    Doc -->|"owner"| Org
    Doc -->|"lines"| Lines
    Doc -->|"date"| DateO
    Doc -->|"direction"| Dir
    Doc -.->|"category? (optional)"| Cat
    style Doc fill:#4f8cf7,color:#fff
    style Org fill:#f77f7f,color:#fff
    style Lines fill:#f7c04f,color:#000
    style DateO fill:#f7c04f,color:#000
    style Dir fill:#cf7fcf,color:#fff
    style Cat fill:#cf7fcf,color:#fff

So a reduction is two moves at once: merge every arrow whose square commuted onto one thing, and keep every arrow without a partner as an optional arrow. The output (one documents table, a direction column, category nullable, the expenses table dropped) is read straight off the diagram.

One dividend: strategies

Two code placements over the same transformation, possibly different code doing the same t_from → t_to job, are parallel arrows fitting one slot. That is the exact shape of a strategy: interchangeable implementations behind one interface. Country tax handlers, swappable PDF templates, a live data provider versus a mock, all have this structure. Adding a strategy is adding one parallel arrow, never editing the core, and the method names that axis in advance.

Objects bundle the atoms too early

We have the four atoms. So why does almost everyone model software with something else, namely objects?

Object-oriented programming taught us to model the world as objects: an Invoice is a class, an Expense is a class. It feels natural because the words are nouns. A class is a premature bundle of several atoms, though. It fuses a data type (its fields) with the transformations that act on it (its methods), pins both to one location (one heap, one process), and asks you to commit to that bundle before you have done any analysis. The CPU never sees the bundle. It sees data, transformation and location separately. The object is a story narrated on top, and the story has a cost.

// The object-oriented instinct: every noun gets a class.
class Invoice {
  lineItems: LineItem[];
  customer:  Customer;       // who we billed
  issuer:    Organization;   // ...us
  total(): Money { /* sum line items, apply tax */ }
  send(): void { /* ... */ }
}

class Expense {
  lineItems: LineItem[];
  supplier:  Supplier;       // who billed us
  payer:     Organization;   // ...us, again
  total(): Money { /* sum line items, apply tax, a second time */ }
  categorize(): void { /* ... */ }
}

// ...and, written by someone else on another day, two more nouns:
class Customer { name: string; taxId: string; address: Address; }
class Supplier { name: string; taxId: string; address: Address; }

Four classes, and the rot is already visible: total() written twice, and a Customer and a Supplier that are line for line identical. Any codebase touched by many hands, or by an AI, drifts into this, because nothing makes the redundancy checkable.

Map it onto the atoms and the checking can begin. All four classes are things in Dat. Their fields (customer, issuer, name, taxId, and so on) are arrows out of each. total is a different kind of thing: an algorithm, so it belongs in Trn. OOP glued the data type and its transformations into each class, and that glue is what hid the structure.

Now write the data model down the way a category demands: every thing, every arrow, source and target named. Five things, six arrows:

graph LR
    Customer["Customer"]
    Supplier["Supplier"]
    Org["Organization"]
    Invoice["Invoice"]
    Expense["Expense"]
    Invoice -->|"i_by"| Org
    Invoice -->|"i_for"| Customer
    Expense -->|"e_by"| Supplier
    Expense -->|"e_for"| Org
    Customer -->|"c_is"| Org
    Supplier -->|"s_is"| Org
    style Customer fill:#4f8cf7,color:#fff
    style Supplier fill:#4f8cf7,color:#fff
    style Invoice fill:#4f8cf7,color:#fff
    style Expense fill:#4f8cf7,color:#fff
    style Org fill:#f77f7f,color:#fff

Read it straight off. An Invoice is issued by an Organization (i_by) for a Customer (i_for); an Expense is billed by a Supplier (e_by) for an Organization (e_for). Customer and Supplier each carry a single arrow, into Organization. Three rules take the diagram the rest of the way; the whole procedure first, then each rule.

flowchart TD
    W["1. Write every thing and arrow down,<br/>source and target named"] --> Ch["2. Chain through the things that have the same shape<br/>(Customer and Supplier look exactly like Organization)"]
    Ch --> Dr["3. Drop the copies nothing else reaches"]
    Dr --> Tr["4. Propose a translation from Expense to Invoice<br/>and pair the arrows by role"]
    Tr --> Sq["5. Check every square:<br/>do the paired arrows land on the same thing?"]
    Sq --> Keep["6a. Arrows that agreed:<br/>merge into one"]
    Sq --> Seg["6b. Arrows with no partner:<br/>keep as an optional arrow,<br/>add a discriminator"]
    Keep --> Sch["7. Read the schema off the diagram"]
    Seg --> Sch
    style W fill:#4f8cf7,color:#fff
    style Ch fill:#7fc47f,color:#000
    style Dr fill:#9a9a9a,color:#fff
    style Tr fill:#7fc47f,color:#000
    style Sq fill:#f7c04f,color:#000
    style Keep fill:#7fc47f,color:#000
    style Seg fill:#cf7fcf,color:#fff
    style Sch fill:#4f8cf7,color:#fff

Rule 1: arrows chain, and copies collapse. Two ideas carry this rule. Chaining: if there is an arrow from A to B and one from B to C, there is an arrow from A to C whether you drew it or not. Same shape (the formal word is isomorphic, written ≅): there is an arrow each way between two things, and a round trip in either direction lands you back where you started. Matching fields only suggest it; the round trips settle it. Customer and Supplier carry exactly the arrows Organization carries: name, taxId, address, into the same primitives. c_is turns a customer into an organization with the same name, tax id and address, and reading those three fields back turns any organization into a customer. Both carry only those three fields, so either round trip gives back what you started with. The same holds for s_is and suppliers. Each has the same shape as Organization. Chain straight through them:

graph LR
    Invoice["Invoice"]
    Customer["Customer<br/>(same shape as Organization)"]
    Org["Organization"]
    Invoice -->|"i_for"| Customer
    Customer -->|"c_is"| Org
    Invoice -.->|"i_for′ = i_for, then c_is"| Org
    style Invoice fill:#4f8cf7,color:#fff
    style Customer fill:#9a9a9a,color:#fff
    style Org fill:#f77f7f,color:#fff

Following i_for and then c_is gives an arrow i_for′ : Invoice → Organization, read "i_for′ takes an Invoice to an Organization." (A mathematician writes that chain right to left, c_is ∘ i_for, which still reads "i_for, then c_is".) Likewise e_by′ : Expense → Organization. Neither chained arrow is new data; both exist whether you draw them or not. And once they do, Customer and Supplier are copies of Organization that nothing else reaches (the only arrow into Customer was i_for, and Invoice now reaches Organization directly), so drop them. Three things remain, and Invoice and Expense now have the same shape: two arrows each, both into Organization.

graph LR
    Invoice["Invoice"]
    Expense["Expense"]
    Org["Organization"]
    Invoice -->|"i_by"| Org
    Invoice -->|"i_for′"| Org
    Expense -->|"e_by′"| Org
    Expense -->|"e_for"| Org
    style Invoice fill:#4f8cf7,color:#fff
    style Expense fill:#4f8cf7,color:#fff
    style Org fill:#f77f7f,color:#fff

That shared centre did not exist a paragraph ago. Rule 1 created it, and the next rule needs it.

Rule 2: a translation that agrees everywhere is the identity in disguise. A translation (the formal word is functor) is a mapping from one diagram to another that sends things to things and arrows to arrows and keeps every chain intact. Propose one from the Expense part of the diagram to the Invoice part. On things it is forced: Expense goes to Invoice, and Organization to itself. On arrows, pair them by role: e_by′ to i_for′, e_for to i_by.

Whether that mapping is a real translation reduces to one concrete question per pair of arrows: do e_by′ and i_for′ point at the same organization? Take an expense. Follow e_by′ and you reach the supplier's organization. Translate the expense into an invoice and follow i_for′ and you reach the customer's organization. Both are the counterparty, the other party to the transaction, so the two routes agree. Ask the same of e_for and i_by: both pick out the owner, the tenant the document belongs to. Two routes, same endpoint, which is what "the square commutes" means:

graph LR
    Expense["Expense"]
    Invoice["Invoice"]
    OrgA["Organization"]
    OrgB["Organization"]
    Expense -->|"translation"| Invoice
    Expense -->|"e_by′"| OrgA
    Invoice -->|"i_for′"| OrgB
    OrgA -->|"identity (same org)"| OrgB
    style Expense fill:#4f8cf7,color:#fff
    style Invoice fill:#4f8cf7,color:#fff
    style OrgA fill:#f77f7f,color:#fff
    style OrgB fill:#f77f7f,color:#fff

The translation matches things one to one and arrows one to one, with nothing left over on either side, so running the pairing backwards is a translation too, and a round trip in either direction changes nothing. Every square commutes as well, so the two parts have the same shape. A translation like that is the identity wearing a disguise: if you can rename Expense to Invoice and back and every arrow still lands where it did, the renaming did nothing, so there was only ever one thing. Invoice and Expense are the same data type written down twice, and the squares record the reason: each pair of arrows plays the same role, counterparty or owner, on both sides. (The commuting squares are what mathematicians call naturality, the exact content of "these two are the same thing.") The duplicated total() from the four-class sketch falls with them. One data type carries one total, and the second was never a transformation, only a class boundary.

Rule 3: what has no partner is kept, separately. Not everything found a partner. An expense also gets a category: categorize() fills a stored category : Expense → Category, and nothing on the invoice side matches it. The direction of the money (leaving us, or coming in) is a real difference too. Those are the true distinctions, and the deduction hands them to you by name. Keep each on the one unified thing, either as a discriminator (a field that says which case this is) or as an optional arrow (an arrow that exists only for some items, like a nullable field, written with a ?):

graph LR
    Doc["Document"]
    Org["Organization"]
    Dir["{ INCOMING, OUTGOING }"]
    Cat["Category"]
    Doc -->|"counterparty"| Org
    Doc -->|"owner"| Org
    Doc -->|"direction"| Dir
    Doc -.->|"category? (optional)"| Cat
    style Doc fill:#4f8cf7,color:#fff
    style Org fill:#f77f7f,color:#fff
    style Dir fill:#cf7fcf,color:#fff
    style Cat fill:#cf7fcf,color:#fff

counterparty is the merged e_by′ and i_for′; owner is the merged e_for and i_by; direction : Document → { INCOMING, OUTGOING } is the discriminator the merge adds to remember which side a document came from. Five things and six arrows became Document plus the Organization it points at, by two chaining steps and a commuting-square check, and anyone can recompute that from the first diagram.

In code, the method pays a second time. The deduction fixes the model: one data type, Document, with direction an arrow into a two-element enum and total the single transformation acting on it. It does not fix the memory layout. You can realise that one type as an array of structures:

// Deduced: one Dat-object. `direction` is a Dat-morphism into a 2-element enum;
// `total` is the single Trn that acts on it.
type Document = {
  lineItems:    LineItem[];
  counterparty: Organization;             // consolidated: Customer + Supplier
  direction:    "OUTGOING" | "INCOMING";  // a Dat-morphism: the discriminator
  category?:    Category;                 // kept separately: optional, only when INCOMING
};

const total = (d: Document): Money => /* the one Trn, defined exactly once */;

const ledger: Document[] = [ /* ... */ ];          // array of structures (AoS)

...or, with the exact same model, as a structure of arrays, where direction becomes which array a document lives in, with no stored field at all:

// Structure of arrays (SoA): same Dat-object, same single `total` Trn; only
// the layout differs. `direction` is now the partition itself: each array
// holds the documents on one side of the `direction` arrow.
const ledger = {
  invoices: [] as Document[],   // the documents whose direction is OUTGOING
  expenses: [] as Document[],   // the documents whose direction is INCOMING
};

Both are faithful to the same diagram: one Document, one total. Array of structures versus structure of arrays is a placement and performance decision (structure of arrays is the cache-friendly layout when a computation sweeps one direction at a time), and the model deliberately leaves it open. OOP erases that line by baking a layout into every class; the atoms keep it.

"Invoice" and "expense" were never two things; direction is an arrow on one data type. The precise version of this move, with the formal names, is in the collapsible above; it is also how Guliel's real schema was reduced.

Components and encapsulation still have a place, at the end, as a conclusion the analysis earned. Bundle too early and you hide the squares before you have checked them.

Placement, and the function that wasn't

Four atoms do not make an architecture yet. An architecture is the logical pair applied to the physical pair: this transformation running on that core, this data resident in that database.

When I first formalised this, I wrote that application as a function: every transformation has a location. One transformation, one place.

That was the old mistake again. A validation runs in the browser and on the server. A render runs server-side and again client-side. The same algorithm gets placed in many spots, sometimes as literally different code that does the same job. "Where does this run" has zero, one or many answers, so it is a relation, and the fix is to make a placement its own first-class record: this transformation, at this location, inside this component. (That is a code placement; a data placement says the same of a piece of data.) Once a placement is something you can point at, you can have as many as the truth requires, and you can check each one.

graph TB
    V["validate<br/>(one transformation)"]
    P1["placement 1<br/>validate at Browser<br/>inside WebAppCmp"]
    P2["placement 2<br/>validate at RouteHandler<br/>inside ApiCmp"]
    B["Browser"]
    S["RouteHandler"]
    P1 -->|"runs"| V
    P2 -->|"runs"| V
    P1 -->|"at"| B
    P2 -->|"at"| S
    style V fill:#7fc47f,color:#000
    style P1 fill:#f7c04f,color:#000
    style P2 fill:#f7c04f,color:#000
    style B fill:#f77f7f,color:#fff
    style S fill:#f77f7f,color:#fff

That check is the thing prose architecture could never do. Law 1, Placement honesty: code may only read data that is stored where it runs or delivered there by a transmission. The server-side validate reads the form payload, so a transmission carrying that payload from Browser to RouteHandler must exist in the diagram, or placement 2 is a defect. The architecture can show you that hole on a whiteboard, before production.

Components compose

A component is a bundle of placements that one team owns: data and transformations, placed at locations. It can span more than one location. And components join: glue two of them along the transmission that connects them and you get one larger component, with that transmission now an internal detail. Join enough and you have a system.

graph LR
    A["WebAppCmp<br/>runs: validate, render<br/>at: Browser"]
    B["ApiCmp<br/>runs: validate, total<br/>stores: documents<br/>at: RouteHandler, Postgres"]
    AB["InvoicingCmp (joined)<br/>runs: validate, render, total<br/>stores: documents<br/>at: Browser, RouteHandler, Postgres<br/>t_submit is now internal"]
    A -->|"t_submit : FormPayload"| B
    A -.->|"glue"| AB
    B -.->|"glue"| AB
    style A fill:#cf7fcf,color:#fff
    style B fill:#cf7fcf,color:#fff
    style AB fill:#cf7fcf,color:#fff

This is the property the boxes-and-arrows diagram never had. Joining has a rule (Law 5, Sound composition): the joined component's data, code and locations are exactly the union of its parts', so nobody redraws them by hand. When two components do not join cleanly, the gap is concrete and nameable: a missing transmission, a transformation reading data that is not there. The architecture can be wrong in a way you can point at. And notice where components landed: at the end, as the boundary the analysis earned, which is where the object should have been all along.

Taming the beast: why this is suddenly urgent

Everything so far I believed when humans wrote all the code. It was true then, and it was a slow tax: a non-rigorous spec is survivable when a human reads between its lines, holds the unwritten context, and notices "wait, isn't this the same as that."

Hand that same spec to an AI and the tax becomes a hemorrhage.

An AI generates against the spec you give it. If "correct" is undefined, and in a prose PRD or a boxes-and-arrows diagram it is undefined, then nothing the AI produces can be wrong. I have watched it invent objects that were never in the model, reorder the architecture between one session and the next, and copy a function definition into five files. None of that violates a prose spec, because a prose spec has nothing to violate. The non-rigor humans absorbed, AI industrialises.

There are numbers for this. GitClear's study of 211 million lines of changed code found that, as AI coding assistants spread, copy-pasted code climbed from 8.3% to 12.3%, duplicated blocks rose roughly eightfold in 2024, and refactoring fell to a record low; 2024 was the first year on record that duplicated code outpaced refactored code (report write-up). GitClear's own diagnosis is this article's thesis in other words: "it is less likely that the AI will propose reusing a similar function elsewhere … partly because of limited context size." It cannot see the whole model, so it retypes pieces of it, and a prose spec gives you nothing to catch that with.

A category closes the hole by turning the spec into a checkable contract. "Correct" becomes precise: the diagram commutes, the six coherence laws hold (Law 1 and Law 5 above are two of them), and no arrow is a copy of a chain that already exists. You can verify that, a test can, and increasingly the AI itself can. The model can hallucinate all it likes; the category is the gate it cannot bluff past.

flowchart TD
    Spec["1. Spec written as a category:<br/>things, arrows, laws"] --> Gen["2. AI generates code or schema"]
    Gen --> Laws["3. Check the six coherence laws"]
    Laws --> Sq["4. Check that every pair of routes agrees"]
    Sq --> Dup["5. Check that no arrow duplicates an existing chain"]
    Dup -->|"all pass"| Acc["6a. Accept"]
    Dup -->|"a check fails"| Rej["6b. Reject, naming the missing piece:<br/>the transmission, the arrow, the law"]
    style Spec fill:#4f8cf7,color:#fff
    style Gen fill:#cf7fcf,color:#fff
    style Laws fill:#f7c04f,color:#000
    style Sq fill:#f7c04f,color:#000
    style Dup fill:#f7c04f,color:#000
    style Acc fill:#7fc47f,color:#000
    style Rej fill:#f77f7f,color:#fff

Because every layer is modelled this way (the PRD, the data model, the architecture), the whole spec is verifiable. When the AI proposes a parallel object, the rule "if X is Y plus extra arrows, X is not a new thing" catches it. When it copies a function, the duplicate shows up as two arrows that should have been one chain, a diagram that fails to commute.

That is what taming the beast means to me: a verifiable target, an objective function the machine cannot game, because being wrong is finally defined.

Every architecture style is this, with one constraint flipped

An architectural style (layered, microservices, event-driven, CQRS) is a set of constraints on how the parts may be arranged. The method gives you a substrate and a style is a configuration on it, so you never learn a new vocabulary per style. You take the same four atoms and flip one constraint.

In request/response, component A names component B and their dependency is one direct transmission. In event-driven, delete that edge: A publishes to a broker and B subscribes, so the transmission from A to B now passes through a third component. Same atoms, one constraint flipped, and "A doesn't know B exists" falls out for free.

graph LR
    subgraph RR["Request/response: one direct edge"]
        A1["ACmp"] -->|"t : Request"| B1["BCmp"]
    end
    subgraph EV["Event-driven: the direct edge is deleted"]
        A2["ACmp"] -->|"t_publish : Event"| Bk["BrokerCmp"]
        Bk -->|"t_deliver : Event"| B2["BCmp"]
    end
    style A1 fill:#cf7fcf,color:#fff
    style B1 fill:#cf7fcf,color:#fff
    style A2 fill:#cf7fcf,color:#fff
    style B2 fill:#cf7fcf,color:#fff
    style Bk fill:#cf7fcf,color:#fff

Below is the whole gallery, each style translated into the method. Open the ones you use; each one reads on its own.

Foundational

Layered / n-tier architecture

The oldest move in the book. Stack the system in layers (presentation, business logic, data access, database) and let each layer call only the one directly beneath it: a page handler calls a service, the service calls a data access module, and that module talks to the database. Nothing reaches up, and nothing skips a level.

The shape. Each layer is a component (a bundle of code and data that one team owns), and each piece of code sits inside its own layer. The arrows are the only calls the style allows, labelled with what each one carries.

graph TD
    P["PresentationCmp<br/>runs: render"]
    B["BusinessCmp<br/>runs: decide"]
    D["DataAccessCmp<br/>runs: load, save"]
    DB["DatabaseCmp<br/>runs: exec"]
    P -->|"q_PB: Request"| B
    B -->|"q_BD: Query"| D
    D -->|"q_D·DB: SQL"| DB
    style P fill:#cf7fcf,color:#fff
    style B fill:#cf7fcf,color:#fff
    style D fill:#cf7fcf,color:#fff
    style DB fill:#cf7fcf,color:#fff

There are no locations (the places where code runs and data rests) in the picture, on purpose: all four layers may share one process. Glue the components together along the three calls and, since every dependency points exactly one step down, you get a single unbranched chain.

One request, step by step. Every call is a round trip, so the way back up gets its own steps.

flowchart TD
    S1["1. q_PB: Presentation hands a Request to Business"]
    S2["2. Business runs decide:<br/>the Request becomes a Command"]
    S3["3. q_BD: Business hands a Query to Data access"]
    S4["4. Data access runs load or save"]
    S5["5. q_D·DB: Data access hands SQL to the Database"]
    S6["6. Database runs exec:<br/>the SQL gives a list of Rows"]
    S7["7. The Rows come back up to Data access as Records"]
    S8["8. Business gets its answer back"]
    S9["9. Presentation runs render:<br/>a ViewModel becomes DOM"]
    S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 --> S8 --> S9
    style S1 fill:#7fc4c4,color:#000
    style S3 fill:#7fc4c4,color:#000
    style S5 fill:#7fc4c4,color:#000
    style S2 fill:#7fc47f,color:#000
    style S4 fill:#7fc47f,color:#000
    style S6 fill:#7fc47f,color:#000
    style S9 fill:#7fc47f,color:#000

The four atoms. Data; transformations, the code that rewrites data; locations; and transmissions, data moving from one location to another. Layered constrains which component may depend on which, and almost nothing else. Its data and locations stay generic on purpose, and that genericity is what separates "layered" from "n-tier", below.

Transformations (Trn), one group per layer. A side effect is anything the code does besides return a value.

Name

Input → output

What it does

render

ViewModel → DOM

Presentation: draws the screen. No side effects

decide

Request → Command

Business: turns a request into a command. No side effects

load / save

Query ⇄ list of Record

Data access: queries go down, records come back (or go down to be saved). Side effect: database reads and writes

exec

SQL → list of Row

Database: runs the statement. Side effect: touches stored data

The links between layers run only between neighbours:

Name

From → To

What travels

q_PB

Presentation → Business

Request

q_BD

Business → DataAccess

Query

q_D·DB

DataAccess → Database

SQL

Two layers in one process call each other as ordinary functions, one transformation handing its output to the next, and the method does not call that a transmission, because Law 2, Typed transmissions, says a transmission really crosses a boundary (start ≠ end). Deploy the layers apart, as n-tier does, and the same three links become transmissions across the wire.

Locations (Loc) are unconstrained. Data (Dat) is the per-layer shapes in the tables, with no structure imposed across layers. Each transformation has a code placement (a record saying "this code, at this location, inside this component") in its own layer. Everything else the style says is about the dependency graph: which component may call which.

The rule that defines it. A layer may depend only on the layer directly below it.

Precisely: number the layers from the top, Presentation 1 down to Database 4, and call that number the rank. (Formally rank is a functor from components to the ordered numbers: a mapping that sends each component to a number and each allowed dependency to a step of exactly one.) Then:

depends-on(c, c′) ⟹ rank(c′) = rank(c) + 1

Read it as: if component c depends on c′, then c′ is exactly one rank below c. A skip-level call (rank(c′) > rank(c) + 1) fails, and so does an upward call (rank(c′) ≤ rank(c)), visibly, before anything runs:

graph TD
    P["rank 1: Presentation"]
    B["rank 2: Business"]
    D["rank 3: Data access"]
    DB["rank 4: Database"]
    P -->|"1 to 2: allowed"| B
    B -->|"2 to 3: allowed"| D
    D -->|"3 to 4: allowed"| DB
    P -.->|"1 to 3: skips a layer, fails"| D
    D -.->|"3 to 2: points up, fails"| B
    linkStyle 3 stroke:#f77f7f
    linkStyle 4 stroke:#f77f7f
    style P fill:#cf7fcf,color:#fff
    style B fill:#cf7fcf,color:#fff
    style D fill:#cf7fcf,color:#fff
    style DB fill:#cf7fcf,color:#fff

Layered and n-tier are two separate rules. The one above is about components and says nothing about locations. N-tier adds a second rule, about placement: each layer runs at a location of its own (the map from layer to location is one-to-one), which is also the moment the three links become transmissions. A monolith passes the first rule and fails the second, fully layered in one process. You can have either without the other.

What follows.

  • Cross-cutting concerns are predictable. Logging, auth and telemetry touch every layer, so they cannot be a dependency edge, which would skip ranks. The model has a different slot for them: a wrapper applied the same way to every component (formally a natural transformation, the same wrapper at every component with every chain kept intact). That is why every real layered system grows an "aspect" or "middleware" escape hatch.

  • Strictness is one inequality. Strict layering forbids skip calls (rank(c′) = rank(c) + 1 exactly); relaxed layering allows them (rank(c′) > rank(c)). That choice is the style's only knob.

  • A straight-line dependency graph is a build order. Compilation, deployment and reasoning all go bottom-up because the order is total: there is exactly one topological sort (one valid order to build in), read straight off the ranks.

Compared with pipe-and-filter. Same straight line of components, different kind of edge. A layered edge is a call: a layer invokes the one below and waits for the answer. A pipe-and-filter edge is composition: data flows one way through each step and never comes back, so each output simply feeds the next input.

Client-server

Two parties at two places. The client wants something and asks for it; the server holds the real data and answers. The browser asks, the API responds. Every exchange opens on the client side.

The shape. Two components (a component is a bundle of code and data that one team owns) at two locations, the places where code runs and data rests. The arrows are transmissions: data crossing from one location to the other, labelled with what it carries.

graph LR
    Cl["ClientCmp<br/>runs: render, validate"]
    DC["Resource, a copy<br/>(at Client)"]
    Sv["ServerCmp<br/>runs: read, mutate, validate"]
    DS["Resource, the authoritative one<br/>(at Server)"]
    DC -->|"held by"| Cl
    DS -->|"held by"| Sv
    Cl -->|"t_request: Command or Query"| Sv
    Sv -.->|"t_response: Resource (only as a reply)"| Cl
    style Cl fill:#cf7fcf,color:#fff
    style Sv fill:#cf7fcf,color:#fff
    style DC fill:#9a9a9a,color:#fff
    style DS fill:#4f8cf7,color:#fff

validate runs on both sides, and the Resource exists twice: the real row on the server and a grey copy on the client.

One request, step by step. A command, from the client to the screen. A query takes the same route, with read doing the server's work.

flowchart TD
    S1["1. Client runs validate:<br/>a quick check, so the UI feels snappy"]
    S2["2. t_request: the Command travels to the Server"]
    S3["3. Server runs validate again:<br/>the check that is enforced"]
    S4["4. Server runs mutate:<br/>writes the authoritative Resource"]
    S5["5. t_response: the Resource travels back to the Client"]
    S6["6. Client keeps a copy and runs render:<br/>the Resource becomes a View"]
    S1 --> S2 --> S3 --> S4 --> S5 --> S6
    style S1 fill:#7fc47f,color:#000
    style S3 fill:#7fc47f,color:#000
    style S4 fill:#7fc47f,color:#000
    style S6 fill:#7fc47f,color:#000
    style S2 fill:#7fc4c4,color:#000
    style S5 fill:#7fc4c4,color:#000

As a path, every exchange is Client → Server → Client: t_request, then the t_response it provokes (formally t_response ∘ t_request, read right to left). A t_response never stands alone, because the only way to be at the server with something to send is to have received a t_request. Join the two components along that pair and you have the system: the pair becomes an internal detail of the whole, and its one dependency runs from ClientCmp to ServerCmp, with no edge back.

The four atoms. Of the four (data, the transformations that rewrite it, the locations it sits at, the transmissions that move it), client-server constrains two: the transmissions, meaning who may open one, and where data is placed, meaning who owns the authoritative copy. The transformations are whatever the domain needs. A signature such as r_id : Resource → Id reads "r_id takes a Resource to its Id".

Data (Dat): one domain type, Resource, stored twice. The style fixes only that both copies are the same type of thing.

Name

From → To

What it means

r_id

Resource → Id

the identity both sides agree on

r_body

Resource → Json

the resource's payload

r_rev

Resource → Revision

which version a given copy holds

Transformations (Trn). A side effect is anything the code does besides return a value.

Name

Input → output

What it does

read

Query → Resource

At Server. Side effect: reads the store

mutate

Command → Resource

At Server. Side effect: writes the store, the only code that does

render

Resource → View

At Client. No side effects

validate

Command → Command

At both. No side effects

Locations (Loc): exactly two. Client is the thread that asks; Server is the process that answers, with its store.

Transmissions (Trm): every exchange is a request and its response.

Name

From → To

What travels

t_request

Client → Server

Command or Query

t_response

Server → Client

Resource

A placement is a record of which code or data sits where, inside which component. Counted that way, validate has two code placements and Resource has two data placements of the same type.

The rule that defines it. Only the client may start an exchange. The server only ever replies.

Start from peer-to-peer, where any component may open a transmission to any other and no node holds privileged data. Make one change: Client is the only initiator, and everything leaving Server is a reply to an earlier request.

∀ τ. c_from(τ) = Server ⟹ ∃ ρ. c_from(ρ) = Client ∧ reply(τ) = ρ

Read it as: for every transmission τ that starts at the Server (c_from is where a transmission starts), there is a transmission ρ that started at the Client, and τ is the reply to ρ.

This strengthens Law 4, Mediated dependencies: when a component depends on one at another location, a transmission connects them, and nothing reaches across the boundary directly. That still holds, and now the connection goes one way only. There is no dependency from ServerCmp to ClientCmp. The data asymmetry needs no second rule. The side that only ever answers is the side it makes sense to trust, so the authoritative copy lands at Server and the client's copy is the grey one.

What follows.

  • "Never trust the client" comes out of Law 1, Placement honesty: code may only read data stored where it runs or delivered there by a transmission. The client's Resource is a separate placement, a copy that t_response delivered. A mutate that must produce the authoritative Resource reads its Command against the real store, so Law 1 allows it only at Server.

  • Validation that runs twice is correct. One validate at Client keeps the UI snappy; the one at Server does the real enforcement. It looks like duplication someone should clean up, but it is two realisations of one Command → Command contract, the case Law 6, Many places, exists for: a transformation can run at zero, one or many locations.

  • The bottleneck is built into the structure. Server is the only responder and the only owner of the authoritative data, so every client's dependency points at it. Load concentration shows up on the diagram before it shows up in production.

Compared with peer-to-peer. Same two-location picture with the initiation rule lifted. Transmissions go both ways, every node may open one, and no single copy of the data is the authoritative one.

MVC / MVVM

Split the presentation tier into three roles. The Model is the data, the View is what the user sees, and between them a Mediator turns input into Model changes and Model state into something the View can render. In MVC the Mediator is a Controller; in MVVM it is a ViewModel. On a settings screen, the form is the View, the saved preferences are the Model, and the Controller or ViewModel handles the Save button. The View and the Model never speak directly.

The shape. Three components (each a bundle of code and data that one team owns) sharing one location, the place where they run. The arrows are the links between them, labelled with what each one carries.

graph TD
    subgraph Pres["One location: Presentation (a UI thread, a browser tab)"]
        V["ViewCmp<br/>runs: render"]
        Md["MediatorCmp (Controller or ViewModel)<br/>runs: intent, apply, vs_of"]
        M["ModelCmp<br/>stores: Model"]
    end
    V -->|"t_input: Input"| Md
    Md -->|"t_push: ViewState"| V
    Md -->|"t_rw: Command / DomainState"| M
    V -.-|"no edge here, either way"| M
    linkStyle 3 stroke:#9a9a9a
    style V fill:#cf7fcf,color:#fff
    style Md fill:#cf7fcf,color:#fff
    style M fill:#4f8cf7,color:#fff
    style Pres fill:none,stroke:#f77f7f

Nothing connects View and Model in either direction; everything goes through the Mediator. Because the three (almost always) share one location, this style is about code structure, with no network involved.

One input, step by step. A user gesture goes through the Mediator to the Model and comes back to the screen as a new ViewState.

flowchart TD
    S1["1. The user acts on the View"]
    S2["2. t_input: the Input goes to the Mediator"]
    S3["3. Mediator runs intent:<br/>the Input becomes a Command"]
    S4["4. t_rw: the Command reaches the Model"]
    S5["5. apply: the Model and the Command give a new Model<br/>side effect: the Model changes"]
    S6["6. Mediator runs vs_of: the new Model becomes a ViewState;<br/>t_push hands it to the View"]
    S7["7. View runs render: the ViewState becomes DOM"]
    S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
    style S1 fill:#f7c47f,color:#000
    style S3 fill:#7fc47f,color:#000
    style S5 fill:#7fc47f,color:#000
    style S6 fill:#7fc47f,color:#000
    style S7 fill:#7fc47f,color:#000
    style S2 fill:#7fc4c4,color:#000
    style S4 fill:#7fc4c4,color:#000

As a path: View → Mediator → Model → Mediator → View, formally t_push ∘ apply ∘ t_rw ∘ intent ∘ t_input, read right to left (t_input, then intent, and so on). Any dependency between View and Model exists only as this chain, never as a direct edge. Join the three components along t_input/t_push and t_rw, so those links become internal to the whole, and you have the system; ViewCmp and ModelCmp share no connection point.

The four atoms. Data; transformations, meaning code that rewrites data; locations, where code runs and data rests; and transmissions, data crossing from one location to another. MVC / MVVM constrains only which component may depend on which. A signature such as m_state : Model → DomainState reads "m_state takes a Model to its DomainState".

Data (Dat). The central object is Model, the domain state. The View never sees it; it gets a render-ready projection instead.

Name

From → To

What it means

m_state

Model → DomainState

the current authoritative value

vs_of

Model → ViewState

the render-ready shape, computed by the Mediator and pushed to the View

Transformations (Trn). A side effect is anything the code does besides return a value; × means "a pair of".

Name

Input → output

What it does

render

ViewState → DOM

The View itself: draws what it was handed. No side effects

intent

Input → Command

Interprets a user gesture. No side effects

apply

Model × Command → Model

Takes the Model and a Command, returns the new Model. Side effect: the Mediator changes the Model

Locations (Loc): one site, Presentation (a UI thread, a browser tab), holding all three components.

The links between the components are the three ports (the points where a component sends or receives). There is no View → Model link.

Name

From → To

What travels

t_input

View → Mediator

Input, the user's gesture

t_push

Mediator → View

ViewState, something to show

t_rw

Mediator → Model

Command going in, DomainState coming out

All three stay inside one location, so they are method calls, one transformation handing data to the next, and the method does not count them as transmissions: Law 2, Typed transmissions, says a transmission really crosses a boundary (start ≠ end). They keep their t_ names because they become real transmissions the moment one role moves to another location, and the triangle does not change when that happens.

A placement is a record of which code or data sits at which location, inside which component. Here: render in ViewCmp, intent, apply and vs_of in MediatorCmp, the Model data in ModelCmp, all at Presentation.

The rule that defines it. The View and the Model may not depend on each other. Every dependency between them goes through the Mediator.

Start from a presentation layer with no rules, where the View reads and writes the Model freely: a complete triangle of dependencies. Delete one edge:

In the dependency graph there is no edge ViewCmp → ModelCmp and none ModelCmp → ViewCmp. Every dependency between View and Model is carried by MediatorCmp.

That missing edge is all of MVC / MVVM. Law 4, Mediated dependencies, says that when a component depends on one at another location, a transmission connects them and nothing reaches across directly. Law 4 has nothing to say here, because all three components sit at Presentation. MVC imposes the same shape by choice: View and Model are kept apart as if a boundary ran between them, to keep rendering separable from domain logic.

MVC and MVVM share the triangle and the missing edge. They differ only in how t_push, the Mediator to View link, comes to exist. In MVC the Controller names the View and pushes ViewState to it in code. In MVVM it is derived from a binding: a declared table of pairs (a field on the View, a field on the ViewModel), formally Binding ⊆ ViewField × ViewModelField, a subset of all such pairs. The framework generates the link in both directions from that table, so t_push is a real link that no component's placements name.

graph LR
    subgraph MVC["MVC: written by hand"]
        C["Controller"]
        V1["View"]
        C -->|"t_push, named in the Controller's code"| V1
    end
    subgraph MVVM["MVVM: derived from a binding"]
        Bd["Binding table<br/>(View field, ViewModel field) pairs"]
        T["t_push, both ways<br/>owned by no component"]
        V2["View"]
        VM["ViewModel"]
        Bd -->|"framework generates"| T
        T -->|"syncs"| V2
        T -->|"syncs"| VM
    end
    style C fill:#cf7fcf,color:#fff
    style V1 fill:#cf7fcf,color:#fff
    style V2 fill:#cf7fcf,color:#fff
    style VM fill:#cf7fcf,color:#fff
    style Bd fill:#f7c47f,color:#000
    style T fill:#7fc4c4,color:#000

What follows.

  • The View is a transformation, so you can test it. render : ViewState → DOM has an input type and an output type like any other code: hand it a ViewState off-screen and check what it draws, with no user and no Model in the test. The step from Model to ViewState, vs_of, lives in the Mediator, so it can be tested with no View at all.

  • MVVM's wiring is invisible by construction. The binding-derived t_push is the event-driven broker's situation again: you get the decoupling and pay with "where did the wiring go". Both sit on the same diagram, as a link that belongs to a binding table and to neither ViewCmp nor MediatorCmp.

  • A second View is one more component: a new ViewCmp with its own render placement and one new t_input/t_push pair to the Mediator. ModelCmp is untouched, and it has no way to import a View, because no edge reaches it.

  • "The Model knows nothing of the View" is usually a convention you have to uphold. Here it is a missing reference between two components, visible on the triangle before anything runs.

Compared with layered. Same dependency graph between components, a different shape imposed on it. Layered forces a straight line, each component depending only on the next one down. MVC / MVVM forces a triangle with one forbidden edge.

Distributed

Microservices

Microservices slice a system into many small services that deploy independently, and give each service its own database. No service reads another service's tables. If the order service needs a customer's name, it asks the customer service over the network, where a monolith would have run a join. "Database per service" is the whole architecture in three words.

The shape. Each service is a component (a bundle of code and data that one team owns), and each component spans two locations, the places where code runs and data rests: the service process and its database. Arrows are transmissions, data crossing between locations, labelled with what they carry.

graph LR
    subgraph OC["OrderCmp"]
        OS["OrderSvc<br/>runs: placeOrder"]
        OD["OrderDB (private)<br/>stores: Order, OrderLine"]
    end
    subgraph CC["CustomerCmp"]
        CS["CustomerSvc<br/>runs: lookupCustomer"]
        CD["CustomerDB (private)<br/>stores: Customer, Address"]
    end
    OS -->|"q_cust: CustomerId"| CS
    CS -.->|"r_cust: Customer"| OS
    style OC fill:#cf7fcf,color:#fff
    style CC fill:#cf7fcf,color:#fff
    style OS fill:#f77f7f,color:#fff
    style OD fill:#f77f7f,color:#fff
    style CS fill:#f77f7f,color:#fff
    style CD fill:#f77f7f,color:#fff

Nothing points into the other side's database.

One request, step by step. Placing an order that needs the customer's details:

flowchart TD
    A["1. placeOrder starts in OrderSvc<br/>with an OrderDraft"]
    B["2. q_cust sends the CustomerId<br/>to CustomerSvc"]
    C["3. lookupCustomer reads CustomerDB<br/>inside CustomerSvc"]
    D["4. r_cust carries the Customer<br/>back to OrderSvc"]
    E["5. placeOrder writes the Order<br/>to OrderDB"]
    N["Steps 2 and 4 cross the network:<br/>each can be slow, fail,<br/>or return stale data"]
    A --> B --> C --> D --> E
    B -.-> N
    D -.-> N
    style A fill:#7fc47f,color:#000
    style B fill:#7fc4c4,color:#000
    style C fill:#7fc47f,color:#000
    style D fill:#7fc4c4,color:#000
    style E fill:#7fc47f,color:#000
    style N fill:#9a9a9a,color:#fff

The four atoms. A system is data, the transformations (code) that rewrite it, the locations where both sit, and the transmissions that carry data between locations. Microservices constrain only where data is placed.

Data (Dat). Each service owns a private set of types: Order and OrderLine for one, Customer and Address for another. What matters is where each one is stored.

Transformations (Trn) are ordinary domain code. A side effect is anything the code does besides return a value.

Name

Input → output

What it does

placeOrder

OrderDraft → Order

turns a draft into an order. Side effect: saves it

lookupCustomer

CustomerId → Customer

finds a customer by id. Side effect: reads the customer database

Locations (Loc): each service has its own location, and its database is a separate location again: OrderSvc, OrderDB, CustomerSvc, CustomerDB.

Transmissions (Trm): every need that crosses services becomes one.

Name

From → To

What travels

q_cust

OrderSvc → CustomerSvc

CustomerId, the question

r_cust

CustomerSvc → OrderSvc

Customer, the answer

Each database is a data placement (DataLoc), meaning a record of which data sits at which location inside which component, and it is owned by its component alone.

The rule that defines it. No kind of data is stored in two components. Every store is private.

Start from a layered monolith, where the data-access and business components share one database, a single data placement. Change one constraint and make data placement exclusive:

For any two data placements dl and dl′: if dl_data(dl) = dl_data(dl′) then dl_cmp(dl) = dl_cmp(dl′).

dl_data is the type a placement holds and dl_cmp the component that owns it, so this reads: two placements of the same type belong to the same component. With the shared store gone, data that another service owns can only arrive by transmission. Law 4, Mediated dependencies (when a component depends on one at another location, a transmission connects them), goes from good advice to the only way two services can interact.

What follows.

  • A new service is a new component with its own private data placement and a few transmissions to the services it queries. No existing data placement changes; nobody's table grows a column for the newcomer.

  • The model predicts the "distributed transaction" tax. Steps 2 to 4 above replace what a monolith did with one consistent join, and each transmission can be slow, fail, or return stale data. The isolation you gain and the eventual consistency you pay for it come from the same deleted store.

  • The boundary is a property of the diagram. Arrows reach another component's transmission ports and never its data placement, so you can redeploy, rescale or rewrite a service without touching anyone's store.

Compared with SOA. One constraint apart. SOA keeps data private per service too, but routes every transmission between services through one shared bus component. Microservices keep the transmissions point to point ("smart endpoints, dumb pipes").

Service-oriented architecture (SOA)

Service-oriented architecture carves an enterprise into a handful of coarse-grained services, such as Billing, Inventory and CRM, and wires none of them to each other. Every service talks to one shared piece of plumbing, the enterprise service bus, which routes messages, translates protocols, transforms messages and orchestrates multi-step flows. Services send to the bus and receive from it. They never hold a reference to one another.

The shape. Each box is a component, the bundle of code and data that one team owns, with the code it runs and the data it stores. Each arrow is a transmission, data crossing from one location (a place where code runs or data rests) to another, labelled with what it carries.

graph TD
    Bus["BusCmp at Bus<br/>runs: route, translate, orchestrate<br/>stores: routing table"]
    A["BillingCmp at BillingSvc<br/>runs: chargeCard<br/>stores: Invoice"]
    B["InventoryCmp at InventorySvc<br/>stores: StockItem"]
    C["CRMCmp at CRMSvc"]
    A -->|"t_out: Message"| Bus
    Bus -->|"t_in: Message"| A
    B -->|"t_out: Message"| Bus
    Bus -->|"t_in: Message"| B
    C -->|"t_out: Message"| Bus
    Bus -->|"t_in: Message"| C
    style Bus fill:#cf7fcf,color:#fff
    style A fill:#cf7fcf,color:#fff
    style B fill:#cf7fcf,color:#fff
    style C fill:#cf7fcf,color:#fff

Every arrow has the bus at one end. No arrow joins two services.

One message, step by step. From Billing to Inventory:

flowchart TD
    S1["1. Billing builds a Message<br/>with msg_to = Inventory"]
    S2["2. t_out: the Message travels<br/>from BillingSvc to the Bus"]
    S3["3. route reads msg_to<br/>and picks InventorySvc's endpoint"]
    D{"4. Does msg_format match<br/>what Inventory expects?"}
    S4["4a. translate rewrites the Message<br/>into Inventory's format"]
    S5["5. t_in: the Message travels<br/>from the Bus to InventorySvc"]
    S6["6. Inventory handles the payload"]
    S1 --> S2 --> S3 --> D
    D -->|"no"| S4 --> S5
    D -->|"yes"| S5
    S5 --> S6
    style S1 fill:#7fc47f,color:#000
    style S3 fill:#7fc47f,color:#000
    style S4 fill:#7fc47f,color:#000
    style S6 fill:#7fc47f,color:#000
    style S2 fill:#7fc4c4,color:#000
    style S5 fill:#7fc4c4,color:#000
    style D fill:#f7c04f,color:#000

The four atoms. Data, transformations (the code that rewrites data), locations and transmissions. SOA constrains the transmissions and leaves the data, the transformations and each service's insides to the domain. A signature such as msg_to : Message → ServiceId reads "msg_to takes a Message to a ServiceId".

Data (Dat). Each service owns its own domain types, Invoice for Billing and StockItem for Inventory, and none of them is special. The one type every service shares is Message, the bus's envelope, which carries a routing field:

Name

From → To

What it means

msg_to

Message → ServiceId

the logical destination; this is the routing key

msg_payload

Message → Json

the domain data, in transit

msg_format

Message → Format

the wire protocol or schema, which the bus may translate

Transformations (Trn): ordinary domain code, plus the bus's own. A side effect is anything the code does besides return a value.

Name

Input → output

What it does

chargeCard

Invoice → Receipt

a Billing transformation. Side effect: charges the card

route

Message → Endpoint

the bus picks the second hop. No side effects

translate

Message and Format → Message

the bus's protocol bridge. No side effects

orchestrate

Message → a list of Message

the bus's multi-step flows. Side effect: sends the messages it produces

Locations (Loc): each service has its own, BillingSvc, InventorySvc and CRMSvc, and the bus is a separate location again, Bus.

Transmissions (Trm): every need that crosses services becomes one, and every one of them has Bus at one end.

Name

From → To

What travels

t_out

BillingSvc → Bus

Message

t_in

Bus → InventorySvc

Message

A placement records which code or data sits at which location, inside which component. Each service is a component, and so is the bus: it has code placements (TrnLoc) for route, translate and orchestrate, and a data placement (DataLoc) for the routing table. In this model the bus is never just a wire.

The rule that defines it. No service sends anything straight to another service. Every transmission between two services starts or ends at the bus.

Precisely: there is no transmission BillingSvc → InventorySvc. When Billing depends on Inventory, that dependency is two transmissions chained through the bus:

BillingSvc → Bus → InventorySvc: t_out, then t_in.

The first hop is fixed, because the service knows only the bus. The bus's route chooses the second hop by reading msg_to. The system is the four components joined along their t_out and t_in pairs, each pair becoming an internal detail of the whole. No two services are joined directly, so their only common reference is BusCmp.

Microservices are the starting point: many services, private data, and transmissions that run point to point, so BillingCmp's placements name a port on InventoryCmp directly. SOA changes one constraint:

For every transmission t between two different service components, c_from(t) = Bus or c_to(t) = Bus.

Here c_from is where a transmission starts and c_to is where it ends.

This strengthens Law 4, Mediated dependencies (when a component depends on one at another location, a transmission connects them): the connection must also be indirect, through a third component. The event-driven style strengthens the law the same way, but SOA's go-between is a named, addressable hub, so services route by msg_to where an event broker routes by event type. Draw who depends on whom and you get a star with Bus at the center.

What follows.

  • The star is SOA's selling point, and it is a fact about shape. Every service points only at the bus, and the bus points at every service. Adding a service adds one component and one t_out/t_in pair at the hub, with no new edges anywhere else. Integration points grow as O(n) instead of O(n²), because no service-to-service transmission exists.

  • The bus is a real component, so it has real placements. route, translate and orchestrate are placed inside BusCmp, next to the data placement for the routing table. The model will not let you call the bus "just infrastructure": behaviour you own is behaviour you test, deploy, version and reason about.

  • The cost is that one component sits on every path. Every dependency between services goes through the bus, so that single component knows every service's contract and every routing rule. It concentrates load and knowledge in one place. The diagram shows the integration win (the star) and the coupling risk (one node on every path) at once, and that critique is what later pushed teams toward microservices.

Compared with microservices. Delete BusCmp and put the service-to-service transmissions back, and you have microservices. BillingCmp names a port on InventoryCmp directly, and the star falls back into point-to-point edges ("smart endpoints, dumb pipes"). With four services the difference looks like this:

graph LR
    subgraph P2P["Microservices: point to point"]
        M1["Billing"] --- M2["Inventory"]
        M1 --- M3["CRM"]
        M1 --- M4["a 4th service"]
        M2 --- M3
        M2 --- M4
        M3 --- M4
    end
    subgraph STAR["SOA: everything through the bus"]
        H["Bus"]
        S1["Billing"] --- H
        S2["Inventory"] --- H
        S3["CRM"] --- H
        S4["a 4th service"] --- H
    end
    style H fill:#cf7fcf,color:#fff
Service mesh

A service mesh takes a microservices system and deploys a small proxy, a sidecar, next to every service on the same host. A service never talks to the network itself: every call leaves through its own sidecar and arrives through the peer's sidecar. The proxies handle mTLS, retries, timeouts, load balancing and tracing, and the business code never sees any of it. The mesh is the set of all those proxies.

The shape. Each outer box is a host. Inside it sit two components (a component is a bundle of code and data that one team owns), each in a process of its own: the service and its sidecar. In this method a process is a location, a place where code runs, so a host holds two locations. Arrows are transmissions, data crossing from one location to another, labelled with what they carry.

graph TB
    subgraph L1["Host OrderSvc: two processes"]
        A["ServiceA component<br/>runs: placeOrder"]
        PA["ProxyA component (sidecar)<br/>runs: mtls, retry, trace"]
    end
    subgraph L2["Host PaymentSvc: two processes"]
        PB["ProxyB component (sidecar)<br/>runs: mtls, retry, trace"]
        B["ServiceB component"]
    end
    A -->|"t_out: Request (loopback)"| PA
    PA -->|"t_wire: Request over mTLS"| PB
    PB -->|"t_in: Request (loopback)"| B
    style L1 fill:#f77f7f,color:#fff
    style L2 fill:#f77f7f,color:#fff
    style A fill:#cf7fcf,color:#fff
    style PA fill:#cf7fcf,color:#fff
    style PB fill:#cf7fcf,color:#fff
    style B fill:#cf7fcf,color:#fff

Only the middle arrow leaves a host. The two loopback arrows still cross a process boundary, which is what makes them transmissions.

One request, step by step. ServiceA calls ServiceB. Steps 2 to 6 are the mesh's work, and the business code in both services sees none of it.

flowchart TD
    S1["1. ServiceA sends a Request<br/>meant for ServiceB"]
    S2["2. t_out: loopback to ProxyA,<br/>the sidecar on the same host"]
    S3["3. ProxyA runs mtls (encrypt)<br/>and trace (emit a span)"]
    D{"4. t_wire: did the Request<br/>reach ProxyB?"}
    R["4a. ProxyA runs retry:<br/>resends the Request"]
    S5["5. ProxyB runs mtls (verify)<br/>and trace (emit a span)"]
    S6["6. t_in: loopback to ServiceB,<br/>on the same host"]
    S7["7. ServiceB handles the Request"]
    S1 --> S2 --> S3 --> D
    D -->|"yes"| S5
    D -->|"no"| R --> S5
    S5 --> S6 --> S7
    style S1 fill:#7fc47f,color:#000
    style S3 fill:#7fc47f,color:#000
    style R fill:#7fc47f,color:#000
    style S5 fill:#7fc47f,color:#000
    style S7 fill:#7fc47f,color:#000
    style S2 fill:#7fc4c4,color:#000
    style D fill:#7fc4c4,color:#000
    style S6 fill:#7fc4c4,color:#000

The four atoms. The same four as every style here: data, transformations (code rewriting data), locations (where it runs or rests) and transmissions (data crossing between locations). A service mesh constrains the transmissions and the components that carry them; the data and the transformations are whatever the domain needs. A signature such as mtls : Bytes → Bytes reads "mtls takes Bytes to Bytes".

Data (Dat). Each service owns its domain types (Order, Customer, and so on). The only data the mesh adds structure to is the wire envelope. What matters is who carries it across the boundary.

Transformations (Trn) come in two kinds. Domain transformations live in the services. The mesh adds the transmission concerns as transformations of its own, placed in the proxy. A side effect is anything the code does besides return a value.

Name

Input → output

What it does

placeOrder

OrderDraft → Order

a service transformation. Side effect: saves the order

mtls

Bytes → Bytes

runs in the proxy. Side effect: encrypts outgoing traffic, verifies incoming

retry

Request → Response

runs in the proxy. Side effect: resends the request on failure

trace

Span → a list of Span

runs in the proxy. Side effect: emits telemetry

Locations (Loc): each service runs in its own process, and its sidecar in another process on the same host, so every host holds two locations. OrderSvc is the host of service A and proxy A; PaymentSvc hosts service B and proxy B.

Transmissions (Trm): every hop between services becomes three.

Name

From → To

What travels

t_out

ServiceA → ProxyA

Request, loopback across the process boundary on one host

t_wire

ProxyA → ProxyB

Request, over mTLS, between hosts

t_in

ProxyB → ServiceB

Request, loopback across the process boundary on one host

A placement records which code or data sits at which location, inside which component. Each service is a component, and the mesh adds one more component per host: a proxy in its own process beside the service. mtls, retry and trace are code placements (TrnLoc) inside the proxy, never inside the service.

The rule that defines it. No service sends straight to another. Every call between services goes through a proxy at each end.

Plain microservices are the starting point: a service reaches its peer through one direct transmission, t : ServiceA → ServiceB. The mesh changes one constraint and puts a proxy on every transmission between services:

No transmission runs ServiceX → ServiceY. For every dependency between services there is a proxy component on each end's host, and the transmission splits into three, chained through them: ServiceA → ProxyA → ProxyB → ServiceB: t_out, then t_wire, then t_in. The proxy is one uniform component, added once per host.

t_out and t_in stay on their service's host but cross from one process to another, so they are transmissions in the sense of Law 2, Typed transmissions (start ≠ end), and only t_wire crosses the network. The system is the four components joined along those three transmissions, each becoming internal to the whole. ServiceA and ServiceB are never joined directly: every port on their boundary names a proxy, never the peer service.

This is Law 4, Mediated dependencies (when a component depends on one at another location, a transmission connects them), with the go-between pinned down: every mediating transmission must end at a proxy.

It also reverses how the layered style handles cross-cutting concerns such as logging, auth and telemetry. Layered weaves such a concern through every component's code as a wrapper (formally a natural transformation, which here means one wrapper applied the same way everywhere). The mesh builds the same concern as one uniform component repeated on every host, so the aspect becomes something you deploy.

What follows.

  • A cross-cutting concern becomes a deployable. mtls, retry and trace are one component pattern that you run and version, placed once per host and applied the same way across every dependency between services. The woven wrapper and the proxy component are two ways to write one idea, and the mesh picks the one you can operate.

  • Policy changes without touching business logic. retry and mtls are placed in the proxy, so upgrading mesh behaviour redeploys proxy components and no service component's placements change. The placements keep policy out of the services, so nobody has to remember to.

  • Every logical hop is now three transmissions, and two extra components sit on every call path. The uniform policy and the added latency and operational surface come from the same two boxes, because the proxies are real components with real placements to run and observe.

Compared with plain microservices. Remove the proxies and restore the direct transmission, t : ServiceA → ServiceB, so the two services share a port, and you are back at plain microservices. The mesh is the same set of services with one uniform component added on every host.

Broker / message-queue

A producer drops a message onto a queue, and a pool of workers pulls from it; each message goes to exactly one worker. The queue is durable and ordered, so messages wait there until a worker takes them. It is how you spread a backlog of work across a fleet: a hundred jobs, ten workers, each job done once.

The shape. Each purple box is a component, a bundle of code and data that one team owns, with the code it runs and the data it stores. The broker's component spans two locations, the broker process and its disk. Arrows are transmissions, data crossing from one location to another, labelled with what they carry.

graph TB
    P["ProducerCmp<br/>runs: enqueue"]
    subgraph BC["BrokerCmp"]
        Bk["Broker<br/>runs: match (pick one)"]
        Q["BrokerQueue (the broker's disk)<br/>stores: log of Message"]
    end
    C1["WorkerCmp A<br/>runs: handle"]
    C2["WorkerCmp B<br/>runs: handle"]
    P -->|"t_enqueue: Message"| Bk
    Bk -->|"t_persist: Message"| Q
    Bk -->|"t_deliver: each Message to one worker"| C1
    Bk -->|"t_deliver: each Message to one worker"| C2
    C1 -.->|"t_ack: Offset"| Bk
    C2 -.->|"t_ack: Offset"| Bk
    style P fill:#cf7fcf,color:#fff
    style BC fill:#cf7fcf,color:#fff
    style C1 fill:#cf7fcf,color:#fff
    style C2 fill:#cf7fcf,color:#fff
    style Bk fill:#f77f7f,color:#fff
    style Q fill:#f77f7f,color:#fff

There is no arrow from the producer to a worker. Both workers are wired to the broker, but any one message travels down only one of those arrows.

One message, step by step. The producer's part ends at step 2. The rest can happen much later, because the message waits on disk.

flowchart TD
    S1["1. Producer runs enqueue:<br/>a Job becomes a Message"]
    S2["2. t_enqueue: the Message travels to the Broker"]
    S3["3. t_persist: the Broker appends it<br/>to the log on BrokerQueue"]
    S4["4. Broker runs match:<br/>exactly one worker is picked"]
    S5["5. t_deliver: the Message travels to that worker<br/>(usually the worker pulls it when it has capacity)"]
    S6["6. Worker runs handle:<br/>the job is done, a JobResult comes out"]
    S7["7. t_ack: the worker sends the Offset<br/>back to the Broker"]
    X["No t_ack: redelivery, visibility<br/>timeouts and dead letters<br/>are handled here"]
    S1 --> S2
    S2 --> S3
    S2 --> S4
    S3 --> S5
    S4 --> S5
    S5 --> S6 --> S7
    S6 -.->|"if the worker never acks"| X
    style S1 fill:#7fc47f,color:#000
    style S4 fill:#7fc47f,color:#000
    style S6 fill:#7fc47f,color:#000
    style S2 fill:#7fc4c4,color:#000
    style S3 fill:#7fc4c4,color:#000
    style S5 fill:#7fc4c4,color:#000
    style S7 fill:#7fc4c4,color:#000
    style X fill:#9a9a9a,color:#fff

The four atoms. Data; transformations, meaning the code that rewrites data; locations, where code runs and data rests; and transmissions, data crossing from one location to another. A signature such as m_at : Message → Date reads "m_at takes a Message to a Date".

Data (Dat). The central piece of data is Message, and it is the only type a producer and a worker both have to know about. There is no shared subscription key.

Name

From → To

What it means

m_payload

Message → Json

the job's data

m_at

Message → Date

when it was enqueued

m_offset

Message → ℕ

its position in the queue order (a whole number)

The queue itself is a log of Message: a finite sequence, appended at one end and taken from the other. (Formally Message*, the free monoid: sequences you can join end to end.)

Transformations (Trn). A side effect is anything the code does besides return a value.

Name

Input → output

What it does

enqueue

Job → Message

wraps a job as a message. Side effect: appends it to the queue

match

Message → Subscriber

the broker's routing: pick one. No side effects

handle

Message → JobResult

the worker does the work. Side effect: whatever the job does

Locations (Loc): Producer, the thread that enqueues; Broker, with BrokerQueue, its durable disk; and Worker, a competing consumer thread. There are usually several workers.

Transmissions (Trm):

Name

From → To

What travels

t_enqueue

Producer → Broker

Message

t_persist

Broker → BrokerQueue

Message

t_deliver

Broker → Worker

Message

t_ack

Worker → Broker

Offset, the worker's "done"

In placement terms (a placement is a record of which code or data sits at which location, inside which component): enqueue runs at Producer, match at Broker, handle at each Worker. The Message has three data placements, one per copy: the object in the producer's RAM, the stored entry on BrokerQueue, and the in-flight copy at one worker.

The rule that defines it. Every message goes to exactly one worker.

As in pub/sub, there is no transmission Producer → Worker. The dependency is two transmissions chained through the broker:

Producer → Broker → Worker: t_enqueue, then t_deliver.

The system is the components joined along t_enqueue and t_deliver, which become internal to the whole, and ProducerCmp and WorkerCmp share no connection point.

Publish/subscribe is the starting point: the same broker, the same stored log of messages, the same transmissions, and a routing transformation match : Message → Subscriber* that fans one message out to every subscriber (Subscriber* is a list of subscribers). The queue changes one constraint, how many recipients match returns:

The broker's routing transformation is match : Message → Subscriber. It selects exactly one consumer. For each Message there is exactly one t_deliver transmission, ending at one Worker.

That is the only edit: a list of subscribers (broadcast) becomes a single subscriber (competing consumers). Pub/sub turns one event into many deliveries; the queue turns one message into one.

A second consequence follows. Since each message has a single recipient, the worker usually starts t_deliver itself, pulling from the queue when it has capacity instead of having messages pushed at it. The arrow still points from broker to worker; only the trigger moves to the worker's end.

What follows.

  • Scaling is adding a component. A new worker is a new WorkerCmp, its own handle placement and one new t_deliver. Because match picks one recipient from the live pool, throughput grows with the number of workers, and no producer placement changes.

  • The disk decouples producer and worker in time. t_enqueue writes to the log on BrokerQueue and t_deliver reads from it later. A burst of enqueues just makes the log longer, and the workers drain it at their own pace. Producer and worker never have to be running at the same time.

  • Exactly-once-ish delivery lives on two arrows. "One message, one worker" is entirely match (pick one) plus the t_ack transmission back from the worker. You reason about redelivery, visibility timeouts and dead-letter handling on those two named arrows and nowhere else.

  • Order comes from the log. m_offset puts every message in one total order, and match takes from the head. FIFO falls out of the log's structure, so no component has to enforce it.

Compared with publish/subscribe. Set delivery back to broadcast, match : Message → Subscriber*, so every subscriber gets every message, and this is publish/subscribe. The two are siblings on one substrate, differing only in how many recipients match picks.

graph LR
    subgraph QU["Message queue: match picks one"]
        M["Message"]
        W1["Worker A"]
        W2["Worker B"]
        M -->|"delivered"| W1
        M -.->|"not delivered"| W2
    end
    subgraph PS["Publish/subscribe: match picks all"]
        E["Event"]
        U1["Subscriber A"]
        U2["Subscriber B"]
        E -->|"delivered"| U1
        E -->|"delivered"| U2
    end
    style M fill:#4f8cf7,color:#fff
    style E fill:#4f8cf7,color:#fff

Data-flow

Pipe-and-filter / pipelines

Data goes in at one end, passes through a straight line of independent stages, and comes out transformed at the other. Each stage is a filter: it does one job and knows nothing about its neighbours. The connectors between stages are pipes, and each pipe carries one type of data forward. A Unix shell command, a build pipeline and an ETL job all have this shape. The example below is a small job that parses raw records, scores them and renders a report.

The shape. Three components (a component is a bundle of code and data that one team owns), one filter in each, joined by two pipes. A pipe is a transmission, meaning data moving from one location (a place where code runs) to another, and each label says which type it carries.

graph LR
    A["FilterCmp A<br/>runs: parse<br/>Raw → Parsed"]
    B["FilterCmp B<br/>runs: score<br/>Parsed → Scored"]
    C["FilterCmp C<br/>runs: render<br/>Scored → Report"]
    A -->|"pipe_AB carries Parsed"| B
    B -->|"pipe_BC carries Scored"| C
    style A fill:#cf7fcf,color:#fff
    style B fill:#cf7fcf,color:#fff
    style C fill:#cf7fcf,color:#fff

The type leaving each filter is the type the next one takes in, and no arrow points backwards.

One record, step by step.

flowchart TD
    S1["1. A Raw record reaches filter A"]
    S2["2. parse turns it into Parsed"]
    S3["3. pipe_AB carries Parsed to filter B"]
    S4["4. score turns it into Scored"]
    S5["5. pipe_BC carries Scored to filter C"]
    S6["6. render turns it into a Report<br/>(side effect: writes it out)"]
    S1 --> S2 --> S3 --> S4 --> S5 --> S6
    style S1 fill:#f7c47f,color:#000
    style S2 fill:#7fc47f,color:#000
    style S3 fill:#7fc4c4,color:#000
    style S4 fill:#7fc47f,color:#000
    style S5 fill:#7fc4c4,color:#000
    style S6 fill:#7fc47f,color:#000

No step waits for an answer from the step after it, and nothing comes back up the line.

The four atoms. Data, transformations (the code that rewrites it), locations and transmissions, for a pipeline. What matters about the data is the sequence of types a record passes through. What sits inside each type doesn't change the architecture.

Name

What it is

What it means

stage₀ … stageₙ

data types

the type at each cut between filters: Raw, Parsed, Scored, Report

Each filter is exactly one transformation. A pure transformation computes its output and touches nothing else.

Transformation

From → To

Kind

parse

Raw → Parsed

pure

score

Parsed → Scored

pure

render

Scored → Report

has a side effect (it writes the report out)

There is one location per filter: FilterLocₐ, FilterLocᵦ, FilterLoc_c. They don't have to be different machines. A shell pipeline runs the three as separate processes on one machine, and each pipe crosses the boundary between two of those processes; a stream job spreads the same processes across several machines.

There is one pipe for each pair of neighbouring filters:

Transmission

From → To

Carries

pipe_AB

FilterLocₐ → FilterLocᵦ

Parsed

pipe_BC

FilterLocᵦ → FilterLoc_c

Scored

Each filter is its own component (FilterCmpₐ, FilterCmpᵦ, FilterCmp_c) and holds exactly one code placement, a TrnLoc: a record saying "this transformation, at this location, inside this component". The whole system is the three components joined along pipe_AB and pipe_BC. Once they are joined, the pipes become internal details of the larger component, and every joint is a typed handoff.

The rule that defines it. In plain words: the components form one straight line, each runs exactly one transformation, and each pipe carries exactly the type the next filter takes in. If parse outputs Parsed, then score must take Parsed.

Precisely, writing t_from(f) for the type a transformation f takes in and t_to(f) for the type it puts out:

The dependency graph is a single line c₀ → c₁ → … → cₙ. Each cᵢ holds exactly one code placement, of one transformation fᵢ. For every link cᵢ → cᵢ₊₁, t_to(fᵢ) = t_from(fᵢ₊₁), and the pipe carries exactly that type.

That equation is the whole style, and it is Law 1, Placement honesty (code may only read data that is stored where it runs or delivered there by a transmission), specialised to a line. Filter fᵢ₊₁ reads its input type at its own location, and the only thing that delivers anything there is the pipe from the filter before it. So that pipe has to carry t_from(fᵢ₊₁), and the types are forced to match. A filter whose input type differs from the upstream output can't be connected, and the model rejects it before anything runs.

Put another way, the pipeline is chaining (following one arrow, then the next) made physical. The whole system is one chained arrow:

Raw → Parsed → Scored → Report: parse, then score, then render

The chain from parse to score exists only because t_to(parse) = Parsed = t_from(score).

What follows.

  • The links are composition, and a layered system's links are calls. A call is a round trip: a transmission down and a reply back up. A pipe is one transmission in one direction between two different locations (Law 2, Typed transmissions), and there is no reply transmission at all. Both styles draw a line of boxes, but the arrows in the line are different kinds.

  • Adding a filter means adding one arrow. You can insert a filter g between fᵢ and fᵢ₊₁ exactly when t_to(fᵢ) = t_from(g) and t_to(g) = t_from(fᵢ₊₁). The new component reconnects two pipes. No other filter's placement changes, and the rest of the chain has no way to notice.

  • Filters move easily. A filter's only contract is its input and output types, so its placement can go to the same thread as its neighbour, a separate process or a separate machine, and the move touches only its own location and the two pipes next to it. (Put two filters in one thread and the pipe between them stops being a transmission and becomes a plain function call, since Law 2 only counts data crossing a boundary; the type rule applies all the same.) A shell pipeline and a distributed stream job are the same architecture with different placements.

  • The cost is shared state. A filter has one input type, one output type, and nowhere to keep a lookup table or an accumulated history. That state has to ride along through the pipes, which makes what they carry fatter, or move into a side channel that breaks the straight line. The constraint that makes filters easy to compose is the same one that makes context across stages awkward.

Compared with event-driven publish/subscribe. Pub/sub also stops a component from naming whoever comes next, but it routes every event through a broker that may fan it out to many consumers. Pipe-and-filter is the same picture with the broker removed and fan-out forbidden: the graph stays a single line, and each filter's successor is fixed by the next link in the chain.

CQRS

CQRS (Command Query Responsibility Segregation) splits a system along the line between changing data and reading it. The write side accepts commands and owns the source of truth, kept in whatever shape best enforces the business rules. The read side answers queries from a separate store, shaped for the screens that read it. Nobody writes that store by hand: after every command it is rebuilt (materialised) from the write side. In an orders system, say, the write side keeps orders in the shape that enforces the rules and the read side keeps a flattened copy for the order-history screen.

The shape. Two components (bundles of code and data that a team owns), each with its own store, connected by transmissions (data moving from one location, a place where code runs or data rests, to another) labelled with what they carry.

graph TB
    subgraph W["WriteCmp: the write side"]
        CH["CommandHandler<br/>runs: decide"]
        WDB["WriteDB<br/>stores: State (the truth)"]
    end
    subgraph R["ReadCmp: the read side"]
        RDB["ReadDB<br/>runs: project<br/>stores: ReadModel (deduced)"]
        QH["QueryHandler<br/>runs: query"]
    end
    CH -->|"t_decide carries State"| WDB
    WDB -->|"t_project carries State"| RDB
    RDB -->|"t_query carries ReadModel"| QH
    style W fill:#cf7fcf,color:#fff
    style R fill:#cf7fcf,color:#fff
    style CH fill:#f77f7f,color:#fff
    style WDB fill:#4f8cf7,color:#fff
    style RDB fill:#9a9a9a,color:#fff
    style QH fill:#f77f7f,color:#fff

The read side has exactly one way in, t_project. Commands never reach ReadDB.

One command, then a query, step by step.

flowchart TD
    S1["1. A client sends a command"]
    S2["2. decide takes the command and the current State<br/>and returns the new State"]
    S3["3. t_decide writes State to WriteDB<br/>(the only write)"]
    S4["4. t_project carries the committed State to ReadDB"]
    S5["5. project rebuilds ReadModel"]
    S6["6. A query arrives: t_query hands ReadModel<br/>to QueryHandler"]
    S7["7. query computes the Answer"]
    S8["8. The client receives the answer"]
    X["A query that arrives before step 5<br/>reads the previous ReadModel (old rm_at)"]
    S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 --> S8
    S3 -.->|"query arrives early"| X
    style S3 fill:#4f8cf7,color:#fff
    style S5 fill:#7fc47f,color:#000
    style X fill:#9a9a9a,color:#fff

Only step 3 writes the truth. The grey branch is eventual consistency.

The four atoms. Data, transformations (the code that rewrites it), locations and transmissions. In CQRS the data comes in two parallel shapes over the same domain. State is the write-side truth, shaped to enforce the rules. ReadModel is the query-side projection, shaped for the screen. Two arrows (relationships, like fields) link them:

Arrow

From → To

What it means

rm_of

ReadModel → State

which write-side truth this view flattens (denormalises)

rm_at

ReadModel → Date

which write-side version this view reflects

In the transformations, × means "a pair of" (decide takes a command and the current state), and pure means no side effects.

Transformation

From → To

Kind

decide

Command × State → State

side effect: writes WriteDB; the only write

project

State → ReadModel

pure: flattens the truth into a view

query

ReadModel → Answer

pure: read-only, never touches State

The write and read stores are different locations, WriteDB and ReadDB:

Transmission

From → To

Carries

t_decide

CommandHandler → WriteDB

State

t_project

WriteDB → ReadDB

State, each one as it is committed

t_query

ReadDB → QueryHandler

ReadModel

A placement is a record of which code runs where, or which data is stored where, and inside which component. decide is a code placement (a TrnLoc) in the write component, and query is one in the read component. State has the one authoritative data placement (a DataLoc), at WriteDB. ReadModel has a second data placement, at ReadDB, and it is deduced: computed by project from other data and never written by a command. project itself runs at ReadDB; it has to, since t_project delivers State there and Law 1, Placement honesty, lets code read only data stored where it runs or delivered there by a transmission.

The system is WriteCmp and ReadCmp joined along t_project, which becomes internal to the whole. The two components share no other connection.

The rule that defines it. In plain words: commands and queries use different stores. The write store is the truth, the read store is computed from it, and no command writes the read store directly.

Start from the ordinary single-model design, where an entity has one data placement that both decide and query touch, and change one constraint: make the read placement separate and derived.

Commands and queries use different data placements. The write placement (State) is authoritative. The read placement (ReadModel) is not: it is project of the write placement, and no command writes it directly. ReadModel = project ∘ decide*.

The equation reads right to left (∘ means "after"): decide* is the log of every decide so far, in order, and project runs on the state they produced. Storing ReadModel as an independent authority would add a redundant arrow, and the diagram would fail to commute: the stored copy and project of the truth would be two routes to the same place with no guarantee that they agree.

This is the main post's rule of designing the result data before the source data, promoted from one step in my head to two physical data placements. The read model is the result-data shape, the write model is the source-data shape, and project is the push from one to the other.

What follows.

  • Eventual consistency is the delay of one named transformation. ReadDB is correct exactly as far as t_project has run, and a query that reads ReadModel before project catches up sees an old rm_at. The model gives the staleness a name and a place: the lag on one edge.

  • The two stores scale independently because they are two placements at two locations. ReadDB can be replicated, re-indexed or reshaped for the screens without touching WriteDB. The isolation comes from the diagram, so nobody has to remember to keep it.

  • A new read shape is a new project. A screen that wants a different flattening gets a new code placement for project' and a new data placement for ReadModel', added alongside the existing ones over the same, unchanged State. The write side can't observe the addition.

  • The cost sits on the project edge: a second store with no authority of its own, plus a transformation that has to run on every write and has its own ways to fail. The gain (independent reads) and the cost (a derived store to keep current) show up on the same diagram.

Compared with event sourcing. Event sourcing is the usual partner, and it is the same picture with the constraint on the other store. It requires the write store to be an append-only log of events and makes project a fold over that log (replaying the events in order, like reduce, to compute a value). CQRS alone says nothing about the shape of the write store, only that the read store is a separate, derived placement. Event sourcing constrains the write store; CQRS constrains the read store.

Event sourcing

Most systems store current state, such as an account balance or an order's status, and overwrite it on every change. Event sourcing refuses to. It keeps the changes themselves, as an append-only log of events, and treats that log as the only source of truth. Current state is never written down. It is recomputed from the log whenever something needs it, so an account's balance is whatever you get by replaying its deposits and withdrawals.

The shape. One store holds the log and everything else is computed from it. The labelled arrows are transmissions: data moving from one location (a place where code runs or data rests) to another.

graph TB
    AP["append<br/>(the only write)"]
    LOG["Event store, a disk<br/>stores: log of Event<br/>(the only truth)"]
    FD["fold<br/>(reduce with handle)"]
    ST["State<br/>(deduced, never stored)"]
    AP -->|"t_append carries an Event"| LOG
    LOG -->|"t_replay carries the log"| FD
    FD -->|"produces"| ST
    style AP fill:#7fc47f,color:#000
    style LOG fill:#4f8cf7,color:#fff
    style FD fill:#7fc47f,color:#000
    style ST fill:#9a9a9a,color:#fff

There is one way into the store and one way out, and State lives outside it.

One event, step by step.

flowchart TD
    E1["1. Something changes: build an Event<br/>(type, payload, time)"]
    E2["2. append adds it to the end of the log<br/>and t_append writes it to the store"]
    E3["3. Later, something needs the current State"]
    E4["4. t_replay reads the log back"]
    E5["5. fold runs handle on each event, oldest first"]
    E6["6. The current State comes out<br/>(computed, not stored)"]
    P1["5b. fold only the first N events"]
    P2["6b. The State as of that past moment"]
    E1 --> E2
    E2 -->|"later"| E3
    E3 --> E4 --> E5 --> E6
    E4 -->|"or a prefix"| P1 --> P2
    style E2 fill:#4f8cf7,color:#fff
    style E6 fill:#9a9a9a,color:#fff
    style P2 fill:#9a9a9a,color:#fff

Writing is one append and reading is a replay. The side branch is time travel.

The four atoms. Data, the transformations (code) that rewrite it, the locations it sits at, and the transmissions that move it. Here the central type is Event, described by three arrows (relationships, like fields):

Arrow

From → To

What it means

ev_type

Event → EventType

which kind of change

ev_payload

Event → Json

the change's data

ev_at

Event → Date

when it was appended

The authoritative store is a log of events, Event*, meaning an append-only list (formally the free monoid on Event: finite sequences you can join end to end). State is a value computed from it, like a balance or a status. Below, × means "a pair of" and pure means no side effects.

Transformation

From → To

Kind

append

Event* × Event → Event*

side effect: writes the store; the only write

handle

State × Event → State

pure: one per EventType

fold

Event* → State

pure: fold = reduce(handle)

Two locations: the event store, a disk, and the process that runs append and fold. t_append carries an event from that process to the disk, and t_replay carries the log back for fold.

In placement terms (a placement is a record of which code runs where or which data is stored where, inside which component), the log has the one authoritative data placement. State has none; it is the output of a placed fold.

The rule that defines it. In plain words: never overwrite. The append-only log is the only thing stored as truth, and every state is computed by replaying it.

Start from a state-stored design, where each command overwrites an entity's current value in its data placement, and change one constraint:

The only authoritative data placement is the append-only log of events. No transformation overwrites state, and every State value is fold of the log.

This is the main post's "deduce, don't store" principle applied to a whole architecture. State = fold ∘ append*, read right to left: every append so far, in order, then fold. Storing it as its own authority would add a redundant arrow, and the diagram would fail to commute: the stored copy and the replay would be two routes to the current state that need not agree.

What follows.

  • Time travel comes free, because fold accepts any log. Every prefix (the first N events) is itself a valid log, so folding one gives the state as of that moment. Folding with a different handle gives a new projection over the same history: a new transformation added alongside, with the stored log unchanged.

  • A cached State is openly secondary. Real systems store snapshots (memoised fold results) as an optimisation. The model is blunt that these are deduced: a snapshot is valid exactly as far as fold says it is, the same status a CQRS read model has.

  • The cost sits on one edge. Replaying the whole log is O(history), and because old events never change, fold has to handle every event shape the log has ever held. Both costs live on the single t_replay edge.

Compared with CQRS. CQRS is the usual partner: the same picture with the constraint on the other store. The event log is the CQRS write store, and a CQRS read model is one fold among several:

graph LR
    LOG["log of Event<br/>(write store: append-only)"]
    F1["fold with handle"]
    F2["fold with another handle"]
    ST["State"]
    RM["ReadModel<br/>(read store: separate, derived)"]
    LOG -->|"t_replay"| F1
    LOG -->|"t_replay"| F2
    F1 -->|"produces"| ST
    F2 -->|"produces"| RM
    style LOG fill:#4f8cf7,color:#fff
    style F1 fill:#7fc47f,color:#000
    style F2 fill:#7fc47f,color:#000
    style ST fill:#9a9a9a,color:#fff
    style RM fill:#9a9a9a,color:#fff

Event sourcing constrains the write store to be a log. CQRS constrains the read store to be separate and derived.

Decoupling & boundaries

Event-driven / publish-subscribe

A publisher never calls a consumer. Say an order service has just taken an order: it emits an event to a broker, and every service that subscribed to that kind of event gets a copy. The order service has no idea who is listening, or whether anyone is. The textbook name for this is implicit invocation.

The shape. Boxes are components, each a bundle of code and data that one team owns, with what they run and store. Arrows are transmissions: data crossing from one location (a place where code runs or data rests) to another, labelled with what it carries.

graph LR
    P["ProducerCmp<br/>runs: publish"]
    Bk["BrokerCmp<br/>runs: match"]
    Log["BrokerLog (the broker's disk)<br/>stores: log of Event"]
    C1["ConsumerCmp A<br/>runs: handle, fold"]
    C2["ConsumerCmp B<br/>runs: handle, fold"]
    P -->|"t_publish: Event"| Bk
    Bk -->|"t_persist: Event"| Log
    Bk -->|"t_deliver: Event"| C1
    Bk -->|"t_deliver: Event"| C2
    C1 -.->|"t_ack: Offset"| Bk
    C2 -.->|"t_ack: Offset"| Bk
    style P fill:#cf7fcf,color:#fff
    style Bk fill:#cf7fcf,color:#fff
    style C1 fill:#cf7fcf,color:#fff
    style C2 fill:#cf7fcf,color:#fff
    style Log fill:#f77f7f,color:#fff

One event, step by step. The producer's part ends at step 2. The rest can happen a second later or a day later, because the event waits in the log.

flowchart TD
    S1["1. Producer runs publish:<br/>a domain change becomes an Event"]
    S2["2. t_publish: the Event travels to the Broker"]
    S3["3. t_persist: the Broker appends it to BrokerLog"]
    S4["4. Broker runs match:<br/>which subscriptions want this EventType?"]
    S5A["5a. t_deliver: a copy to Consumer A"]
    S5B["5b. t_deliver: a copy to Consumer B"]
    S6A["6a. Consumer A runs handle:<br/>old State + Event gives new State"]
    S6B["6b. Consumer B runs handle:<br/>old State + Event gives new State"]
    S7["7. t_ack: each consumer sends its Offset back to the Broker"]
    S1 --> S2
    S2 --> S3
    S2 --> S4
    S3 --> S5A
    S3 --> S5B
    S4 --> S5A --> S6A --> S7
    S4 --> S5B --> S6B --> S7
    style S1 fill:#7fc47f,color:#000
    style S4 fill:#7fc47f,color:#000
    style S6A fill:#7fc47f,color:#000
    style S6B fill:#7fc47f,color:#000
    style S2 fill:#7fc4c4,color:#000
    style S3 fill:#7fc4c4,color:#000
    style S5A fill:#7fc4c4,color:#000
    style S5B fill:#7fc4c4,color:#000
    style S7 fill:#7fc4c4,color:#000

Steps 3 and 4 both come before a delivery; the style does not say which of the two the broker does first.

The four atoms. Data, transformations (code that turns one kind of data into another), locations and transmissions. ev_type : Event → EventType reads "ev_type takes an Event to an EventType".

Data (Dat). The central piece of data is Event, and EventType is the only data type that publisher and subscriber both name.

Name

From → To

What it means

ev_type

Event → EventType

what kind of event this is; the one thing both sides share

ev_payload

Event → Json

the event's data

ev_at

Event → Date

when it was published

The stream is an append-only log of Event. A Subscription is a pair: one consumer and one EventType it wants.

Transformations (Trn). A side effect is anything the code does besides return a value.

Name

Input → output

What it does

publish

DomainChange → Event

turns something that happened into an event. Side effect: appends to the stream

match

Event → a list of Subscription

the broker's routing: who wants this event. No side effects

handle

State and Event → State

one consumer step, one version per EventType. No side effects

fold

log of Event → State

rebuilds state by running handle over the whole log: fold = reduce(handle). No side effects

Locations (Loc): Producer, the thread that publishes; Broker, the broker process, with BrokerLog, its durable disk; and Consumer, a subscribing worker (usually several).

Transmissions (Trm):

Name

From → To

What travels

t_publish

Producer → Broker

Event

t_persist

Broker → BrokerLog

Event

t_deliver

Broker → Consumer

Event

t_ack

Consumer → Broker

Offset

Placement, the record of which code or data sits at which location inside which component: publish runs at Producer, match at Broker, handle and fold at Consumer. The Event has three data placements, one per copy: the object in the producer's RAM, the stored entry in BrokerLog, and the copy the consumer received.

The rule that defines it. The producer and the consumer never talk to each other; every route between them goes through the broker.

Precisely: there is no transmission Producer → Consumer. An event gets from one to the other only by two transmissions chained:

Producer → Broker → Consumer: t_publish, then t_deliver.

The components are joined along t_publish and t_deliver, which become internal to the result, and ProducerCmp and ConsumerCmp share no connection point at all. In the graph of which component depends on which, there is no edge ConsumerCmp → ProducerCmp; both point at BrokerCmp.

This is Law 4 (Mediated dependencies) taken to its limit. Law 4 says that when a component depends on one at another location, a transmission connects them and nothing reaches across the boundary directly. Pub/sub adds that the connection always runs through a third component, the broker, so no producer and consumer are ever directly coupled. "A doesn't know B exists" becomes a fact you can check: the producer's component holds no reference to any consumer.

What follows.

  • A new subscriber is a new ConsumerCmp, its own handle and fold, one Subscription and one new t_deliver. No existing placement changes, and the publisher cannot even observe the addition.

  • Producer and consumer never have to be up at the same time: t_publish writes to the log in BrokerLog and t_deliver reads from it later. The log is the buffer.

  • Fan-out is match. It turns one event into one t_deliver per subscriber, and that is the broker's whole job.

  • The cost is that control flow goes missing. The dependency graph is sparse and stable, since everything points at the broker. But which consumer runs after a given publish is in no component's placements. It lives only in the Subscription data and in match.

Compared with request/response. Take the broker out and put the direct edge back, and this is request/response: a single transmission t : A → B connects the two, and ACmp names BCmp, so they share a connection point. The arrows below are dependencies, meaning who names whom.

graph LR
    subgraph RR["Request/response"]
        A1["ACmp"]
        B1["BCmp"]
        A1 -->|"depends on, names BCmp"| B1
    end
    subgraph PS["Publish/subscribe"]
        A2["ProducerCmp"]
        Br["BrokerCmp"]
        B2["ConsumerCmp"]
        A2 -->|"depends on"| Br
        B2 -->|"depends on"| Br
    end
    style A1 fill:#cf7fcf,color:#fff
    style B1 fill:#cf7fcf,color:#fff
    style A2 fill:#cf7fcf,color:#fff
    style Br fill:#cf7fcf,color:#fff
    style B2 fill:#cf7fcf,color:#fff
Hexagonal (ports & adapters)

A domain core, meaning the rules of your business, that touches the outside world only through abstract slots. The core never names a database driver, an HTTP client or a queue SDK. It declares a port, and an adapter plugged in from outside fills it. An invoicing core says "save this draft document" and has no idea whether Postgres or an in-memory map does the saving.

The shape. Purple boxes are components (a component is a bundle of code and data that one team owns), with the code they run. The yellow box is the port and the red boxes are the stores, which are locations: places where code runs or data rests. Solid arrows are transmissions, data crossing from one location to another, labelled with what they carry; a dashed arrow means "fills this port".

graph LR
    Core["DomainCoreCmp<br/>runs: decide"]
    Port["save port (a slot)<br/>contract: DocumentDraft to SaveResult"]
    PG["PgAdapterCmp<br/>runs: pg_save"]
    Mem["MemAdapterCmp<br/>runs: mem_save"]
    PgL["Postgres"]
    Heap["Heap"]
    Core -->|"t_port: DocumentDraft"| Port
    PG -.->|"fills"| Port
    Mem -.->|"fills"| Port
    PG -->|"t_pg: SQL"| PgL
    Mem -->|"t_mem: DocumentDraft"| Heap
    style Core fill:#cf7fcf,color:#fff
    style Port fill:#f7c04f,color:#000
    style PG fill:#cf7fcf,color:#fff
    style Mem fill:#cf7fcf,color:#fff
    style PgL fill:#f77f7f,color:#fff
    style Heap fill:#f77f7f,color:#fff

The core's one outgoing arrow stops at the port. Whichever adapter fills the port is the arrow's far end at run time.

One command, step by step. Which branch runs was settled when the system was assembled. The core does not choose, and it cannot tell the difference.

flowchart TD
    S1["1. A Command reaches the core"]
    S2["2. Core runs decide:<br/>Command becomes a DocumentDraft"]
    S3["3. t_port: the draft goes to the save port"]
    S4A["4a. If PgAdapterCmp is glued in:<br/>it runs pg_save"]
    S4B["4b. If MemAdapterCmp is glued in:<br/>it runs mem_save"]
    S5A["5a. t_pg: SQL travels to Postgres"]
    S5B["5b. t_mem: the draft is written to the Heap"]
    S6["6. The port's answer, a SaveResult,<br/>comes back to the core"]
    S1 --> S2 --> S3
    S3 --> S4A --> S5A --> S6
    S3 --> S4B --> S5B --> S6
    style S2 fill:#7fc47f,color:#000
    style S4A fill:#7fc47f,color:#000
    style S4B fill:#7fc47f,color:#000
    style S3 fill:#7fc4c4,color:#000
    style S5A fill:#7fc4c4,color:#000
    style S5B fill:#7fc4c4,color:#000

The four atoms. Data, transformations (code that turns one kind of data into another), locations and transmissions. Hexagonal leaves the first three to the domain. It constrains the component graph (which component may name which) and the kind of transmission allowed across the core's edge. decide : Command → DocumentDraft reads "decide takes a Command to a DocumentDraft".

Data (Dat). The core owns its own domain types (Document, Money, Counterparty). A port also fixes a contract type, the data on the wire. That contract type is the only data type the core and its adapters both name.

Transformations (Trn): domain logic in the core, plus each adapter's translation. A side effect is anything the code does besides return a value.

Name

Input → output

What it does

decide

Command → DocumentDraft

the core business rule. No side effects

save

DocumentDraft → SaveResult

the port's contract, a signature that adapters fill. Side effect: stores the draft

pg_save

DocumentDraft → SaveResult

the Postgres adapter. Side effect: writes to Postgres

mem_save

DocumentDraft → SaveResult

the in-memory adapter. Side effect: writes to the heap

pg_save and mem_save are parallel implementations of save: the same input and output types, different code.

Locations (Loc): Core, the domain thread; PgAdapter and MemAdapter, where each adapter's code runs; and the stores behind them, Postgres and Heap (a test double's RAM would be one more). The port has no location. It is an interface, and an interface is not somewhere code runs.

Transmissions (Trm). The core's only outgoing transmission is addressed to a port, never to a store. Its far end is the location of whichever adapter is glued in.

Name

From → To

What travels

t_port

Core → Port (filled by PgAdapter or MemAdapter)

DocumentDraft

t_pg

PgAdapter → Postgres

SQL

t_mem

MemAdapter → Heap

DocumentDraft

A placement is a record of which code or data sits at which location, inside which component. The core is one component and each adapter is a separate one. decide runs at Core, and the core's use of t_port belongs to the core component. pg_save runs at PgAdapter inside PgAdapterCmp, and the rows it writes are that component's data placement at Postgres; mem_save runs at MemAdapter inside MemAdapterCmp, with its data placement at Heap. The port is the transmission that the core and one adapter are joined along.

The rule that defines it. The core never names what it talks to. Everything it sends out goes to a port, and the concrete component on the other end is plugged in later, from outside.

Precisely: no transmission the core uses starts or ends inside a concrete external component. Every boundary transmission of the core lands on a port, a transmission identified only by the type of data it carries. In the graph of which component depends on which, the core names ports and never adapters.

The running system is the core joined with one adapter along the port: the core and PgAdapterCmp reference the same transmission from opposite ends, so in the joined system it becomes internal. Join MemAdapterCmp instead and you get a second system in which the core's placements are byte-identical. With behaviour(c) meaning the code placements a component owns:

behaviour(DomainCoreCmp) is the same whichever adapter is glued in.

In layered, the business layer depends on the data-access layer directly, an edge BusinessCmp → DataAccessCmp in which the core names its concrete partner. Law 4 (Mediated dependencies) accepts that edge: it only asks that when a component depends on one at another location, a transmission connects them and nothing reaches across the boundary directly. Hexagonal strengthens Law 4 in one specific way: the connecting transmission may not name a concrete component at all. It is identified only by its contract type, and which component sits on the other end is decided by joining, later, from outside.

What follows.

  • Hexagonal is the strategy structure from the deep dive, applied to a component's boundary. A strategy is a set of interchangeable implementations behind one interface; in the model they are parallel arrows that fill one slot. The port is the slot (DocumentDraft → SaveResult), every adapter is a parallel placement of that one signature, and picking an adapter means picking one of the arrows.

  • Testability comes from the structure. Because the core joins to a port and not to an adapter, the core joined with a test-double adapter is a valid system with zero change to the core's placements (the equation above). "The core is unit-testable in isolation" just means the core joins with any component whose end of the port matches the contract.

  • A new backing technology is a new adapter component with its own DocumentDraft → SaveResult code and one concrete transmission: one more parallel arrow into the existing port. No existing placement changes, and the core cannot observe the addition.

  • The cost is that adapters are real components. Every port needs at least one, and each is its own bundle of placements with its own concrete transmission to maintain. The indirection that buys swappability also multiplies components. For a system with one database forever, that ceremony earns nothing, and you can see the price on the same diagram as the gain.

Compared with layered. Layered keeps the direct edge: the core names the data-access layer, a dependency straight to DataAccessCmp. Hexagonal cuts that one edge and routes the dependency through a port, so the concrete partner becomes a pluggable component, one of several parallel ones, joined in from outside.

Serverless / FaaS

You write a function and hand it to the platform. You rent no server and keep no process alive. A request arrives, the platform spins up a container, runs your function once and tears the container down; the next request gets a fresh one. Between invocations nothing of yours is running anywhere.

The shape. Boxes are components (each a bundle of code and data that one team owns) with what they run and store, and the location each sits at: a location is a place where code runs or data rests. Arrows are transmissions, data crossing from one location to another, labelled with what they carry.

graph LR
    Ed["EdgeCmp<br/>the trigger source, at EdgeLoc"]
    Fn["FunctionCmp<br/>runs: handle, at EphLoc<br/>stores: nothing"]
    St["StateCmp<br/>stores: Record, at StateLoc<br/>(long-lived)"]
    Ed -->|"t_invoke: Request"| Fn
    St -->|"t_load: Record"| Fn
    Fn -->|"t_write: Record"| St
    Fn -->|"t_return: Response"| Ed
    style Ed fill:#cf7fcf,color:#fff
    style Fn fill:#cf7fcf,color:#fff
    style St fill:#cf7fcf,color:#fff

One request, step by step. Steps 2 and 4 are the ones a warm server would skip; here they repeat on every request.

flowchart TD
    S1["1. A Request reaches the trigger at EdgeLoc"]
    S2["2. The platform creates a fresh container: EphLoc"]
    S3["3. t_invoke: the Request goes into the container"]
    S4["4. connect opens a new connection, and loadState<br/>pulls the Record in through t_load: both again, every call"]
    S5["5. handle runs: Request in, Response out"]
    S6["6. t_write: the changed Record goes back to StateLoc"]
    S7["7. t_return: the Response goes back to EdgeLoc"]
    S8["8. handle has returned, so the container is destroyed"]
    S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 --> S8
    style S2 fill:#f77f7f,color:#fff
    style S8 fill:#f77f7f,color:#fff
    style S4 fill:#7fc47f,color:#000
    style S5 fill:#7fc47f,color:#000
    style S3 fill:#7fc4c4,color:#000
    style S6 fill:#7fc4c4,color:#000
    style S7 fill:#7fc4c4,color:#000

The four atoms. Data, transformations (code that turns one kind of data into another), locations and transmissions. Serverless changes one thing, how long a location lives, and that change spreads to everything else; the transformations are whatever the domain needs. handle : Request → Response reads "handle takes a Request to a Response".

Data (Dat): ordinary domain types. What matters is where each copy lives. The function's input arrives on the wire, and everything else lives in a managed store.

Transformations (Trn): one placed handler, plus the reads it is forced into. A side effect is anything the code does besides return a value.

Name

Input → output

What it does

handle

Request → Response

the function body. Side effect: writes the changed Record back, through t_write

loadState

Key → Record

fetches a record. Side effect: reads from the managed store

connect

no input → Connection

opens a connection. Side effect: a new connection, re-opened on every invocation

Locations (Loc). There are two kinds, and the split between them is the whole style. EphLoc is the container for one invocation: created when the request arrives, destroyed when handle returns. StateLoc is the managed database or object store: long-lived, owned by the platform, never by your function. A third location, EdgeLoc, is the trigger source.

Transmissions (Trm). Every piece of data reaches the function over a wire, on every call.

Name

From → To

What travels

t_invoke

EdgeLoc → EphLoc

Request

t_load

StateLoc → EphLoc

Record

t_write

EphLoc → StateLoc

Record

t_return

EphLoc → EdgeLoc

Response

A placement is the record of which code or data sits at which location, inside which component. handle runs at EphLoc inside FunctionCmp, but that placement record is itself temporary: it is created with the container and thrown away when the container dies. StateCmp owns the only data placement that lasts. FunctionCmp owns no data placement at all.

The rule that defines it. The place where your function runs lives for one call. Nothing it loaded or computed is still there for the next request, so everything it needs has to be delivered again, every time.

Precisely: FunctionCmp owns no long-lived location. Its EphLoc exists for one cycle of t_invoke, handle, t_return, so no data is stored where handle runs. Because EphLoc does not survive the call, the placement of handle cannot be carried forward in time; the next request rebuilds it. Every input handle uses must arrive by transmission inside the one invocation window:

StateLoc → EphLoc → StateLoc: t_load, then handle, then t_write.

State leaves StateLoc and comes back on every call. It never rests at EphLoc. The three components (EdgeCmp, FunctionCmp, StateCmp) are joined along the four boundary transmissions, which become internal to the result, and FunctionCmp adds nothing to the system's stored data. It owns behaviour and no storage.

A long-lived server is the starting point: one component at one stable location that keeps its code and its data (caches, pools, indexes) across requests. Change one thing, the lifetime of the location, down to a single invocation. That makes Law 1 (Placement honesty) bite at its hardest. Law 1 says code may only read data that is stored where it runs or delivered there by a transmission. On a warm server you get the first half for free: the location persisted, so the cache is already there. In FaaS that half can never apply, because nothing is stored at EphLoc between calls, and only "delivered by a transmission" remains. Cold starts and statelessness follow from this one missing data placement.

What follows.

  • All lasting data must live in a separate, long-lived store. Since FunctionCmp owns no durable location, the only place a Record can rest is StateLoc in StateCmp, a managed database or object store. The split between EphLoc and StateLoc is forced on you.

  • Every piece of data is delivered again on every invocation. With the "stored where it runs" half of Law 1 gone, each input to handle needs a transmission that ends at EphLoc. Load the row, open the connection, fetch the config: t_load and connect fire on every call because the diagram leaves no shortcut.

  • Scaling has one axis left: more placements of the same code side by side, which the deep dive calls the strategy move (interchangeable copies of one signature, each a parallel arrow). A load spike adds parallel EphLocs, each with its own temporary placement of the same handle. There is no capacity to plan and nothing to keep warm, and the method's way of extending a system is exactly what the platform's autoscaler does.

  • The trade-off is one missing data placement. You give up every optimisation that depended on a warm location (pooling, in-memory caches, sticky state). In exchange you own no location at all, and billing drops to zero between calls because nothing is placed there to bill for.

Compared with client-server. Client-server pins each component to a durable location that holds both its code and its data for the life of the system, and peer-to-peer does the same for every peer. Serverless is the same picture with one constraint changed: a location lives for a single invocation, so the data placement at the function's location disappears and all state moves out to a persistent store.

graph LR
    subgraph CS["Client-server: the server's location"]
        Srv["Server<br/>runs: read, mutate<br/>stores: Resource<br/>lives as long as the system"]
    end
    subgraph FS["Serverless: the function's location"]
        Eph["EphLoc<br/>runs: handle<br/>stores: nothing<br/>lives for one call"]
        StL["StateLoc<br/>stores: Record<br/>long-lived"]
        StL -->|"t_load, every call"| Eph
    end
    style Srv fill:#f77f7f,color:#fff
    style Eph fill:#f77f7f,color:#fff
    style StL fill:#f77f7f,color:#fff
Peer-to-peer

No server. Every node runs the same software and plays the same role: it asks other nodes for data and answers their requests. A file-sharing swarm is the classic case, and blockchain networks and gossip clusters work the same way. There is no privileged box in the middle, and no centre to lose.

The shape. Boxes are components, each a bundle of code and data that one team owns, at its own location (a place where code runs and data rests), with what it runs and stores. Each arrow is a pair of transmissions, data crossing from one location to another, and can be opened from either end.

graph TB
    A["PeerCmp 1, at Peer 1<br/>runs: serve, request, merge, locate<br/>stores: a Resource replica"]
    B["PeerCmp 2, at Peer 2<br/>runs: serve, request, merge, locate<br/>stores: a Resource replica"]
    C["PeerCmp 3, at Peer 3<br/>runs: serve, request, merge, locate<br/>stores: a Resource replica"]
    A <-->|"t_ask: Query<br/>t_send: Resource"| B
    B <-->|"t_ask: Query<br/>t_send: Resource"| C
    A <-->|"t_ask: Query<br/>t_send: Resource"| C
    style A fill:#cf7fcf,color:#fff
    style B fill:#cf7fcf,color:#fff
    style C fill:#cf7fcf,color:#fff

The three boxes are identical, and none of the three replicas is the master copy.

One lookup, step by step. Say peer 1 is missing a resource that peers 2 and 3 hold. The same exchange can start from any peer, with the same code.

flowchart TD
    S1["1. Peer 1 needs a Resource it does not hold"]
    S2["2. Peer 1 runs locate(Id):<br/>peers 2 and 3 hold it"]
    S3A["3a. Peer 1 runs request; t_ask sends a Query to peer 2"]
    S3B["3b. Peer 1 runs request; t_ask sends a Query to peer 3"]
    S4A["4a. Peer 2 runs serve on its local store"]
    S4B["4b. Peer 3 runs serve on its local store"]
    S5A["5a. t_send: peer 2's copy travels to peer 1"]
    S5B["5b. t_send: peer 3's copy travels to peer 1"]
    S6["6. Peer 1 runs merge:<br/>reconciles the two copies by r_rev"]
    S1 --> S2
    S2 --> S3A --> S4A --> S5A --> S6
    S2 --> S3B --> S4B --> S5B --> S6
    style S2 fill:#7fc47f,color:#000
    style S4A fill:#7fc47f,color:#000
    style S4B fill:#7fc47f,color:#000
    style S6 fill:#7fc47f,color:#000
    style S3A fill:#7fc4c4,color:#000
    style S3B fill:#7fc4c4,color:#000
    style S5A fill:#7fc4c4,color:#000
    style S5B fill:#7fc4c4,color:#000

The four atoms. Data, transformations (code that turns one kind of data into another), locations and transmissions. Peer-to-peer constrains the components (there is only one type) and the transmissions (who may open one); the transformations are whatever the domain needs. r_id : Resource → Id reads "r_id takes a Resource to its Id".

Data (Dat). One domain type, Resource, with a copy at every peer. The style fixes only that all those copies are the same data type, with none of them privileged.

Name

From → To

What it means

r_id

Resource → Id

the identity all peers agree on

r_body

Resource → Json

the resource's payload

r_rev

Resource → Revision

which version a given copy holds

Transformations (Trn): ordinary domain code, the same set placed at every peer. A side effect is anything the code does besides return a value.

Name

Input → output

What it does

serve

Query → Resource

answers a peer. Side effect: reads the local store

request

Query → Resource

asks a peer for what is missing. Side effect: sends a query over the network

merge

two Resources → Resource

reconciles two copies by r_rev. No side effects

locate

Id → a list of Peer

finds which peers hold this resource. Side effect: asks other peers

Locations (Loc): one kind of location, Peer, repeated n times (Peer 1 to Peer n), each with its own store.

Transmissions (Trm): one kind, and it is symmetric.

Name

From → To

What travels

t_ask

Peer i → Peer j

Query

t_send

Peer j → Peer i

Resource

i and j range over every peer, so t_ask exists for every ordered pair, in both directions.

In placement terms (a placement records which code or data sits at which location, inside which component), every component is an instance of one type, PeerCmp. Each instance places the identical code (serve, request, merge, locate) at its own Peer location and owns one stored copy of Resource there. No copy is marked as a mere cache, and none is authoritative.

The rule that defines it. There are no roles. Every node is the same kind of component, and any node may start a conversation with any other.

Precisely, take client-server and erase the role distinction:

  • There is exactly one component type, PeerCmp. Every component is an instance of it, so their placement sets are equal up to location.

  • Starting is symmetric. For every pair of peers i and j, there is a transmission from Peer i to Peer j exactly when there is one from Peer j to Peer i. No node only starts conversations and no node only answers them.

An exchange is still two transmissions chained, but either end may open it:

Peer i → Peer j → Peer i: t_ask, then t_send, for every ordered pair (i, j).

The system is many PeerCmps joined along whichever transmissions are live, each becoming internal to the result. Because every piece has the same interface, the joining is symmetric: no piece is the preferred one, and the graph of which component depends on which is undirected (no edge has a direction).

So client-server's rule that only the client starts is relaxed back to symmetry, and its single authoritative copy is deleted. Law 4 (Mediated dependencies) still holds: when a component depends on one at another location, a transmission connects them. The difference is that the dependencies now run both ways. Peer i's component depends on every other peer's component, and each of those depends on it.

What follows.

  • Scaling means adding another instance of a type that already exists. A new peer is one more PeerCmp, with no new component type, no new transformation and no central node to reconfigure. The system grows by copying something the diagram already contains.

  • Resilience comes from the symmetry. No component is privileged, so deleting any single PeerCmp leaves a smaller graph of the same shape. Losing one node is no different from losing any other, so there is no single point of failure, and nobody had to bolt on redundancy to get there.

  • The cost is that no copy is authoritative. Client-server gets consistency cheaply, with one store and one source of truth. P2P has n stored copies of one data type and no canonical one. Law 1 (Placement honesty) says code may only read data that is stored where it runs or delivered there by a transmission, and for the network as a whole that can only be satisfied by a consensus or gossip protocol: merge run pair by pair until the replicas agree. That protocol is the price of having no centre.

  • Finding data is a transformation of its own. With no central registry, working out which peer holds a resource is locate, which takes an Id to a list of peers and is itself heavy on transmissions. Client-server never needs it.

Compared with client-server. Same picture, with the role split put back: two component types instead of one, a one-way rule about who may start an exchange, and a single authoritative copy of the data that only the server owns.

This is how Guliel is built

None of this is a hobby. It is the working method behind Guliel, the financial operations platform my team builds, and the consolidations it produced are the proof.

Guliel started, like every system does, with nouns: customers, suppliers, invoices, expenses, orders. Five tables, five "objects," the object-oriented instinct applied to the schema. Modelling it as a category dissolved that. Customer and Supplier are roles on one directed edge between parties, so they collapsed into a single Counterparty. An Expense is a Document we received instead of issued, the same thing with direction = INCOMING. A purchase Order is a Document with documentType = PURCHASE_ORDER. Five "objects" reduced toward two, and every collapse was deduced by one rule ("if X is Y plus arrows, X is not new") and written down as a reasoning trail someone else can check, before any code hurt.

flowchart TD
    Cu["customers"] -->|"1. a role on an edge between parties"| CP["Counterparty"]
    Su["suppliers"] -->|"1. the other role on the same edge"| CP
    In["invoices"] -->|"2. direction = OUTGOING"| Doc["Document"]
    Ex["expenses"] -->|"2. direction = INCOMING"| Doc
    Or["orders"] -->|"3. documentType = PURCHASE_ORDER"| Doc
    Doc -->|"counterparty"| CP
    style Cu fill:#9a9a9a,color:#fff
    style Su fill:#9a9a9a,color:#fff
    style In fill:#9a9a9a,color:#fff
    style Ex fill:#9a9a9a,color:#fff
    style Or fill:#9a9a9a,color:#fff
    style CP fill:#4f8cf7,color:#fff
    style Doc fill:#4f8cf7,color:#fff

A dispute got the same treatment. When we designed per-document disputes, the noun instinct said: a dispute is a thing, give it a Dispute table. The category said otherwise. A document carries an append-only log of events, and its current state is computed by folding over that log (reducing the list to one value, the way Array.reduce does). A dispute is the sub-sequence of dispute-typed events inside that log. Storing a Dispute table would have stored an arrow that already exists as a chain (filter the log), a redundant edge and a diagram that does not commute. So there is no Dispute table.

graph LR
    Doc["Document"] -->|"events (a log of Event)"| Log["Event log"]
    Log -->|"fold"| State["Current state"]
    Log -->|"filter: dispute-typed events"| Disp["Dispute<br/>(a sub-log, not a table)"]
    Log -.->|"would store the filter chain a second time"| X["Dispute table<br/>(not built)"]
    style Doc fill:#4f8cf7,color:#fff
    style Log fill:#4f8cf7,color:#fff
    style State fill:#7fc47f,color:#000
    style Disp fill:#7fc47f,color:#000
    style X fill:#9a9a9a,color:#fff

And we build Guliel itself with AI, including, at times, posts like this one. The categorical specs are what make that safe: the diagram is the contract the AI generates against, and the contract we verify its output against. That is how AI speeds the work up without eroding it.

If you read this far, there is a part of Guliel built for you: a fully typed REST API and an MCP server, so you can wire your own financial operations (issue documents, pull reports, reconcile expenses) straight from your own code or an AI agent. The same method that keeps our architecture honest is what makes that surface clean enough to hand to you.

Explore the Guliel API & MCP

Sapir

architecturesoftware-designcategory-theoryaiengineering