The rules the code has to follow
How a grow monitor is built: domain-driven design enforced by tests, an absolute rule about nulls, one writer to the repository, and a pipeline that fixes red before it does anything else.
Croplock is a Kotlin service on a single-board computer, a React dashboard it serves on the LAN, a fleet of ESPHome sensor pods, and a Python sidecar that runs the vision and language models on the NPU. It is built by a very small team — a retired software engineer directing an AI coding agent — which is the context for everything below: the rules are written down in a file the agent reads at the start of every session, and most of them exist because something went wrong once and we decided it would not go wrong twice. Sixty-seven architecture decision records so far. This is the shape they add up to.
The domain is generic. The species is a file.
The first decision, and the one that shaped everything after it, was that the core has no idea what plant it is growing. The domain model speaks in CultivationRun, LifecyclePhase, GeneticEntry and Member — never grow, strain or grower — because those words carry a species with them and the kernel must not. Everything species-specific lives in a signed knowledge pack loaded at boot: the phase bands, the protocols, the pest signatures, the citations, the display labels. The cannabis pack activates 43 protocols. An orchid pack activates different ones. The kernel runs whichever it is handed.
This is enforced, not hoped for. A CI job called the Orchid Guard greps the kernel for cannabis-coupled identifiers on every build — THC, CBD, terpene, cannabinoid — and fails if one appears outside a frozen baseline. It also refuses any “cannabis” string in the orchid pack. The name is the point: if the orchid pack could not run, the kernel has leaked.
It turned out to be the advertising passport too, as we have written elsewhere, but it was done for the code.
Sixteen bounded contexts, three layers each, and a test that knows the difference
The service is split into sixteen bounded contexts — cultivation, genetics, sensors, steering, vision, diagnosis, assistant, ledger, compliance, identity and so on — and every one has the same three packages: domain, application, infrastructure. Domain holds aggregates, value objects and the ports they need. Application orchestrates. Infrastructure is where Spring, JPA, HTTP, MQTT and the filesystem are allowed to exist.
Spring never enters the domain. That is the rule people hear first, and it is one of about twenty that ArchUnit checks on every build, each written as a test with a sentence for a name:
- domain layer has no Spring framework dependencies — nor Jakarta persistence, nor Hibernate, nor Jackson outside a frozen list, nor filesystem IO.
- domain layer does not depend on application layer; application layer does not depend on infrastructure layer; output ports live in domain port package.
- core bounded contexts cannot import each other’s domain layers. A context that needs another’s data goes through its application service or a shared kernel, never its aggregates.
- web layer cannot inject Repository ports from domain port packages. Controllers orchestrate through application services; they do not reach into storage. The test carries its own bite-test: a synthetic controller that violates the rule must fail, and one that obeys it must pass, or the rule is not proven to be plugged in.
- enrolled aggregate roots declare at least one business method. An aggregate that is only a bag of fields with generated getters is anaemic, and the test says so.
- JPA entities only live in infrastructure persistence entity package, and a mapper file translates domain to entity and back. That file is the one place a function-count lint is suspended, because the boundary is the file, not the count.
Read models are a separate package from the domain model — a projection reconstructible from events, with no invariants of its own, is not an aggregate and does not get treated as one. Absence is modelled as a sealed sum, a row that exists or does not, or a first-class event — and that brings us to the rules.
The house rules
A few of these are ordinary engineering discipline. Most were written the day after a specific failure, and the wording has not been softened since.
“Do not add any more nulls, period, ever.” The project’s null-handling policy predates this sentence by months, and the sentence exists because the policy was found to be enforced everywhere except the seam that got used. A bug fix shipped three nullable parameters into a domain service — in the very bug whose lesson was that an invisible default becomes a silent wrong answer — and it passed ktlint, detekt, fourteen hundred tests and both existing architecture rules. Our response was to make the rule absolute. No new nullable on any domain type, port, service, DTO or column, for any reason, under any deadline. If absence must be modelled, it is a sealed sum, row-existence, or an event. And a campsite rule: you may not leave a pre-existing nullable in code you touch. The existing population is inventoried and scheduled; it is not licence.
Four days later we extended it to the schema with a question — is a relational schema with nullable columns really normalised? — and the answer is no, so the rule now reads: new variant-carrying state lands in its own table with every column NOT NULL, never as nullable columns on an existing one. Row-existence carries the disjunction. Three ArchUnit-style ratchets enforce the direction: domain port return types, domain service signatures and Flyway migrations may each shrink their nullable count and may never grow it, against a baseline that itself must stay truthful — a deleted class must leave the list. It is the billion-dollar mistake, and we treat it as one.
Test first. Since April, no production frontend code without a failing test that specifies the behaviour, and the same expectation on the backend. Coverage floors live in the build files, not in documentation — today the backend holds 92% line / 89% instruction / 75% branch, the frontend 81% lines — and they ratchet upward. Lowering one requires an explicit, recorded decision and has happened once, as a baseline correction after a subsystem was removed. Eighty-four Gherkin scenarios across fifteen feature files run inside the build; the full grower flow from creating a group to recording a measurement is guarded by one of them, and a commit that breaks it must carry the reason in its message.
Design for all, never for the instance. We deliberately did not find out what light an early customer runs — not because it was secret, but because knowing it would have meant designing for that tent instead of designing for all of them. The device has to work in a tent it has never seen. So the unidentified case is the default path, the identified case is an exception that must be earned by a positive check, and the only accepted validation is held-out generalisation: fit on one instance, predict a different one. Where generality cannot be shown, the software abstains loudly rather than widening the fit.
Get it right; do not suppress it. When the diagnosis pipeline was measured wrong on real frames, the engineering proposal was to stop issuing verdicts. We rejected the frame: turning it off is an admission of failure. The result is the gate-and-shadow design described in the vision post — the model still runs on every frame, its output is recorded and not shown, and the device says worth your eyes instead of naming what it cannot see.
Opt-ins ship off. Any toggle, however good the evidence for it, defaults to off. The grower turns it on.
One writer. There is exactly one entity that commits to the repository. An earlier arrangement with two agents editing in parallel corrupted the index three times in a week and silently truncated eight documents on the main branch, and it made a person the courier of text between them. We ended it. Trunk-based, no pull requests, explicit file paths on every commit, and a rule that the human is handed only genuinely human-only actions, stated plainly, never process for its own sake.
The pipeline
The hook budget was rebalanced in July around one question: what is irreversible if deferred? The answer is a leaked secret, which is in history the moment it is committed. So pre-commit runs gitleaks and nothing else — about a second — plus one glob-gated exception for 3D-print files, because a bad STL costs hours of printer time and filament rather than a red build. Everything else is recoverable by fixing forward, so it moved to the push.
Pre-push is one Gradle invocation: ktlint, detekt, the unit suite with all the architecture tests inside it, and the frontend’s lint, typecheck, architecture and design-token checks — backend and frontend running concurrently inside a single build, which separate invocations cannot do. It used to be five serial Gradle runs on every commit; it is now about forty-seven seconds once per push. The integration suite and the coverage floors run in CI only, a few minutes later. The trade is stated plainly in the rules: a push can now land red, and CI tells you rather than the hook.
Which is why the first rule of every session is always green: check the last run on the main branch before touching anything, and if it is red, fixing it is the task. A red push is followed to its terminal state by whoever pushed it, including a documentation-only push — the CI filter evaluates the whole push, not the head commit, so a docs commit riding on top of someone else’s code can carry a red run under its name.
CI itself detects which surfaces changed and runs the stages that apply: the Orchid Guard; backend static → unit → integration → smoke against a real Postgres; frontend static → unit → build, then Playwright in Chromium; the pod firmware’s unit tests and configuration validation, plus a job whose whole purpose is to prove the leaf-IR external component still compiles; and the marketing site’s build, HTML validation, brand gate and smoke tests — which include a guard that fails the build if any page claims the camera names a problem unprompted. That guard fired on this site earlier today, on a sentence in a post saying the opposite. It was right.
Deploys are deliberate. The site publishes on a manual dispatch, never on push, and the dispatch builds the tip of main — so the rule is to check that nothing landed between the commit you tested and the tip before pressing the button. The hub takes a signed JAR by a swap script that verifies the running version afterwards; nothing auto-deploys to a device in someone’s house.
What the rules got wrong
Two things, both recorded in the same file as the rules.
A deferred enforcement target is a permission slip. The null policy set an ArchUnit target of “Sprint 6 or later.” Seventeen sprints passed without the rule landing, and in that time three nullable ports accumulated that the rule would have refused. Now a rule may not be written without its enforcement item filed at the same time with a deadline one sprint out; if it cannot be enforced by then, it is advisory and says so.
A frozen baseline stops the bleeding and preserves every existing wound. Most of the architecture rules ship with a baseline of current violations and bite only the next one. That is the right way to land a rule on a living codebase — and it means the grandfathered population is real debt. The web layer’s nullable ratchet correctly refused a new nullable field in August and, the same month, two dashboard readings were serving a trust flag of false with a reason of null, because a budgeted class was allowed to keep the fourteen it had. Three states flattened into two nullable fields, exactly the shape the policy bans. It was fixed to a total type; the budget went from fourteen to eleven. We saw it on the dashboard before any test did, and the question it raised was the obvious one: why is it null, when there is a rule against that?
What we would tell someone starting this
- Write the architecture as tests with sentences for names. A rule in a document is a hope. A rule in the build is a rule.
- Land rules with a frozen baseline and a ratchet, then treat the baseline as a debt list, not a ceiling.
- Put in the pre-commit hook only what is irreversible if deferred. Everything else belongs at the push, once, in one build.
- Green first. The red run on main is the next task, whoever caused it.
- Model absence as a type or a row, never as a hole. Then say so in the schema too.
- One writer to the repository. Coordination between writers costs more than the parallelism buys, and it costs it in the index.
- Keep the domain ignorant of the product. If your kernel knows what it is for, it will leak, and a guard with a stranger’s name on it will tell you when.
Sources
Counts are from the repository on 2026-09-09: 16 bounded contexts, 67 architecture decision records, 84 Gherkin scenarios in 15 features, 50 Postgres and 33 SQLite migrations. Coverage floors are as declared in the build files on that date and ratchet upward; do not quote them from here later. Stack: Spring Boot 4.1 on Kotlin 2.3, React 18 with MUI 6 on Vite 8, ESPHome on the pods, a Python RKNN sidecar on the hub. The house rules are quoted from the project’s own conventions file, dated where given. Nothing here is a claim about what the device can find in a tent.