From the book's front matter
The code came first and the writing followed it — with a lot of machine help in both. The two applications were built with heavy AI coding assistance, and so was this book: the chapters were drafted in collaboration with AI models, working directly against the two repositories, under rules written down before drafting began.
What you should judge is the discipline, not the drafting. Every listing is copied from the repositories at the pinned commits and re-checked by an audit script that compares the printed text against the source, line by line; every count in the prose was measured by a command rather than remembered, and re-measured before release; and the full text has been through independent technical review. Where this book teaches you to trust the artifact over the author and your terminal over the text, it is describing its own editorial method, applied to itself.
If that provenance changes how much you trust a sentence here, good — that is the correct instinct, and the remedy is the one Chapter 1 teaches: check. Everything in this book is checkable, on purpose.
Chapter 3
contracts, normalisation, and boundaries
“All model geometry stored in millimeters… Convert to inches only at UI edges…
Never store or calculate geometry in inches.” That’s .github/copilot-instructions.md,
bolded, near the top. CLAUDE.md and CONTRIBUTING.md state the identical rule near
their own tops too, each in its own words — “canonical millimeters,” “unit conversion
… happens only at the UI edge.” Same non-negotiable, three documents, three different
phrasings. Rules that get restated that often, in that many voices, are load-bearing.
This chapter is about what kind of thing that rule is, why it’s worth writing rules
like that down, and what happens to a program that doesn’t have any.
In this chapter you will
In 1999 NASA lost the Mars Climate Orbiter. The spacecraft’s trajectory software received thruster impulse data in pound-force seconds; it expected newton-seconds. Nobody made an arithmetic error. Every individual calculation was correct. The system simply had two units circulating in it, and no single place that said which one was true.
ShaftSchematic has exactly that hazard. The machinists on the client’s shop floor work in inches and think in fractions; marine engineering documents are frequently metric. The app has to accept both, display either, and never confuse them — across ~39,000 lines, eighteen model files, four PDF composers, and two independent rendering paths.
The solution the codebase lands on is the standard one, and it has a name.
Canonicalisation means: pick one representation, convert everything to it at the moment it enters the system, and use only that representation internally.
ShaftSchematic’s canonical form is the millimetre. Every geometric field in the model
is named to say so — lengthMm, diaMm, startFromAftMm, keywayOffsetFromEndMm.
The suffix isn’t decoration. It’s the unit assertion carried in the identifier, so
that a mistake is visible at every use site, not just at the declaration.
lengthMm is a field name and a tiny act of self-defence.
Note what the diagram does not show: any arrow that carries inches into the core.
By design there isn’t one — the sanctioned boundary is exactly two functions,
parseToMm on the way in and UnitSystem.fromMillimeters on the way out. Grep for
the underlying constant instead of the two function names, though, and the picture
gets more interesting — see the Pinch Point below.
Shop-floor analogy
Every shop has one set of master gauge blocks, kept in a temperature-controlled case
and treated as the final word on a dimension, and everything else in the shop traces
back to them. Not because inches are better than millimetres, but because one
reference beats two. When an inspector and a machinist disagree about a measurement,
they don’t argue about it — they both go to the same blocks. UnitSystem.MILLIMETERS
is ShaftSchematic’s gauge block set: the one reference everything else is checked
against.
Here is the precise definition, and it’s worth memorising:
An invariant is a condition that is true before and after every operation on a piece of state, for the entire lifetime of that state.
Not “checked once at startup.” Not “validated on save.” Always true. If you could freeze the program at any instant and inspect memory, the invariant would hold.
“All geometry in the model is in millimetres” is an invariant of ShaftSpec.
Compare that with Body.isValid() from the last chapter. That’s a validation
check — a function you call at a moment of your choosing to ask whether a
particular condition currently holds. Validation is a question. An invariant is a
promise.
The distinction matters because it changes where the work goes:
| Validation | Invariant | |
|---|---|---|
| Enforced by | calling a function | design of the boundary |
| Can be violated? | yes, then detected | never, if the boundary is complete |
| Cost | one check per call site | one conversion per entry point |
| Failure mode | forgot to call it | a new entry point nobody thought about |
Look at the cost row. Validation costs you a check everywhere. An invariant costs you a conversion at the border. In a program with two hundred call sites and two borders, the invariant is a hundred times cheaper — which is the whole reason the technique exists.
It’s also, in a very literal sense, what a contractor is being paid for. A client doesn’t ask for an invariant — they state a rule, in plain language, and hand it to you: never store or calculate geometry in inches. Encoding that rule into the shape of the data, so it can’t be forgotten rather than merely checked, is a large part of what the fee is for.
Chew on this
An invariant is only as strong as the completeness of its boundary. What would break
the millimetre invariant in ShaftSchematic? Think about it before reading on. Not
“someone writes bad code” — think about new features. A new import format. A CSV
paste. An API. Each is a new door into the core, and each one has to be taught to
convert. This is why the rule is written in CLAUDE.md, CONTRIBUTING.md, and
copilot-instructions.md: those documents are the boundary being defended in prose,
because it can’t be defended in code.
An invariant is one of three related promises, and it is worth having all three names because they attach to different moments.
Together they are called design by contract, and the framing is exactly what it sounds like: two parties, each owing the other something specific. If the caller meets the precondition, the function owes the postcondition. If the caller doesn’t, the function owes nothing at all — including a sensible error.
Kotlin gives you three ways to state a contract, and both codebases use all of them:
| Mechanism | Checked | Example |
|---|---|---|
| The type system | at compile time | pattern: Pattern cannot be null |
require / check |
at run time, throwing | require(modelClass.isAssignableFrom(...)) |
| Documentation | by human beings | the file headers and KDoc |
ShaftViewModelFactory shows the middle one:
require(modelClass.isAssignableFrom(ShaftViewModel::class.java)) {
"Unsupported ViewModel type: $modelClass"
}
That is a precondition stated in code. require throws IllegalArgumentException
— the caller passed something wrong. Its sibling check throws IllegalStateException
— this object is in a state where the operation makes no sense. The distinction is
about whose fault it is, and choosing correctly is a message to whoever reads the
stack trace at two in the morning.
Nobody has ever read a stack trace at a reasonable hour.
Now look at a postcondition stated in prose, from ShaftSpec.withBodyAt:
“Returns a copy with the body at
indexset to the given geometry, orthisunchanged whenindexis out of range. Length and Ø are clamped to ≥ 0;startMmis kept verbatim (authored positions are sacred — golden rule). Keyway fields, id, and label are untouched.”
Four guarantees about the returned value, none of which the type system can express —
ShaftSpec is just ShaftSpec. So they live in KDoc, and WithBodyAtTest is what
actually enforces them. That pairing is the practical form design by contract usually
takes when the type system can’t express the rule: the contract is written in the
doc comment and enforced by the test.
Chew on this
Notice that withBodyAt has no precondition on index. An out-of-range index is
not an error; it returns this. That’s a deliberate choice with a real trade-off.
A require(index in bodies.indices) would catch a caller bug loudly, at the moment it
happens. Returning this makes the function total — defined for every input — which
means callers never have to guard, and a race between a delete and an edit degrades to
a no-op instead of a crash. For a UI-driven edit path, silent tolerance is probably
right. For a batch importer, loud failure probably is.
The question to ask is never “should I validate?” but “who is better placed to handle this being wrong — me, or my caller?”
Pinch point — the contract only holds at the boundary you defend
Design by contract has a failure mode: a contract nobody can check is a wish. The
sanctioned boundary for the millimetre invariant is two functions and three
documents — but grep -rn "25\.4" app/src/main turns up the same conversion,
reimplemented, in well over a dozen other files. Three of them even redeclare their
own private copy of the constant under three different names: pdf/ShaftPdfComposer.kt
has its own MM_PER_IN, ui/viewmodel/SessionAddDefaults.kt has MM_PER_INCH,
ui/viewmodel/SnapUtils.kt has INCH_TO_MM — none of the three calling parseToMm or
fromMillimeters at all. Every one of those sites happens to compute 25.4 correctly
today. But “happens to” is precisely the failure mode an invariant exists to remove,
and a boundary that has quietly grown a dozen side doors is a boundary in name only.
Push the scenario one step further: someone adds a CSV importer that writes Float
inches straight into a Body. Nothing throws, nothing fails to compile, and no test
goes red. The drawing is simply wrong by a factor of 25.4 — the same factor already
loose in the building.
This is why §3.3 said an invariant is only as strong as the completeness of its boundary. Preconditions and postconditions defend functions; invariants defend data, and data has more doors than this one keeps finding.
Here is the entire unit system:
util/UnitSystem.kt — the entire enum — package line omitted
@kotlinx.serialization.Serializable
enum class UnitSystem(val displayName: String) {
INCHES("Inches") {
override fun toMillimeters(value: Double): Double = value * 25.4
override fun fromMillimeters(value: Double): Double = value / 25.4
},
MILLIMETERS("Millimeters") {
override fun toMillimeters(value: Double): Double = value
override fun fromMillimeters(value: Double): Double = value
};
abstract fun toMillimeters(value: Double): Double
abstract fun fromMillimeters(value: Double): Double
}
This is worth a slow read, because it’s doing something more sophisticated than it looks.
What this file is actually doing
abstract, which forces every constant — present
and future — to supply both directions. Add CENTIMETERS tomorrow and the code
will not compile until you’ve written both conversions.MILLIMETERS implements both as identity. It converts nothing. But it exists,
which means calling code never has to branch on which unit it has.That fourth point is the good part. Compare with a version that’s easy to reach for first:
// The version this design avoids
fun toMm(value: Double, unit: UnitSystem): Double =
if (unit == UnitSystem.INCHES) value * 25.4 else value
Functionally identical today. But the if is a conditional on type, and every
one you write is a place that must be found and edited when a third case appears.
The enum-with-bodies version has zero conditionals: you call unit.toMillimeters(v)
and the right implementation runs. This is the Strategy pattern, and the
“replace conditional with polymorphism” refactoring is one of the most reliably
valuable moves in the catalogue.
Questions from the floor
Q: Isn’t MILLIMETERS.toMillimeters() a pointless function call?
A: It’s a real call, and it does nothing, and that’s the point — it’s a null
object: an implementation that satisfies the contract by doing nothing, so callers
never need a special case. The cost is one virtual dispatch; the benefit is that no
call site anywhere contains the word if.
Q: Why Double here, when the model uses Float?
A: Deliberate. Conversion is where precision loss compounds, so it’s done in the
wider type and narrowed once, at assignment into the model. Parsing (parseToMm)
also returns Double. This is a small, correct decision that’s easy to get wrong.
Q: Why is UnitSystem @Serializable at all, if the model is always mm?
A: Because the user’s preferred display unit is part of a saved document —
reopening a shaft you drew in inches should show inches. That’s UI state, not
geometry, so it rides along in the envelope without violating anything. Chapter 12
looks at that envelope.
The inbound edge is more interesting than the outbound one, because humans type strange things. The parser has to accept, from a machinist with oily hands on a phone screen:
12 1.25 3/4 15 1/2 4.250" 6 mm
Here it is:
util/Parsing.kt — trimmed
fun parseToMm(raw: String, unit: UnitSystem): Double {
val v = parseFractionOrDecimal(raw) ?: return 0.0
return if (unit == UnitSystem.MILLIMETERS) v else v * MM_PER_IN
}
fun parseFractionOrDecimal(raw: String): Double? {
val t = normalizeNumericText(raw)
if (t.isEmpty()) return null
// Mixed fraction: W N/D
val parts = t.split(' ').filter { it.isNotBlank() }
if (parts.size == 2 && parts[1].contains('/')) {
val whole = parts[0].toDoubleOrNull() ?: return null
val frac = parseSimpleFraction(parts[1]) ?: return null
return if (whole < 0) whole - frac else whole + frac
}
// Simple fraction: N/D
if (t.contains('/')) return parseSimpleFraction(t)
return t.toDoubleOrNull()
}
Three structural observations, each of which generalises far beyond this file.
First: it’s a pipeline of narrowing. normalizeNumericText strips commas,
trailing unit suffixes, and stray whitespace. Only then does anything try to
interpret the result as a number. Separating cleaning from interpreting is why
this function is readable at all; a version that handled quotes and fractions in the
same pass would be a thicket.
Second: failure is a value, not an exception. parseFractionOrDecimal returns
Double? — null for “I couldn’t.” No exception is thrown. The caller decides what
“couldn’t” means, and parseToMm decides it means zero. Text arriving from a human
is expected to be malformed sometimes; malformed input is not an exceptional
circumstance, so it isn’t an exception.
Third, and note this one — the KDoc states a deliberate non-responsibility:
“This keeps parsing neutral: do not clamp negatives or enforce ranges here. Callers (e.g., ViewModel setters) can layer validation if needed.”
That is a separation of concerns decision written down at the point where somebody would otherwise be tempted to violate it. Parsing answers “what number is this text?” Validation answers “is that number allowed here?” They are different questions with different right answers in different contexts, and fusing them produces a parser that can only be used in one place.
One more thing worth noticing before moving on. parseToMm’s own body — return if
(unit == UnitSystem.MILLIMETERS) v else v * MM_PER_IN — is exactly the
conditional-on-type shape §3.5 named “the version this design avoids.” The sanctioned
boundary doesn’t call unit.toMillimeters(v), the strategy object living one file
away; it branches instead. With exactly two units, forever, that costs nothing today.
But spotting that kind of inconsistency — a pattern named as the thing to avoid two
pages back, sitting unremarked in the one function that guards the actual boundary —
is exactly the reading skill this book is training.
Pinch point — the parser is more permissive than you’d bet money on
normalizeNumericText strips trailing non-numeric characters by walking backwards
from the end until it finds an allowed one. So 4.250" becomes 4.250 — good. And
4.250abc also becomes 4.250, which is probably fine.
But trace 12x7. The walk starts at the last character, 7, which is allowed — so
it stops immediately, nothing is stripped, and "12x7".toDoubleOrNull() returns null.
The string fails to parse and parseToMm yields 0.0. Reasonable behaviour, but not
what most readers predict on a first reading, and not what the function’s name
suggests.
The lesson isn’t that this is a bug — for a shop-floor numeric field it is defensible.
The lesson is that a permissive parser’s exact behaviour on malformed input is rarely
obvious from reading it. Behaviour that is hard to predict from the source is
behaviour that belongs in a test, where it becomes an executable specification.
ParsingTest.kt pins the mixed-fraction and unit-suffix cases; the three above are
worth adding to it.
Going out is a different problem. LengthFormat.formatInchesSmart() doesn’t just
divide by 25.4 — it has to render a number the way a machinist expects to read it:
data class InchFormatOptions(
val maxDenominator: Int = 16,
val snapToleranceInches: Double = 1e-4, // ~0.0001" ≈ 0.0025 mm
val decimalPlaces: Int = 3
)
The logic: try to express the value as a fraction with denominator up to 16; if the value is within 0.0001″ of such a fraction, print the fraction (½, ¾, ⅝ — it even has a Unicode lookup table); otherwise print three decimal places.
Look at snapToleranceInches and think back to the float-equality warning in
Chapter 2. A value that entered as 15 1/2 inches became 393.7 mm, was stored as a
Float, was maybe involved in arithmetic, and now has to come back out as 15 1/2
and not 15.500. Exact equality would fail. The tolerance is the round-trip
guarantee. Without it, typing a fraction and reading it back would produce a
decimal, and the app would feel subtly broken to the exact user it’s built for.
On the bench
Open util/LengthFormatTest.kt. Find a test case involving a value that is not
representable as a sixteenth — something like 0.3 inches. Write down what the test
says the output should be. Then answer: what would the user see if
snapToleranceInches were changed from 1e-4 to 1e-2? Would that be better or
worse, and for whom?
Step back from units for a moment. The shape you’ve just seen is completely general, and it appears three more times across the two applications:
| Boundary | Outside representation | Canonical inside | Converter |
|---|---|---|---|
| Numeric input | "15 1/2" (text, inches) |
Float mm |
parseToMm |
| Saved document | JSON envelope | ShaftSpec |
ShaftDocCodec |
| SMS provider | content://sms cursor rows |
MessageEntity |
SmsSyncHandler |
| Reaction fallback | Loved 'hello' (a text message) |
ReactionEntity |
ReactionFallbackParser |
Every one of these is the same move: the outside world has a representation you don’t control, so you convert it once, at a named place, into a representation you do control, and everything downstream only sees yours.
That named place has a name of its own: an anti-corruption layer. The term comes
from Domain-Driven Design and the metaphor is accurate — without it, the outside
world’s model leaks inward and corrupts yours. A Postmark that passed raw
content://sms cursors around would have Android’s schema, Android’s null rules, and
Android’s MMS/SMS ID collisions spread through every screen. Instead it has
MessageEntity, and the mess is confined to data/sync/.
Questions from the floor
Q: Isn’t this just a lot of extra copying? A: Yes. Deliberately. You’re spending CPU cycles and a bit of memory to buy a guarantee about where a class of bugs can live. That’s usually a spectacular trade — cycles are cheap and debugging is not — but it is a trade, and there are systems (very high throughput, very tight memory) where it isn’t worth it. Knowing it’s a trade is the professional part.
Q: Postmark’s MMS_ID_OFFSET = 10_000_000_000L — is that an invariant too?
A: It’s an invariant in the making. The rule “MMS rows are stored at
rawMmsId + MMS_ID_OFFSET” must hold everywhere or IDs collide with SMS rows. Notice
that it’s enforced by convention and comment rather than by type — a
data class MmsId(val raw: Long) would make it impossible to forget. That’s the same
primitive obsession smell from Chapter 2, in a place where it’s genuinely
consequential. For now, the offset is doing the job of a type system that hasn’t
been hired yet.
Spec sheet
require blames the caller (IllegalArgumentException); check blames the object’s state (IllegalStateException). Pick the one that tells the truth to whoever reads the stack trace.lengthMm carries the unit assertion to every use site, not just the declaration.UnitSystem uses enum constants with bodies to replace a conditional with polymorphism; MILLIMETERS is a null object whose conversions do nothing so that callers never branch.Double and narrow to Float once — precision loss is confined to one place.Cold bench — orientation
Every other exercise in this book hands you a codebase two people have already spent months making sense of on your behalf. This one doesn’t, and it’s the first of five — one roughly every third chapter, each picking up where the last one left off.
Pick a Kotlin Android app on GitHub: a few hundred commits at least, more than one contributor if you can find it, and — the important part — one you have never opened before. Not a tutorial repo. Not a fork of something you already half-know. Keep it; the next cold bench assumes you still have it cloned. Set a timer for twenty minutes and don’t go over.
model/ and geom/ before reading either.Activity, Application, or MainActivity — whatever the
platform hands control to first.data
class, plain class, interface, something else.require, a check, or a comment that reads like a promise;
you don’t need the whole file.No answer key. Nobody has read this repository on your behalf and written down what you’re supposed to find — that’s rather the exercise.
Further reading
UnitSystem’s enum constants with bodies are doing when they replace a conditional with polymorphism.Review questions
UnitSystem.MILLIMETERS implement toMillimeters() as an identity function instead of the enum simply having no method for that case?UnitSystem an example of, and what does that refactoring buy you when a third unit is added?parseFractionOrDecimal returns Double? rather than throwing. Justify that choice in terms of what kind of event malformed input is.parseToMm explicitly forbids clamping negatives. What principle is being defended, and what would be lost if the rule were ignored?snapToleranceInches in LengthFormat, connecting it to the way Float equality behaves.MMS_ID_OFFSET keeps MMS IDs from colliding with SMS IDs. Describe how you would move that rule from convention into the type system, and one cost of doing so.require and check throw different exception types. Explain the distinction and why it matters to whoever reads the failure.ShaftSpec.withBodyAt deliberately has no precondition on index — an out-of-range value returns this. Give one argument for that choice and one against, and state the general question that decides it.