Software Architecture

A system as a graph, principles as typed records, and the predicate set that makes an architecture real when a model writes the code.

Model

A1A system is a graph

Any system is four things: its components, the relations between them, the evaluative schema applied to that graph, and the propagation topology a change travels through. The graph at rest is what the system is. Propagation is the system in motion. A system is healthy when its behaviour is fully derivable from its own self-description.

A1.1Components, relations, schema, propagation

A system is components, relations, an evaluative schema and a propagation topology, and it is healthy when its behaviour is derivable from its self-description. Software is usually reasoned about as a pile of parts rather than as a graph, so nobody can say what a change reaches or what the system claims about itself.

A limit is declared in two places, a state is written by hand where nothing reads it back, a description goes stale beside the code it describes, and a step is performed by a person, and none of the four is anyone's defect because none of them has a name. A system described in prose has parts nobody can enumerate and relations nobody can traverse, so every question about it is answered by recollection.

Treat the system as a graph, and treat every value it cannot observe in itself as a fracture. Model a system as a dependency graph before designing any part of it. Name the components and the relation kinds. State the schema as invariants a walk can evaluate over the graph. State how a change propagates, so impact analysis is a traversal rather than a guess. Then ask of every value the system carries whether the system can observe that value in its own self-description, and treat a value that fails the question as a fracture.

Pick a component and derive what a change to it reaches, from the graph alone. Then change it and observe what actually moved. The difference between the two sets is the part of the topology nobody has modelled.

The model is a description of structure, and it says nothing about which components are good. A graph with clean edges and a bad decomposition is still a graph, and the decomposition is judged by the schema applied to it, never by the model that holds it.

A1.2Why a graph and not a list

Every question worth asking about a system is a question about reach. Which modules a change touches is a walk over the dependency graph. Which of two components knows about the other is the direction of one edge. Whether the whole can be built in one pass is whether the graph is a directed acyclic graph, and a circular dependency is the one shape that makes the answer no.

Traceability, from a requirement to the code that carries it and back, is a path. Modularity, loose coupling and high cohesion are each a statement about how many edges cross a boundary and how many stay inside it. A list can hold every one of those facts and can answer none of the questions, because a list has no edges to walk.

A1.3The four fractures

Self-describing architecture is the property the health test names, and it is stronger than observability. An observable system can be watched from outside. A self-describing one carries its own description as data it can read, and introspection over that data answers the reach questions without running anything.

The four fractures are the four ways a description and a behaviour come apart. A dual write is one fact declared twice, and it disagrees with itself the moment either copy moves. A hidden side effect is a value the system changes where nothing reads it back, so it can be wrong forever. Schema drift is a description the system cannot check against itself, right on the day it was written. Manual-only governance is a step the description does not contain, so the system behaves differently depending on who performs it.

A1.4The example is the system you have

Take any system you already have and ask of each fact where it is declared and what reads that declaration. A port written in a manifest and again in a start script is a dual write. A feature toggle flipped by hand in a console is a hidden side effect. A document that names three services where the tree holds four is schema drift. A release checklist a person walks is manual-only governance.

None of the four is a bug in the ordinary sense. All four are fractures in the derivation, and the repair for each is one shape: one declaration, and every other appearance derived from it or deleted.

The four parts of any system, and the one question that decides whether it is healthy.
flowchart TB
    components["Components · the nodes"]
    relations["Relations · the edges"]
    schema["The evaluative schema · applied to the graph"]
    propagation["The propagation topology · how a change travels"]
    rest["The graph at rest · what the system is"]
    motion["Propagation · the system in motion"]
    healthy{"Is the behaviour derivable from the self-description?"}
    yes["Healthy"]
    fracture["A fracture · dual write, hidden side effect, schema drift, manual-only governance"]
    components --> rest
    relations --> rest
    schema --> rest
    propagation --> motion
    rest --> healthy
    motion --> healthy
    healthy -- yes --> yes
    healthy -- no --> fracture
A dependency graph of one feature, layered, with the one back-edge that makes it cyclic.
flowchart LR
    subgraph domain["domain"]
        model["order model"]
        policy["pricing policy"]
    end
    subgraph application["application"]
        coordinator["checkout coordinator"]
        store["cart store"]
    end
    subgraph processing["processing"]
        converter["order converter"]
        validator["order validator"]
    end
    subgraph product["product"]
        view["checkout view"]
    end
    view --> coordinator
    coordinator --> store
    coordinator --> converter
    converter --> model
    validator --> model
    validator --> policy
    coordinator --> validator
    policy -. circular dependency .-> coordinator
