Start a project

Blog

A gate for Flutter architecture: keeping a codebase clean after handover

Clean architecture rules written in a document fade. We check them on every commit with a short Dart script that reads every import, so they outlast the people who wrote them.

· 3 min read

Every Flutter project we start has an architecture document: feature folders, three layers inside each, and a table of which layer may import which. Every team that has written one knows what happens next. Six months later, under a deadline, a widget imports a repository directly, and the table is a description of how the code used to be.

We hand our first versions over to the founder's team. If the rules only live in a document, they leave with us. So in Pocket Music they live in a check that runs on every commit.

The shape being protected

The app is split into features — 24 of them, from the tuner to the settings screen — and each feature has the same layers:

  • domain: the models and the interfaces. Pure Dart, no Flutter, no plugins.
  • data: the implementations, talking to storage and plugins.
  • presentation: the screens and their state, which may use the domain but never the data layer.
  • di: the one place that wires an implementation to its interface.

On top of that sits a shared core that never imports a feature, and a rule for features that need each other: they go through a small published contract, never into each other's internals.

The gate

The check is a Dart script of about 450 lines. It reads every import in lib/, works out which feature and layer each file belongs to, and compares each import with the table. It reports every violation in one pass and fails the build.

A few choices make it hold up:

  • An allowlist, not a blocklist. The domain layer may use a short, named list of pure-Dart packages. A new dependency is rejected until someone adds it to the list on purpose, in a change a reviewer reads.
  • Relative imports count. ../../data/ is checked like any other import, so there is no quiet way around it.
  • Cycles are rejected between features, and so is a contract that nobody outside its feature uses. A seam has to earn its place.
  • Exemptions are visible. The list of exceptions is empty today, and the script prints that section on every run anyway. An exception names one file, one import and the reason, so it shows up in a diff instead of disappearing into a config file.
  • The document and the script change together. The table in the docs is the spec; the script is the gate. A pull request that changes one and not the other is wrong by definition.

A second, smaller script guards the wiring: registering dependencies may only declare how to build things — it never awaits and never calls a method on what it builds. Anything that has to run at startup goes in a separate, ordered list. That keeps a test that wants the registrations from paying for the whole app's warm-up.

One command

Both gates run inside the same check as formatting, static analysis and the test suite. There is one command to run before pushing, and a read-only mode for CI that writes nothing. Nobody has to remember the rules; the build remembers them.

Why this matters to a founder

You will not read the import graph, and you should not have to. What you get is a codebase where the next developer — yours or ours — cannot accidentally weld the screens to the database. That is what keeps the second version cheaper than the first, and it is why we count the architecture as part of the handover, not as our private taste.

Have a first version to light?

Tell us what it should do. We reply within two working days.

hello@flamedots.com