Most Flutter apps start the same way. A single lib folder. A few files. Everything in one place because the app is small and the team is moving fast.
Then the app grows. Features pile up. The single folder turns into a maze. One developer edits a file and breaks something in a completely different screen. New team members spend their first week just figuring out where things live.
This is the point where modular Flutter apps pay off. A feature-based architecture splits your app into self-contained modules, one per feature, each with its own UI, logic, and data layers. Changes in one module do not ripple into others. Teams can work in parallel without stepping on each other. Testing becomes manageable because each module has clear boundaries.
This guide walks through how feature-based architecture works in Flutter, how to structure it, and what to watch out for as your codebase grows. The team at FBIP has applied these patterns across Flutter projects of varying sizes, and the structure here reflects what actually holds up in production.
What Is Feature-Based Architecture in Flutter?
Let’s break it down.
Feature-based architecture organizes code by what it does, not by what type of file it is. Instead of grouping all your models together, all your screens together, and all your services together, you group everything related to a single feature together.
Type-based structure (common but painful at scale):
lib/
├── models/
│ ├── user.dart
│ ├── product.dart
│ └── order.dart
├── screens/
│ ├── home_screen.dart
│ ├── profile_screen.dart
│ └── checkout_screen.dart
└── services/
├── auth_service.dart
└── cart_service.dart
Feature-based structure (scales cleanly):
lib/
├── features/
│ ├── auth/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ ├── profile/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ └── checkout/
│ ├── data/
│ ├── domain/
│ └── presentation/
└── core/
├── network/
├── storage/
└── routing/
Every file related to authentication lives inside features/auth. Every file related to checkout lives inside features/checkout. A developer working on profile never needs to open a checkout file.
The Three Layers Inside Each Feature Module
Each feature module in a well-structured Flutter app contains three layers. This mirrors clean architecture principles adapted for Flutter’s widget-driven environment.
1. Presentation Layer
This layer holds everything the user sees and interacts with: screens, widgets, and state management (whether that is Bloc, Riverpod, or a simple ChangeNotifier). The presentation layer depends on the domain layer but knows nothing about data sources. It asks for data through abstractions and displays whatever it receives.
auth/
└── presentation/
├── screens/
│ ├── login_screen.dart
│ └── register_screen.dart
├── widgets/
│ └── auth_text_field.dart
└── bloc/
├── auth_bloc.dart
├── auth_event.dart
└── auth_state.dart
2. Domain Layer
The domain layer holds your business logic: use cases, entity models, and repository interfaces. This layer has zero Flutter dependencies. It is pure Dart. That means you can test every business rule without spinning up a widget tree or mocking platform channels.
auth/
└── domain/
├── entities/
│ └── user.dart
├── repositories/
│ └── auth_repository.dart // Abstract interface
└── usecases/
├── login_usecase.dart
└── logout_usecase.dart
3. Data Layer
The data layer handles the actual work of fetching, storing, and transforming data. It implements the repository interfaces defined in the domain layer, talks to APIs, reads from local storage, and maps raw responses to domain entities.
auth/
└── data/
├── models/
│ └── user_model.dart // JSON-serializable version of the entity
├── sources/
│ ├── auth_remote_source.dart
│ └── auth_local_source.dart
└── repositories/
└── auth_repository_impl.dart // Concrete implementation
Here is why the separation matters. When your API changes its response format, you update user_model.dart and auth_remote_source.dart. The domain layer and the presentation layer do not change at all. The business logic stays intact regardless of where the data comes from.
Setting Up a Modular Flutter Project From Scratch
Next steps. Here is a practical way to set up a modular Flutter project structure from the beginning.
Step 1: Create the Folder Structure
lib/
├── core/
│ ├── di/ // Dependency injection setup
│ ├── error/ // Shared error types
│ ├── network/ // HTTP client, interceptors
│ ├── routing/ // App router configuration
│ └── storage/ // Local storage abstraction
├── features/
│ ├── auth/
│ ├── home/
│ └── settings/
└── main.dart
The core folder holds code shared across features: network clients, routing, error handling, and dependency injection. It is not a dumping ground for things that do not fit elsewhere. Each file in core should be there because multiple features genuinely need it.
Step 2: Define the Dependency Direction
Dependencies flow in one direction only: presentation depends on domain, domain defines interfaces, data implements them.
Presentation → Domain ← Data
The data layer and the presentation layer both depend on the domain layer. They never depend on each other. This is what makes swapping implementations possible, replacing a real API with a mock for tests, for example, without touching the UI.
Step 3: Set Up Dependency Injection
Use a dependency injection package to wire up implementations to interfaces. get_it is the most common choice in Flutter for this:
// core/di/injection.dart
final sl = GetIt.instance;
void initAuth() {
// Data sources
sl.registerLazySingleton<AuthRemoteSource>(
() => AuthRemoteSourceImpl(sl()),
);
// Repository
sl.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(sl(), sl()),
);
// Use cases
sl.registerFactory(() => LoginUseCase(sl()));
// Bloc
sl.registerFactory(() => AuthBloc(loginUseCase: sl()));
}
Each feature registers its own dependencies in a separate function. main.dart calls each registration function at startup.
Step 4: Keep Feature Modules Independent
A feature module should never import directly from another feature module. If checkout needs user data, it should receive it through a shared interface in core, not by importing from auth.
When two features need to communicate, use one of these approaches:
- Shared domain entities in core: Put the User entity in core/entities if multiple features need it.
- Event bus or shared stream: Publish events to a core-level stream that interested features can subscribe to.
- Navigation with parameters: Pass data between features via route arguments.
Using Dart Packages for True Module Isolation
For teams that want the strongest possible boundaries between features, Dart supports splitting features into separate packages within a monorepo. Each feature becomes a standalone Dart package with its own pubspec.yaml.
The Monorepo Structure
packages/
├── core/
│ └── pubspec.yaml
├── feature_auth/
│ └── pubspec.yaml
├── feature_checkout/
│ └── pubspec.yaml
└── app/
└── pubspec.yaml // Main app depends on all feature packages
Each feature package declares its own dependencies. The main app package imports all feature packages. Features cannot access each other’s internals because Dart’s package system enforces the boundary at the compiler level.
This approach adds overhead. Every change to a shared type requires updating the core package and bumping versions. For teams of three or four developers, a well-organized single-package structure is usually the right call. For teams of ten or more working on a large app, the isolation is worth the cost.
Routing in a Modular Flutter App
Routing gets complicated in modular apps because each feature should define its own routes without the central router knowing the details of every screen.
Feature-Owned Routes
Each feature exports a set of route definitions:
// features/auth/auth_routes.dart
class AuthRoutes {
static const login = ‘/login’;
static const register = ‘/register’;
static Map<String, WidgetBuilder> get routes => {
login: (_) => const LoginScreen(),
register: (_) => const RegisterScreen(),
};
}
The central router collects routes from all features:
// core/routing/app_router.dart
final Map<String, WidgetBuilder> appRoutes = {
…AuthRoutes.routes,
…HomeRoutes.routes,
…CheckoutRoutes.routes,
};
For more complex navigation with guards, nested routes, and deep linking, packages like go_router handle modular route registration cleanly.
Testing Modular Flutter Apps
Feature-based architecture pays dividends in testing. Here is why.
Each layer is independently testable. The domain layer, being pure Dart, tests with no Flutter dependencies at all. You write unit tests for use cases with simple mock repositories, and they run in milliseconds.
The data layer tests focus on whether your repository implementations correctly transform API responses and handle errors. You mock the HTTP client and assert on the output.
The presentation layer tests use Flutter’s widget testing tools and mock the domain layer. Because the presentation layer only depends on abstractions, swapping in a fake repository takes one line.
// Testing a use case with no Flutter dependencies
void main() {
late LoginUseCase loginUseCase;
late MockAuthRepository mockAuthRepository;
setUp(() {
mockAuthRepository = MockAuthRepository();
loginUseCase = LoginUseCase(mockAuthRepository);
});
test(‘should return User when login succeeds’, () async {
when(mockAuthRepository.login(any, any))
.thenAnswer((_) async => Right(tUser));
final result = await loginUseCase(LoginParams(
email: ‘test@test.com’,
password: ‘password’,
));
expect(result, Right(tUser));
});
}
FBIP includes unit and widget tests as part of the standard Flutter project delivery process. A feature-based structure makes that test coverage achievable rather than aspirational.
Common Mistakes in Modular Flutter Architecture
Watch for these patterns as your project grows.
Treating core as a second lib folder. Core should contain shared infrastructure, not business logic that belongs to a specific feature. When core grows as large as a feature module, something is wrong with how responsibilities are divided.
Circular dependencies between features. Feature A importing from Feature B, which imports from Feature A, creates a circular dependency that breaks the modular structure entirely. If you spot this, extract the shared code to core.
Over-engineering small features. Not every feature needs all three layers. A simple settings screen with no API calls and no business logic does not need a domain layer with use cases. Add layers when they earn their place.
Mixing feature-specific models with shared entities. Keep API response models (which map JSON fields) inside the feature’s data layer. Only promote a model to a shared entity in core if multiple features genuinely use the same concept.
Quick Reference: Modular Flutter App Structure
Use this as a starting template:
- lib/core/ holds network, routing, storage, DI, and shared error types
- lib/features/<name>/presentation/ holds screens, widgets, and state management
- lib/features/<name>/domain/ holds entities, use cases, and repository interfaces (pure Dart)
- lib/features/<name>/data/ holds models, API sources, and repository implementations
- Dependencies flow inward: presentation and data both depend on domain, never on each other
- Features never import directly from other features
- Shared types live in core, not in any single feature
FAQs About Modular Flutter Apps
Q: What is the difference between feature-based and layer-based Flutter architecture?
Layer-based architecture groups all models together, all services together, and all screens together across the entire app. Feature-based architecture groups everything related to a single feature together, regardless of file type. Feature-based structures scale better because changes to one feature do not touch files belonging to others.
Q: Do I need to use Dart packages to build modular Flutter apps?
No. You can get most of the benefits of modular architecture with a well-organized folder structure inside a single package. Splitting features into separate Dart packages adds stronger boundaries and catches accidental cross-feature imports at compile time, but it also adds overhead that smaller teams often do not need.
Q: How does dependency injection fit into a feature-based Flutter app?
Dependency injection wires up concrete implementations to the interfaces defined in each feature’s domain layer. Each feature registers its own dependencies separately. This lets you swap real implementations for test doubles in tests without changing any production code, and it keeps feature modules from creating their own dependencies directly.
Q: How should navigation work between features in a modular Flutter app?
Each feature defines its own route names and screen mappings. A central router in the core layer collects and registers them. Features never navigate directly to each other’s screens by importing widget classes. They use route names instead, keeping the features decoupled from each other’s internals.
Q: When should a Flutter app switch from a simple structure to feature-based architecture?
The right time is before the pain starts, not after. If your app has more than three or four distinct features, or if more than one developer is working on it, a feature-based structure is worth setting up from the start. Refactoring a large, flat codebase into modules is significantly harder than starting with a modular structure.





