Skip to content

Guides

Feature-first Flutter architecture: a clean architecture template ​

Short answer. A Flutter clean architecture template that scales is feature-first. Group your code by feature (auth, home, profile), not by type (models, screens, services). Inside each feature, keep three layers: domain for the rules, data for talking to the outside world, and presentation for the screens. Put only code that several features use into a shared place. When a feature grows a team, turn its folder into a package with the same structure.

This guide is about the folders and packages inside one app. For the repository around it, with several apps and a shared workspace, see Flutter monorepo with Melos.

Layer-first versus feature-first ​

Layer-first groups files by what they are:

text
lib/
├── models/
│   ├── user.dart
│   └── order.dart
├── services/
│   ├── user_service.dart
│   └── order_service.dart
├── screens/
│   ├── login_screen.dart
│   └── orders_screen.dart
└── widgets/

It is easy to start, and it works for a small app. As the app grows, one change touches four folders, and nothing tells you which files belong together. Anyone can import anything.

Feature-first groups files by what they are for:

text
lib/
├── core/
├── features/
│   ├── auth/
│   └── orders/
├── app.dart
└── main.dart

All files for "orders" live in one place. You can read, change, test or delete a feature without searching the whole project.

The layout of one feature ​

text
lib/features/orders/
├── data/
│   ├── order_dto.dart             # JSON shape from the API
│   ├── orders_api.dart            # HTTP calls
│   └── order_repository_impl.dart
├── domain/
│   ├── order.dart                 # plain Dart entity
│   ├── order_repository.dart      # abstract interface
│   └── load_orders.dart           # use case
├── presentation/
│   ├── orders_page.dart
│   ├── orders_cubit.dart          # or bloc, notifier, view model
│   └── widgets/
└── orders.dart                    # the feature's public exports

What each layer holds, and what it may import:

LayerHoldsMay import
domainEntities, repository interfaces, use casesOnly Dart. No Flutter, no HTTP, no JSON
dataAPI clients, DTOs, repository implementationsdomain
presentationPages, widgets, state holdersdomain

The arrows point to domain. It is the middle of the feature and knows nothing about the others. That makes it the easiest part to test. It also means you can replace the API or the UI without touching the rules.

A small example of the seam:

dart
// domain/order_repository.dart
abstract interface class OrderRepository {
  Future<List<Order>> loadOrders();
}
dart
// data/order_repository_impl.dart
class OrderRepositoryImpl implements OrderRepository {
  OrderRepositoryImpl(this._api);
  final OrdersApi _api;

  @override
  Future<List<Order>> loadOrders() async {
    final dtos = await _api.fetchOrders();
    return dtos.map((dto) => dto.toEntity()).toList();
  }
}
dart
// presentation/orders_cubit.dart
class OrdersCubit extends Cubit<OrdersState> {
  OrdersCubit(this._repository) : super(const OrdersState.loading());
  final OrderRepository _repository; // the interface, not the implementation
}

The presentation layer asks for an OrderRepository. It does not know there is an HTTP client behind it. Wiring the real class in is the job of dependency injection. See Flutter dependency injection with get_it.

You do not need all three layers for every feature. A feature that only shows a static page needs presentation and nothing else. Add domain and data when there are rules and a data source worth separating.

What goes in core or shared ​

Code that two or more features use goes in a shared place:

text
lib/core/
├── network/       # HTTP client, interceptors
├── theme/         # colors, text styles
├── widgets/       # buttons, text fields used everywhere
└── utils/

Two rules keep it from turning into a dumping ground:

  • Move code there when the second feature needs it, not before. Duplicating a small class once is cheaper than a shared abstraction that fits nobody.
  • Shared code never imports a feature. The arrows point from features to core, never back.

How features talk to each other ​

Features should not reach into each other's folders. Two habits prevent most of the mess:

1. Export a small public surface. Each feature has one file that lists what other code may use:

dart
// lib/features/orders/orders.dart
export 'domain/order.dart';
export 'presentation/orders_page.dart';

Everything else stays private by convention: other features import orders.dart only.

2. Talk through contracts, not classes. If profile needs the current user from auth, it depends on a small interface that auth provides (an abstract CurrentUser, for example), not on auth's repository or its cubit. If two features must navigate to each other, use route names or a small navigation interface instead of importing pages.

Packages make these rules harder to break. Dart's implementation_imports lint warns when code imports another package's src/ folder. The next section shows how.

From folders to packages ​

Folders enforce nothing: any file can import any file. A package has a pubspec.yaml, so it lists its dependencies, and importing something it does not list is an error. A feature that is a package cannot quietly depend on another feature.

Promote a feature to a package when:

  • More than one person or team works on it.
  • You want its tests to run on their own, and in CI only when it changed (see Only test what changed).
  • Other code keeps reaching into its internals.

The folder moves nearly as it is:

text
features/
└── orders/
    ├── pubspec.yaml
    └── lib/
        ├── orders.dart          # public exports
        └── src/                 # everything else
            ├── data/
            ├── domain/
            └── presentation/

