Scaling Flutter Apps: Feature-Based Architecture, Dependency Injection & Modularization

As Flutter applications grow, maintaining a clean and scalable codebase becomes significantly more challenging.

A small application might work perfectly with a simple folder structure, a few services, and direct dependencies between widgets. But once the application starts adding more features, developers, APIs, business rules, and platform-specific functionality, that same structure can quickly become difficult to maintain.

At that stage, the problem is usually not Flutter itself.

The problem is architecture.

In this article, we will explore how to structure a production Flutter application using:

  • Feature-based architecture
  • Clean separation of responsibilities
  • Dependency injection
  • Modularization
  • Shared core components
  • Clear dependency boundaries
  • Scalable navigation
  • Testable business logic

The goal is not to create unnecessary abstraction.

The goal is to build a structure that remains understandable when the application grows from 10 screens to 100+ screens.


Why Flutter Architecture Becomes Important at Scale

When starting a Flutter application, developers often use a structure like this:

lib/
├── models/
├── screens/
├── services/
├── widgets/
├── utils/
└── main.dart

For a small project, this is completely acceptable.

However, imagine the application later contains:

  • Authentication
  • User profiles
  • Payments
  • Notifications
  • Subscription management
  • Search
  • Favorites
  • Chat
  • Analytics
  • Settings
  • Offline storage
  • Multiple APIs

Now folders such as screens, models, and services may contain dozens or even hundreds of files.

For example:

screens/
├── login_screen.dart
├── register_screen.dart
├── profile_screen.dart
├── edit_profile_screen.dart
├── payment_screen.dart
├── subscription_screen.dart
├── search_screen.dart
├── favorites_screen.dart
├── settings_screen.dart
...

The application technically works, but understanding ownership becomes difficult.

A developer working on the payment feature may need to jump between:

screens/payment_screen.dart
models/payment_model.dart
services/payment_service.dart
providers/payment_provider.dart
widgets/payment_card.dart

Feature-based architecture solves this by keeping related code together.


Feature-Based Architecture

Instead of grouping files by technical type, group them by business feature.

For example:

lib/
├── core/
├── features/
│   ├── auth/
│   ├── profile/
│   ├── payments/
│   ├── subscriptions/
│   └── settings/
└── main.dart

Each feature owns its implementation.

Example:

features/
└── auth/
    ├── data/
    ├── domain/
    └── presentation/

A more complete version might look like:

features/
└── auth/
    ├── data/
    │   ├── datasources/
    │   ├── models/
    │   └── repositories/
    │
    ├── domain/
    │   ├── entities/
    │   ├── repositories/
    │   └── usecases/
    │
    └── presentation/
        ├── bloc/
        ├── pages/
        └── widgets/

This structure makes the ownership of every file clear.

If a developer needs to work on authentication, most required files are located inside:

features/auth/

That dramatically improves maintainability.


Understanding the Three Main Layers

A scalable Flutter feature can usually be divided into three layers:

Presentation
    ↓
Domain
    ↓
Data

Each layer has a specific responsibility.


1. Presentation Layer

The presentation layer handles everything related to the user interface.

It may contain:

  • Screens
  • Widgets
  • BLoC
  • Cubit
  • UI state
  • Controllers
  • ViewModels

Example:

presentation/
├── bloc/
│   ├── login_bloc.dart
│   ├── login_event.dart
│   └── login_state.dart
│
├── pages/
│   └── login_page.dart
│
└── widgets/
    └── login_form.dart

The presentation layer should not directly perform HTTP requests or database operations.

Instead, it communicates with the domain layer.

For example:

class LoginCubit extends Cubit<LoginState> {
  final LoginUser loginUser;

  LoginCubit(this.loginUser) : super(LoginInitial());

  Future<void> login({
    required String email,
    required String password,
  }) async {
    emit(LoginLoading());

    final result = await loginUser(
      email: email,
      password: password,
    );

    result.fold(
      (failure) => emit(LoginFailure(failure.message)),
      (user) => emit(LoginSuccess(user)),
    );
  }
}

