Software Architecture · Technical Debt

Tacit Design Knowledge and the Cost of Unwritten Decisions

Code records what the system does and discards why, so the constraints behind each structure survive only in memory. What decays, what it costs, and which artifacts carry rationale.

A retry loop in a payment client sleeps for three seconds before its second attempt. The sleep looks arbitrary, and it is the kind of thing a reviewer flags as sloppiness and a later cleanup deletes. It is there because the counterparty’s API acknowledges a request before the record it creates is visible, and an immediate retry creates a duplicate. Nothing in the code says this. The knowledge lives in the memory of the engineer who debugged the duplicates, and it will survive exactly as long as that memory does.

This is the most expensive class of architectural debt and the hardest to inventory, because it has no artifact to scan. The structure is correct, the tests pass, and the cost is deferred until someone changes the structure for a good local reason and reintroduces a failure that had already been paid for once.

Two records of a system, one of them lossy

Every system is described twice: by its artifacts and by the understanding of the people who built it. The artifacts — code, schemas, tests, configuration — encode the decisions that were made. They do not encode the alternatives that were rejected, the constraint that forced the choice, or the boundary within which the choice is valid.

The asymmetry has a mechanical explanation. Code is a description of behaviour, and behaviour is what compiles and runs. A constraint like “this cache is safe because writes for a given account are serialized upstream” is not a behaviour of this component; it is a property of the caller, and it is enforced nowhere local. A type system can capture some constraints, and static analysis can capture others, but the ones that span components or depend on external systems remain as prose that has to be written deliberately.

Michael Polanyi’s description of tacit knowledge is apt here: practitioners know more than they can state, and the part they cannot state is transmitted by apprenticeship rather than by document. Some of that is irreducible — judgement about when a pattern applies does not compress into a rule. But the specific constraints behind specific structures are not like that. They are statable, and they are simply not stated, because the moment of the decision is the moment of maximum context and the moment of minimum perceived need.

What is actually lost

The loss is not uniform, and enumerating its parts makes the remedy concrete.

Rejected alternatives. The code shows the chosen design. It cannot show that the simpler design was tried and failed because of a specific requirement, so the next engineer will propose it again, and the effort of disproving it will be repeated. In organisations where designs are re-litigated every year, the cause is usually an unrecorded rejection.

Invariants not enforced by types. A field is nullable because one legacy path writes it late; a function is called only after initialization; a collection is always non-empty because of a validation upstream. These are properties the code relies on and does not check. They are the most valuable thing to write down, because a violation is silent at the point of violation and visible only at the point of failure.

Applicability boundaries. A decision has a domain. A cache with no invalidation is fine for data that changes monthly and wrong for data that changes per request. The code is identical in both cases, and the boundary exists only in the head of whoever chose it.

The failure that motivated the structure. Structures are usually shaped by an incident: an ordering bug that forced a queue, a race that forced a lock. Without the record, the structure looks like unnecessary complexity, and its removal is a natural simplification. This is the sleep-before-retry case, and it is the most common way a fixed defect returns.

Ownership and contact. Knowing whom to ask is itself a form of knowledge, and it decays as teams change. A codebase where the only route to understanding a module is finding a specific person is one resignation away from an expensive reconstruction.

Why code cannot carry all of it

The claim that well-written code is self-documenting is true about intent at the statement level and false about rationale at the design level. Reading a function tells you what it does; reading a module tells you how its parts cooperate; reading a system tells you what it does under the conditions that exist. The reasons the system has this shape rather than another are not in the shapes.

Comments are the obvious vehicle and a poor one, for a structural reason: comments are attached to code, so they are edited when the code is edited, by whoever is editing, usually to remove the mismatch. A comment stating a constraint is deleted when the constraint appears violated, and the deletion is not reviewed as a design decision. Tests are better, because an executable assertion about an invariant fails loudly when the invariant is broken, but a test can only encode invariants that are observable through the component’s interface.

The vehicle that survives is a record kept outside the code, keyed to the decision rather than to a line, and explicitly allowed to become stale in a controlled way. Architecture decision records are the standard form: a short document, one per significant decision, containing the context, the decision, the alternatives considered, and the consequences accepted. Their critical property is that they are not edited when the decision changes — a new record supersedes the old one. The history of decisions then remains readable, including the reasons the current choice is not the first choice.

