I have inherited two Flutter codebases that started simple and grew into something nobody could navigate. One was layer-first: every model in models/, every screen in screens/, every service in services/. By the time it had eighty screens, finding all the code for a single feature meant grepping across six folders and praying the naming was consistent. The other was feature-first but the features all imported each other freely. After a year, removing any feature was impossible.
Both were preventable. This post is the structure I would now use for any Flutter codebase that I expect to outlive its first author. Not the textbook version. The version that has survived contact with engineers who join, ship, and leave.
Context: what "scales" actually means
When people say "this scales," they usually mean one of three things:
- The codebase remains navigable as it grows.
- New features can be added without touching unrelated code.
- Old features can be removed cleanly.
Folder structure is not the only thing that affects these. But it is the thing every engineer touches every day, so getting it wrong is expensive.
The biggest determinant of which structure works is your team's discipline about cross-feature imports. If your team will not respect a boundary, no folder layout will save you. If your team will, almost any consistent structure works. The structure I recommend is the one that makes the boundary explicit and the violation visible in a code review.
Feature-first, with strict boundaries
lib/
app/
app.dart # MaterialApp
router.dart # GoRouter config
theme.dart
core/ # cross-feature primitives only
network/
api_client.dart
interceptors/
storage/
error/
extensions/
features/
auth/
data/
auth_repository.dart
dto/
domain/
user.dart
auth_state.dart
presentation/
sign_in_screen.dart
widgets/
controllers/
auth.dart # public barrel: only this is imported
transactions/
data/
domain/
presentation/
transactions.dart # public barrel
settings/
...
main.dart
Three rules make this work:
- A feature folder owns everything it needs: models, repositories, screens, widgets.
- Cross-feature imports go through the public barrel file (
auth.dart,transactions.dart). Anything not exported from the barrel is private to the feature. - The
core/folder is for things every feature can depend on (HTTP client, storage). Nothing incore/may depend on afeature/.
Enforce rule 2 with dart_code_metrics or a custom lint that bans imports of features/X/data/ from outside feature X. Without enforcement, the structure decays in three months.
Layer-first, for comparison
lib/
models/
repositories/
services/
screens/
widgets/
utils/
main.dart
This is the structure most tutorials recommend. It works for a single-developer project under twenty screens. After that, every feature change touches multiple folders, code review diffs become harder to read, and grepping for "where does the user model get created" returns matches across five folders.
Caption: layer-first creates one big graph where every layer depends on the one above. Feature-first creates small graphs that are independent except through declared interfaces.
Where dependency injection fits
Whichever structure you choose, the DI scope must mirror the feature scope or the structure breaks. With Riverpod:
The apiClientProvider lives in core/. The authRepositoryProvider lives in the feature. A feature consumes its own providers internally and offers a small public surface to other features.
In tests, override at the public boundary:
This rule — "override at the public boundary" — is what makes feature-first scalable. If you find yourself overriding a private provider from another feature in your test, the boundary has leaked.
Routing in a feature-first layout
GoRouter is configured once in app/router.dart. Each feature exports its routes from the barrel:
The router file becomes a manifest of what is in the app. New features add a line to the manifest and a routes file in their feature folder. Removing a feature is two lines: delete the folder, delete the spread.
When feature-first hurts
Feature-first is wrong for a one-person side project under ten screens. The overhead of barrel files, scope rules, and per-feature DI is real and you do not need it.
It is also wrong if your "features" are not really independent. A note-taking app's "editor", "list", and "search" all read and write the same notes. They are not features; they are views over one domain. Use a domain-first split instead:
lib/
domain/
notes/ # the model lives here, owned by domain
features/
editor/
list/
search/
The features depend on the domain, not on each other. Same idea, different shape.
A real folder example, end to end
lib/
app/
app.dart
router.dart
theme.dart
core/
network/
api_client.dart
interceptors/
auth_interceptor.dart
logging_interceptor.dart
storage/
secure_storage.dart
error/
app_exception.dart
error_logger.dart
features/
auth/
auth.dart
data/
auth_repository.dart
dto/
sign_in_request.dart
sign_in_response.dart
domain/
user.dart
auth_state.dart
presentation/
sign_in_screen.dart
sign_up_screen.dart
auth_routes.dart
controllers/
auth_controller.dart
widgets/
email_field.dart
password_field.dart
transactions/
transactions.dart
data/
transactions_repository.dart
domain/
transaction.dart
presentation/
transactions_screen.dart
transaction_detail_screen.dart
transactions_routes.dart
controllers/
transactions_controller.dart
widgets/
transaction_tile.dart
l10n/
main.dart
A new engineer can be told: "find the feature you are working on, everything is in that folder." That sentence is the whole win.
What I would do differently
- I would have introduced the public barrel rule on day one. Adding it after the fact required untangling four months of cross-feature imports.
- I would have written a custom lint to enforce the barrel rule the same week. Without enforcement, the rule degraded.
- I would have kept
core/smaller. We let utilities accumulate there and it became a junk drawer. Anything that only one feature uses belongs in that feature. - I would have used a
domain/folder for shared models when features genuinely shared data. Forcing those into one feature created a dependency chain that hurt later. - I would not have allowed circular dependencies between features even temporarily. Once permitted "just for now," they never get fixed.
Closing opinion
Use feature-first with public barrels and a lint to enforce them. It scales further than anything else I have used. The structure is wrong for very small apps and very interconnected domain models, but for a typical product with five-plus features, it is the right default. For the state-management piece that fits inside this structure, see Riverpod vs Bloc vs GetX. I used all three in production. For why this often only matters once the team grows, see Should your team rewrite the native app in Flutter?.
Written by the author of Flutterstacks
A developer who shipped production apps in Swift, Kotlin, and Dart — with a genuine native reference point that most Flutter writers simply don't have.
More articles →