The Cubit does not know whether login happens through REST API, Firebase, or another service.

It only knows about the LoginUser use case.

That separation is extremely valuable.


2. Domain Layer

The domain layer contains the core business logic of the feature.

It should ideally remain independent from Flutter, HTTP libraries, Firebase, and database implementations.

Typical domain layer:

domain/
├── entities/
├── repositories/
└── usecases/

Example entity:

class User {
  final String id;
  final String name;
  final String email;

  const User({
    required this.id,
    required this.name,
    required this.email,
  });
}

Example repository contract:

abstract class AuthRepository {
  Future<User> login({
    required String email,
    required String password,
  });
}

And a use case:

class LoginUser {
  final AuthRepository repository;

  LoginUser(this.repository);

  Future<User> call({
    required String email,
    required String password,
  }) {
    return repository.login(
      email: email,
      password: password,
    );
  }
}

The domain layer defines what the application needs.

It does not care how the operation is implemented.

This creates an important architecture principle:

High-level business logic should not depend directly on low-level infrastructure.


3. Data Layer

The data layer contains implementation details.

Examples include:

  • REST APIs
  • Dio
  • Firebase
  • SQLite
  • Hive
  • Isar
  • Secure storage
  • Remote APIs
  • Local caching

Example:

data/
├── datasources/
│   ├── auth_remote_data_source.dart
│   └── auth_local_data_source.dart
│
├── models/
│   └── user_model.dart
│
└── repositories/
    └── auth_repository_impl.dart

Example repository implementation:

class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;

  AuthRepositoryImpl(this.remoteDataSource);

  @override
  Future<User> login({
    required String email,
    required String password,
  }) async {
    final model = await remoteDataSource.login(
      email: email,
      password: password,
    );

    return model.toEntity();
  }
}

The domain layer exposes the contract:

AuthRepository

The data layer implements it:

AuthRepositoryImpl

This separation allows infrastructure to change without rewriting business logic.


Dependency Direction Matters

One of the most important architecture rules is dependency direction.

A healthy dependency flow looks like:

Presentation
     ↓
Domain
     ↑
Data

The presentation layer depends on domain abstractions.

The data layer also depends on domain abstractions.

The domain layer does not depend on either one.

This makes the domain layer the stable center of the feature.


Dependency Injection in Flutter

Once features become separated into layers, the application needs a clean way to create and provide dependencies.

Without dependency injection, you might write:

final dio = Dio();

final dataSource = AuthRemoteDataSourceImpl(dio);

final repository = AuthRepositoryImpl(dataSource);

final loginUser = LoginUser(repository);

final cubit = LoginCubit(loginUser);

This works, but repeating this setup across the application quickly becomes difficult.

Dependency injection solves this problem.

Popular Flutter dependency injection approaches include:

  • get_it
  • injectable
  • Provider
  • Riverpod
  • Manual constructor injection

One simple production-friendly option is get_it.


Example Using GetIt

Install:

dependencies:
  get_it: ^8.0.0

Create a service locator:

final sl = GetIt.instance;

Then configure dependencies:

Future<void> configureDependencies() async {
  sl.registerLazySingleton<Dio>(
    () => Dio(
      BaseOptions(
        baseUrl: 'https://api.example.com',
      ),
    ),
  );

  sl.registerLazySingleton<AuthRemoteDataSource>(
    () => AuthRemoteDataSourceImpl(sl()),
  );

  sl.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(sl()),
  );

  sl.registerLazySingleton(
    () => LoginUser(sl()),
  );

  sl.registerFactory(
    () => LoginCubit(sl()),
  );
}

Now a screen can access the Cubit through:

BlocProvider(
  create: (_) => sl<LoginCubit>(),
  child: const LoginPage(),
);

This keeps object creation centralized.


Register Dependencies Per Feature

As applications grow, one massive dependency injection file becomes difficult to manage.

Avoid:

dependency_injection.dart

with 1,000+ lines.

Instead, allow each feature to register its own dependencies.

Example:

core/
└── di/
    └── dependency_injection.dart

