All posts
Production Architecture6 min read

Structuring a large Flutter codebase. What actually scales.

Feature-first or layer-first? Where does dependency injection go? A real folder structure that survives ten engineers and three years of churn.

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:

  1. The codebase remains navigable as it grows.
  2. New features can be added without touching unrelated code.
  3. 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:

  1. A feature folder owns everything it needs: models, repositories, screens, widgets.
  2. Cross-feature imports go through the public barrel file (auth.dart, transactions.dart). Anything not exported from the barrel is private to the feature.
  3. The core/ folder is for things every feature can depend on (HTTP client, storage). Nothing in core/ may depend on a feature/.

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 →

Continue reading

You may also enjoy

Production Architecture

Riverpod vs Bloc vs GetX. I used all three in production. Here is the honest breakdown.

Three production codebases, three state management choices. Where each one earned its keep, where each one cost me a weekend, and a recommendation matrix.

Read article
Production Architecture

I recommended GetX for 2 years. I changed my mind. Here is why.

An honest account of what made GetX appealing, where it broke at scale, and the specific bug that made me switch a production app to Riverpod.

Read article