← Back to Articles

Flutter Dependency Injection: Managing Dependencies with get_it and Beyond

Flutter Dependency Injection: Managing Dependencies with get_it and Beyond

Flutter Dependency Injection: Managing Dependencies with get_it and Beyond

If you've been building Flutter apps for a while, you've probably noticed that as your app grows, managing dependencies between classes becomes increasingly complex. You might find yourself passing services through multiple constructors, creating singletons manually, or struggling to test your code because everything is tightly coupled.

This is where dependency injection (DI) comes in. Dependency injection is a design pattern that helps you write cleaner, more testable, and more maintainable code. In this article, we'll explore how to implement dependency injection in Flutter using the popular get_it package, and we'll also look at alternative approaches.

What is Dependency Injection?

At its core, dependency injection is about providing objects with their dependencies rather than having them create those dependencies themselves. Instead of a class creating its own dependencies (which creates tight coupling), dependencies are "injected" from the outside.

Let's look at a simple example to illustrate the problem:


class UserService {
  final ApiClient apiClient = ApiClient(); // Tight coupling!
  
  Future<User> getUser(String id) {
    return apiClient.fetchUser(id);
  }
}

In this example, UserService is tightly coupled to ApiClient. This makes testing difficult because you can't easily replace ApiClient with a mock version. It also means UserService is responsible for creating its dependencies, which violates the Single Responsibility Principle.

With dependency injection, we'd write it like this:


class UserService {
  final ApiClient apiClient;
  
  UserService(this.apiClient); // Dependency injected via constructor
  
  Future<User> getUser(String id) {
    return apiClient.fetchUser(id);
  }
}

Now UserService receives its dependency from outside, making it much easier to test and maintain.

Tight Coupling vs Dependency Injection UserService ApiClient creates Tight Coupling UserService ApiClient injected Dependency Injection

Why Use a Dependency Injection Container?

While constructor injection is great, manually passing dependencies through multiple layers can become tedious. Imagine you have a widget that needs a service, which needs another service, which needs yet another service. You'd end up with code like this:


class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final apiClient = ApiClient();
    final authService = AuthService(apiClient);
    final userService = UserService(apiClient, authService);
    final orderService = OrderService(apiClient, userService);
    
    return OrderScreen(orderService: orderService);
  }
}

This is where a dependency injection container comes in handy. A DI container is a central registry that manages the creation and lifetime of your dependencies. You register your services once, and the container handles creating and providing them wherever they're needed.

How get_it Service Locator Works get_it Container ApiClient UserService AuthService registers registers registers MyWidget requests

Getting Started with get_it

get_it is one of the most popular dependency injection packages in Flutter. It's simple, lightweight, and doesn't require code generation. Let's see how to use it.

First, add get_it to your pubspec.yaml:


dependencies:
  get_it: ^7.6.4

Now, let's set up a service locator. It's common practice to create a separate file for this:


import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

void setupServiceLocator() {
  // Register a singleton
  getIt.registerSingleton<ApiClient>(ApiClient());
  
  // Register a factory (creates a new instance each time)
  getIt.registerFactory<UserService>(() => UserService(getIt()));
  
  // Register a lazy singleton (created only when first accessed)
  getIt.registerLazySingleton<AuthService>(() => AuthService(getIt()));
}

Call setupServiceLocator() in your main() function before running your app:


void main() {
  setupServiceLocator();
  runApp(MyApp());
}

Now you can access your dependencies anywhere in your app:


class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final userService = getIt<UserService>();
    
    return FutureBuilder<User>(
      future: userService.getUser('123'),
      builder: (context, snapshot) {
        // Build your UI
      },
    );
  }
}

Understanding Registration Types

get_it offers three main registration types, each serving different purposes:

1. Singleton

A singleton is created immediately when registered and the same instance is returned every time it's requested. Use this for services that should have a single instance throughout your app's lifetime:


getIt.registerSingleton<ApiClient>(ApiClient());

2. Lazy Singleton

A lazy singleton is created only when first accessed, but then the same instance is returned for all subsequent requests. This is useful for expensive services that might not always be needed:


getIt.registerLazySingleton<DatabaseService>(
  () => DatabaseService.initialize(),
);

3. Factory

A factory creates a new instance every time it's requested. Use this for services that should have a new instance for each use:


getIt.registerFactory<OrderService>(
  () => OrderService(getIt()),
);
Registration Types Comparison Singleton Created immediately Lazy Singleton Created on first access Factory New instance each time Instance A Instance A same Instance B Instance B same Instance C1 Instance C2 different

Registering Dependencies with Parameters

Sometimes you need to register dependencies that require parameters. get_it supports this through factory functions:


getIt.registerFactoryParam<OrderService, String, void>(
  (userId, _) => OrderService(getIt(), userId: userId),
);

// Later, when accessing:
final orderService = getIt<OrderService>(param1: 'user123');

For more complex scenarios, you can use registerFactoryAsync for asynchronous initialization:


getIt.registerFactoryAsync<DatabaseService>(
  () async => await DatabaseService.initialize(),
);

// Access it asynchronously:
final dbService = await getIt.getAsync<DatabaseService>();

Dependency Injection in Practice

Let's build a complete example that demonstrates dependency injection in a real-world scenario. We'll create a simple app that fetches and displays user data:


