ARCHITECTURE RULES THAT FAIL THE BUILD, NOT THE REVIEW Making a Flutter codebase enforce its own design decisions

ARCHITECTURE RULES THAT FAIL THE BUILD, NOT THE REVIEW Making a Flutter codebase enforce its own design decisions

INTRO

Every team has an architecture document. Very few teams have an architecture.

The gap between the two is where most mobile codebases quietly rot. Someone
writes "features must not import each other." Six months and four contractors
later, half the modules import each other, and the document is now fiction that
takes an afternoon of archaeology to disprove. The rule was never wrong it was
just unenforceable. It lived in a wiki, and wikis do not have an opinion at
2 a.m. when a release is due.

We build a Flutter app for pet care at Hoomanely, and we took a different
position early: if an architectural rule matters enough to write down, it
matters enough to fail a build. Not a review comment. Not a linter warning
someone can scroll past. A hard stop, in the same place and with the same
authority as a type error.

This is what that looks like in practice what we enforce, how, and what it
costs.

THE PROBLEM: CODE REVIEW IS THE WRONG ENFORCEMENT LAYER

Code review is excellent at judgement "is this the right abstraction?", "does
this handle the empty case?" It is terrible at vigilance.

Vigilance means noticing, on a 600-line diff at the end of a long day, that one
widget used a raw hex colour instead of a theme role, so the screen will look
broken in dark mode on exactly the devices you did not test. It means catching
that a new screen wrapped a try/catch around a call that a fault boundary was
supposed to own, so a crash now silently degrades into an inert button instead
of being reported. These are not judgement calls. They are pattern matches, and
humans are measurably bad at pattern matching under fatigue.

The failure mode compounds. Each unnoticed violation raises the ambient level of
"well, the codebase already does this here," and the next reviewer has one fewer
reason to object. Standards do not collapse; they erode.

The three categories we found ourselves re-litigating in review, over and over:

  1. Boundary violations a feature reaching directly into another feature, or
    into a vendor SDK it has no business knowing about.
  2. Vocabulary violations a raw colour, a raw text style, a raw spacing
    number, instead of the design system's token for the same thing.
  3. Contract violations a screen holding business state in setState, a
    stream listener with no error handler, a caught and swallowed exception.

Every one of them is mechanically detectable. So we stopped detecting them by
eye.

Modules talk to the kernel, never to each other. The arrows are not conventions each one is a lint rule that fails the build.

THE APPROACH: THREE LAYERS, ONE ENFORCEMENT MECHANISM

The architecture itself is not exotic. It is a service-registry kernel: feature
modules plug into a host, declare which typed intents they handle, and
communicate only by dispatching intents. Services (auth, networking, storage,
on device inference, Bluetooth transport) sit behind typed keys and are
domain agnostic the Bluetooth service knows how to write a GATT
characteristic, and knows nothing about what the bytes mean.

What is unusual is that the diagram is executable.

Dart's custom_lint package lets you ship analyzer rules as a package inside
your own repo. They run in the IDE while you type, in CI, and in a pre-commit
hook the same rule, the same verdict, three places. We currently run 48 of
them. A representative sample, by category:

Boundaries
no_cross_module_imports a feature cannot import a sibling feature
vendor_sdk_only_in_foundation vendor SDKs stay in one package
bluetooth_only_via_kernel BLE transport is a service, not an import
no_direct_getit_in_modules no reaching into the container directly

Vocabulary
require_theme_aware_color colours come from brightness-aware roles
avoid_hardcoded_color no raw Color() outside token files
avoid_literal_spacing no SizedBox(height: 14)
avoid_raw_material_button one button component, every variant
use_app_overlays no raw showDialog / showModalBottomSheet

Contracts
no_catch_in_modules modules never catch; the host fault
boundary records and recycles
screen_state_is_view_model business state lives in a view model
stream_listeners_need_error_handler
dispose_controllers lifecycle controllers must be disposed
responsive_screen_required every screen builds through a responsive
primitive, with a landscape path

Each rule is roughly the same shape walk the AST, match a node, report a code.
The mechanics are unremarkable; the discipline is in choosing rules that are
decidable. "Use a good abstraction" is not a lint rule. "A file matching
*_screen.dart may not instantiate Column" is.