One test, four ways to fail it, and the one shape that passes.
flowchart LR
    test{"Can the system observe this value in its own self-description?"}
    dual["Dual write · one fact declared twice"]
    hidden["Hidden side effect · a value nothing reads back"]
    drift["Schema drift · a description the system cannot check"]
    manual["Manual-only governance · a step the description does not contain"]
    closed["Closed · one declaration, derived everywhere"]
    test -- no --> dual
    test -- no --> hidden
    test -- no --> drift
    test -- no --> manual
    test -- yes --> closed

B1Definitions own what, code owns how

Definitions own what and code owns how, and code never redeclares what a definition already declares. A registry declares what variants exist and the code discovers them, a schema declares what a record carries and the code validates against it, a manifest declares what a module is for and the code derives its surface.

B1.1One declaration, derived everywhere

Definitions own what, code owns how, and a fact with two declarations and no derivation between them is a fracture. Code that restates a definition looks complete on the day it is written and becomes a second truth the day the definition moves.

A registry lists twelve variants, a switch in the composer handles eleven, and the twelfth exists everywhere except where it is dispatched, because the switch was a second declaration nobody knew was one. Restating a definition in code is cheaper than reading it at the moment of writing, and the cost only arrives when one of the two copies changes and the other keeps the old truth.

Let definitions own what, let code own how, and derive rather than restate. Decide for every fact which side owns it. A fact about what exists goes into a definition the code reads, and the code derives everything else from it: the list of variants, the shape of a record, the surface of a module. Where a fact already has a definition, delete the copy in the code and read the definition instead. Where the code holds a fact nothing declares, write the declaration and make the code derive it, because a fact that lives only in behaviour cannot be checked without running the behaviour.

Take any fact the system carries and count the places it is stated. One, plus derivations, is the target. Two statements with no edge between them will disagree, and the only question is when someone notices.

A definition declares what and never how. A schema that carries a validation routine, or a manifest that carries a build step, has crossed into code and gained a second implementation of something the code already does; the split holds only while each side stays on its own side.

B1.2The patterns that keep the split

This is single source of truth stated for a whole system rather than for a database. Declarative configuration states what is wanted and leaves the how to whatever reads it. Manifest-based design puts what a module is for into data beside the module, so its surface is derived rather than described. Metadata-driven design lets the description drive the behaviour.

That is where code as data starts to pay. A definition that is data can be inspected, transformed, validated and generated from. A definition that is code can only be run. The registry pattern with auto-discovery applies the same idea to variants: the registry declares that variants exist, the tree holds one file per variant, and a glob-resolvable tree lets the code find them without a list that must be edited when one is added.

Convention over configuration is the honest complement. A convention is a definition too, written once as a rule the reader derives from rather than a value the reader looks up.

B1.3The model as a type

The graph model is what makes the split checkable. A declaration is a node, a derivation is an edge, and a fact with two nodes and no edge between them is the dual write in the graph's own terms. The model as a type is small, and its smallness is the point.

A component carries an identity, a concern and a layer. A relation carries its two ends and a kind from a closed vocabulary, so a new relation kind is a vocabulary edit rather than a new field. The schema carries the invariants and one evaluation over the graph, and schema validation is that evaluation run over every record that claims the shape. Health is one derivation: evaluate the schema against the system's description of itself, and an empty finding set is a healthy system.

B1.4Where the line sits

A definition may say a record has a name and a kind from a closed set. It may not say how the kind is checked, because checking is behaviour. A manifest may say a module publishes three entry points. It may not build them.

The moment a definition carries a procedure it has become a second implementation. The moment code carries a fact it has become a second declaration. Either crossing produces the same defect, a truth held in two places with no edge between them, and both are found by the same count: one declaration, plus derivations, and nothing else.

A derivation is an edge from the definition; a restatement is a second node with no edge.
flowchart LR
    definition["A definition · what exists"]
    code["Code · how it behaves"]
    derived["A derivation · read from the definition"]
    restated["A restatement · written twice"]
    definition --> derived --> code
    definition -. never .-> restated
    restated -. disagrees the moment either copy moves .-> code

C1The layer spine

