My Flutter Architecture Is Unbreakable — Even by AI

I have a problem with AI that writes code: it cheats.
Give it a prompt, even a detailed one, and it’ll take the shortest path. Import a repository directly into a widget. Shove business logic inside a build() method. Create a 400-line file with three classes in it. And the worst part: it compiles. It works. And it silently rots the codebase.
After 7 years of Flutter and tens of thousands of lines maintained solo, I built an architecture where the Dart compiler itself prevents these shortcuts. Not conventions. Not code reviews. Compilation errors.
I call it the Layou Dev Kit, and here’s how it works.
The problem: AI respects nothing
If you use Claude Code, Cursor, Copilot, or any AI agent to write Flutter code, you’ve experienced this:
- The agent imports a Firestore
DataSourcedirectly into a widget - It creates a 300-line
StatefulWidgetmixing UI, state, and network calls - It puts three classes in the same file
- It uses
setStateinstead of Riverpod because it’s “simpler”
The result: code that runs, but is unmaintainable. And the larger the project gets, the faster the debt compounds, exponentially.
The real problem is that documented conventions aren’t enough. AI “forgets” them as soon as its context fills up. You need structural constraints, things that even a 200K-token model can’t bypass.
The idea: the compiler as a guardrail
Clean Architecture, everyone knows the concept. Domain at the center, Data on the periphery, Presentation only talks to Domain. The problem in Flutter is that everything lives in the same lib/ package. Nothing prevents a forbidden import, it’s just a convention.
My solution: separate each layer into its own Dart package.
packages/
├── core/ # Shared utils, theme, i18n
├── domain/ # Entities, UseCases, Repository interfaces
├── data/ # DTOs, DataSources, Repo implementations
├── presentation/ # Screens, Widgets, Connectors, Providers
└── lint_rules/ # Custom lint rules (custom_lint)
The magic is that the Dart compiler enforces cross-package dependency rules. If you try to import data from presentation, it’s a compilation error. Not a warning. Not a lint. A red error that blocks the build.
| Package | Can import | CANNOT import |
|---|---|---|
core | nothing internal | domain, data, presentation |
domain | core | data, presentation |
data | core, domain | presentation |
presentation | core, domain | data |
app (root) | everything | , |
When an AI agent writes import 'package:myapp_data/auth/datasources/...' in a presentation/ file, the build crashes. The agent gets the error, understands the constraint, and fixes it. Without human intervention.
Custom lint rules: the second lock
The compiler prevents forbidden imports. But there are patterns it can’t catch alone. For those, I created a custom lint rules package using custom_lint.
Six rules, each with a specific purpose:
| Rule | What it prevents |
|---|---|
molecule_no_riverpod | Molecules cannot import Riverpod |
organism_no_riverpod | Same for organisms |
connector_must_be_consumer | Connectors must be ConsumerWidgets |
max_function_lines | Max 50 lines per function |
max_class_lines | Max 200 lines per class |
one_public_class_per_file | One public class per file |
The most important one: molecule_no_riverpod. It guarantees that base UI components (“molecules”) receive all their data through the constructor. Zero dependency on global state. Zero mocks needed for testing.
When AI generates a molecule widget and tries to inject a ref.watch() into it, the lint blocks it. It must pass data as parameters. This forces clean architecture mechanically.
Atomic design: every widget has a role
I use an Atomic Design variant adapted for Flutter + Riverpod. Four widget types, with clear rules:
Molecule, Uses only Flutter primitive widgets (Text, Container, Icon). No Riverpod, no custom widgets. Lint-enforced.
Organism, Combines molecules. Still pure: all data through the constructor.
Connector, The bridge between Riverpod and pure widgets. It’s a ConsumerWidget that watch()es providers and passes data down to organisms. Handles loading/error/data states.
Screen, Full page. Handles navigation, analytics, orchestration.
The decision tree is simple:
Need Riverpod? → Yes → Full page? → Yes → SCREEN / No → CONNECTOR → No → Uses custom widgets? → Yes → ORGANISM / No → MOLECULE
The direct benefit for testing: molecules and organisms test with zero mocks. Just a pumpWidget(MaterialApp(home: ScoreBadge(score: 150))) and you’re done.
DI wiring: the only place that sees everything
The Dependency Injection pattern uses provider stubs in the domain layer:
// packages/domain/, declares the type
@Riverpod(keepAlive: true)
AuthRepository authRepository(Ref ref) {
throw UnimplementedError('Must be overridden in app');
}
The concrete implementation is wired only in lib/di/, the only place in the project allowed to import both data AND presentation:
// lib/main.dart, wires the concrete
ProviderScope(
overrides: [
authRepositoryProvider.overrideWith((ref) {
return AuthRepositoryImpl(
remoteDataSource: AuthRemoteDataSource(),
);
}),
],
child: const MainApp(),
);
Presentation never knows where data comes from. It asks for an AuthRepository, it gets one. Whether it’s from Firestore, a REST API, or a test mock, the UI code doesn’t change.
Why this is a game-Changer with AI
Here’s what this architecture concretely changes when working with AI agents:
Before (classic architecture):
- Agent takes shortcuts → code compiles → I review everything manually → I fix 60% of what it produces
- Technical debt accumulates between sessions
- Each new agent/context “forgets” the conventions
After (Layou Dev Kit):
- Agent takes a shortcut → build crashes → it self-corrects
- Architecture is structurally unbreakable
flutter analyzewith custom lint rules catches the rest
| Without Layou Dev Kit | With Layou Dev Kit | |
|---|---|---|
| Forbidden import | Compiles (but it’s broken) | Compilation error ❌ |
| Widget too large | Nobody notices | Lint error at 200 lines |
| Molecule with Riverpod | Works fine | Lint error |
| Testing a widget | Mock hell | Zero mocks (molecules/organisms) |
| New AI agent | Must re-read all conventions | The compiler guides it |
The real insight: I don’t need to trust the AI. The architecture itself is the guardrail. The AI can hallucinate all it wants, if it compiles and flutter analyze passes, the code respects the architecture.
What it costs
I’d be dishonest if I said it was free. The initial setup takes time:
- Melos + separate packages : 2-3 days of scaffolding on the first project
- Custom lint rules : 1 day of writing (with tests)
- build_runner : slower on a monorepo (I use targeted
build.yamlfiles to optimize) - Learning curve : a dev joining the project needs to understand the 4 layers
But after that? Every new feature follows the same pattern mechanically. AI scaffolds a new module respecting the structure. Tests almost write themselves. And most importantly: I never need to refactor the architecture, it’s held across 3 apps and 100k+ lines of code.
Who this is for
This architecture isn’t for everyone. It makes sense if:
- You maintain a Flutter app long-term (not a prototype)
- You work with AI agents that code for you
- You’re solo or a small team and can’t afford to review every line
- You want code that’s testable without pain
If you’re doing a hackathon or a throwaway MVP, it’s overkill. But if you’re building a product that needs to run for years? It’s infrastructure. And like all good infrastructure, once it’s in place, you wonder how you ever lived without it.
Resources
- 📹 My video on Flutter Clean Architecture, full walkthrough
- 🔧 BeeDone on the web, built with this architecture
- 🎨 Muse Otter, built with this architecture
- 📦 Melos, the Dart monorepo tool
- 📦 custom_lint, write your own rules
- 📦 Riverpod, state management
- 📦 Freezed, code generation for entities
Want to structure your next Flutter project so AI codes cleanly? Start by separating your layers into packages. The compiler will do the rest.