Code in lib/src/ is private by Dart convention. Only lib/orders.dart is the public face. The package depends on core and on other packages only through its pubspec.yaml, which you can read and review.

You do not have to split everything. Many teams keep small features as folders in the app, and only promote the ones with boundaries that matter. The folder layout above is the same in both cases, so promoting is mostly moving files and writing a pubspec.yaml.

A second split: API and implementation ​

Sometimes another feature needs to use yours (open the orders screen, ask for the order count) without depending on all of it. Then split the feature into two packages: orders_api with the interface other code uses, and orders_impl with the screens and data code. Other features depend only on orders_api. Do this only when a feature is used by others or when you want to swap the implementation, for example a payment or analytics vendor. It doubles the package count, so do not do it by default.

The app on top ​

The app should only assemble the pieces: create the router, set up dependency injection, wrap the theme, start the features. If business logic lives in the app, features can no longer be moved or tested alone.

A rule you can check in review: no import 'package:app/...' inside a feature.

Common mistakes ​

  • One giant shared or utils folder. It becomes the place where everything lands. Give shared code a name that says what it is: network, theme, formatting.
  • Features importing each other's screens. Use route names or a contract.
  • A domain layer that imports Flutter or dio. Then it is not domain any more, and you cannot test it without them.
  • Layers for the sake of layers. A mapper, a use case and a repository for a feature that reads one value. Start small and add a layer when you feel the need.
  • Models shared across features. Each feature should have its own model of the thing it cares about, even if two look alike. Sharing them ties the features together.
  • State management in the wrong layer. Cubits, blocs and notifiers belong in presentation. They call use cases or repository interfaces from domain.

How Archipelago does this ​

archipelago create generates this structure as a Dart workspace, with each feature as packages.

  • Four layers of packages. apps/ holds the app, which only wires things. features/ holds one folder per feature. infrastructure/ holds SDK wrappers such as network and analytics. shared/ and utilities/ hold what everyone uses. Dependencies flow downward only.
  • A feature is <name>_api plus <name>_impl. The _api package is a small port that other features may use (for example launch()). The _impl package holds the rest. The folders inside _impl follow the layout above. The auth feature, for example, has data, domain, presentation, router, di and l10n.
  • A feature registers itself. The _impl package has a FeatureManifest that lists its routes, and its own dependency injection module. You add a feature without editing a central router or startup file. See Feature Manifests and SDKs.
  • Each feature has its own DI container, so one feature cannot resolve another feature's internal classes. See the Dual-GetIt pattern.
  • The state management is your choice per project and per feature: bloc, cubit, riverpod or provider.
  • The dependency graph is checked. The toolkit's graph validate command checks the layer rules, so a feature that imports upward fails in CI.
  • archipelago generate <brick> adds a feature that already follows the layout. The feature_monorepo_skeleton brick makes an empty one.

Generate this layout ​

The folders above are plain Dart, so you can create them by hand. To get them with dependency injection, routing and tests already wired, run:

bash
dart pub global activate archipelago_cli
archipelago create --output ./my_app
cd my_app && melos bootstrap
archipelago generate feature_monorepo_skeleton

The generated auth feature is a real example of the layout. These are its real folders:

text
features/auth/auth_impl/lib/src/
├── data/            # datasources, models, repositories, token store
├── domain/usecases/
├── presentation/    # pages and cubits
├── router/
└── di/

For the whole workspace layout, see Flutter monorepo template.

References: Architecture, API/Impl split, the feature_monorepo_skeleton brick and the flutter_modular_monorepo brick.

When not to use Archipelago for this ​

  • Your app is small. A single lib/ with feature folders gives you the same ideas with no workspace. The folders above work in a plain Flutter project.
  • You do not want a monorepo or an account. Archipelago generates packages and needs a free account to download bricks. Very Good CLI makes a single app with no sign-in.
  • You use a different layering rule. The generated code follows the layout above. If your team uses another one, write your own Mason bricks.

FAQ ​

Is feature-first the same as clean architecture? ​

No. Feature-first is how you group files. Clean architecture is a rule about which layer may depend on which. They fit together: group by feature, then apply the layer rule inside each feature.

Do I need all of data, domain and presentation? ​

No. Add layers when a feature needs them. A feature with a static screen needs only presentation.

Where do widgets used by many features go? ​

In a shared UI package or folder, such as core/widgets or a ui_kit package. Keep feature-specific widgets inside the feature.

Where do I put models that come from the API? ​

DTOs belong in data, next to the API code that parses them. Convert them to plain entities in domain before they reach the UI.

How many features should be packages? ​

Start with folders. Promote a feature to a package when it has its own owner, its own tests, or boundaries that people keep crossing.

Does it matter for a small app? ​

Feature-first costs nothing in a small app: it is the same files in different folders. The package split is the part you can wait for.

Try Archipelago

Make a free account, install the CLI, and generate a project:

dart pub global activate archipelago_cli
archipelago auth login --interactive
archipelago create --output ./my_app

The Free plan includes 3 project scaffolds per month and the Free bricks. See Getting Started for each step, and pricing for Pro and Enterprise.