A second, blunter tool covers what the analyzer cannot see. A guard script
greps for patterns that are textual rather than syntactic a banned asset path,
a raw animation file extension that should have been compiled, a generated
registry that has drifted from its manifest. Generated code gets a --check
mode in its own generator: regenerate into memory, diff against what is
committed, fail on mismatch. Stale generated code is a class of bug that no
review catches, because the diff looks fine.

THE PROCESS: WHAT A COMMIT ACTUALLY WALKS THROUGH

The gate is a pre-commit hook and it mirrors CI exactly, in this order:

Cheapest checks first, slowest last. The build is last because it is the only step that proves the tree actually compiles analysis catches type errors, not every build break.

Two details matter more than they look.

--fatal-infos. Info-level diagnostics fail the gate. This sounds
excessive, and for the first week it is irritating. Then the info-level noise
floor goes to zero and stays there, because nothing can accumulate. A codebase
with 400 "just informational" warnings has effectively no warnings at all
nobody reads them.

Every package's test suite runs, not just the app's. A root-level test run
does not execute tests inside sibling packages in a monorepo. Discovering that
after a design system regression ships is a bad afternoon.

THE COST (BECAUSE THERE IS ONE)

Honest accounting matters more than the sales pitch:

  • The gate is slow. A full pre-commit run ends with a real debug build. This
    is deliberate the slow step is the one that catches what nothing else
    does but it is not free, and it pushes you toward smaller commits.
  • Rules need escape hatches, and escape hatches need evidence. Our
    no-catch rule permits exactly one exception: a third-party API with no
    check-before-call, marked with a // no-precheck: <reason> comment. The
    marker is the point. It converts an invisible exception into a greppable,
    reviewable one.
  • A rule that fires wrongly is worse than no rule. Every false positive
    teaches engineers that the gate is noise. We have rewritten rules more
    than once rather than let people work around them.
  • Some things remain un-lintable. Minimum edge clearance in every
    orientation, or whether a screen reads correctly in dark mode, are runtime
    layout properties, not syntactic ones. Those stay manual review gates —
    and we say so explicitly, rather than pretending the tooling covers them.

RESULTS

Figures counted from the repository at time of writing:

18 independently compilable feature packages, none importing another
48 custom architectural lint rules running in IDE, hook, and CI
133 design-system component files the single vocabulary for all UI
1 package permitted to import vendor SDKs
3 generated registries with drift gates (theme assets, animations,
on-device model manifest)

The same rule, moved from prose to the analyzer, changes category — from an aspiration into a property of the codebase.

The qualitative outcome is the one we actually care about: onboarding
conversations changed shape.
New engineers stopped asking "what's the
convention here?" and started asking "why is this the convention?" which is a
far better question, and one a document can answer well. The tooling handles
compliance; humans spend review on design.

KEY TAKEAWAYS

  1. If a rule is mechanically decidable, a human should not be enforcing it.
    Reviewers are for judgement; analyzers are for vigilance.
  2. Write rules that are decidable, not aspirational. "No Column in a file
    named *_screen.dart" ships. "Use good abstractions" does not.
  3. Make the escape hatch visible. A required marker comment turns an exception
    into something you can grep, count, and review.
  4. Fail on info-level diagnostics. A warning backlog is the same as no
    warnings the noise floor must be zero for the signal to mean anything.
  5. Gate generated code with a drift check. Stale generated files pass review
    every time, because the diff looks clean.
  6. Say out loud what the tooling cannot catch. Dark mode, landscape, edge
    clearance and real-device behaviour stay human gates naming them keeps
    them from being quietly assumed.

ABOUT HOOMANELY

Hoomanely builds technology for pet parents connected hardware and an app
that together turn everyday pet care into something measurable, proactive and
calm, so that fewer problems are caught late. That mission puts unusual weight
on software discipline.
An architecture that enforces itself is how a small team keeps that surface
trustworthy while it grows. Every rule in this post exists because it protects
something a pet parent would notice if it broke a screen unreadable at night,
a crash swallowed instead of reported, a device that pairs on one phone and not
another. The tooling is not ceremony. It is how we make reliability the default
rather than the outcome of a good review day.

Read more