← Flutter Reference

Flutter Feature-First Modular Architecture

Architecture · Intermediate

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.

Feature-First vs. Layer-First

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.

The Shared Core Problem

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:

  • core/ — framework-level utilities (network client, error handling, DI setup, routing, theme). No business logic.
  • shared/ — cross-feature UI components and shared domain concepts (the User entity used by auth, profile, and social features).

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.

Package Splitting Strategy

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.

Cross-Feature Communication

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.

Managing 50+ Modules

At iStoria's scale (50+ modules, 5M+ users), feature-first modular architecture is survival. The key practices:

  • Each feature has an owner. When a bug appears in the lesson flow, there is exactly one engineer who knows that feature cold.
  • Features are independently testable. Each feature has its own test directory testing its BLoC, repository, and widgets in isolation.
  • The core/ package changes rarely and is reviewed by a lead. Most PRs touch a single feature directory.
  • We use barrel files (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.

Common Failure Modes

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/.

Recommended folder structure

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

Code example

// 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']!,
      ),
    ),
  ],
);

Implementation checklist

  • Group every feature's domain, data, and presentation code under one feature directory — never split by type.
  • Create a core/ package for framework-level concerns (networking, DI, routing, theme) with zero business logic.
  • Create a shared/ package for cross-feature entities and widgets that core/ must not depend on.
  • Add a barrel file per feature that exports only the public API other features are allowed to import.
  • Route cross-feature navigation through named routes, never direct widget imports between features.
  • Enforce boundaries with import linting or, at large scale, by extracting features into separate Dart packages.
  • Assign each feature an owner so there is always one engineer who knows that feature cold.

Common mistakes

  • Letting core/utils/ become a dumping ground for helpers that belong in a feature or in shared/.
  • Features importing each other's internal widgets or repositories directly instead of through barrel files or contracts.
  • Starting with a full Melos monorepo before the feature boundaries are stable — premature package splitting adds build complexity.
  • Putting navigation logic inside features that push other features' widgets directly, creating import cycles.
  • Not maintaining a barrel file, so implementation files leak into other features and boundaries silently erode.

Related definitions

Related reading

Related case studies

FAQ

When should I split features into separate Dart packages?

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.

How do features communicate without importing each other?

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.

What goes in core/ vs. shared/?

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