← Abdelrahman Saed · All articles

Best Practices for Managing Large Flutter Codebases

Subtitle: A small Flutter app is held together by memory. A large one has to be held together by structure — because nobody can keep 220,000 lines in their head.

A large, orderly Flutter codebase — modules arranged like a city grid


Most Flutter advice is written for the app you start with: one lib/ folder, a handful of screens, a main.dart you can read top to bottom. That advice quietly stops working somewhere around the point where no single person has read the whole codebase anymore.

I lead mobile on iStoria, a story-based English-learning app on iOS and Android. Over a few years it has grown into roughly 220,000 lines of Dart across ~1,300 files, 60+ feature modules, and nearly 30,000 commits — built and maintained by a small team, shipping weekly, at a 99.9% crash-free rate to 5M+ learners. At that size, the enemy isn't any single hard problem. It's entropy: the slow drift where every change gets a little riskier, every merge a little scarier, and onboarding a new engineer takes a little longer than it did last quarter.

Managing a large Flutter codebase is mostly about fighting that entropy with structure. The practices below are the ones that actually held up under load: feature-first modularization with a hard core/features boundary; one predictable data-flow layering in every module; disciplined dependency injection; code generation to delete boilerplate; conventions enforced by tooling instead of reviewers; owning the dependencies you can't afford to have break; treating offline-first as an architecture rather than a feature; and automating the repository itself so the boring, error-prone chores stop landing on people. None of them are exotic. The discipline is in applying them consistently across 60 modules, not just the three you touched this week.

What "large" actually changes

Small-app instincts are dangerous at scale precisely because they worked so well when the app was small. When everything fits in your head, a shared utils.dart, a global singleton, and a bit of copy-paste are all fine. The bill comes later:

  • No one holds the whole map. Decisions get made locally, and local decisions that don't share a shape produce 60 slightly different architectures in one repo.
  • Blast radius grows silently. A tweak to a "shared" helper now touches a dozen features you've never opened. Without boundaries, everything is coupled to everything.
  • The build gets slow and the reviews get shallow. Boilerplate multiplies, diffs balloon, and reviewers start rubber-stamping because reading it all is impossible.

So the goal isn't elegance for its own sake. The goal is to keep the app shippable as it grows — to make the codebase navigable by people who didn't write it, and to shrink the blast radius of any single change. Every practice below is in service of that.

1. Modularize by feature, and defend the boundary

The single most important decision is the top-level shape. Organizing by layer (screens/, models/, services/) collapses the moment you have 60 features: every folder becomes a junk drawer of unrelated things, and no one can find "everything for streaks" without grepping the whole tree.

Organize by feature instead. Each feature is a self-contained module that owns its data and its UI, and the only shared space is a deliberately small core:

lib/
├── core/            # shared and ONLY shared: api, services, widgets, errors
│   ├── api/
│   ├── services/
│   └── widgets/
├── features/
│   ├── streak/
│   ├── leaderboard/
│   ├── payment/
│   └── … 60+ more, each self-contained
└── main_*.dart      # one entry point per flavor

The rule that makes this work is a boundary you actually enforce: features never import from other features. If two features need the same thing, it moves down into core. If it's used by exactly one feature, it stays inside that feature — no speculative "someone might need this later" promotions. This one rule is what keeps blast radius small: opening features/streak/ tells you everything streak-related, and changing it can't silently break leaderboard.

The counterintuitive part is that a little duplication across features is often cheaper than a shared abstraction. A helper shared by three features is a helper three features are now coupled to. Promote to core when the sharing is real and stable — not on the first coincidence.

2. One predictable layering, in every single module

Consistency beats cleverness. If every feature invents its own way to fetch and hold data, "large" just means "many different apps in a trench coat." Pick one layering and apply it everywhere so that opening any module — even one you've never seen — feels familiar within seconds.

The layering that held up for us is the classic Clean-Architecture flow: data source → repository → controller, with the UI reading from the controller. The detail that matters most at scale isn't the layer names, though — it's turning failures into values instead of exceptions. A thrown exception is an invisible control-flow edge that a reviewer can miss and a crash reporter finds for you in production. A returned failure is right there in the type signature:

// Failures are values, not surprises. The signature forces every caller
// to handle both outcomes — the compiler becomes your reviewer.
abstract class StoriesRepository {
  Future<Either<Failure, List<Story>>> fetchStories();
}

class StoriesRepositoryImpl implements StoriesRepository {
  StoriesRepositoryImpl(this._remote, this._local);

  final StoriesRemoteSource _remote;
  final StoriesLocalSource _local;

  @override
  Future<Either<Failure, List<Story>>> fetchStories() async {
    try {
      final cached = await _local.cachedStories(); // read locally first
      unawaited(_remote.refreshInBackground());     // sync, never blocks UI
      return Right(cached);
    } on CacheException catch (e) {
      return Left(CacheFailure(e.message));
    }
  }
}

When every repository returns an Either<Failure, T> (I use dartz for it), the UI stops being a minefield of try/catch and becomes a simple fork: render the data, or render the error. Multiply that discipline across 60 modules and an enormous class of "we forgot to handle the failure path" bugs simply can't compile.

Keep one reactive controller (or cubit — pick a state solution and standardize on it) per feature as the single place UI state lives. Which state-management library you choose matters far less than choosing one and using it the same way everywhere.

3. Make dependency injection boring on purpose

Sixty modules that all new up their own dependencies is sixty modules welded to concrete implementations — impossible to test, impossible to swap. But an over-clever DI setup is its own tax. The goal is boring: one obvious place things get registered, and one obvious way to fetch them.