An ADR that omits the alternatives and the consequences is a note, not a record. The three fields that carry the value are the constraint that forced the decision, the option that was rejected and why, and the condition under which the decision should be revisited. The last field is what turns the record from a historical artifact into an operational one.

Making invariants executable

Where the loss can be prevented rather than recorded, prevention is cheaper. The general technique is to move the invariant into a form that the toolchain checks, so that violating it is a compile or test failure rather than a matter for memory.

Types are the strongest available carrier. A function that requires a validated input can take a distinct type that only the validation function produces, so the call cannot be made with an unvalidated value. A string that must be a known currency can be a type with a closed set of values rather than a string, which removes an entire class of runtime checks and the possibility of forgetting one.

// The invariant "amount is in minor units" is carried by the type, not by a comment.
type MinorUnits = number & { readonly __brand: "MinorUnits" };

function minorUnits(n: number): MinorUnits {
  if (!Number.isSafeInteger(n)) throw new RangeError("minor units must be an integer");
  return n as MinorUnits;
}

// Minor-unit exponents differ: 2 for USD, 0 for JPY, 3 for KWD.
function formatPrice(amount: MinorUnits, currency: string, exponent: number): string {
  return new Intl.NumberFormat("en", { style: "currency", currency })
    .format(amount / 10 ** exponent);
}

The brand is erased at runtime; its value is entirely in the compiler’s refusal to accept a plain number. That is enough to stop the specific mistake, and it costs one wrapper at the boundary where values enter the system. The design constraint is that the boundary must be narrow, or the wrapper proliferates and gets bypassed.

The same reasoning applies to the data layer, where an unstated invariant becomes a permanent obligation. A nullable column whose nullability means “written by the legacy path” encodes a rule that only the reader’s memory can enforce; the invariant’s true home is a constraint plus a record of when the constraint was added and what it excludes. The interaction between unstated invariants and stored shapes is one reason data migrations are so often completed incorrectly, as discussed in Data Shape Debt and the Migration That Outlives the Code.

Concentration as the measurable part

The distribution of knowledge can be approximated from history: who has modified which files, how many distinct authors each file has had, and how recently. A module whose entire history belongs to one person is a concentration risk, independent of how good the code is. This is a proxy, and it understates the problem in one direction — a person can know a module without having committed to it — and overstates it in another, since a large number of authors can mean healthy rotation or can mean nobody owns it.

The genuinely unmeasurable part is what is not in any file. The honest response is not to attempt a metric but to ask direct questions of the people who hold the knowledge, and to treat the answers as artifacts: which constraints are not visible in the code, what has been tried and failed, what would break if a particular module were restructured. The answers arrive as prose, and prose is what has to be stored. Brooks called the resulting property conceptual integrity and observed that it is best preserved by a small, coherent design authority — which is an organisational constraint, not a documentation one, and it is the reason large systems tend to lose coherence even when every individual decision is recorded.

The maintenance cost of recording

The obvious failure of any recording discipline is that the record becomes a second system to maintain, and a stale record is worse than none because it is trusted. Three rules keep the cost bounded.

Record decisions that are expensive to reverse or surprising to a reader. Not every choice. A codebase with a record per commit has no records that anyone reads.

Record the constraint, not the implementation. The implementation is in the code; duplicating it creates a copy that will diverge, which is the duplication problem in its most expensive form, described in Divergent Duplicates and the Cost of Two Truths.

Attach a review trigger. A record that names the condition under which it should be revisited can be checked when that condition arises, rather than audited continuously.

The economics are the same as for any other debt: the principal is the writing time, the interest is the reconstruction cost avoided, and the ratio is highest for decisions that span components, depend on external systems, or were forced by a failure. Those are also the decisions nobody can reconstruct from the code, which is why this debt is the one that a codebase-wide metric will never find, and why the repayment order in Sequencing Repayment Under a Fixed Capacity has to treat it as a prerequisite rather than as an item.

References

  1. Documenting Architecture Decisions — Michael Nygard, 2011
  2. The Tacit Dimension — Michael Polanyi, 1966
  3. The Mythical Man-Month: Essays on Software Engineering — Frederick P. Brooks, Addison-Wesley, 1975
  4. Working Effectively with Legacy Code — Michael Feathers, Prentice Hall, 2004