Dependency Injection with get_it: A Complete Deep Dive, From First Principles to Production
Contents
If you've built a Flutter application that grew beyond a prototype, you've almost certainly felt the pain of dependency management. You create an object at the top of your app, and then you have to drag it through layers of widgets that don't care about it, just to reach a single widget at the bottom that does.
In previous posts in this series—like our deep dives into Clean Architecture and Modularization with Melos—we mentioned get_it in passing as "the common pairing" for managing dependencies. But we didn't explain how it works under the hood.
This post changes that. Today, we are taking a complete, production-grade look at the most popular dependency injection and service locator package in the Flutter ecosystem. Whether you are prepping for a senior-level system design interview or just tired of messy constructors, this is your definitive guide.
Part 1: The Problem This Actually Solves
Let's not start with abstract definitions. Let's start with a headache you've probably experienced first-hand.
Imagine a standard login and authenticated user flow. You have an ApiClient that handles HTTP requests, an AuthRepository that manages user sessions, and a Logger for tracking errors. We'll use these three classes throughout this entire post.
Deep inside your app's UI, a ProfileAvatar widget needs to fetch the user's profile picture. To do that, it needs the ApiClient. Without Dependency Injection (DI), you are forced into one of two terrible choices:
- Construct a new instance locally: The
ProfileAvatarcreates its ownApiClient(). Now, every widget makes its own HTTP client. You've lost connection pooling, you're wasting memory, and critically: you cannot mock this for testing. If a widget hardcodes its own dependencies, unit tests will hit real production servers. - Constructor Threading (Prop Drilling): You create the
ApiClientonce at the top of your app, and pass it down via constructors through every single widget in the tree.
Here is what Constructor Threading actually looks like in code:
class HomeScreen extends StatelessWidget {
final ApiClient apiClient; // Needs ApiClient
const HomeScreen({required this.apiClient});
@override
Widget build(BuildContext context) {
// HomeScreen doesn't use ApiClient, it just passes it down.
return ProfileSection(apiClient: apiClient);
}
}
class ProfileSection extends StatelessWidget {
final ApiClient apiClient; // Needs ApiClient too
const ProfileSection({required this.apiClient});
@override
Widget build(BuildContext context) {
// ProfileSection doesn't use it either. Still passing...
return ProfileAvatar(apiClient: apiClient);
}
}
class ProfileAvatar extends StatelessWidget {
final ApiClient apiClient; // FINALLY! The widget that actually uses it.
const ProfileAvatar({required this.apiClient});
// ... build method uses apiClient.fetchAvatar() ...
}
This is brittle. The moment ProfileAvatar also needs the Logger, you have to modify the constructor signatures of ProfileAvatar, ProfileSection, HomeScreen, and wherever HomeScreen is created. A single dependency change triggers a cascade of boilerplate modifications.
Defining Dependency Injection vs. Service Locator
Now we have earned the definitions. Dependency Injection is a broad design pattern where a class receives the objects it depends on from the outside, rather than constructing them itself.
However, what get_it actually implements is a highly specific pattern called a Service Locator. Instead of dependencies being explicitly passed in via constructors everywhere (pure DI), dependencies are registered once in a central, globally accessible registry. Any class that needs a dependency simply reaches out to this registry to "locate" it.
A common senior interview question is: "Is get_it actually Dependency Injection?" The precise answer is: No, it is a Service Locator. In pure constructor injection, a class's API clearly states what it needs. With a service locator, the class's dependencies are hidden inside its implementation. However, the Flutter community conventionally refers to get_it as "the DI solution" because it pragmatically solves the same core problem: decoupling interface from implementation and enabling testability.
Part 2: How get_it Actually Works
The core mental model of get_it is a two-phase lifecycle based on a Singleton registry. You access this registry via a global instance, typically assigned to a variable named getIt or sl (for service locator).
Occurs once before runApp()
Called any number of times
You map a Type to a way of producing an instance of that type. Then, later in your app, you ask GetIt for that Type, and it returns the instance.
The Four Major Registration Types
get_it provides four distinct ways to register your objects. Choosing the wrong one is a frequent source of bugs (like stale data or memory leaks). Let's look at them using our Auth/Login domain.
1. registerSingleton<T>
When to use: When the object is cheap to create, safe to instantiate immediately, and must exist for the entire lifetime of the app.
final getIt = GetIt.instance;
// Registration (At app startup)
getIt.registerSingleton<Logger>(ConsoleLogger());
// Resolution (Inside a widget or BLoC)
final logger = getIt<Logger>(); // Returns the exact same ConsoleLogger instance
2. registerLazySingleton<T>
When to use: When the object is expensive to create (e.g., establishing a database connection, allocating large memory structures) or not always needed by every user in every session. By deferring creation, you speed up app launch time.
// Registration passes a builder function, not an instance
getIt.registerLazySingleton<ApiClient>(() => ApiClient(baseUrl: 'https://api.com'));
// Instance is ONLY constructed the first time this line runs
final client = getIt<ApiClient>();
// Second time it runs, it returns the cached instance
3. registerFactory<T>
When to use: When the object holds temporary, per-use state that should absolutely not be shared across different screens or calls. Think ViewModels, Form Validators, or distinct BLoC/Cubit instances.
getIt.registerFactory<LoginValidator>(() => LoginValidator());
final validator1 = getIt<LoginValidator>();
final validator2 = getIt<LoginValidator>();
// validator1 and validator2 are entirely separate objects in memory.
4. Named Instances
Occasionally, you need multiple instances of the exact same type to live as singletons. For example, an app might need two ApiClient instances pointed at different microservices. You solve this with the instanceName parameter:
getIt.registerLazySingleton<ApiClient>(() => ApiClient('auth.com'), instanceName: 'auth');
getIt.registerLazySingleton<ApiClient>(() => ApiClient('data.com'), instanceName: 'data');
// Resolution requires the name
final authClient = getIt<ApiClient>(instanceName: 'auth');
Resetting and Unregistering (The Flaky Test Killer)
Because GetIt.instance is a global singleton, its state persists for the entire life of the Dart VM process. This is fine in production, but disastrous in testing. If Test A registers a mocked AuthRepository, and Test B runs afterward, Test B will accidentally use Test A's mock unless you clean up.
This is a classic cause of flaky tests—tests that pass or fail depending on the order they run.
setUp(() {
// Clears all registrations before every single test
getIt.reset();
// Now it's safe to register test-specific fakes
getIt.registerSingleton<AuthRepository>(FakeAuthRepository());
});
Async Registration
Some dependencies simply cannot be instantiated synchronously. `SharedPreferences.getInstance()` returns a Future. Opening a SQLite database returns a Future. If you try to register these as normal singletons, you'll end up registering a Future<Prefs> instead of the Prefs themselves.
get_it provides registerSingletonAsync<T> and a crucial awaitable method called allReady(). This allows your app shell to pause execution and show a splash screen until all async dependencies are completely resolved before calling runApp().
// 1. Register the async dependency
getIt.registerSingletonAsync<SharedPreferences>(
() => SharedPreferences.getInstance()
);
void main() async {
WidgetsFlutterBinding.ensureInitialized();
setupDependencies(); // Calls the registration above
// 2. WAIT for all async singletons to finish their Futures
await getIt.allReady();
// 3. Now it is perfectly safe to boot the UI
runApp(MyApp());
}
Scopes: Managing Memory for Temporary Sessions
Registering everything globally is fine for small apps, but what about dependencies that should only exist while a user is logged in? If a user logs out, their UserProfileBloc and authenticated WebsocketClient should be destroyed, not left lingering in memory.
get_it handles this via Scopes. You can push a new scope, register dependencies into it, and when you pop the scope, all of those registrations are instantly removed.
AuthenticatedApiClient
UserSessionBloc
void onUserLogin(String token) {
// Push a new scope. Optionally name it for debugging.
getIt.pushNewScope(scopeName: 'authenticated');
// These are now registered ONLY in the 'authenticated' scope
getIt.registerLazySingleton<AuthRepository>(() => RealAuthRepository(token));
}
void onUserLogout() {
// Instantly destroys the AuthRepository registration
getIt.popScope();
}
Disposal: Preventing Silent Resource Leaks
Popping a scope or resetting GetIt removes the registration, but what if the object holds open network connections, streams, or file handles? Garbage collection won't automatically close a StreamController.
You must use the dispose parameter. This tells get_it exactly what function to call on the object right before it is unregistered.
getIt.registerLazySingleton<UserSessionBloc>(
() => UserSessionBloc(),
// When this scope is popped, get_it will automatically call close()
dispose: (bloc) => bloc.close(),
);
Part 3: get_it in a Real Architecture
Knowing the API is one thing; architecting a production app is another. Hand-writing dozens of getIt.registerLazySingleton() calls into a massive setup.dart file quickly becomes an unmaintainable merge-conflict magnet.
The Get_it + Injectable Pairing
To scale, the Flutter community pairs get_it with injectable. injectable is a code-generation package that scans your Dart classes for annotations (like @lazySingleton, @injectable) and automatically writes the messy get_it registration code for you via build_runner.
| Manual Registration (Painful) | With Injectable (Scalable) |
|---|---|
|
|
Integration with Modular Architecture (Melos)
In our Modularization post, we discussed splitting features into separate packages (e.g., core_network, feature_auth). A common mistake is letting the main App Shell know about the internal implementation details of feature_auth just to register its dependencies.
Instead, each feature package should expose its own DI configuration function. injectable supports this beautifully via MicroPackages. The Auth package exposes an `initAuthScope()` function, and the App Shell merely calls it. The App Shell never imports RealAuthRepository, maintaining strict architectural boundaries.
The Landscape: get_it vs. Constructors vs. Riverpod
Interviewers love comparing DI paradigms. Here is the nuanced breakdown you need:
Pros: 100% explicit. You look at a class and know exactly what it needs. Compile-time safe.
Cons: Prop-drilling hell. Changing a deep dependency requires modifying 10 files.
Pros: Solves prop-drilling instantly. Extremely flexible scopes. Decoupled feature packages.
Cons: Runtime resolution. If you forget to register something, it crashes at runtime, not compile time. Hidden dependencies.
Pros: Compile-time safe graph. Handles async states natively. Providers watching providers.
Cons: Heavily couples your domain logic to the Riverpod package. Steeper learning curve for simple singleton tasks.
The Ultimate Payoff: Testing with Fakes
The entire reason we endure the setup of DI is to make testing painless. When a Widget requires data, we don't want to make real HTTP requests during a widget test. By using get_it, we can swap the real ApiClient for a lightweight, in-memory FakeApiClient right before the test runs.
void main() {
setUp(() {
getIt.reset();
// Register our test fake instead of the real HTTP client
getIt.registerSingleton<ApiClient>(FakeApiClient());
});
testWidgets('Avatar displays correctly', (tester) async {
// When ProfileAvatar calls getIt<ApiClient>(), it gets the Fake.
// The widget has NO IDEA it is running in a test!
await tester.pumpWidget(MaterialApp(home: ProfileAvatar()));
expect(find.byType(Image), findsOneWidget);
});
}
Senior Interview Resources
Before moving to the next post, answer this out loud: If you have a ViewModel that maintains text field state for a specific form, should you register it as a Singleton, LazySingleton, or Factory? Why?
(Answer: Factory. If you use a Singleton, navigating away from the form and coming back will reuse the exact same ViewModel instance, keeping the old typed text visible. A Factory guarantees a fresh slate.)
Expect these questions when discussing architecture:
- "Is get_it actually Dependency Injection?" (Pointer: Explain the difference between pure Constructor DI and the Service Locator pattern).
- "How do you handle memory leaks when a user logs out?" (Pointer: Discuss
pushNewScope,popScope, and providingdispose:callbacks to close streams/connections). - "Why did my Widget Test fail with 'No registered type found' on the second run?" (Pointer: Explain the VM-wide global nature of GetIt and the necessity of
getIt.reset()in thesetUpblock).
Senior-Level Sample Answer
If asked: "Walk me through how you'd set up dependency injection for a new Flutter app, and why?"
"For a production Flutter app, I default to pairing get_it with injectable. Pure constructor injection creates too much prop-drilling boilerplate in deep widget trees, making the UI layer brittle. By using get_it, we implement the Service Locator pattern, allowing any bloc or widget to resolve its dependencies directly. I'd use injectable to generate the registrations to prevent human error and keep setup files clean. For app-wide services like a Logger or ApiClient, I use lazy singletons so we don't block the UI thread parsing them on startup. For session-specific data like an AuthRepository, I'd push a get_it scope upon login and pop it with proper dispose callbacks on logout to prevent memory leaks. Most importantly, this setup allows us to call getIt.reset() in our widget tests and seamlessly inject fakes, completely decoupling our UI tests from the network layer."

Comments
Post a Comment