Register truly global services (auth, networking, the local database) once at startup in a single central binding. Then give each feature its own binding that registers only what that feature needs, wired up lazily when you navigate into it and torn down when you leave. A feature depends on abstractions (StoriesRepository), never on the concrete class, so it can be tested against a fake and refactored without a chain reaction. The win at scale is that DI becomes a lookup you never have to think about — which is exactly what you want from plumbing.

4. Delete boilerplate with code generation

A large app generates staggering amounts of mechanical code: JSON serialization for every model, database table classes, route tables. Hand-writing and hand-maintaining that is both a waste of humans and a rich source of subtle bugs (the fromJson that forgot a field, the copy-paste that mismatched a type).

Push all of it to code generation. build_runner with json_serializable for models, a typed database layer for local storage, and generated route definitions mean the boilerplate is derived from a single declaration rather than maintained by hand:

@JsonSerializable()
class Story {
  Story({required this.id, required this.title, required this.wordCount});

  final String id;
  final String title;
  final int wordCount;

  // The generator writes fromJson/toJson. You never touch — or misspell — them.
  factory Story.fromJson(Map<String, dynamic> json) => _$StoryFromJson(json);
  Map<String, dynamic> toJson() => _$StoryToJson(this);
}

Generated code doesn't drift, doesn't get a typo in review, and doesn't show up as noise in your diffs. On a codebase this size, that's thousands of lines nobody has to read or trust by eye.

5. Enforce conventions with tooling, not code review

Every large team writes a style guide. Nobody reads it, and reviewers can't hold 40 rules in their head while also checking whether the logic is correct. So the rules quietly decay — unless a machine enforces them.

Lean on the analyzer hard. Beyond the standard flutter_lints, add a metrics plugin that fails the build on the things humans stop noticing: functions that grew too complex, nesting that got too deep, arguments lists that ballooned.

# analysis_options.yaml — the parts that scale
analyzer:
  plugins:
    - dart_code_metrics

dart_code_metrics:
  metrics:
    cyclomatic-complexity: 50
    maximum-nesting-level: 6
    number-of-arguments: 4
  rules:
    - newline-before-return
    - prefer-first
    - prefer-last
    - no-equal-then-else

Then codify the softer conventions and let the pipeline block violations: self-documenting code over comments (a comment explaining what code does is a smell — rename until it doesn't need one), no stray print/debugPrint in committed code, and — the one I'd fight for hardest — structured, diagnostic error reports. Every recorded error carries a [Source] tag and pipe-delimited context, so a crash in your reporter is actionable on its own:

[GrowthBook] getValue conversion failure | key=offer_settings | expectedType=Map | actualType=int | rawValue=304

The difference between that and "something went wrong" is the difference between a five-minute fix and an afternoon spelunking through source. When the machine enforces the rules, review gets to spend its scarce attention on what actually needs a human: is this logic correct?

6. Own the dependencies you can't afford to lose

A large app leans on a lot of packages, and the pub ecosystem is uneven — some critical dependencies are lightly maintained, or stall on exactly the platform bug that's blocking your release. When a dependency you rely on goes quiet, you have three options: wait, rip it out, or take control.

For the ones that matter, take control: fork the package, fix or patch it, and point your pubspec at your fork. I maintain several forks of packages iStoria depends on for exactly this reason — a shimmer effect, a showcase overlay, a layout helper — where upstream couldn't move as fast as our release cycle needed. You give up "just run pub upgrade," but you gain the ability to ship on your own schedule instead of waiting on a maintainer who has a day job. At scale, not being blocked is worth a lot.

7. Treat offline-first as an architecture, not a feature

If your app has real users on real networks — commutes, spotty Wi-Fi, metered data — "works offline" can't be bolted on at the end. It's a decision that shapes your whole data layer. Learners shouldn't wait on a round-trip to see their own progress.

The pattern that scales is local-first reads with background sync: the app reads from a local database immediately and treats the network as something that reconciles state in the background, not something the UI blocks on. That's why the repository in practice #2 returns cached data first and kicks off a refresh without awaiting it. Getting this right early means every feature you build afterward inherits offline support for free, instead of each one reinventing its own fragile caching.

8. Automate the repository itself

The last multiplier isn't in the app code at all — it's in the repo around it. A codebase with tens of thousands of commits and a busy team drowns in mechanical chores: enforcing branch hygiene, keeping PRs current, chasing stale branches, surfacing merge conflicts before they rot. Left to humans, these are the tasks that get skipped under pressure and bite you at the worst time.

So automate them. Small, sharp CI jobs that guard master, keep pull requests in sync, and clean up after themselves take that entire category of work off people's plates — and, done right, do it more reliably than a human ever would. I wrote about this at length in The Self-Driving Repo series, so I'll leave the details there; the point is that at scale, the repository's own hygiene is part of the architecture.

The payoff

None of this is about being fancy. Every practice here trades a little up-front discipline for a codebase that stays shippable as it grows. On iStoria, that discipline is what let a small team take the release cycle from four weeks down to one, hold a 99.9% crash-free rate, and keep shipping new features into a 60-module app without the whole thing turning into a house of cards.

Large Flutter codebases don't fail because Flutter can't scale. They fail because structure decays faster than features get added. Pick a shape, enforce it with machines instead of willpower, and keep the blast radius of any single change small. Do that consistently, and 220,000 lines can still feel like a codebase you understand.

Want the longer version of how these decisions played out in production? Read the iStoria case study.

Tags: Flutter · Dart · Architecture · Scale

Related case study: iStoria


Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →

Book a 20-minute call · Download the CV (PDF) · See how I work