Module 7: Documentation That Survives
Docs-as-code
Overview
The previous lesson established the principle: the doc that survives is the one that lives glued to the code, because proximity is the only thing that keeps it synchronized. But a principle doesn't execute itself —"keep the doc close to the code" is good advice that's forgotten on the first busy day—. This lesson installs the concrete practice that turns that principle into a mechanism that doesn't depend on goodwill: docs-as-code, treating documentation exactly as we treat code. That means four things that go together: the doc lives in the repo (not in a separate wiki); it's written in plain text (Markdown for the prose, PlantUML or Mermaid for the diagrams, so it's versioned and compared line by line); it goes through review in the same PRs as the code (the same change that touches payments touches its doc, and the reviewer sees both things together); and —most powerful— it's validated in CI, like the tests, so that if the doc desyncs from the code, the build fails.
That fourth point is the one that transforms the discipline into a guarantee. In lesson 2 we saw that the wiki rots because the desync is invisible: no one audits whether the doc stayed up to date, so the residue accumulates in silence until the doc is rotted. Docs-as-code does the opposite: it turns the desync into a test that breaks the build. If an ADR references a billing module that was already renamed to payments, a validator in CI detects it and fails, just as a broken test would fail. "The doc rotted" stops being a problem discovered months later, when a new dev trusts something false, and becomes a red light that fires in the PR that introduced the desync —while it's still cheap to fix—. This lesson runs that validator over Mercado's documentation and watches the build fail.
Connection with the module. It's the direct continuation of lesson 2. That one taught why proximity keeps doc alive (the principle: living documentation); this one teaches how to force that proximity so it doesn't depend on someone remembering (the practice: docs-as-code). With the module's analogy: lesson 2 explained that the label has to be stuck on the machine; this one installs the shop rule that guarantees it —"you don't close the maintenance order without updating the label", verified by a supervisor—. Frontier with the rest: here we don't re-teach how to draw C4 (M3) or write an ADR (architecture-decisions); here we put those artifacts in the repo and validate them. The validator we build doesn't judge whether the C4 is well drawn; it verifies that it doesn't reference modules that no longer exist.
An analogy: the amendment both parties sign in the same act
Think of a contract between two companies, and two ways of handling the changes made to it over time.
The folder of loose emails. The original contract is signed and filed. Over the months, the parties agree on changes —a new price, a different term, an extra clause— and each change is sent by email, discussed by phone, noted in a minute. Each agreement is real, but it lives loose: in someone's inbox, in a note, in the memory of whoever was on the call. A year later, no one knows for certain what the current contract is: the signed document says one thing, the emails say another, and there's a change that only one person, who no longer works there, remembers. When a dispute arises, there's no source of truth —there's an outdated official document and a swarm of loose changes that contradict it—. The contract "exists", but you can't trust it, because the changes were never integrated into it.
The amendment signed in the same act. The same two companies, with another discipline: each time they agree on a change, they draft an amendment that's integrated into the contract, and both parties sign it in the same act in which they agree on the change —not "later", not "when there's time"—. The amendment stays in the same file as the contract, numbered, versioned. At any moment, the current contract is the original plus all its signed amendments, and there's no ambiguity: if a change isn't signed and integrated, it isn't current. The change and its record happen together, before the same two signatures, in the same file. A year later, the contract says exactly what the parties agreed, because each agreement was integrated in the moment and passed through both signatures.
Here's docs-as-code: the change to the system and the change to its documentation happen in the same act, in the same file, before the same review. The PR is the "act": the change to payments' code and the change to its doc travel in the same PR, and the reviewer —the "two signatures"— approves both things together or neither. The repo is the "file": the doc lives there, versioned, next to the code, not in a loose email inbox (the wiki). And the CI validator is the notarial rule that rejects a badly-made amendment: if the doc references something that no longer exists, the "act" isn't closed —the build fails— until it's fixed. The folder of loose emails is the wiki of lesson 2: real changes that were never integrated into the official document, until the official document no longer tells the truth. Docs-as-code is the signed amendment: the document always says what the system does, because changing them is the same reviewed act.
Worked example: the validator that breaks the build
We're going to build the piece that makes docs-as-code a guarantee and not a wish: the out-of-sync doc validator, the equivalent of a test that runs in CI. The idea is simple and powerful: the source of truth is the code —the set of modules that really exist—; the documentation asserts things about those modules (an ADR talks about certain modules, a C4 diagram draws certain boxes, the README mentions certain pieces). The validator cross-checks what the doc asserts against what the code has, and detects two kinds of desync: broken references (the doc points to a module that no longer exists —renamed or deleted—) and undocumented modules (the code has a module no doc mentions).
In Mercado, the code has six modules. And there are two planted desyncs that a real team accumulates without noticing: an old ADR talks about billing, which was renamed to payments a while ago; and a C4 diagram draws notifications, a module that was deleted. Besides, platform exists in the code but no doc mentions it. The validator catches them:
# Docs-as-code: the doc lives in the REPO, next to the code, and is reviewed in the PRs.
# That allows VALIDATING it in CI, like the code. Here a validator that detects
# OUT-OF-SYNC doc: an ADR or a C4 diagram that references a module that no longer exists
# (renamed or deleted), and modules of the code that no one documented.
# The real state of the code today (the source of truth):
CODE_MODULES = {"catalog", "orders", "payments", "shipping", "platform", "search"}
# What the documentation ASSERTS exists (references in ADRs and diagrams):
DOC_REFERENCES = {
"ADR-014": {"orders", "billing"}, # 'billing' was renamed to 'payments'
"ADR-021": {"catalog", "search"},
"C4-container": {"catalog", "orders", "payments", "shipping", "notifications"},
"README": {"catalog", "orders", "payments"}, # 'notifications' does not exist
}
# 1) Broken references: the doc points to modules the code no longer has.
print("== Broken references (doc -> nonexistent module) ==")
broken = 0
for doc, refs in DOC_REFERENCES.items():
for mod in sorted(refs - CODE_MODULES):
broken += 1
print(f" {doc}: references '{mod}', which does not exist in the code")
print(f" total broken references: {broken}")
print()
# 2) Undocumented modules: the code has them, no doc mentions them.
documented = set().union(*DOC_REFERENCES.values())
undocumented = CODE_MODULES - documented
print("== Undocumented modules (code -> no doc) ==")
for mod in sorted(undocumented):
print(f" '{mod}' exists in the code and no doc mentions it")
print(f" total undocumented: {len(undocumented)}")
print()
exit_code = 0 if (broken == 0 and not undocumented) else 1
print(f"CI: {'PASS' if exit_code == 0 else 'FAIL'} (exit {exit_code})")
print("Docs-as-code turns 'the doc rotted' into a test that BREAKS the build.")
What to expect. Running the file, the output is exactly this:
== Broken references (doc -> nonexistent module) ==
ADR-014: references 'billing', which does not exist in the code
C4-container: references 'notifications', which does not exist in the code
total broken references: 2
== Undocumented modules (code -> no doc) ==
'platform' exists in the code and no doc mentions it
total undocumented: 1
CI: FAIL (exit 1)
Docs-as-code turns 'the doc rotted' into a test that BREAKS the build.
Read the output as what it is: the output of a test, red, in a PR.
The two broken references are doc that lies. The validator found that ADR-014 talks about a billing module that no longer exists —it was renamed to payments a while ago, but the old ADR kept the dead name—, and that the C4-container diagram draws a notifications module that was deleted. These are exactly the desyncs that in a wiki would be invisible: no one audits the old ADR, no one checks that the diagram still matches the code, so there they stay, lying to whoever reads them. A new dev who reads ADR-014 will look for a billing module in the code, won't find it, and will lose half an hour confused before discovering it's called payments —or worse, will believe there's a module that isn't there—. The validator turns that future half-hour of confusion into a red line today, in the PR.
The undocumented module is a gap. The validator also detected that platform exists in the code but no doc mentions it —not an ADR, not the diagram, not the README—. That's the other kind of desync: not doc that lies, but a system no one described. platform could be exactly the piece a new dev needs to understand, and there isn't a single line about it. Detecting it automatically turns "oops, we never documented platform" —which is normally discovered when someone needs it and finds nothing— into an explicit warning.
And the heart: CI: FAIL (exit 1). This is what changes everything. The validator doesn't print a report someone will read someday; it returns an exit code other than zero, which is the universal language of "the build fails". In a CI pipeline, an exit 1 stops the merge: the PR is blocked, in red, until the desync is fixed. Think about what that means: it's impossible for Mercado's doc to desync without someone noticing, because the build doesn't let through a change that breaks a reference or leaves a module undocumented. The silent rot of the wiki —the residue that accumulates release after release down to the 20% accuracy of lesson 2— becomes impossible, because every release has to pass through this light. The doc can't rot in silence if its synchronization is a test.
As a flow, docs-as-code looks like this:
The PR that changes the code also changes its doc, and CI validates both:
dev opens PR ─┬─ changes code (renames billing -> payments)
└─ changes doc (ADR, C4, README)
│
▼
CI runs the tests ──┬── code tests: pass
└── DOC VALIDATOR: broken references?
│
┌──────────────────────────┴───────────────────────┐
all green exit 1: red
merge allowed merge BLOCKED until
(code AND doc up to date) fixing the desync
Deep dive: what docs-as-code does, and what it doesn't
The example's validator is deliberately simple —it cross-checks two sets of names— but it embodies the whole idea of docs-as-code, and it's worth breaking down why it works and where its limits are.
What docs-as-code does, at bottom, is apply to documentation the four things we already do with code and know work. Version: the doc is in git, so you can see who changed what and when, roll back to a previous version, compare two states —impossible with a wiki where the history is a mess—. Plain text: the doc in Markdown and the diagrams in PlantUML/Mermaid are compared line by line in the PR, so a reviewer sees exactly what changed in the doc, not a binary blob or a page that was overwritten. Review: the doc goes through the same approval as the code, so a change to the doc is reviewed by another person —and a change to the code that should touch the doc and doesn't is visible in the PR—. And validation: the doc is tested in CI, so its correctness doesn't depend on someone auditing it by hand. None of these four is about "writing better"; all are about putting the doc inside the same system of guarantees as the code, where quality doesn't depend on individual discipline but on the machinery.
The subtlest point is the review in the same PR, because it attacks the root cause of the rot. In lesson 2 we saw that the wiki rots because updating it is a separate act that gets postponed. Docs-as-code eliminates the "separate": since payments' doc lives in the repo next to payments' code, the PR that changes payments naturally includes its doc, and if it doesn't, the reviewer notices —"you changed payments' behavior but didn't touch its ADR, is it still current?"—. Proximity not only makes it possible to update the doc in the moment; it makes it visible when it wasn't updated. That visibility is what turns good intention into habit: it's not that people are more disciplined, it's that the system makes the gap evident.
Now, the honest limits, because docs-as-code isn't magic. The validator can only verify what's mechanically checkable. It can verify that an ADR doesn't reference a nonexistent module, that a diagram doesn't draw a deleted box, that each module in the code has some doc, that the links aren't broken, that the code examples in the doc compile. What it can't verify is whether the content is correct in its judgment: it can't tell whether the ADR explains the why well, whether the diagram is at the right level for its audience, whether the decision it documents is still sensible. That's verified by the human review —the other leg of docs-as-code—, not by the validator. The division is clear: the validator (CI) catches the mechanical desync (references, links, existence); the reviewer (human) judges the quality (clarity, level, currency). Docs-as-code needs both; the validator doesn't replace the reviewer, it takes the boring, mechanical work off their hands so they can concentrate on the judgment.
And a nuance about what to validate, which connects with future lessons. The validator is more valuable the more stable what it verifies is. Verifying that the module names in the ADRs exist is valuable because module names change little and a broken reference is a clear error. Trying to mechanically validate very volatile things —that the doc lists exactly the same endpoints as the code at every moment— is possible but often noisy, and there the best answer isn't to validate the hand-written doc but to generate it from the code (living documentation, lesson 2) so it can't desync. The rule: validate by hand the stable (references, existence, structure), generate the volatile. This anticipates lesson 5 —document the stable, not the volatile—: docs-as-code works better on stable doc, which is exactly the one worth maintaining by hand.
Common mistakes
The "official" wiki outside the repo (the worst of all, and the most common). What happens: the team keeps the doc in Confluence/Notion/Docs "because it's nicer for writing and sharing", and the code in the repo. The two live separately, so there's no way to review them together or validate one against the other, and the doc rots exactly as in lesson 2. Why it happens: wiki tools are more comfortable for writing (visual editor, comments, share a link), and that writing comfort hides the enormous cost of the desync. How to spot it: if your doc isn't in the same repo as the code, you can't do docs-as-code, period —the separation makes joint review and validation impossible—. How to fix it: move the architecture doc that must survive (ADRs, diagrams, README, the boundaries doc) to the repo, in Markdown and PlantUML/Mermaid; leave the wiki, if anything, for the ephemeral (meeting notes, drafts) that doesn't pretend to survive.
Putting the doc in the repo but not validating it (half docs-as-code). What happens: the team moves the doc to the repo —good— but doesn't add any validator in CI, so the doc can desync just the same, only now in the repo. You gain the versioning and the review, but you lose the automatic guarantee. Why it happens: writing the validator takes a while and seems optional ("the human review catches it anyway"). How to spot it: if your doc is in the repo but nothing in CI verifies its sync, you have docs-as-code without the "code": versioned plain text that still depends on a human noticing each desync. How to fix it: add validators for the mechanical stuff —module references, links, doc existence per module, examples that compile—; start with a simple one (like the example's) and grow. Validation is what turns discipline into a guarantee; without it, docs-as-code is just "docs near the code", which helps but doesn't guarantee.
Over-validating and drowning in noise. What happens: the team, excited, writes validators so strict they fail over anything —every time the doc and the code differ on a volatile detail—, and the build starts failing so much over doc that people disable the validator or ignore it. Why it happens: mechanical validation is attempted on volatile things that change all the time, generating constant false positives. How to spot it: if the doc validator fails often over things that don't matter, or if people skip it with --skip, it became noise. How to fix it: validate only the stable and clearly checkable (module references, broken links, missing doc per module), and for the volatile generate the doc from the code instead of validating the hand-written one. A validator should fail only when there's a real desync that matters; if it fails over noise, it loses its power —people learn to ignore the red, and then it doesn't catch even the real desyncs—.
Exercises
Exercise 1 — Why the exit code changes everything. The example's validator ends with CI: FAIL (exit 1). An engineer says: "we could do the same with a weekly report that lists the desyncs and email it; why break the build?". Explain why the exit 1 that breaks the build is qualitatively different —and more effective— than a report someone will read.
See solution
The exit 1 that breaks the build is qualitatively different because it blocks the merge in the moment and in the place where the problem was introduced, while a weekly report only informs of a problem that already happened and that someone will have to fix later —if someone reads the report and decides to prioritize it—. With the report, the desync enters the repo, lives there a week (or forever, if no one acts on the report), and accumulates with the others; it's exactly the mechanism of the wiki that rots, only with a weekly email no one reads. With the broken build, the desync can't enter: the PR that introduced it is blocked until it's fixed, so the problem is fixed when it's cheapest —in the context of the change that caused it, by the person who caused it, who has everything fresh in their head—.
There's a difference of incentives too. A weekly report creates a separate task, with no clear owner, that competes with everything else and almost always loses ("I'll fix it next week"). A broken build creates a concrete barrier for this person now: they can't merge until they fix it, so they fix it. The report depends on collective discipline and prioritization (which fails); the broken build depends on no one —it's a mechanical gate—. It's the whole lesson of docs-as-code: don't trust that someone remembers or prioritizes; put the guarantee in the machinery. A report is information; a broken build is a guarantee.
Exercise 2 — The two kinds of desync. The validator detects two distinct things: broken references (doc that points to a nonexistent module) and undocumented modules (code with no doc). For each, explain what concrete harm it causes a new Mercado dev if not detected, and why both are ways for the doc to "not survive".
See solution
Broken reference (doc that points to something nonexistent). The concrete harm: a new dev reads ADR-014, which talks about a billing module, and looks for it in the code. They don't find it —because it was renamed to payments—. In the best case they lose time confused until someone explains that billing is the old name of payments; in the worst case they conclude there's a module that doesn't actually exist, or they distrust the whole ADR ("if this is wrong, what else is wrong?") and stop using it. It's doc that lies: it says something exists when it doesn't, and sends the reader down a false path. It's a way of not surviving because the doc fell behind the code —the code evolved (renamed), the doc kept the dead name—.
Undocumented module (code with no doc). The concrete harm: the new dev needs to understand platform —maybe it's where they have to make their first change— and finds not a line: no ADR explaining why it exists, no box in the diagram, no mention in the README. They have to reconstruct platform's purpose and boundaries by reading the code blindly, or by asking whoever knows (lowering the bus factor to depending on that person). It's doc that's missing: the system has a piece no one described. It's a way of not surviving because the knowledge of that piece was never captured —it lives only in heads, and when those heads leave, it leaves with them—.
Both are two sides of the same coin: the doc and the code have to correspond. The broken reference is doc that's in excess (points to something that's no longer there); the undocumented module is doc that's missing (something that's there has no doc). The validator demands the correspondence in both directions —nothing in the doc without backing in the code, nothing in the code without mention in the doc— and thus guarantees the doc stays a faithful map of the territory, which is what makes it survive.
Exercise 3 — What CI validates and what the human validates. The text insists the validator catches the mechanical desync and the human reviewer judges the quality. Classify each of these five checks into "done by the CI validator" or "done by the human reviewer", and explain the criterion: (a) ADR-014 references a module that doesn't exist; (b) the ADR explains clearly and convincingly why the decision was made; (c) the README links aren't broken; (d) the C4 diagram is at the right level for its audience; (e) each module in the code has at least one document that mentions it.
See solution
- (a) Reference to a nonexistent module → CI validator. It's mechanically checkable: cross the set of references against the set of code modules. It requires no judgment; it's true or false. The machine does it.
- (b) The ADR explains the why clearly and convincingly → human reviewer. It requires judgment: "clear" and "convincing" aren't mechanically checkable; a machine can verify that the ADR has a context section, but not whether that section convinces. A person judges it in the review.
- (c) README links not broken → CI validator. Mechanically checkable: try to resolve each link and verify it exists. The machine does it.
- (d) The C4 diagram is at the right level for its audience → human reviewer. It requires judgment about the audience and the abstraction level —exactly what was taught in module 3—; a machine can count boxes (and warn if there are too many, mechanical hygiene), but it can't judge whether the Container is the right level for the VP. A person judges it.
- (e) Each code module has doc that mentions it → CI validator. Mechanically checkable: cross the set of modules against the set of documented modules and report the gaps. The machine does it.
The criterion: the CI validator does everything that's an objective correspondence check —existence, references, links, coverage— with no need for judgment; the human reviewer does everything that requires judgment about the quality —clarity, level, currency, whether the decision is still sensible—. Docs-as-code needs both: the validator takes the mechanical, boring work off the human (which the machine does better and without tiring), so the human spends their attention on the one thing the machine can't do —judge whether the doc, besides being synchronized, is good—. Putting the judgment on the machine would drown it in false positives; putting the mechanical on the human would tire them until they stopped looking. Each to their own.
Summary and next step
In this lesson you turned lesson 2's principle —proximity keeps doc alive— into a concrete practice: docs-as-code. The doc lives in the repo, in plain text (Markdown, PlantUML/Mermaid), goes through review in the same PRs as the code, and —the decisive part— is validated in CI. You saw, with the amendment signed in the same act vs. the folder of loose emails, that the point is for the change to the system and the change to its doc to happen in the same reviewed act, in the same file. And you executed it: a validator that cross-checks what the doc asserts against what the code has, detects the broken references (billing renamed, notifications deleted) and the undocumented modules (platform), and returns exit 1 —breaks the build—. With that, "the doc rotted" stops being an invisible problem discovered months later and becomes a red test in the PR. You also understood the limits: the validator catches the mechanical desync, the human reviewer judges the quality, and for the volatile it's better to generate than to validate.
Before moving on you should be able to: name the four legs of docs-as-code (repo, plain text, review in PR, validation in CI); explain why the exit 1 that breaks the build is more effective than a report; and classify what the machine verifies and what the human does.
Lesson 4 goes up a level: you now know where to put the doc (in the repo) and how to keep it synchronized (docs-as-code); now we see what pieces make up a complete architecture documentation, and how they assemble into a system. You'll see the combo C4 + ADR + arc42 —the diagram that shows the structure, the ADR that keeps the why, and arc42 as the skeleton that organizes them and covers what's missing— and measure why no piece alone is enough: each answers only a fraction of the questions a new dev or an auditor asks, and only the combo answers them all. The doc that survives isn't an artifact; it's a system, and the next lesson assembles it.
Resources
- Write the Docs — "Docs as Code" — the canonical reference of the practice: doc in the repo, in plain text, reviewed in PRs, built and validated with the same tools as the code. The basis of this lesson. In English.
- Cyrille Martraire, Living Documentation (Addison-Wesley, 2019), chapters on automated documentation — the step from "doc close to the code" to "doc validated and generated from the code", which is the heart of this lesson's validator. In English.
- Michael Nygard — "Documenting Architecture Decisions" and adr.github.io — ADRs as plain-text files in the repo, versioned with the code: the quintessential example of docs-as-code applied to decisions. Its mechanics are the sister guide
architecture-decisions. In English. - PlantUML and Mermaid — tools for writing diagrams as plain text (which is versioned and compared in PRs), instead of binary images that can't be diffed. What makes a C4 diagram diffable. In English.
- Andrew Hunt and David Thomas, The Pragmatic Programmer, 20th Anniversary Edition (Addison-Wesley, 2019), topics on automation and "Don't Repeat Yourself" — why any check that depends on human memory ends up failing, and the solution is to put it in the machinery. In English.