# Flutter project structure: packages, boundaries, and add-to-app

> How to lay out a Flutter codebase so boundaries are enforced rather than suggested — a package per feature, a dependency rule a tool can check, routing as a registry — and what add-to-app actually costs when you embed into an existing native app.

- Canonical (HTML): https://kaguc.com/blog/flutter-project-structure/
- Date: 2026-08-28


*Also in [中文](/blog/flutter-project-structure-zh/). Flutter implementation notes for [Trading app architecture](/blog/trading-app-architecture/); the principle is in [Modularity and migration](/blog/modular-evolution/).*

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

```text
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](/blog/modular-evolution/).

## 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:

```dart
// 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](/blog/client-security-model/), 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.

```dart
// 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](/blog/modular-evolution/) — 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](/blog/flutter-native-channels/) 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](https://dart.dev/tools/pub/workspaces) (Dart 3.6+) — one resolution and one lockfile across the packages of a monorepo.
- [`custom_lint`](https://pub.dev/packages/custom_lint) on pub.dev — project-specific lints, including import-boundary rules, that fail CI.
- R. C. Martin, ["The Clean Architecture"](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html), 2012 — the dependency rule: source dependencies point inward, toward policy.
- D. L. Parnas, ["On the Criteria To Be Used in Decomposing Systems into Modules"](https://doi.org/10.1145/361598.361623), *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"](https://martinfowler.com/articles/injection.html), 2004 — dependency injection vs service locator, and what each costs in testability.
- Flutter docs, [Add Flutter to an existing app](https://docs.flutter.dev/add-to-app) and [Multiple Flutter instances](https://docs.flutter.dev/add-to-app/multiple-flutters) — engine warm-up, `FlutterEngineGroup`, and the cost of several live engines.

---

*Previous: [Offline storage and search](/blog/flutter-offline-store/) · Next: [A reference design for a quotes MVP](/blog/flutter-trading-mvp/)*

