Also in 中文. Flutter implementation notes for Trading app architecture; the principle is in Modularity and migration.
Folder conventions do not create boundaries. A lib/features/ directory is a suggestion, and any file can import any other file across it. Once more than a few people work in a codebase, suggestions stop holding.
Dart packages are the smallest unit where a boundary is actually enforced — a package cannot import what it doesn’t declare. That is the whole reason to reach for them.
A package per feature
apps/
trading_app/ # composition root: wiring, routes, flavors
packages/
design_system/ # tokens, components, theme
domain/ # entities, value objects, rules — no Flutter import
data/ # repositories, api client, database
feature_quotes/
feature_watchlist/
feature_orders/
One rule makes this work: features depend on domain, data, and design_system; features never depend on each other; only the app depends on features.
That single constraint is what buys you anything. It means a feature can be developed, tested, and deleted in isolation; that a change to feature_orders cannot break feature_quotes by construction; and that your build system can compile and test them in parallel.
The corollary that people resist: if two features need the same thing, it belongs in domain or a shared package — not imported sideways. Sideways imports are how a modular codebase quietly becomes a monolith with extra pubspec.yaml files.
Keep domain free of Flutter entirely. It should be plain Dart: entities, rules, and the interfaces the outside world implements. That makes it fast to test without a widget binding, and it means the business logic could outlive a UI framework change — the practical form of dependency inversion.
Enforce the rule with a tool
A boundary nobody checks is a boundary that erodes in the first busy sprint. Make violations fail CI.
The cheap version is a lint that forbids importing across features, using custom_lint or an analysis option banning specific import paths. The cheaper version still: because features are separate packages, an undeclared import is already a compile error — you get most of the enforcement for free simply by not putting everything in one package.
Add a check that no package depends on the app, and that domain depends on nothing but Dart. Both are one-line rules that catch real architectural drift.
Routing as a registry
If the app imports every feature’s screens directly, adding a feature means editing the app, and the app becomes the coupling point you tried to avoid.
Instead have each feature expose its routes, and let the app compose them:
// in feature_quotes
final quotesRoutes = <RouteDef>[
RouteDef('/quotes/:symbol', (ctx, args) => QuoteDetailPage(symbol: args['symbol']!)),
];
// in apps/trading_app
final router = AppRouter([
...quotesRoutes,
...watchlistRoutes,
...ordersRoutes,
]);
Navigation between features then goes through paths rather than through imported widget classes, which is what keeps features from depending on each other while still being able to link to one another. It also gives deep links a single place to be validated — and per the security article, an incoming link must be treated as hostile input and re-checked at the destination, not trusted because it arrived.
Composition at the root only
Dependency injection belongs in the app package. Features declare what they need in constructors; the root builds the object graph.
// apps/trading_app/lib/bootstrap.dart
final db = await openCatalog();
final api = ApiClient(baseUrl: env.apiUrl);
final quotes = QuoteRepository(api: api, db: db);
runApp(App(quotes: quotes));
Reaching for a global service locator from inside a feature undoes this: the feature now has a hidden dependency that doesn’t appear in its constructor, cannot be substituted in a test without global setup, and makes the package non-portable. A locator at the composition root is fine. A locator called from a widget deep in a feature is a global variable with good manners.
Add-to-app, and what it costs
If you are embedding Flutter into an existing native app — the strangler-fig migration — there are four costs to plan for.
Engine startup. The first Flutter screen pays engine initialization. Pre-warm a cached engine during app start so the first navigation isn’t the moment the user waits.
Multiple engines. FlutterEngineGroup lets additional engines share the snapshot and most of the memory cost, which makes several live engines viable where independent engines would not be. It is still not free — prefer one engine with internal routing unless you genuinely need isolated screens.
Two navigation stacks. The native stack and Flutter’s router both exist, and the back button must behave sensibly across the seam. Decide early who owns the stack for a given flow, and route all back handling through that decision rather than patching cases as they surface.
Duplicated state. Session, theme, and locale now live on both sides. Make one side authoritative and push changes across a single channel; do not let both sides maintain their own copy and hope they agree.
Pass initial route and arguments when creating the engine, and return results through one narrow channel rather than several ad-hoc ones — the coarse-boundary rule from the channels article applies to navigation too.
When this is too much
For one to three developers on a single app, this structure is overhead: more pubspec.yaml files, more version wrangling (pub workspaces, Dart 3.6+, remove most of that — one resolution and one lockfile for the repo — but not the rest), slower cold builds, and indirection with nothing on the other side of it.
Start with one package and folders. Extract a package when a boundary starts to hurt — you want to test a layer without a widget binding, two people keep colliding in the same area, or you need to reuse something in a second app. Those are observations. “We might grow” is not, and a small team maintaining a large team’s structure pays coordination costs for an organization it doesn’t have.
References
API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).
- Dart docs, Pub workspaces (Dart 3.6+) — one resolution and one lockfile across the packages of a monorepo.
custom_linton pub.dev — project-specific lints, including import-boundary rules, that fail CI.- R. C. Martin, “The Clean Architecture”, 2012 — the dependency rule: source dependencies point inward, toward policy.
- D. L. Parnas, “On the Criteria To Be Used in Decomposing Systems into Modules”, CACM 15(12), 1972 — a module should hide a decision that is likely to change.
- M. Fowler, “Inversion of Control Containers and the Dependency Injection pattern”, 2004 — dependency injection vs service locator, and what each costs in testability.
- Flutter docs, Add Flutter to an existing app and Multiple Flutter instances — engine warm-up,
FlutterEngineGroup, and the cost of several live engines.
Previous: Offline storage and search · Next: A reference design for a quotes MVP