Systems decompose along one spine: domain, application, processing, runtime, infrastructure, operations, product. Every concern a file can play is tagged to one of those seven, so the layer of a file is read from its concern rather than guessed from its folder. The spine is a classification axis. It says what kind of thing a file is, and nothing about who may import whom.

C1.1Classification

The layer spine classifies what a file is, and a file's layer is read from its concern. Layering is usually a diagram and a hope: nobody can say which layer a given file is on, because the layer was never derived from anything the file declares.

A converter sits in a folder named for the feature it serves, the feature is renamed, and every rule that keyed on the folder now sees a file of no layer at all. A layer inferred from a folder name changes when the folder is renamed, and a layer inferred from a file's importance is argued at every review, so only a layer derived from the concern stays true without attention.

Classify by concern on the spine, and read the layer from the concern. Tag every concern in the vocabulary to one layer of the spine, and let a file's layer follow from its concern. Classify a file by reading what it does, never by the folder it happens to sit in, and where a file fits two concerns equally well treat it as two files rather than as a tie to break. Hold the tagging in data a check reads, so a layer is a derivation from the concern and never a fact somebody remembers.

Take a file and derive its layer from its concern tag alone, without opening it. A file whose layer cannot be derived is outside the model, and a file whose derived layer surprises you is misclassified, or is two files.

The spine orders kinds of thing and never orders importance. A product-layer file is not lower than a domain-layer file, and a layer is never a folder: two files in one concern folder sit on the same layer because their concern does, whatever the folder above them is called.

C1.2Belonging by kind, never by folder

Every layered architecture has to answer what makes a thing belong to a layer, and most answer it by folder. That is a rule about placement and none about kind, so the layer of a file is whatever its author believed on the day. Clean architecture and hexagonal architecture answer the direction question well and leave this one to taste.

The spine answers it by kind. Every concern in a closed vocabulary is tagged to one layer, the concern is decided by reading what the file does under one concern per file and the narrowest concern that fits, and the layer is a derivation. Concern-folder correspondence makes the derivation visible in the tree: the folder names the concern, the concern names the layer, and nobody remembers anything.

C1.3The seven layers

The seven layers map onto how a system decomposes rather than onto how a team is organised. The domain holds what the system is about: its models, records, policies and specifications. The application holds what it does with that: coordinators, behaviours, intents, selectors, stores. Processing holds transformation: converters, normalizers, analyzers, validators, pipelines.

Runtime holds what runs and when: entrypoints, lifecycles, timers, pools. Infrastructure holds what everything else stands on: registries, factories, adapters, resolvers, constants, schemas, the vocabulary itself. Operations holds observation and upkeep: probes, counters, reporters. Product holds what a person meets: views, components, renderers, styles, the strings.

A concern belongs to exactly one layer, and a concern whose layer is contested is two concerns. Layer spine precedence is the one tie-break the canon holds: a file that genuinely fits two concerns classifies to the domain-ward one. It is a classification rule and never a dependency rule, because one concern per file and the narrowest concern are what the classification protects.

C1.4A converter, placed twice

Take a converter. It takes one shape and returns another, so it is processing whatever it converts and whichever feature asked for it. Put it in a folder named for the feature and it has a home but no layer, and the next feature that needs the same conversion either reaches across a boundary or copies the file.

Put it under its concern and the layer follows, the second feature finds it where the concern says it is, and a check can hold that nothing in processing reaches into product. That is separation of concerns with a mechanism behind it. Package by feature answers a different question, how a team navigates, and a feature cuts across every layer as a layer cuts across every feature, so only one of the two can be the folder.

The spine: seven layers a concern is tagged to.
block-beta
    columns 1
    domain["domain · what the system is about"]
    application["application · what it does with that"]
    processing["processing · how data is transformed"]
    runtime["runtime · what runs and when"]
    infrastructure["infrastructure · what everything else stands on"]
    operations["operations · how it is observed and kept alive"]
    product["product · what a person meets"]
Concerns tagged to layers: the file's layer is read off its concern.
flowchart LR
    converter["converter"] --> processing["processing"]
    validator["validator"] --> processing
    registry["registry"] --> infrastructure["infrastructure"]
    factory["factory"] --> infrastructure
    view["view"] --> product["product"]
    renderer["renderer"] --> product
    model["model"] --> domain["domain"]
    policy["policy"] --> domain

D1The direction axis

