Why Clean Flutter Apps Use Dependency Injection and Yours Should Too

Table of contents
- First, What the Heck Is Dependency Injection?
- Without DI
- With DI
- Why Should You Care?
- Your Code Becomes Modular
- You Can Actually Write Unit Tests
- It Makes Clean Architecture Possible
- How to Do Dependency Injection in Flutter
- Real-World Example: User Profile Feature
- Step 1: Add Dependencies
- Step 2: Set Up the DI Container
- Step 3: Annotate Classes
- Best Practices for DI in Flutter
- Quick Bonus: Mocking in Tests
- Wrap-Up: What You Learned
- Final Thought

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+ linesYour
HomeScreen
knows about every feature in the appWriting 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 ofget_it
Riverpod
– Great for DI + state management in oneprovider
– 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 servicesUse 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 maintainableYou 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.
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.