Architecture review
In short: An architecture review reads the real dependencies from the code, finds arrows pointing the wrong way and cycles, and turns the rules into a fitness function that guards them on every commit.
Problem
The diagram says "layers"
You inherit the online shop. The README has a diagram: four layers, arrows only downwards.
web web/shopservice service/orders service/paymentsdomain domain/pricingdata data/orders
web → service → domain → dataYour first task is small: the price on the page should have two decimal places. You change formatPrice in web/shop, and the payment tests fail. It turns out that domain/pricing uses formatPrice from web/shop, and orders and payments call each other.
The diagram did not lie on purpose. It was true two years ago, and then nobody checked it.
The idea
The truth is in the imports
An architecture review answers a few questions about a system that already runs:
- What are the real boundaries? Which module calls which, according to the code.
- Are the rules followed? The layers from "Layers", the direction of dependencies from "Hexagonal", the module boundaries from "Monolith, modular monolith, services".
- Do the requirements hold? The percentiles and availability from "Non-functional requirements", measured on the live system, not promised.
- What can go wrong? The risks, ranked.
- Which decisions did nobody record? For those you write ADRs after the fact, so it is clear why the system is the way it is.
Here we start with the first two. Every import is an arrow from one module to another. A tool pulls them out of the code as a list, one dependency per line:
web/shop -> service/ordersservice/orders -> domain/pricingservice/orders -> service/paymentsservice/payments -> service/ordersservice/orders -> data/ordersdomain/pricing -> web/shopThe program turns the list into a graph and looks for two things. An upward arrow: a lower layer calls a higher one. A cycle: a module that reaches itself again through other modules. A cycle means the two modules cannot be changed, tested or shipped separately. Cycles are found with a depth-first search with colours.
See it
Declared against real
const layer = (module) => LAYERS.indexOf(module.split("/")[0]);
// a lower layer calls a higher one: an upward arrowfunction violations(deps) { return deps .filter(([from, to]) => layer(from) > layer(to)) .map(([from, to]) => from + " -> " + to);}
function findCycles(deps) { const next = new Map(); for (const [from, to] of deps) { if (!next.has(from)) next.set(from, []); if (!next.has(to)) next.set(to, []); next.get(from).push(to); } const mark = new Map(); const path = []; const found = []; const visit = (at) => { mark.set(at, "grey"); // grey: on the current path path.push(at); for (const to of next.get(at)) { if (!mark.has(to)) visit(to); else if (mark.get(to) === "grey") { const loop = path.slice(path.indexOf(to)); found.push([...loop, to].join(" -> ")); } } path.pop(); mark.set(at, "black"); // black: done }; for (const [from] of deps) if (!mark.has(from)) visit(from); return found;}Step by step, in words
- The diagram in the README: four layers, arrows only downwards. Every module sits in its layer.
- The tool reads the imports: 6 real dependencies. Most point down or stay within a layer.
- domain/pricing imports web/shop: layer 2 calls layer 0. The arrow points upwards.
- A depth-first walk starts at web/shop. Every module on the current path is grey.
- domain/pricing points back to the grey web/shop: a cycle, web/shop → service/orders → domain/pricing → web/shop.
- The walk goes on: service/payments points back to the grey service/orders. A second cycle.
- Every module is done (black). Declared: layers, arrows only down. Real: 1 upward arrow and 2 cycles.
The layer rule is one comparison: the layer index grows downwards, so an upward arrow is layer(from) > layer(to).
const LAYERS = ["web", "service", "domain", "data"];const layer = (module) => LAYERS.indexOf(module.split("/")[0]);
// a lower layer calls a higher one: an upward arrowfunction violations(deps) { return deps .filter(([from, to]) => layer(from) > layer(to)) .map(([from, to]) => from + " -> " + to);}A depth-first search finds the cycles. A grey module is on the current path, a black one is fully explored. An arrow to a grey module goes back along the path: that is a cycle, and the cycle is the part of the path from that module until now.
function findCycles(deps) { const next = new Map(); for (const [from, to] of deps) { if (!next.has(from)) next.set(from, []); if (!next.has(to)) next.set(to, []); next.get(from).push(to); } const mark = new Map(); const path = []; const found = []; const visit = (at) => { mark.set(at, "grey"); // grey: on the current path path.push(at); for (const to of next.get(at)) { if (!mark.has(to)) visit(to); else if (mark.get(to) === "grey") { const loop = path.slice(path.indexOf(to)); found.push([...loop, to].join(" -> ")); } } path.pop(); mark.set(at, "black"); // black: done }; for (const [from] of deps) if (!mark.has(from)) visit(from); return found;}Map keeps insertion order, so the cycles come out in the order of the dependency list. visit is an arrow function and sees mark, path and found from outside.The whole review on the list of imports:
const deps = parse(IMPORTS);console.log("Dependencies: " + deps.length);show("Layer violations", violations(deps));show("Cycles", findCycles(deps));Dependencies: 6Layer violations: domain/pricing -> web/shopCycles: web/shop -> service/orders -> domain/pricing -> web/shop service/orders -> service/payments -> service/ordersThe one violation and the two cycles are not three separate problems. domain/pricing -> web/shop is both the violation and the arrow that closes the first cycle: fix it, and both go away.
Try it
Fill in
Which comparison catches the upward arrow? The web layer has index 0 and data has index 3.
deps.filter(([from, to]) => layer(from) layer(to))>: the module the arrow starts from is in a lower layer (a bigger index) than the module it points to. An arrow within the same layer (service/orders -> service/payments) does not break the layers, but it can be part of a cycle.
Try it
Find the bug
A colleague "simplified" visit. Now the review prints:
Dependencies: 6Layer violations: domain/pricing -> web/shopCycles: nonePick the line with the bug.
The module becomes black on entry instead of grey. Then no module is ever grey, the check for an arrow back is never true, and the cycles disappear. The worst part is that the output looks calm: "none". A check that stays silent must also be tested with input where you know there is a cycle.
The idea
Fitness function: the review on every commit
A review is a snapshot. Tomorrow someone adds a new import and the snapshot is out of date. A fitness function is an automatic check of an architectural characteristic: it runs on every commit, like a test, and fails when the rule is broken. The name comes from the book "Building Evolutionary Architectures" (Ford, Parsons, Kua).
The inherited system already has a violation. If the check fails because of it, it will fail every day and the team will switch it off. So the old debt goes into a baseline: the check fails only for a new violation, and a fixed one must leave the baseline so it cannot come back unnoticed. This way the debt only shrinks.
// runs on every commit: the old debt is in the baselinefunction fitness(deps, baseline) { const now = violations(deps); return [ ...now.filter((v) => !baseline.includes(v)).map((v) => "✗ new upward dependency: " + v), ...baseline.filter((old) => !now.includes(old)).map((old) => "✓ fixed, remove it from the baseline: " + old), ];}
function check(commit, deps, baseline) { const report = fitness(deps, baseline); const fails = report.some((r) => r.startsWith("✗")); console.log(commit + ": " + (fails ? "FAILS" : "OK")); for (const r of report) console.log(" " + r);}Commit 1 adds domain/pricing -> service/payments: pricing wants the payment fee. Commit 2 moves formatPrice into the domain and removes the old violation:
const deps = parse(IMPORTS);const baseline = ["domain/pricing -> web/shop"];
const first = [...deps, ["domain/pricing", "service/payments"]];check("Commit 1", first, baseline);
const second = deps.filter(([from, to]) => from + " -> " + to !== "domain/pricing -> web/shop");check("Commit 2", second, baseline);Commit 1: FAILS ✗ new upward dependency: domain/pricing -> service/paymentsCommit 2: OK ✓ fixed, remove it from the baseline: domain/pricing -> web/shopA fitness function is not only for dependencies. The same idea guards other characteristics:
- the p95 of payment in the load test stays under the budget from "Non-functional requirements";
- the sellers module calls only the public API of orders, not its internals;
- the page's JavaScript bundle is under 200 KB (an example limit);
- every ADR has a "Consequences" section.
How exactly the check runs in the pipeline (CI) is a topic for ZeroToOps, the DevOps course. Here is the part the programmer writes: the check itself.
The idea
Risk register
The review finds more than there is time to fix. A risk register puts it in order. For each risk you record:
- what can happen;
- how likely it is, from 1 to 5;
- how much it hurts if it happens (impact), from 1 to 5;
- what you do about it and who owns it.
The score is probability × impact. You sort by it, and on a tie by impact: the rare but heavy event comes before the frequent, light one.
class Risk { constructor(name, probability, impact, response = "") { Object.assign(this, { name, probability, impact, response }); }
get score() { return this.probability * this.impact; }
get level() { return this.score >= 12 ? "high" : this.score >= 6 ? "medium" : "low"; }}// by score; on a tie, the bigger impact goes firstfunction ranked(risks) { return [...risks].sort((a, b) => b.score - a.score || b.impact - a.impact);}The risks from the review of the shop:
const risks = [ new Risk("cycle orders ⇄ payments", 4, 3, "reduce: an event instead of a call back"), new Risk("payments have no timeout and slow orders down", 3, 5, "reduce: a timeout and a breaker"), new Risk("the domain calls web", 4, 2, "accept: fixed with the next pricing change"), new Risk("restoring from backup was never tried", 2, 5, "reduce: a test restore every month"), new Risk("only one person knows the 2009 warehouse", 3, 4, "reduce: a second person and a write-up"), new Risk("the PDF library for invoices is unmaintained", 2, 2, "accept"),];for (const r of ranked(risks)) console.log(`${String(r.score).padStart(2)} ${r.level.padEnd(6)} ${r.name} → ${r.response}`);15 high payments have no timeout and slow orders down → reduce: a timeout and a breaker12 high only one person knows the 2009 warehouse → reduce: a second person and a write-up12 high cycle orders ⇄ payments → reduce: an event instead of a call back10 medium restoring from backup was never tried → reduce: a test restore every month 8 medium the domain calls web → accept: fixed with the next pricing change 4 low the PDF library for invoices is unmaintained → acceptThere are four responses:
- avoid: remove the cause, for example do not ship the feature;
- reduce: the probability or the impact, for example a timeout and a circuit breaker from "When the neighbour is slow";
- transfer: to someone else, for example insurance or a contract with the provider;
- accept: consciously and in writing, with a date for another look.
The numbers from 1 to 5 are a judgement, not a measurement. 12 is not "twice as bad" as 6. They are there to sort and to start the conversation, not to calculate with.
b.score - a.score || b.impact - a.impact: if the difference in score is 0, || moves on to the impact.Try it
Predict the output
Four risks from another review, sorted with the same ranked. What does the program print?
const risks = [ new Risk("the email provider changes its API", 3, 2), new Risk("the site certificate is renewed by hand", 3, 3), new Risk("the warehouse goes down on Black Friday", 2, 5), new Risk("monthly reports are late", 5, 1),];for (const r of ranked(risks)) console.log(r.score, r.name);The scores are 6, 9, 10 and 5. From top to bottom: the warehouse on Black Friday (2 × 5 = 10), the certificate (3 × 3 = 9), the emails (3 × 2 = 6), the reports (5 × 1 = 5). The reports are the most likely, but they hurt the least if they are late.
The idea
When the review does harm
A review is a tool, not a ritual. It does harm when:
- it is an exam for the team. A review that does not invite the people who wrote the code finds culprits, not risks. They know why things are the way they are.
- the fitness function fails from noise. A flaky check (a load test on a shared machine) fails for no reason and gets switched off. One stable rule guards more than ten flaky ones.
- the baseline only grows. Then it is a list of excuses. Watch its size over time.
- the register sits in a folder. A risk with no owner and no date for another look is a forgotten risk.
- the imports are the whole truth. They are not. A shared database table, a call over HTTP and a queue connect modules without an import. Modules that read each other's tables ("Monolith, modular monolith, services") do not show in the import graph.
More precisely, if you are curious
The tools. Nobody writes the dependency check from scratch. Java has ArchUnit (the rules are JUnit tests) and jdeps in the JDK; C# has ArchUnitNET and NetArchTest; Python has import-linter; JavaScript has dependency-cruiser and madge (for cycles). All of them pull the graph from the code and check rules like the ones in this lesson, including a baseline: in ArchUnit it is called FreezingArchRule.
How many cycles are there? The search in this lesson finds one arrow back for each cycle it meets. That is not the number of all cycles: if you run the review after domain/pricing -> service/payments, the path to payments goes through pricing, and the small cycle "orders ⇄ payments" is not counted separately. Tools look for strongly connected components (Tarjan's algorithm): the groups of modules where every one reaches every other. The question "which modules are in a cycle" is more useful than "how many cycles".
Review methods. ATAM (Architecture Tradeoff Analysis Method, from the SEI) is a formal review with quality scenarios and the stakeholders. The C4 model is a way to draw the architecture at four levels of detail, so that the diagram answers the reader's question.
Exercise
Cycles in the check too
Solve this one in your editor.
Add a second rule to the fitness function: the number of cycles must not grow above the cycle baseline (2 today). Write checkAll(deps, baseline, cycleBaseline), which prints all findings and at the end whether it fails. Run it with a commit that adds data/orders -> service/orders: the data layer calls the service to send an email after a save.
✗ new upward dependency: data/orders -> service/orders✗ cycles: 3, baseline: 2FAILSMore exercises
- Who is in a cycle. Instead of the number of cycles, keep the set of modules that are in some cycle. Which rule is harder to get around?
- Modules in a layer. Add a rule:
service/paymentsmust not callservice/ordersdirectly, only through an event (the lesson "Events and queues"). How do you put it in the baseline until the cycle is fixed? - The register. Give every risk an owner and a date for another look. Print the risks with a score of 12 or more that have no owner.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Events and queues · ADR: architecture decision records · Non-functional requirements you can measure · When the neighbour is slow: timeout, retry, circuit breaker
- Continues in: The enterprise landscape and capabilities · Your first architecture task
- Tasks on this topic: The architect at work