// api_client.dart
class ApiClient {
  Future<Map<String, dynamic>> get(String url) async {
    // Simulated API call
    await Future.delayed(Duration(seconds: 1));
    return {'id': '123', 'name': 'John Doe', 'email': 'john@example.com'};
  }
}

// user_repository.dart
class UserRepository {
  final ApiClient apiClient;
  
  UserRepository(this.apiClient);
  
  Future<User> getUser(String id) async {
    final data = await apiClient.get('/users/$id');
    return User.fromJson(data);
  }
}

// user_service.dart
class UserService {
  final UserRepository userRepository;
  
  UserService(this.userRepository);
  
  Future<User> getUser(String id) {
    return userRepository.getUser(id);
  }
}

// service_locator.dart
import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

void setupServiceLocator() {
  getIt.registerLazySingleton<ApiClient>(() => ApiClient());
  
  getIt.registerLazySingleton<UserRepository>(
    () => UserRepository(getIt()),
  );
  
  getIt.registerLazySingleton<UserService>(
    () => UserService(getIt()),
  );
}

// user_screen.dart
class UserScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final userService = getIt<UserService>();
    
    return FutureBuilder<User>(
      future: userService.getUser('123'),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return CircularProgressIndicator();
        }
        
        if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        }
        
        final user = snapshot.data!;
        return Column(
          children: [
            Text('Name: ${user.name}'),
            Text('Email: ${user.email}'),
          ],
        );
      },
    );
  }
}

Testing with Dependency Injection

One of the biggest advantages of dependency injection is how much easier it makes testing. You can easily replace real dependencies with mocks:


// test/user_service_test.dart
void main() {
  late MockUserRepository mockRepository;
  late UserService userService;
  
  setUp(() {
    mockRepository = MockUserRepository();
    userService = UserService(mockRepository);
  });
  
  test('getUser returns user from repository', () async {
    // Arrange
    final expectedUser = User(id: '123', name: 'Test User');
    when(mockRepository.getUser('123')).thenAnswer((_) async => expectedUser);
    
    // Act
    final result = await userService.getUser('123');
    
    // Assert
    expect(result, equals(expectedUser));
    verify(mockRepository.getUser('123')).called(1);
  });
}

You can also reset get_it between tests to ensure clean state:


void main() {
  setUp(() {
    getIt.reset();
    setupServiceLocator();
  });
  
  tearDown(() {
    getIt.reset();
  });
  
  // Your tests here
}

Alternative Approaches

While get_it is popular, there are other dependency injection solutions in Flutter:

1. InheritedWidget (Built-in)

Flutter's built-in InheritedWidget can be used for dependency injection, though it's more verbose:


class ServiceProvider extends InheritedWidget {
  final UserService userService;
  
  const ServiceProvider({
    Key? key,
    required this.userService,
    required Widget child,
  }) : super(key: key, child: child);
  
  static UserService of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<ServiceProvider>()!.userService;
  }
  
  @override
  bool updateShouldNotify(ServiceProvider oldWidget) {
    return userService != oldWidget.userService;
  }
}

2. injectable (Code Generation)

If you prefer code generation, injectable works well with get_it and reduces boilerplate:


@injectable
class UserService {
  final UserRepository repository;
  
  UserService(this.repository);
}

@module
abstract class RegisterModule {
  @lazySingleton
  ApiClient get apiClient => ApiClient();
}

3. Provider

While Provider is primarily a state management solution, it can also be used for dependency injection:


MultiProvider(
  providers: [
    Provider<ApiClient>(create: (_) => ApiClient()),
    ProxyProvider<ApiClient, UserService>(
      create: (context) => UserService(context.read<ApiClient>()),
    ),
  ],
  child: MyApp(),
)

Best Practices

Here are some tips to get the most out of dependency injection in Flutter:

  • Register dependencies early: Set up your service locator in main() before running your app.
  • Use lazy singletons for expensive operations: They're created only when needed, improving startup time.
  • Keep registration logic organized: Group related registrations together or use modules.
  • Prefer constructor injection: It makes dependencies explicit and easier to test.
  • Don't overuse singletons: Only use singletons when you truly need a single instance.
  • Reset in tests: Always reset your service locator between tests to avoid state leakage.

Common Pitfalls to Avoid

As you start using dependency injection, watch out for these common mistakes:

  • Circular dependencies: If Service A depends on Service B, and Service B depends on Service A, you'll run into issues. Redesign your architecture to break the cycle.
  • Registering too early: Don't register dependencies that depend on Flutter context before the app is fully initialized.
  • Forgetting to reset in tests: Always reset your service locator in test teardown to ensure clean state.
  • Over-injecting: Not everything needs to be injected. Simple value objects or utilities don't need DI.

Conclusion

Dependency injection is a powerful pattern that can significantly improve the quality of your Flutter code. By using get_it or similar solutions, you can write more testable, maintainable, and scalable applications. Start small, register your services gradually, and you'll soon see the benefits in your codebase.

Remember, the goal isn't to inject everything—it's to inject the right things at the right time. With practice, you'll develop a sense for when dependency injection adds value and when it's unnecessary complexity.

Happy coding, and may your dependencies always be properly injected!