The direction axis has two tiers and one rule. The engine knows nothing about any specific consumer, and a consumer depends on the engine, so the direction is one way and an import from the engine into a consumer is a blocker. The axis is orthogonal to the spine: the spine says what kind of thing a file is, the direction says who may depend on whom.

D1.1Engine and consumer

The dependency direction is an orthogonal axis: one way, from consumer to engine, held by its own check. Nothing refuses the import that crosses the wrong way, so the engine slowly learns about its consumers one convenient import at a time.

A shared module gains one import from a page, the page changes, the module now breaks on every page, and the layering that was supposed to prevent that never had a rule behind it. A dependency rule that has no check behind it is a diagram, and the first import that crosses the wrong way is the one that was convenient that afternoon.

Govern dependency direction on its own axis, held by its own check, and repair a crossing by moving the file. Name which tier is the engine and which is the consumer. Classify each container by prefix, and classify per file only the trees a prefix cannot decide. Hold both in data a check reads, and let the check refuse an import that runs from the engine toward a consumer. Repair a wrong-way import by moving the file to the module whose layer and entry point satisfy every dependency it has, never by a trick that makes the graph pass while the structure stays wrong.

Take any import and ask which tier each end is on. An import whose ends nobody can tier is outside the model, and an import that runs from the engine toward a consumer is a move waiting to happen.

The direction rule governs dependencies and says nothing about classification. A file is not on the engine tier because it is generic, and a consumer is not lower because it is specific; the tier is a fact declared about a tree, and the check reads the fact rather than inferring it.

D1.2One rule, many pictures

This is the dependency inversion principle drawn at the scale of a whole tree, and the canon's architecture styles are each one way of drawing it. Hexagonal architecture, ports and adapters architecture and clean architecture put the thing that knows nothing at the centre and let everything specific depend inward. Layered architecture draws the same arrow downward.

What they share is one direction and one rule. What they differ on is a picture, and the picture is not the mechanism. The mechanism is a tier declared for every file, a check that reads the dependency graph and refuses an edge from the engine toward a consumer, and a repair that is always a move.

Inversion of control and dependency injection are the two techniques the rule pushes you toward. The only way an engine uses something specific without knowing it is to be handed it, and extension points with runtime discovery are how the engine finds the consumers it must not import.

D1.3Where a tier comes from

Most of a tree classifies by where it sits, because a container is built for one tier and everything under it inherits that. A few kinds of file resist that reading. Copy and type declarations serve whichever side names them, so their location says nothing about their tier, and those are classified one file at a time in data that starts empty.

What a file with no entry resolves to is a decision with a reason, not a default that fell out of the code. A type nobody classified resolves to no tier, so the check treats it as unclassified rather than guessing a side. Copy nobody classified resolves to the consumer tier, because copy is nearly always specific to one product. The two defaults differ because the cost of a wrong guess differs, and that is the general rule: a default is chosen by which mistake is cheaper to discover.

D1.4The repair is a move

The check reads a dependency graph derived from the tree, never the tree's claims about itself, and it fails closed: a missing graph is a refusal, not a pass over nothing. It refuses rather than repairs, because the only honest repair for a wrong-way import is to move the file.

A file that produces a circular dependency, an upward dependency or a bypass of a declared entry point is in the wrong module. Lazy evaluation of an import, a direct path past the entry point, a re-export across modules and an import-sort trick each make the graph pass while the structure stays wrong. Concrete coupling and inappropriate intimacy are what those tricks preserve: the engine still knows a consumer, only through a door nothing watches. Encapsulation and information hiding are what the entry point protects.

The direction axis: one way, classified by prefix and overridden per file where a prefix cannot decide.
flowchart TB
    engine["The engine tier · knows nothing about any consumer"]
    consumer["The consumer tier · depends on the engine"]
    consumer -- imports --> engine
    engine -. never .-> consumer
    prefix["Classified by container prefix"]
    override["Two trees a prefix cannot decide · classified per file"]
    prefix --> engine
    prefix --> consumer
    override --> engine
    override --> consumer
A wrong-way edge has one repair, and every trick leaves the structure wrong.
flowchart LR
    edge["An edge from the engine toward a consumer"]
    trick["A trick · lazy import, direct path, re-export, sort order"]
    move["A move · to the module whose layer and entry point satisfy every dependency"]
    lies["The graph passes and the structure stays wrong"]
    holds["The graph and the structure agree"]
    edge -. tempting .-> trick --> lies
    edge --> move --> holds