By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.
Feature-first modular architecture is the difference between a Flutter codebase that scales and one that collapses under its own weight. At iStoria, we run 50+ modules across a 4-engineer squad. If everything lived in a flat lib/ folder, nobody would be able to find anything, and every change would risk breaking an unrelated feature.
Feature-first means the code is organized by feature (lesson, course, auth, profile), not by type (all models together, all widgets together, all repositories together). Each feature owns its own widgets, state, data, and domain. Features communicate through contracts, not through direct imports of each other's internals.
The goal is blast-radius containment: a change inside the lesson feature should not require reading or modifying the course feature. This guide shows the pattern we use to achieve that at scale.
Layer-first organization (lib/models/, lib/widgets/, lib/repositories/) works for apps under ~10 screens. Beyond that, it breaks down. To change a feature, you open six directories across the project. Merge conflicts multiply because every feature touches the same folders. Feature-first fixes this by grouping everything a feature needs under one directory.
Feature-first and Clean Architecture are complementary, not competing. Within each feature directory, you apply the domain/data/presentation split. The feature directory is the horizontal boundary; Clean Architecture is the vertical boundary within each feature.
Every modular architecture hits the same wall: features need to share something. Common UI widgets, networking utilities, the DI container, the theme. If features import these directly from each other, you have coupling. The solution is a shared core/ or shared/ package that features depend on, but that depends on nothing.
At iStoria, we split this into:
The rule: core/ and shared/ never import from features/. Dependencies flow inward. This is the same dependency rule as Clean Architecture, applied at the package level.
For a large app, consider splitting features into actual Dart packages (Melos-managed monorepo). Each feature becomes a packages/feature_lesson/ with its own pubspec.yaml. This enforces boundaries at the compiler level — a feature literally cannot import another feature's internals because it is not a dependency.
We did not start this way. We started with a single app package and feature directories. When we hit ~30 features, we began extracting the most independent ones into packages. The lesson: do not start with a monorepo. Start with feature directories inside the app. Extract to packages only when the boundary is stable and the compilation time becomes a problem.
Features should not import each other directly. Instead:
1. Shared contracts — if feature A needs to trigger something in feature B, define an abstract interface in shared/ that both depend on. Feature B implements it; feature A calls it through DI. 2. Navigation via routing — features do not push each other's widgets directly. They call a route name registered in the core router. This decouples features from each other's widget trees. 3. Event-based — for loose coupling, a simple event bus or stream can work, but use sparingly. Over-reliance on events makes the data flow impossible to trace.
At iStoria's scale (50+ modules, 5M+ users), feature-first modular architecture is survival. The key practices:
core/ package changes rarely and is reviewed by a lead. Most PRs touch a single feature directory.feature_lesson.dart) that export only the public API of each feature — the pages and widgets other features are allowed to see. Everything else is private to the feature.The most common failure is the "shared utils" dump. Engineers under deadline pressure throw a helper into core/utils/ instead of asking whether it belongs in the feature. Over time, core/ grows into a dependency that every feature transitively couples to. The fix is discipline: core/ should contain only framework-level concerns. If it is business logic, it goes in a feature or in shared/.
lib/
├── main.dart
├── core/ # framework-level, no business logic
│ ├── di/
│ │ └── injection.dart # get_it / injectable setup
│ ├── networking/
│ │ ├── dio_client.dart
│ │ └── interceptors/
│ ├── error/
│ │ ├── failures.dart
│ │ └── error_handler.dart
│ ├── routing/
│ │ └── app_router.dart # centralized GoRouter / AutoRoute config
│ ├── theme/
│ └── constants/
├── shared/ # cross-feature domain + UI
│ ├── domain/
│ │ └── entities/
│ │ └── user.dart # shared User entity
│ └── widgets/
│ ├── loading_indicator.dart
│ └── empty_state.dart
└── features/
├── auth/
│ ├── domain/
│ │ ├── entities/
│ │ ├── repositories/
│ │ │ └── auth_repository.dart
│ │ └── usecases/
│ ├── data/
│ │ ├── datasources/
│ │ ├── models/
│ │ └── repositories/
│ │ └── auth_repository_impl.dart
│ ├── presentation/
│ │ ├── cubit/
│ │ │ ├── auth_cubit.dart
│ │ │ └── auth_state.dart
│ │ ├── pages/
│ │ └── widgets/
│ └── auth.dart # barrel file — public API only
├── lesson/
│ ├── domain/
│ ├── data/
│ ├── presentation/
│ └── lesson.dart
└── course/
├── domain/
├── data/
├── presentation/
└── course.dart
// features/auth/auth.dart — BARREL FILE: the only thing other features import
export 'presentation/pages/login_page.dart';
export 'presentation/cubit/auth_cubit.dart';
export 'domain/entities/user.dart';
// NOTE: auth_repository_impl.dart, auth_datasource.dart, auth_models.dart
// are NOT exported. They are implementation details private to the auth feature.
// shared/domain/entities/user.dart — shared across features
class AppUser {
final String id;
final String name;
final String email;
final String? avatarUrl;
const AppUser({
required this.id,
required this.name,
required this.email,
this.avatarUrl,
});
}
// core/di/injection.dart — features register their dependencies here
// but only through their public barrel API
final getIt = GetIt.instance;
Future<void> configureDependencies() async {
// Core
getIt.registerLazySingleton<DioClient>(() => DioClient());
// Auth feature — only the public types are visible
getIt.registerFactory<AuthCubit>(
() => AuthCubit(repository: getIt<AuthRepository>()),
);
}
// Cross-feature navigation — features call route names, not each other's widgets
// core/routing/app_router.dart
final appRouter = GoRouter(
routes: [
GoRoute(
path: '/login',
name: 'login', // other features use appRouter.go('login')
builder: (context, state) => const LoginPage(),
),
GoRoute(
path: '/lesson/:id',
name: 'lessonDetail',
builder: (context, state) => LessonDetailPage(
lessonId: state.pathParameters['id']!,
),
),
],
);
Start with feature directories inside a single app package. Extract into packages (via Melos or similar) only when you have 20+ features and the boundaries are stable enough that package-level dependency enforcement is worth the build-complexity cost. Compiler-enforced boundaries are valuable but premature splitting slows iteration.
Three ways: shared abstract contracts in core/ or shared/ that both features depend on (implemented by one, called by the other through DI), named routes for navigation (features call route names, not each other's widgets), and sparingly, an event bus for truly decoupled notifications.
core/ holds framework-level concerns: the network client, DI container, router, theme, error handling. shared/ holds cross-feature domain concepts (like the User entity) and reusable UI components. The rule: core/ has no business logic and never imports from features/. shared/ may hold shared business entities but also never imports from features/.
Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →
Book a 20-minute call · Download the CV (PDF) · See how I work