<FlutterSolution/> flutter · dart · clean architecture

Flutter Prep #36 Dependency Injection with get_it: A Complete Deep Dive, From First Principles to Production

Dependency Injection with get_it: A Complete Deep Dive, From First Principles to Production

Mastering the service locator pattern to build testable, scalable, and modular Flutter applications.

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:

  1. Construct a new instance locally: The ProfileAvatar creates its own ApiClient(). 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.
  2. Constructor Threading (Prop Drilling): You create the ApiClient once 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:

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

Without DI (Prop Drilling)
HomeScreen
apiClient
ProfileSection
apiClient
ProfileAvatar
With get_it
GetIt Registry
HomeScreen
ProfileSection
ProfileAvatar

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.

🧠 Interview Nuance

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

1. Registration
App Start

Occurs once before runApp()

GetIt Registry
2. Resolution
Anywhere

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.

πŸ“Œ
registerSingleton
Created immediately at registration time. The exact same instance is returned every time.
⏱️
registerLazySingleton
Created on first resolution. Cached and reused for all subsequent calls.
✨
registerFactory
A brand new instance is created every single time you resolve it. Never cached.
⚙️
registerFactoryParam
A new instance created every time, but accepts runtime arguments upon resolution.

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.

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

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

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

Dart
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().

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

Dart
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)
getIt.registerLazySingleton<ApiClient>(
  () => ApiClient()
);
getIt.registerLazySingleton<AuthRepo>(
  () => AuthRepo(getIt<ApiClient>())
);
@lazySingleton
class ApiClient {}

@lazySingleton
class AuthRepo {
  // Injectable figures out it needs ApiClient!
  AuthRepo(this.apiClient); 
}

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:

Constructor Injection

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.

get_it (Service Locator)

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.

Riverpod as DI

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.

Dart
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

✅ Quick Self-Check

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

🧠 Interviewer's Follow-Up

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 providing dispose: 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 the setUp block).

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

Official Resources

Previous in Series
Modularization with Melos
Nachiketa Pandey
// written by

Nachiketa Pandey

Senior Flutter Developer · Cross-Platform & Fintech Applications

Building production Flutter apps at Kotak Neo — clean architecture, BLoC, agentic AI integrations, and fintech-grade security. Sharing everything I learn, one post at a time.

Flutter & Dart Clean Architecture BLoC Firebase FinTech
View Full Portfolio

Comments

↑
Nachiketa
Meet Author// tap to open
▶