Back to blog
flutter

My Flutter Architecture Is Unbreakable — Even by AI

By Youcef EL KAMEL
9 min read

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 DataSource directly into a widget
  • It creates a 300-line StatefulWidget mixing UI, state, and network calls
  • It puts three classes in the same file
  • It uses setState instead 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.

PackageCan importCANNOT import
corenothing internaldomain, data, presentation
domaincoredata, presentation
datacore, domainpresentation
presentationcore, domaindata
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:

RuleWhat it prevents
molecule_no_riverpodMolecules cannot import Riverpod
organism_no_riverpodSame for organisms
connector_must_be_consumerConnectors must be ConsumerWidgets
max_function_linesMax 50 lines per function
max_class_linesMax 200 lines per class
one_public_class_per_fileOne 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 analyze with custom lint rules catches the rest
Without Layou Dev KitWith Layou Dev Kit
Forbidden importCompiles (but it’s broken)Compilation error ❌
Widget too largeNobody noticesLint error at 200 lines
Molecule with RiverpodWorks fineLint error
Testing a widgetMock hellZero mocks (molecules/organisms)
New AI agentMust re-read all conventionsThe 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.yaml files 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

Want to structure your next Flutter project so AI codes cleanly? Start by separating your layers into packages. The compiler will do the rest.

#Flutter #Clean Architecture #Riverpod #AI #AI agents #software architecture #Dart #Melos #custom lint #monorepo #indie dev #code quality