Skip to content
ExponaBLOG
FLUTTER · DAY 035 / 100

Feature-first folders: Flutter best practices explained

Organize by feature, not by layer.

Expona Admin7 min read

Why folder structure matters more than you think

By day 35 of building Flutter apps, you've likely felt the pain of navigating a codebase where everything lives in monolithic folders—models/, screens/, services/—spread across dozens of files. Feature-first folders: Flutter best practices explained is about moving away from that layer-based thinking and organizing your code around the features users actually interact with. A well-structured Flutter project scales with your team's velocity, not against it.

The case for features/ over layers/

Layer-based folder structures divide your app by technical concerns: all screens in one place, all state management in another, all data models elsewhere. This sounds logical at first, but it breaks down fast. Imagine you're building a real estate app and you need to modify the leads feature—viewing, filtering, and scoring leads. With layers, you'd touch screens/leads_screen.dart, providers/leads_provider.dart, models/lead.dart, services/lead_service.dart, and maybe a repositories/lead_repository.dart. That's five different folders just to ship one coherent feature.

Feature-first organization inverts this. You create a features/ directory, and inside it, one folder per feature: features/leads/, features/auth/, features/analytics/. Each feature folder contains everything needed to make that feature work: UI, state management, data access, and models. When you need to modify leads, you navigate to one logical place.

Real-world example: features/leads/ structure

Let's walk through a concrete directory tree for a leads feature in a real Flutter CRM app:

dart
features/leads/
├── presentation/
│   ├── pages/
│   │   ├── leads_list_page.dart
│   │   └── lead_detail_page.dart
│   ├── widgets/
│   │   ├── lead_card.dart
│   │   ├── lead_filter_sheet.dart
│   │   └── lead_status_badge.dart
│   └── controllers/
│       └── leads_controller.dart
├── logic/
│   ├── leads_bloc.dart
│   ├── leads_event.dart
│   └── leads_state.dart
├── data/
│   ├── models/
│   │   ├── lead.dart
│   │   └── lead_filter.dart
│   ├── datasources/
│   │   ├── lead_local_datasource.dart
│   │   └── lead_remote_datasource.dart
│   └── repositories/
│       └── lead_repository.dart
└── bindings.dart

The presentation/ folder holds all UI code: pages are full-screen widgets, widgets are reusable components, and controllers (or providers in Riverpod) manage feature-level state. The logic/ folder (or bloc/ if you use BLoC) centralizes business logic. The data/ folder handles everything below the presentation layer—models, local and remote data sources, and repositories. The bindings.dart file registers all dependencies for this feature, making it injectable and mockable.

When a team member needs to add a "bulk score leads" action, they know exactly where to look: features/leads/presentation/ for the UI trigger, features/leads/logic/ for the state updates, and features/leads/data/ for the API call. No jumping between four different top-level directories.

Shared code: core/ and shared/ folders

Not everything belongs in a feature folder. You'll have UI components, utilities, and themes used across multiple features. That's where core/ and shared/ directories come in. The distinction is subtle but useful:

  • core/ holds low-level infrastructure: network clients, local database setup, analytics wrappers, constants, and theme definitions. Think of it as the app's foundation.
  • shared/ contains higher-level, UI-adjacent code: reusable widgets like buttons and cards, formatters, validators, and app-wide extensions.

Example structure:

dart
core/
├── network/
│   ├── api_client.dart
│   └── interceptors.dart
├── local_storage/
│   ├── hive_service.dart
│   └── preferences_service.dart
├── di/
│   └── service_locator.dart
└── constants/
    └── app_constants.dart

shared/
├── widgets/
│   ├── custom_button.dart
│   ├── custom_text_field.dart
│   └── loading_indicator.dart
├── extensions/
│   ├── context_extensions.dart
│   └── string_extensions.dart
├── formatters/
│   ├── date_formatter.dart
│   └── currency_formatter.dart
└── themes/
    └── app_theme.dart

If you're building a multi-screen form with custom inputs, the custom_text_field.dart widget lives in shared/widgets/. But the form's state logic and the API call to save it? That stays in the relevant feature folder, like features/lead_onboarding/.

Easier navigation as the app grows

Small apps with 5–10 features work fine with any structure. But once you hit 20+ features, a layer-based approach becomes a maze. Finding where "search leads" logic lives requires pattern-matching across multiple folders and files. With feature-first organization, you can almost always find what you need by typing the feature name into your IDE's file search. Jump to features/leads/, and 80% of what you need is right there.