features/
├── auth/
│   └── auth_dependencies.dart
│
├── payments/
│   └── payment_dependencies.dart
│
└── profile/
    └── profile_dependencies.dart

Example:

void registerAuthDependencies(GetIt sl) {
  sl.registerLazySingleton<AuthRemoteDataSource>(
    () => AuthRemoteDataSourceImpl(sl()),
  );

  sl.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(sl()),
  );

  sl.registerLazySingleton(
    () => LoginUser(sl()),
  );

  sl.registerFactory(
    () => LoginCubit(sl()),
  );
}

Then your main dependency setup becomes much cleaner:

Future<void> configureDependencies() async {
  registerCoreDependencies(sl);

  registerAuthDependencies(sl);
  registerProfileDependencies(sl);
  registerPaymentDependencies(sl);
}

This approach scales much better.


What Should Go Inside the Core Folder?

The core folder should contain things used across multiple features.

Example:

core/
├── constants/
├── errors/
├── network/
├── router/
├── storage/
├── theme/
├── utils/
└── widgets/

Typical reusable components include:

ApiClient
NetworkInfo
AppException
Failure
AppRouter
ThemeConfig
SecureStorage
Logger
ResponsiveLayout

However, there is an important rule:

Do not move something into core just because two files use it.

If something belongs logically to one feature, keep it inside that feature.

Otherwise, the core folder becomes another dumping ground.


Shared Widgets vs Feature Widgets

Suppose you have a button used everywhere:

AppButton()

It can live inside:

core/widgets/

But a widget such as:

SubscriptionPlanCard()

belongs inside:

features/subscription/presentation/widgets/

Even if another subscription screen uses it.

A good rule is:

Core widget

Generic and reusable across unrelated features.

Feature widget

Contains business meaning specific to one feature.


Avoid Cross-Feature Dependencies

One common scaling problem is one feature directly importing another feature.

For example:

import '../../profile/data/profile_repository_impl.dart';

inside the payment feature.

Over time, this creates tightly coupled features.

A better approach is to depend on abstractions or shared domain services.

For example:

abstract class CurrentUserProvider {
  Future<User> getCurrentUser();
}

Then both profile and payment features can depend on that abstraction.

This prevents one feature from becoming responsible for another.


Modularization in Large Flutter Apps

Feature-based folders work well for many projects.

However, very large applications may benefit from package-level modularization.

Instead of:

lib/features/auth/

you may move features into separate Dart packages:

packages/
├── auth/
├── payments/
├── profile/
├── design_system/
└── networking/

The main application becomes:

apps/
└── mobile_app/

Example monorepo:

project/
├── apps/
│   └── mobile/
│
├── packages/
│   ├── auth/
│   ├── payments/
│   ├── profile/
│   ├── networking/
│   └── design_system/
│
└── pubspec.yaml

This provides stronger module boundaries.


When Should You Create Separate Packages?

Do not modularize too early.

Creating packages adds additional complexity.

Package-level modularization makes sense when:

  • Multiple teams work on the application
  • Features have clear ownership
  • Modules are reused across applications
  • Build times are becoming difficult
  • Independent testing is valuable
  • A design system is shared across projects
  • Networking or analytics are reusable libraries

For smaller applications, feature folders are usually sufficient.


Example Production Structure

A scalable Flutter application may look like this:

lib/
├── app/
│   ├── app.dart
│   └── bootstrap.dart
│
├── core/
│   ├── config/
│   ├── di/
│   ├── errors/
│   ├── extensions/
│   ├── network/
│   ├── router/
│   ├── storage/
│   ├── theme/
│   ├── utils/
│   └── widgets/
│
├── features/
│   ├── auth/
│   │   ├── data/
│   │   │   ├── datasources/
│   │   │   ├── models/
│   │   │   └── repositories/
│   │   │
│   │   ├── domain/
│   │   │   ├── entities/
│   │   │   ├── repositories/
│   │   │   └── usecases/
│   │   │
│   │   └── presentation/
│   │       ├── bloc/
│   │       ├── pages/
│   │       └── widgets/
│   │
│   ├── home/
│   ├── profile/
│   ├── payments/
│   └── settings/
│
└── main.dart

This gives each feature a predictable structure.

A developer opening the project for the first time can quickly understand where things belong.


Scalable Routing

Navigation can also become difficult in large Flutter applications.

Avoid spreading route strings everywhere:

Navigator.pushNamed(
  context,
  '/profile',
);

Instead, centralize routes.

Using a router such as go_router, your architecture might contain:

core/
└── router/
    ├── app_router.dart
    ├── route_names.dart
    └── route_paths.dart

Example:

abstract final class RoutePaths {
  static const login = '/login';
  static const home = '/';
  static const profile = '/profile';
}

Router:

final router = GoRouter(
  routes: [
    GoRoute(
      path: RoutePaths.login,
      builder: (_, __) => const LoginPage(),
    ),
    GoRoute(
      path: RoutePaths.home,
      builder: (_, __) => const HomePage(),
    ),
    GoRoute(
      path: RoutePaths.profile,
      builder: (_, __) => const ProfilePage(),
    ),
  ],
);

For very large applications, routes can also be declared per feature and combined inside the root router.


Separate DTOs From Domain Entities

Another useful practice is separating API models from domain entities.

Suppose an API returns:

{
  "user_id": "123",
  "full_name": "Pintoo",
  "email_address": "example@email.com"
}

Your API model might look like:

class UserModel {
  final String userId;
  final String fullName;
  final String emailAddress;

  const UserModel({
    required this.userId,
    required this.fullName,
    required this.emailAddress,
  });
}

But your application domain may use:

class User {
  final String id;
  final String name;
  final String email;

  const User({
    required this.id,
    required this.name,
    required this.email,
  });
}

Conversion:

extension UserModelMapper on UserModel {
  User toEntity() {
    return User(
      id: userId,
      name: fullName,
      email: emailAddress,
    );
  }
}

This prevents backend response changes from leaking into the entire application.


Handle Failures Centrally

Large applications should also have consistent error handling.

Instead of throwing random strings:

throw Exception('Something went wrong');

define structured failures.

Example:

abstract class Failure {
  final String message;

  const Failure(this.message);
}

Then:

class NetworkFailure extends Failure {
  const NetworkFailure(super.message);
}

class ServerFailure extends Failure {
  const ServerFailure(super.message);
}

class CacheFailure extends Failure {
  const CacheFailure(super.message);
}

Now the presentation layer can handle failures consistently.

result.fold(
  (failure) {
    emit(
      LoginFailure(failure.message),
    );
  },
  (user) {
    emit(
      LoginSuccess(user),
    );
  },
);

Keep State Management Local to the Feature

Another important scaling rule is state ownership.

Avoid creating one huge global BLoC containing unrelated state.

Bad example:

AppBloc
├── Auth
├── Profile
├── Payments
├── Search
├── Settings
├── Notifications
└── Subscription

Instead:

AuthBloc
ProfileCubit
PaymentBloc
SearchCubit
SettingsCubit
SubscriptionBloc

Each feature should manage its own state.

Global state should only exist when the data is truly global.

Examples:

Authentication state
Application theme
Locale
Connectivity status

Even then, keep global state minimal.


Avoid Overengineering

Clean Architecture is useful, but it should not become a goal by itself.

Not every feature requires:

10 interfaces
8 use cases
5 repository layers
20 abstractions

For a simple local settings screen, this may be unnecessary.

Architecture should be proportional to complexity.

A simple feature may use:

settings/
├── data/
├── presentation/
└── settings_repository.dart

while a payment module may require full separation.

The most important principle is consistency.


Testing Becomes Easier

A well-separated architecture improves testability.

For example, testing a Cubit becomes simple because its dependency can be mocked.

class MockLoginUser extends Mock implements LoginUser {}

Then:

blocTest<LoginCubit, LoginState>(
  'emits loading and success when login succeeds',
  build: () {
    when(
      () => mockLoginUser(
        email: any(named: 'email'),
        password: any(named: 'password'),
      ),
    ).thenAnswer(
      (_) async => mockUser,
    );

    return LoginCubit(mockLoginUser);
  },
  act: (cubit) {
    cubit.login(
      email: 'test@example.com',
      password: '123456',
    );
  },
  expect: () => [
    LoginLoading(),
    LoginSuccess(mockUser),
  ],
);

Because the Cubit is not directly coupled to Dio or Firebase, testing is fast and predictable.


Folder Structure Should Communicate Intent

Architecture is not only about technical correctness.

A good folder structure should explain the application.

Compare:

models/
controllers/
services/
screens/

with:

auth/
payments/
subscriptions/
profile/
notifications/

The second structure immediately communicates what the application does.

That is an important characteristic of scalable software architecture.


Common Mistakes to Avoid

1. Putting Everything in Core

Do not create:

core/
├── auth/
├── payment/
├── profile/
└── subscription/

Business features belong inside features.


2. Creating a Generic Utils Folder for Everything

A folder such as:

utils/

often becomes a collection of unrelated functions.

Prefer specific locations:

core/extensions/
core/formatters/
core/validators/
core/helpers/

when they have clear responsibilities.


3. Allowing UI to Call APIs Directly

Avoid:

onPressed: () async {
  final response = await Dio().post(...);
}

UI should trigger state management or application logic.


4. Returning API Models Directly to UI

Avoid exposing backend structures throughout the application.

Map data into domain entities where appropriate.


5. Creating Dependencies Inside Classes

Avoid:

class PaymentCubit extends Cubit<PaymentState> {
  final repository = PaymentRepositoryImpl(
    PaymentApi(Dio()),
  );
}

Prefer constructor injection:

class PaymentCubit extends Cubit<PaymentState> {
  final PaymentRepository repository;

  PaymentCubit(this.repository);
}

This improves testability and flexibility.


Architecture for Team Scalability

Good architecture does more than improve code.

It improves team productivity.

Imagine three developers working simultaneously:

Developer A → Authentication
Developer B → Payments
Developer C → Profile

With feature-based architecture, each developer mainly works inside a separate folder.

This reduces:

  • Merge conflicts
  • Accidental dependencies
  • Code ownership confusion
  • Navigation between unrelated files

Feature boundaries become organizational boundaries as well.


Architecture Should Evolve With the Product

You do not need enterprise architecture on day one.

A reasonable evolution might look like:

Stage 1
Simple Flutter project

↓

Stage 2
Feature-based folders

↓

Stage 3
Feature-based clean architecture

↓

Stage 4
Shared infrastructure modules

↓

Stage 5
Package-level modularization / monorepo

Move to the next level only when the current structure starts creating real problems.

Architecture should solve complexity, not create it.


When designing a scalable Flutter project, I generally follow these principles:

  1. Organize code by feature.
  2. Keep UI separate from infrastructure.
  3. Use domain abstractions for important business logic.
  4. Inject dependencies instead of creating them internally.
  5. Keep feature state local.
  6. Minimize global state.
  7. Avoid cross-feature dependencies.
  8. Extract shared infrastructure carefully.
  9. Separate API models from domain entities where useful.
  10. Introduce package-level modularization only when justified.
  11. Keep architecture understandable for new developers.
  12. Prefer consistency over unnecessary abstraction.

Final Thoughts

Building a scalable Flutter application is less about choosing the perfect folder structure and more about creating clear boundaries.

A strong architecture makes it obvious:

  • Where new code belongs
  • Which layer owns a responsibility
  • How dependencies flow
  • Which feature owns a particular piece of logic
  • How code can be tested independently
  • How multiple developers can work without creating unnecessary coupling

Feature-based architecture combined with Clean Architecture principles and dependency injection provides a strong foundation for production Flutter applications.

But the most important rule is simple:

Your architecture should make the application easier to change.

If adding a new feature requires modifying dozens of unrelated files, your boundaries may be wrong.

If developers can understand, test, replace, and extend features independently, your architecture is doing its job.

Scalable architecture is not about creating more folders.

It is about reducing the cost of change.