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:#000In 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.
Requirements. What does the user need to accomplish?
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.
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.
Source data and transformations. What do I need to store, and what algorithms turn the stored shape into the result shape?
Locations. Where does each of those things physically live and run: which server, which thread, the browser, the database?
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:#fffStep 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.customeris 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:#000The 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:#fffIn 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 |
|
Chain (formal: composition) | follow one arrow, then the next |
|
|
Commutes | two routes the design declares equal really do give the same result |
|
|
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 |
|
Same shape (formal: isomorphic) | an arrow each way between two things, where both round trips change nothing |
|
|
Optional arrow (formal: partial morphism) | an arrow defined for only some items | a nullable column |
|
Log of X (formal: free monoid) | an append-only list of X | an events table ordered by time |
|
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 |
|---|---|---|
|
| the input type the transformation consumes |
|
| 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 |
|---|---|---|
|
| the location the data leaves |
|
| the location the data arrives at |
|
| 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 |
|---|---|---|
|
| this transformation, deployed at this location, inside this component |
|
| this kind of data, stored at this location, inside this component |
|
| 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:#fffGluing 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.
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 withcarries = t_from(T)andc_to = L. If the server-sidevalidatereads a form payload and no transmission carries that payload toRouteHandler, Law 1 fails and names the missing wire.Typed transmissions. Every transmission carries a named kind of data and really crosses a boundary (start ≠ end). Formally:
c_from,c_toandcarriesare 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.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.
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.
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.
Many places. A transformation can run at zero, one or many locations; a single "runs at" column is wrong. Encode placement as
TrnLocrows.
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:#fffA 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:
| target |
| target |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:#fffSo 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:#fffRead 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:#fffRule 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:#fffFollowing 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:#fffThat 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:#fffThe 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:#fffcounterparty 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:#fffThat 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:#fffThis 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:#fffBecause 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:#fffBelow 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:#fffThere 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:#000The 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 |
|---|---|---|
|
| Presentation: draws the screen. No side effects |
|
| Business: turns a request into a command. No side effects |
|
| Data access: queries go down, records come back (or go down to be saved). Side effect: database reads and writes |
|
| Database: runs the statement. Side effect: touches stored data |
The links between layers run only between neighbours:
Name | From → To | What travels |
|---|---|---|
|
|
|
|
|
|
|
|
|
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:#fffLayered 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) + 1exactly); 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:#fffvalidate 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:#000As 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 |
|---|---|---|
|
| the identity both sides agree on |
|
| the resource's payload |
|
| 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 |
|---|---|---|
|
| At Server. Side effect: reads the store |
|
| At Server. Side effect: writes the store, the only code that does |
|
| At Client. No side effects |
|
| 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 |
|---|---|---|
|
|
|
|
|
|
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
Resourceis a separate placement, a copy thatt_responsedelivered. Amutatethat must produce the authoritativeResourcereads itsCommandagainst the real store, so Law 1 allows it only atServer.Validation that runs twice is correct. One
validateatClientkeeps the UI snappy; the one atServerdoes the real enforcement. It looks like duplication someone should clean up, but it is two realisations of oneCommand → Commandcontract, 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.
Serveris 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:#f77f7fNothing 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:#000As 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 |
|---|---|---|
|
| the current authoritative value |
|
| 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 |
|---|---|---|
|
| The View itself: draws what it was handed. No side effects |
|
| Interprets a user gesture. No side effects |
|
| 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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
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 → ModelCmpand noneModelCmp → ViewCmp. Every dependency between View and Model is carried byMediatorCmp.
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:#000What follows.
The View is a transformation, so you can test it.
render : ViewState → DOMhas an input type and an output type like any other code: hand it aViewStateoff-screen and check what it draws, with no user and no Model in the test. The step fromModeltoViewState,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_pushis 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 neitherViewCmpnorMediatorCmp.A second View is one more component: a new
ViewCmpwith its ownrenderplacement and one newt_input/t_pushpair to the Mediator.ModelCmpis 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:#fffNothing 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:#fffThe 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 |
|---|---|---|
|
| turns a draft into an order. Side effect: saves it |
|
| 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 |
|---|---|---|
|
|
|
|
|
|
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
dlanddl′: ifdl_data(dl) = dl_data(dl′)thendl_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:#fffEvery 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:#000The 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 |
|---|---|---|
|
| the logical destination; this is the routing key |
|
| the domain data, in transit |
|
| 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 |
|---|---|---|
|
| a Billing transformation. Side effect: charges the card |
|
| the bus picks the second hop. No side effects |
|
| the bus's protocol bridge. No side effects |
|
| 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 |
|---|---|---|
|
|
|
|
|
|
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, thent_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
tbetween two different service components,c_from(t) = Busorc_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_inpair 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,translateandorchestrateare placed insideBusCmp, 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:#fffService 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:#fffOnly 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:#000The 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 |
|---|---|---|
|
| a service transformation. Side effect: saves the order |
|
| runs in the proxy. Side effect: encrypts outgoing traffic, verifies incoming |
|
| runs in the proxy. Side effect: resends the request on failure |
|
| 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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
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, thent_wire, thent_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,retryandtraceare 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.
retryandmtlsare 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:#fffThere 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:#fffThe 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 |
|---|---|---|
|
| the job's data |
|
| when it was enqueued |
|
| 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 |
|---|---|---|
|
| wraps a job as a message. Side effect: appends it to the queue |
|
| the broker's routing: pick one. No side effects |
|
| 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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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, thent_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 eachMessagethere is exactly onet_delivertransmission, ending at oneWorker.
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 ownhandleplacement and one newt_deliver. Becausematchpicks 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_enqueuewrites to the log onBrokerQueueandt_deliverreads 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 thet_acktransmission 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_offsetputs every message in one total order, andmatchtakes 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:#fffData-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:#fffThe 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:#000No 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 |
|---|---|---|
| data types | the type at each cut between filters: |
Each filter is exactly one transformation. A pure transformation computes its output and touches nothing else.
Transformation | From → To | Kind |
|---|---|---|
|
| pure |
|
| pure |
|
| 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 |
|---|---|---|
|
|
|
|
|
|
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ₙ. Eachcᵢholds exactly one code placement, of one transformationfᵢ. For every linkcᵢ → 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, thenscore, thenrender
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
gbetweenfᵢandfᵢ₊₁exactly whent_to(fᵢ) = t_from(g)andt_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:#fffThe 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:#fffOnly 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 |
|---|---|---|
|
| which write-side truth this view flattens (denormalises) |
|
| 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 |
|---|---|---|
|
| side effect: writes |
|
| pure: flattens the truth into a view |
|
| pure: read-only, never touches |
The write and read stores are different locations, WriteDB and ReadDB:
Transmission | From → To | Carries |
|---|---|---|
|
|
|
|
|
|
|
|
|
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 isprojectof 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.
ReadDBis correct exactly as far ast_projecthas run, and a query that readsReadModelbeforeprojectcatches up sees an oldrm_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.
ReadDBcan be replicated, re-indexed or reshaped for the screens without touchingWriteDB. 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 forproject'and a new data placement forReadModel', added alongside the existing ones over the same, unchangedState. The write side can't observe the addition.The cost sits on the
projectedge: 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:#fffThere 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:#fffWriting 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 |
|---|---|---|
|
| which kind of change |
|
| the change's data |
|
| 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 |
|---|---|---|
|
| side effect: writes the store; the only write |
|
| pure: one per |
|
| pure: |
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
Statevalue isfoldof 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
foldaccepts 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 differenthandlegives a new projection over the same history: a new transformation added alongside, with the stored log unchanged.A cached
Stateis 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 asfoldsays 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,foldhas to handle every event shape the log has ever held. Both costs live on the singlet_replayedge.
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:#fffEvent 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:#fffOne 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:#000Steps 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 |
|---|---|---|
|
| what kind of event this is; the one thing both sides share |
|
| the event's data |
|
| 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 |
|---|---|---|
|
| turns something that happened into an event. Side effect: appends to the stream |
|
| the broker's routing: who wants this event. No side effects |
|
| one consumer step, one version per |
| log of | rebuilds state by running |
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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, thent_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 ownhandleandfold, oneSubscriptionand one newt_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_publishwrites to the log inBrokerLogandt_deliverreads from it later. The log is the buffer.Fan-out is
match. It turns one event into onet_deliverper 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
Subscriptiondata and inmatch.
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:#fffHexagonal (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:#fffThe 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:#000The 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 |
|---|---|---|
|
| the core business rule. No side effects |
|
| the port's contract, a signature that adapters fill. Side effect: stores the draft |
|
| the Postgres adapter. Side effect: writes to Postgres |
|
| 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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
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 → SaveResultcode 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:#fffOne 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:#000The 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 |
|---|---|---|
|
| the function body. Side effect: writes the changed |
|
| fetches a record. Side effect: reads from the managed store |
| no input → | 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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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, thenhandle, thent_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
FunctionCmpowns no durable location, the only place aRecordcan rest isStateLocinStateCmp, a managed database or object store. The split betweenEphLocandStateLocis 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
handleneeds a transmission that ends atEphLoc. Load the row, open the connection, fetch the config:t_loadandconnectfire 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 samehandle. 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:#fffPeer-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:#fffThe 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:#000The 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 |
|---|---|---|
|
| the identity all peers agree on |
|
| the resource's payload |
|
| 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 |
|---|---|---|
|
| answers a peer. Side effect: reads the local store |
|
| asks a peer for what is missing. Side effect: sends a query over the network |
| two | reconciles two copies by |
|
| 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 |
|---|---|---|
|
|
|
|
|
|
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
iandj, there is a transmission fromPeer itoPeer jexactly when there is one fromPeer jtoPeer 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, thent_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
PeerCmpleaves 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:
mergerun 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 anIdto 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:#fffA 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:#fffAnd 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.
Sapir