Why Clean Flutter Apps Use Dependency Injection and Yours Should Too

Md. Al - AminMd. Al - Amin
5 min read

Have you ever written a widget that somehow ended up knowing about your database, API client, local storage, and maybe even Firebase auth?

Be honest we’ve all been there.

At first, everything works fine. You call a service from your UI, get the result, display it. But a few weeks later…

  • Your widget’s build() method is 100+ lines

  • Your HomeScreen knows about every feature in the app

  • Writing a test requires bootstrapping your entire app

Welcome to tight coupling hell.

The good news? There’s a simple fix it’s called Dependency Injection (DI). And in this post, I’m going to show you why it matters, how it works in Flutter, and how to start using it in your real apps.

Let’s go.

First, What the Heck Is Dependency Injection?

Think of it like this:

Instead of your class creating everything it needs, you just give it what it needs from the outside.

For example, if a class needs an ApiClient, you don’t create one inside it. You pass one in like a gift.

This makes the class:

  • Easier to test

  • Easier to replace

  • Easier to maintain

Let’s compare:

Without DI

class UserService {
  final _api = ApiClient(); // tightly coupled

  Future<User> getUser() async => _api.fetchUser();
}

Now UserService is stuck with ApiClient. You can’t test it with mock data, and swapping out the API layer will break your app.

With DI

class UserService {
  final ApiClient api;

  UserService(this.api);

  Future<User> getUser() async => api.fetchUser();
}

Now you can inject any version of ApiClient a mock, a test version, or a real one and UserService won’t care. That’s the beauty of DI.

Why Should You Care?

Let me show you 3 reasons you’ll thank yourself later:

Your Code Becomes Modular

When each class receives its dependencies, you can change one thing without touching everything.

Imagine replacing Firebase Auth with Supabase. If you injected your AuthService, the change is painless. If not… you’re in for a lot of refactoring.

You Can Actually Write Unit Tests

With DI, testing becomes clean and easy:

final userService = UserService(MockApiClient());

No need to mock the entire app or spin up a widget. You test what matters and only that.

It Makes Clean Architecture Possible

If you want to follow clean architecture in Flutter (UI → Use Cases → Repositories → Data Sources), you need DI to keep layers separate.

Otherwise, your app becomes one big spaghetti plate where everything touches everything.

How to Do Dependency Injection in Flutter

Let’s talk tools.

You can do DI manually (by passing things into constructors), or use tools like:

  • get_it – Service locator (basic but powerful)

  • injectable – Code generator built on top of get_it

  • Riverpod – Great for DI + state management in one

  • provider – Can be used for simple DI too

In this blog, I’ll show you get_it + injectable the combo I personally use in most of my apps.

Real-World Example: User Profile Feature

Let’s say we’re building a user profile screen. Here’s the flow:

ProfileScreen → ProfileCubit → GetUserProfileUseCase → UserRepository → ApiClient

Our goal is:

  • The screen only knows about the Cubit

  • The Cubit only knows about the use case

  • Each layer is injected, not hardcoded

Step 1: Add Dependencies

In your pubspec.yaml:

dependencies:
  get_it: ^7.6.0
  injectable: ^2.3.0

dev_dependencies:
  build_runner: ^2.4.0
  injectable_generator: ^2.4.0

Step 2: Set Up the DI Container

Create a file: lib/di/injection.dart

import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart'; // this will be generated

final getIt = GetIt.instance;

@InjectableInit()
Future<void> configureDependencies() async => getIt.init();

Then run:

flutter pub run build_runner build

Step 3: Annotate Classes

Let’s annotate our repository and services.

API Client

@lazySingleton
class ApiClient {
  Future<User> getUser() async {
    // Fetch user from API
  }
}

User Repository

abstract class UserRepository {
  Future<User> getProfile();
}

@LazySingleton(as: UserRepository)
class UserRepositoryImpl implements UserRepository {
  final ApiClient api;

  UserRepositoryImpl(this.api);

  @override
  Future<User> getProfile() => api.getUser();
}

Use Case

@injectable
class GetUserProfileUseCase {
  final UserRepository repo;

  GetUserProfileUseCase(this.repo);

  Future<User> call() => repo.getProfile();
}

Cubit

@injectable
class ProfileCubit extends Cubit<ProfileState> {
  final GetUserProfileUseCase getUser;

  ProfileCubit(this.getUser) : super(ProfileInitial());

  Future<void> loadProfile() async {
    final user = await getUser();
    emit(ProfileLoaded(user));
  }
}

Now, anywhere in your app, you can get your Cubit like this:

final cubit = getIt<ProfileCubit>();

Clean. Testable. Scalable.

Best Practices for DI in Flutter

Here are a few do’s and don’ts I’ve learned the hard way:

Do This

  • Use @LazySingleton for services

  • Use abstract interfaces

  • Mock dependencies in tests

  • Group dependencies in one place (like injection.dart)

  • Keep your layers decoupled

Don’t Do This

  • Create services directly in widgets

  • Hardcode concrete classes everywhere

  • Depend on real APIs during unit tests

  • Scatter initialization logic across files

  • Let UI talk directly to repositories or APIs

Quick Bonus: Mocking in Tests

Let’s say you want to test GetUserProfileUseCase.

class FakeRepo implements UserRepository {
  @override
  Future<User> getProfile() async => User(name: 'Fake User');
}

Then test it:

void main() {
  final useCase = GetUserProfileUseCase(FakeRepo());

  test('returns fake user', () async {
    final user = await useCase();
    expect(user.name, 'Fake User');
  });
}

No app. No UI. Just pure logic.

Wrap-Up: What You Learned

  • Dependency Injection decouples your code and keeps things clean

  • It’s the foundation of scalable, testable Flutter apps

  • Tools like get_it + injectable make DI simple and maintainable

  • You can follow clean architecture patterns without the pain

Final Thought

If your app is still wiring up dependencies manually in widgets it’s time to level up.

Start small. Use DI for one feature.
Once you see how clean and testable it becomes you won’t go back.

0
Subscribe to my newsletter

Read articles from Md. Al - Amin directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Md. Al - Amin
Md. Al - Amin

Experienced Android Developer with a demonstrated history of working for the IT industry. Skilled in JAVA, Dart, Flutter, and Teamwork. Strong Application Development professional with a Bachelor's degree focused in Computer Science & Engineering from Daffodil International University-DIU.