For a ride-hailing app we worked on, adding a new "scheduled rides" feature involved creating one new folder: features/scheduled_rides/. Within a day, a junior developer was shipping code without asking where things go. With layers, they would have asked three times where to put the scheduled ride model, the API fetching logic, and the cancellation UI.

Team onboarding time drops significantly. New engineers can explore a feature folder top-to-bottom and understand the entire flow: pages, state management, data models, and API integration. Compare that to bouncing between models/, providers/, screens/, and services/—each with 50+ files.

Feature boundaries limit coupling

One of the biggest wins of feature-first organization is enforcing boundaries between features. A feature folder should not directly import from another feature's internals. If features/leads/ needs to trigger something in features/notifications/, it should do so through a shared event bus or a service in core/, not by importing features/notifications/logic/notifications_bloc.dart.

This constraint has real benefits:

  • Testability: You can test a feature in isolation because its dependencies are explicit and injectable.
  • Modularity: If the notifications feature changes its internal structure, leads doesn't break as long as the public API (the shared service) stays the same.
  • Code reuse clarity: You never accidentally create circular dependencies. If both features need the same logic, it goes in shared/ or core/.
  • Future refactoring: Moving a feature to a separate package or plugin becomes feasible because the boundaries are already defined.

On a SaaS project with 30+ features, we noticed that enforcing these boundaries made code reviews faster. Reviewers knew that a PR touching features/reporting/ wouldn't have hidden side effects in features/auth/ or features/billing/. The scope was bounded.

Practical implementation: dependency injection

Feature-first organization shines when you combine it with proper dependency injection. Each feature folder should have a bindings.dart file (if using GetX) or a setup function (if using get_it) that registers all the feature's dependencies. This makes your main app file clean and ensures features are loosely coupled:

dart
// features/leads/bindings.dart
import 'package:get_it/get_it.dart';
import 'datasources/lead_remote_datasource.dart';
import 'repositories/lead_repository.dart';
import 'logic/leads_bloc.dart';

final getIt = GetIt.instance;

void setupLeadsFeature() {
  getIt.registerSingleton<LeadRemoteDatasource>(
    LeadRemoteDatasource(getIt<ApiClient>()),
  );
  getIt.registerSingleton<LeadRepository>(
    LeadRepository(getIt<LeadRemoteDatasource>()),
  );
  getIt.registerSingleton<LeadsBloc>(
    LeadsBloc(getIt<LeadRepository>()),
  );
}

// main.dart
void main() {
  setupCoreServices(); // network, storage, analytics
  setupLeadsFeature();
  setupAuthFeature();
  // ... other features
  runApp(const MyApp());
}

This approach lets you lazy-load features if needed (register them only when first accessed) and swap implementations during testing without touching feature code.

Common mistakes

Mistake 1: Creating one gigantic shared/ folder. It's tempting to dump everything cross-cutting into shared/. But a 500-line shared/utils/app_utils.dart is just a layer in disguise. Split utilities by domain: shared/formatters/, shared/validators/, shared/extensions/. Keep individual files focused.

Mistake 2: Allowing features to import each other's internals. If features/leads/presentation/pages/leads_page.dart imports features/notifications/logic/notifications_bloc.dart, you've broken the boundary. Use a shared service or event bus instead. Lint rules like avoid_relative_lib_imports and manual code review catch this.

Mistake 3: Confusing presentation structure. Don't create a widgets/ folder with 30 files for one page. Keep the hierarchy shallow: presentation/pages/leads_list_page.dart and presentation/widgets/lead_card.dart is enough. If a widget is complex, break it into smaller files within a widgets/ subfolder or inline it in the page.

Mistake 4: Mixing feature-first and layer-based thinking. Pick one approach and stick with it. If you have a features/ directory, don't also have a top-level models/ folder. All models live in features/*/data/models/. Hybrid structures create confusion and defeat the purpose of organization.

Key takeaways

  • Organize your Flutter app by feature, not by technical layer. A features/leads/ folder beats splitting leads across screens/, providers/, and models/.
  • Place shared UI components and utilities in shared/, and infrastructure (network, storage, DI) in core/. Keep these folders focused and small.
  • As your app grows beyond 10 features, feature-first organization saves hours of navigation and onboarding time. New team members can explore a feature folder and understand the entire flow.
  • Enforce feature boundaries through dependency injection and lint rules. Features should not directly import each other's internals; use shared services instead.
  • Combine feature-first organization with BLoC, Riverpod, or GetX for maximum clarity. Register each feature's dependencies in a setup function, making the dependency graph explicit and testable.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles