Flutter stays fast to iterate in the first year and painful in the third when features import each other freely, global state hides data flow, and platform calls litter widgets. Scale is not about picking Riverpod vs Bloc—it is about ownership, dependency direction, and seams that let teams ship without whole-app regressions.
This note treats a large Flutter codebase as a product platform: how to partition packages, bound state, route across features, test at the right layers, and evolve from one repo to many contributors—without prematurely splitting into micro-frontends that nobody can run locally.
1. Symptoms of an unbounded codebase
| Symptom | Root cause | Cost |
|---------|------------|------|
| Touching settings breaks checkout | Feature imports feature UI directly | Regression hunts |
| “Just use the global provider” | No state scope rules | Hidden rebuilds, test flakiness |
| 40-minute CI for a one-line change | No package graph; everything rebuilds | Velocity collapse |
| Platform crash only on Samsung | MethodChannel in widgets | Untestable native seams |
| Web and mobile diverge silently | #ifdef in presentation | Double maintenance |
If your graph looks like a hairball, framework choice will not save you. Draw boundaries first.
2. Package topology: what depends on what
For apps beyond ~50k LOC or 3+ squads, prefer a monorepo of packages (Melos or plain path dependencies):
apps/
mobile/ # thin shell: bootstrap, router, DI root
packages/
design_system/ # tokens, widgets, themes
core/ # logging, analytics, env, shared errors
feature_auth/
auth_api/ # public models + interfaces only
auth_impl/ # UI + data + domain (or split further)
feature_messaging/
messaging_api/
messaging_impl/
feature_settings/
...
Dependency rules (enforce in CI with dependency_validator or custom script):
| Package | May depend on | Must not depend on |
|---------|---------------|-------------------|
| *_api | core, other *_api (sparingly) | Any *_impl, widgets from other features |
| *_impl | own *_api, design_system, core | Other features’ *_impl |
| design_system | core (minimal) | Feature packages |
| apps/mobile | *_impl, router glue | — |
Cross-feature needs (e.g. “open chat from order detail”) go through api contracts—interfaces, IDs, deep-link routes—not through importing another feature’s screens.
flowchart LR
App[mobile shell]
FA[feature_a_impl]
FB[feature_b_impl]
AA[feature_a_api]
AB[feature_b_api]
DS[design_system]
Core[core]
App --> FA
App --> FB
FA --> AA
FB --> AB
FA --> DS
FB --> DS
FA --> Core
FB --> Core
AA --> Core
AB --> Core
3. Layers inside a feature
Within each *_impl, keep presentation → domain → data dependency direction:
feature_x/
presentation/ # widgets, view models, notifiers
domain/ # entities, use cases, repository interfaces
data/ # DTOs, API clients, local DB, repository impls
| Layer | Holds | Avoid |
|-------|-------|-------|
| Presentation | Widgets, state notifiers, routing args mapping | SQL, JSON keys, MethodChannel |
| Domain | Business rules, pure Dart entities | BuildContext, Flutter imports |
| Data | Retrofit/Dio, Drift/Hive, mappers DTO↔domain | Widget rebuild logic |
Use cases (single-purpose classes or functions) keep widgets thin: LoadThread, SendMessage, MarkRead—each orchestrates repositories and exposes explicit failure types.
4. State: scope before framework
Pick Riverpod, Bloc, or MobX—but rules matter more than brand:
| Scope | Examples | Lifetime |
|-------|----------|----------|
| Ephemeral / local | Animation, form draft, scroll position | StatefulWidget, HookWidget |
| Feature | Inbox list, compose flow | Provider scoped to feature route subtree |
| App session | Auth session, locale, theme | Root providers; minimal surface |
| Persistent | User prefs, offline cache | Repository + domain; not raw global singletons |
Rules worth enforcing early:
- No feature reads another feature’s private notifiers;
- Side effects (API, navigation) live in notifiers/blocs, not in
build(); - Async: explicit
loading / data / errorstates—avoid boolean soup (isLoading && !hasError && …); - Dispose subscriptions; cancel in-flight requests when routes pop.
Global everything → every change is a full-app regression. Scope providers at the route (ProviderScope overrides, BlocProvider in feature shell).
5. Navigation as a public contract
Use a declarative router (go_router, auto_route) owned by the app shell:
- Routes are URLs —
/orders/:id,/chat/:threadId; enables deep links and web; - Feature registers routes via plugin pattern or generated route table;
- Cross-feature navigation passes IDs and intent, not widget instances:
context.go('/chat/$threadId'), notNavigator.push(OtherFeatureScreen(...))with concrete types from another package; - Auth redirects centralized in router redirect callback;
- Nested navigation — shell routes for tabs; inner navigators for stacks.
Keep route params parsing in presentation mappers; domain receives typed IDs/value objects.
6. Data layer: repositories and offline
6.1 Repository pattern
Widgets and notifiers depend on interfaces in domain:
abstract class MessageRepository {
Stream<List<Message>> watchThread(ThreadId id);
Future<Result<void>> send(SendCommand cmd);
}
Implementation composes remote + local sources; domain never sees HTTP status codes.
6.2 DTO vs domain
- DTOs match API JSON (
freezed+json_serializable); - Mappers at data boundary;
- Domain models stable even when API v2 adds fields.
6.3 Networking
- Single
Dio(orhttp) with interceptors: auth refresh, trace id, retry policy; - Timeouts per endpoint class;
- Idempotency keys on mutating calls where backend supports.
6.4 Local storage
- Drift/Isar/Hive for structured cache; define TTL and invalidation per entity;
- Offline-first only where product requires—conflict policy (LWW, server wins) must be explicit;
- Migrations versioned like backend schema.
7. Platform channels: facades, not widget calls
Native capabilities (push, biometrics, background tasks, BLE) cross a hostile boundary:
| Practice | Why |
|----------|-----|
| Facade interface in Dart (PushNotifications, SecureStorage) | Widgets stay testable |
| Pigeon or typed channel wrappers | Less stringly-typed errors |
| Single channel name per domain | Avoid collisions |
| Result types for platform failures | Map to domain errors |
| Fakes in unit/widget tests | CI without emulators |
| Integration tests for real channels | Nightly or pre-release |
Never scatter MethodChannel('foo') in build(). Platform teams own the native side; Dart teams own the facade contract.
8. Design system as a product
design_system/ is not “common widgets folder”:
- Tokens — color, spacing, typography, radius (light/dark/high-contrast);
- Components — buttons, fields, sheets with documented states (disabled, loading, error);
- Accessibility — semantics, contrast, min tap targets baked in;
- No business logic — no API calls, no feature flags inside atoms;
- Versioning — breaking visual changes go through design review + golden updates.
Features compose screens from design system + feature-specific molecules. Do not fork a button per feature.
9. Testing pyramid for Flutter at scale
| Layer | Target | Tools |
|-------|--------|-------|
| Unit | Use cases, mappers, pure domain | test |
| Widget | Screens with faked repos | flutter_test, ProviderScope overrides |
| Golden | Visual regressions on design system | golden_toolkit / Alchemist |
| Integration | End-to-end flows, real channels | integration_test, device lab |
Contract tests at *_api boundaries: fake implementations used by both consumer tests and producer tests.
CI should run affected packages only (Melos exec --scope or build graph)—not full matrix on every doc typo.
10. Performance: release truth
Debug mode lies. Habits that survive production:
constconstructors where values are stable;- List virtualization —
ListView.builder, sliver lists; avoid unboundedColumnof children; - Images — cache width/height, decode size,
RepaintBoundaryon heavy cells; - Heavy work —
compute()/ isolates for JSON parse, image processing, crypto; - Rebuild control —
selectin Riverpod,buildWhenin Bloc, split widgets; - Shader jank — profile on release; watch Impeller vs Skia on target devices;
- Startup — defer non-critical init; lazy-register routes and SDKs.
Set budgets: first frame, scroll jank %, APK/IPA size—track in CI where feasible.
11. Environments, flavors, and flags
- Flavors —
dev,staging,prodwith distinct bundle IDs, API base URLs, logging levels; - Config via
--dart-defineor env files — never hardcode secrets in repo; - Feature flags — remote (LaunchDarkly, custom) with defaults in code for offline;
- Shell reads flags; features receive bool via DI—not direct SDK singletons everywhere.
12. Codegen and consistency
At scale, hand-written boilerplate drifts:
freezed— unions for UI state, immutable models;json_serializable— DTOs;retrofitor typed clients — API definitions;drift— SQL with generated queries;go_router/auto_routebuilders — typed routes.
Standardize in a template feature (melos run create_feature) so new modules do not invent a third pattern.
13. Errors, logging, and observability
- Domain errors — sealed classes (
NetworkFailure,Unauthorized,ValidationError); - Presentation maps to user copy + retry actions;
- Global handler — log + crash report (
Firebase Crashlytics, Sentry) with breadcrumbs; - Analytics events — defined in
core, implemented once; features emit typed events; - PII policy — no raw tokens in logs; scrub in interceptors.
Correlate client traces with backend via X-Trace-Id or equivalent from Dio interceptor.
14. Multi-platform seams
One codebase ≠ identical behavior:
| Concern | Mobile | Web | Desktop | |---------|--------|-----|---------| | Navigation | Deep links, back gesture | URL bar, refresh | Window resize | | Storage | Secure enclave | localStorage limits | file paths | | Background | OS constraints | tab sleep | varies |
Use Platform.isX at facades, not sprinkled in widgets. Consider conditional imports (stub/io/web) for platform implementations.
Web builds: watch bundle size, defer WASM/heavy libs, test on Safari not only Chrome.
15. Evolution path
- Single app, feature folders — enforce layer lint rules; learn domains;
- Extract
design_system+core— stop copy-paste UI; - Split hot features to packages — auth, messaging first;
- Introduce
*_apipackages — when second team integrates; - Thin shell app — routing + DI only; features version independently where needed.
Split packages when ownership or build time hurts—not when LOC crosses an arbitrary line.
16. Anti-patterns
- Layer-only root (
/widgets,/models,/servicesfor whole app); - Feature A imports Feature B’s screens;
- God
AppStateholding user, cart, chat, and theme; - Business rules in widgets or JSON maps in presentation;
- Untyped
Map<String, dynamic>across layers; - Copy-paste Dio per feature with different interceptors;
- Skipping golden/design tests until “after launch”;
- Debug-only profiling before release;
- Micro-frontend split before local run story works;
- Platform channels without facades or fakes.
Summary
Flutter at scale is modular product engineering:
- Package graph with rules —
apivsimpl, no sideways impl imports; - Feature layers — presentation → domain → data; use cases at the center;
- State scoped — local, feature, session; not one global bag;
- Router-owned navigation — URLs, IDs, deep links;
- Repositories + offline policy — DTO/domain split;
- Platform facades — testable, typed, centralized;
- Design system — tokens and components, not shared junk drawer;
- Testing + CI by package — fakes at seams; release profiling;
- Evolve packages with team boundaries — not premature distribution theater.
Flutter wins on interaction quality across surfaces; discipline on dependencies is what keeps that win as headcount and features grow.