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
def layer(module): return LAYERS.index(module.split("/")[0])
# a lower layer calls a higher one: an upward arrowdef violations(deps): return [f"{a} -> {b}" for a, b in deps if layer(a) > layer(b)]
def find_cycles(deps): nxt = {} for a, b in deps: nxt.setdefault(a, []).append(b) nxt.setdefault(b, []) mark, path, found = {}, [], []
def visit(at): mark[at] = "grey" # grey: on the current path path.append(at) for to in nxt[at]: if to not in mark: visit(to) elif mark[to] == "grey": loop = path[path.index(to):] + [to] found.append(" -> ".join(loop)) path.pop() mark[at] = "black" # black: done
for a, _ in deps: if a not in mark: visit(a) return foundStep 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).
LAYERS = ["web", "service", "domain", "data"]
def layer(module): return LAYERS.index(module.split("/")[0])
# a lower layer calls a higher one: an upward arrowdef violations(deps): return [f"{a} -> {b}" for a, b in deps if layer(a) > layer(b)]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.
def find_cycles(deps): nxt = {} for a, b in deps: nxt.setdefault(a, []).append(b) nxt.setdefault(b, []) mark, path, found = {}, [], []
def visit(at): mark[at] = "grey" # grey: on the current path path.append(at) for to in nxt[at]: if to not in mark: visit(to) elif mark[to] == "grey": loop = path[path.index(to):] + [to] found.append(" -> ".join(loop)) path.pop() mark[at] = "black" # black: done
for a, _ in deps: if a not in mark: visit(a) return foundvisit is a function inside the function and sees mark, path and found from outside, without passing them. Recursion in Python stops at about 1000 levels: for a graph with thousands of modules the search is written with a loop and its own stack.The whole review on the list of imports:
deps = parse(IMPORTS)print(f"Dependencies: {len(deps)}")show("Layer violations", violations(deps))show("Cycles", find_cycles(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.
[f"{a} -> {b}" for a, b in deps if layer(a) layer(b)]>: 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 baselinedef fitness(deps, baseline): now = violations(deps) report = [f"✗ new upward dependency: {v}" for v in now if v not in baseline] report += [f"✓ fixed, remove it from the baseline: {old}" for old in baseline if old not in now] return report
def check(commit, deps, baseline): report = fitness(deps, baseline) fails = any(r.startswith("✗") for r in report) print(f"{commit}: {'FAILS' if fails else 'OK'}") for r in report: print(" " + 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:
deps = parse(IMPORTS)baseline = ["domain/pricing -> web/shop"]
first = deps + [("domain/pricing", "service/payments")]check("Commit 1", first, baseline)
second = [d for d in deps if d != tuple("domain/pricing -> web/shop".split(" -> "))]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.
@dataclassclass Risk: name: str probability: int # 1..5 impact: int # 1..5 response: str = ""
@property def score(self): return self.probability * self.impact
@property def level(self): return "high" if self.score >= 12 else "medium" if self.score >= 6 else "low"# by score; on a tie, the bigger impact goes firstdef ranked(risks): return sorted(risks, key=lambda r: (r.score, r.impact), reverse=True)The risks from the review of the shop:
risks = [ Risk("cycle orders ⇄ payments", 4, 3, "reduce: an event instead of a call back"), Risk("payments have no timeout and slow orders down", 3, 5, "reduce: a timeout and a breaker"), Risk("the domain calls web", 4, 2, "accept: fixed with the next pricing change"), Risk("restoring from backup was never tried", 2, 5, "reduce: a test restore every month"), Risk("only one person knows the 2009 warehouse", 3, 4, "reduce: a second person and a write-up"), Risk("the PDF library for invoices is unmaintained", 2, 2, "accept"),]for r in ranked(risks): print(f"{r.score:2} {r.level:<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.
The key is the tuple(score, impact): tuples compare by the first element first. reverse=True reverses both.Try it
Predict the output
Four risks from another review, sorted with the same ranked. What does the program print?
risks = [ Risk("the email provider changes its API", 3, 2), Risk("the site certificate is renewed by hand", 3, 3), Risk("the warehouse goes down on Black Friday", 2, 5), Risk("monthly reports are late", 5, 1),]for r in ranked(risks): print(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 check_all(deps, baseline, cycle_baseline), 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