3 Popular Methods to Implement Dependency Injection in Flutter

Dependency Injection in Flutter is a vital pattern for managing dependencies, ensuring testable and scalable code. Popular DI methods are Provider, GetIt, and Riverpod.

dependency-injection-in-flutter

In Flutter, DI is crucial for managing dependencies efficiently, especially as applications grow in complexity. It allows for the injection of mock dependencies during testing, ensuring focused and reliable tests. DI promotes the reuse of services and components, reducing redundancy and enhancing modularity. By using DI, developers can create scalable, maintainable, and testable applications more effectively.

This guide will equip you with the knowledge to implement DI in Flutter, leading to better-structured and more manageable projects.

>> Read more about Flutter coding:

Comparison: With and Without Dependency Injection

Dependency Injection (DI) is a design pattern that separates the creation of an object's dependencies from its behavior, allowing dependencies to be provided externally. This approach passes the required dependencies into a class through its constructor, properties, or methods.

Here is some quick comparison when our Flutter project uses and doesn’t use Dependency Injection:

 Without Dependency InjectionWith Dependency Injection
CouplingClasses directly create and manage their dependencies. This leads to tight coupling between the classes and their dependencies, making it difficult to change or replace dependencies without modifying the classes themselves.Dependencies are provided to classes externally, usually through constructors, properties, or interfaces. This results in loose coupling, making classes less dependent on specific implementations of their dependencies.
TestingDirect dependency creation makes it challenging to isolate classes for testing. Mocking dependencies is cumbersome and often requires class modifications.DI facilitates the injection of mock dependencies, making it easier to write and run unit tests. Classes can be tested in isolation by providing mock implementations of their dependencies.
FlexibilityWith dependencies hard-coded into classes, making changes to those dependencies or swapping them for alternatives (e.g., different implementations or mocks) is cumbersome and error-prone.DI allows easy swapping of dependencies without modifying the classes, useful for different configurations (e.g., development, testing, production).
MaintainabilityAs the application grows, managing dependencies within the classes becomes more complex, leading to a tangled and hard-to-maintain codebase.By managing dependencies externally, the codebase becomes more modular and easier to maintain.
ReusabilityClasses with hard-coded dependencies are less reusable because they cannot be easily repurposed in different contexts where different dependencies are needed.Classes designed with DI are more reusable. They can be used in different contexts with different dependencies injected, making them more versatile and adaptable.

3 Methods to Implement Dependency Injection in Flutter

Dependency Injection is a crucial pattern for managing dependencies in Flutter applications, ensuring that your code is more maintainable, testable, and scalable. Flutter offers several popular DI methods and packages that facilitate this pattern, each with its own set of features and use cases. Among the most widely used are Provider, GetIt, and Riverpod.

In the following sections, we will provide an overview of these DI methods, explaining their functionalities and how they can be integrated into your Flutter projects to improve code quality and development efficiency.

Using the Provider Package

The Provider package is a popular and powerful tool for dependency injection and state management in Flutter. It simplifies the process of passing data down the widget tree and allows for efficient and easy state management. Provider is built on top of the InheritedWidget and offers a clean and simple syntax to manage dependencies and state across your Flutter application.

  • Step 1: Setting Up Provider

To use the Provider package, add it to your *pubspec.yaml* file:

typescript
dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.2

Or use the Flutter CLI to add the latest package version:

typescript
flutter pub add provider
  • Step 2: Creating Mock Services

Now let’s create two simple mock services class (ApiService and LoggingService) to represent the services that need to inject to our project:

typescript
class ApiService {
  String fetchData() {
    return "Data fetched from API";
  }
}

class LoggingService {
  void log(String message) {
    print("Log: $message");
  }
}
  • Step 3: Injecting Services with MultiProvider

This ApiService will return a simple String and the LoggingService will print a message we provide. So, we can focus on implementing Dependency Injection with Provider for our project.

typescript
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'services/api_service.dart';
import 'services/logging_service.dart';

void main() {
  runApp(
    MultiProvider(
      providers: [
        Provider<ApiService>(create: (_) => ApiService()),
        Provider<LoggingService>(create: (_) => LoggingService()),
      ],
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final apiService = Provider.of<ApiService>(context);
    final loggingService = Provider.of<LoggingService>(context);

    // Log a message using the LoggingService
    loggingService.log("Fetching data from API");

    return Scaffold(
      appBar: AppBar(
        title: Text("Using Provider with Two Services"),
      ),
      body: Center(
        child: Text(apiService.fetchData()),
      ),
    );
  }
}

 In this example, both ApiService and LoggingService are injected into the widget tree using the Provider package. The MyHomePage widget then consumes both services, fetching data from ApiService and logging a message using LoggingService. This approach demonstrates how to manage multiple dependencies efficiently with the Provider package in a Flutter application.

Using the GetIt Package

Alongside Provider, which mainly focuses on reducing boilerplate code and managing state, the GetIt package is designed specifically for Dependency Injection (DI) in Flutter. While Provider manages and consumes data using a provider class as the source of truth for your app's state, DI is not its primary purpose. For a more focused approach to DI, GetIt is highly effective.

Using the GetIt package for DI in Flutter involves registering your services and accessing them from anywhere in your application. GetIt acts as a simple Service Locator for Dart and Flutter projects and can replace InheritedWidget or Provider for accessing objects from your UI.

  • Step 1: Setting Up GetIt

To use the GetIt package, add it to your pubspec.yaml file:

typescript
dependencies:
  flutter:
    sdk: flutter
  get_it: ^7.2.0

Alternatively, you can use the Flutter CLI to add the latest version of the package:

typescript
flutter pub add get_it
  • Step 2: Registering Services

We will use the same ApiService and LoggingService as in the Provider example. So, no need to have any update on it, we will only need to update in our main method as follow:

typescript
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'services/api_service.dart';
import 'services/logging_service.dart';

final GetIt getIt = GetIt.instance;

void setup() {
  getIt.registerSingleton<ApiService>(ApiService());
  getIt.registerSingleton<LoggingService>(LoggingService());
}

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

We can see here in the main method, we now add a setup method to inject two services into our application.

For simplicity, we will register our services as singletons (design pattern). But before we proceed, let’s take a quick look at the different ways get_it can register dependencies:

  1. Singleton: means the instance of the object is created once and used all the way though out the life-cycle of our application, there are following methods:
    • registerSingleton: Registers a singleton instance of the service. This instance is created immediately when registered.
    • registerSingletonAsync: Registers a singleton instance of the service that is created asynchronously. This is useful for services that require asynchronous initialization, such as those depending on I/O operations.
    • registerLazySingleton: Registers a singleton instance of the service that is created only when it is accessed for the first time. This can help improve startup performance by delaying the instantiation until it’s actually needed.
    • registerLazySingletonAsync: Registers a lazy singleton instance of the service that is created asynchronously when it is accessed for the first time.
  2. Factory: means each time the instance of the object is created, it’s a new instance of that object which hold no data of other instances:
    • registerFactory: Registers a factory function that creates a new instance of the service every time it is requested. This is useful for services that should not share state.
    • registerFactoryParam: Registers a factory function that accepts up to two parameters for creating instances of the service.
  • Step 3: Using GetIt

That’s a quick look into the methods that the get_it package provides to inject our dependencies. Now, let's update our MyHomePage to see how to use access the injected dependencies:

typescript
class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final apiService = GetIt.instance.get<ApiService>();
    final loggingService = GetIt.instance.get<LoggingService>();

    loggingService.log("Fetching data from API");

    return Scaffold(
      appBar: AppBar(
        title: Text("Using GetIt"),
      ),
      body: Center(
        child: Text(apiService.fetchData()),
      ),
    );
  }
}

In this example, we retrieve the required instances of ApiService and LoggingService using the get method of the GetIt instance. This demonstrates how to efficiently manage multiple dependencies using the GetIt package in a Flutter application.

Using the Riverpod Package

Riverpod is a reactive caching and data-binding framework that simplifies state management and dependency injection in Flutter applications. It provides a safer and more robust way to handle state and dependencies compared to the traditional Provider package. Riverpod ensures that your providers are always in sync with the widget tree, making your application more maintainable and testable.

  • Step 1: Setting Up Riverpod

Like two example above, let’s add the Riverpod package into our example project:

We can either do it manually:

typescript
dependencies:
  flutter:
    sdk: flutter
  riverpod: 2.5.1

or using Flutter CLI:

typescript
flutter pub add riverpod
  • Step 2: Registering Services

We will still be using the same two services (ApiService and LoggingService) as the provider and get_it examples. Therefore, we will only need to update our main method and the MyHomePage class:

typescript
final apiServiceProvider = Provider<ApiService>((ref) {
  return ApiService();
});

final loggingServiceProvider = Provider<LoggingService>((ref) {
  return LoggingService();
});

This is often seen as a bad practice to declare a global variable without wrapping it in a class, but it is a standard approach in Riverpod, as outlined in its documentation here.

  • Step 3: Wrapping the App with ProviderScope
Now let’s wrap our app under ProviderScope so that we can access our dependencies:
typescript
void main() {
  runApp(
    ProviderScope(
      child: MyApp(),
    ),
  );
}
  • Step 4: Accessing Services in the Widget Tree

Now let’s access these services in our MyHomePage:

typescript
class MyHomePage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final apiService = ref.read(apiServiceProvider);
    final loggingService = ref.read(loggingServiceProvider);

    // Log a message using the LoggingService
    loggingService.log("Fetching data from API");

    return Scaffold(
      appBar: AppBar(
        title: Text("Using Riverpod with Two Services"),
      ),
      body: Center(
        child: Text(apiService.fetchData()),
      ),
    );
  }
}

In this example, both ApiService and LoggingService are exposed via providers using Riverpod. The MyHomePage widget consumes these providers to fetch data and log a message. This approach ensures a clean separation of concerns and makes the codebase more maintainable and testable.

>> Read more: Clean Architecture Flutter: Build Robust and Scalable Apps

Comparing 3 Dependency Injection Methods Above

Pros and Cons

MethodProsCons
Provider
  • Simplicity: Easy to understand and use, making it ideal for beginners and those seeking a straightforward approach.
  • Integration: Seamless integration with Flutter’s state management system, allowing cohesive handling of state and dependencies.
  • Community Support: Extensive resources and documentation are available, facilitating learning and problem-solving.
  • Scalability: Can become cumbersome and less maintainable as project complexity increases.
  • Verbosity: The code can become more complicated and less readable in larger applications.
GetIt
  • Lightweight: Requires minimal setup and allows global access to dependencies without needing to pass them down the widget tree, simplifying the application’s architecture.
  • Efficiency: Enhances both efficiency and performance, particularly in smaller projects.
  • Simplicity: A straightforward method of managing dependencies without extensive boilerplate code.
  • Integration: Less integrated with state management compared to Provider.
  • Global State Management: Can lead to potential issues if not carefully managed, complicating debugging and maintenance.
Riverpod
  • Safety and Performance: Provides compile-time safety for providers, reducing runtime errors.
  • Scalability: Highly scalable and maintainable, making it suitable for large and complex projects.
  • Flexibility: Allows for sophisticated state management and dependency injection patterns, making it a powerful tool for complex applications.
  • Learning Curve: Slightly steeper learning curve compared to Provider.
  • Complexity: May involve more boilerplate code for simpler use cases, making initial setup and understanding more challenging.

Use Cases for Each Method

  • Provider: Ideal for small to medium-sized projects where simplicity and quick setup are key. Best for projects that do not require extensive scalability or complexity in dependency management.
  • GetIt: Ideal for managing simple dependencies with minimal overhead. Suitable for scenarios requiring global access to dependencies without the need for passing them down the widget tree. Effective when performance is a priority and extensive state management integration is not required.
  • Riverpod: Ideal for large and complex projects that demand a robust and scalable dependency injection solution. Best for projects with sophisticated state management needs. Despite its steeper learning curve, it provides powerful and modern dependency management capabilities.

Best Practices for Dependency Injection in Flutter

Organizing Your DI Setup

Maintaining a clean and manageable codebase is crucial for the success of your project. Here are some tips:

  • Separation of Concerns: Keep your service definitions separate from your UI code. This decoupling enhances modularity and makes your code easier to maintain.
  • Dedicated Directories: Create dedicated directories or files for your service classes and their providers or injectors. This organization helps in maintaining a clear structure as your project grows.
  • Group Related Services: Group related services together to keep your DI configuration coherent and easy to navigate.
  • Consistent Naming Conventions: Use consistent naming conventions and structured organization for DI-related files to maintain clarity.

Ensuring Proper Scope and Lifecycle Management

Proper scope and lifecycle management are essential for preventing memory leaks and ensuring optimal performance. Here’s how to manage it:

  • Define Scope Clearly: Define whether your dependencies are singletons, request-scoped, or transient.
  • Use Appropriate DI Methods: Use appropriate DI methods to manage the lifecycles of your dependencies. For example, use singleton scope for services that should be instantiated once and shared across the application, and request or transient scope for services that need to be created anew for each use.
  • Resource Allocation: Efficiently allocate resources by understanding and managing the lifecycle of your dependencies. This ensures your application runs smoothly.

Common Pitfalls and How to Avoid Them

Avoiding common pitfalls can significantly enhance the effectiveness of your DI setup:

  • Overuse of Singletons: Overusing singletons can lead to unintended state sharing, making your application harder to test and maintain. Use singletons only when necessary.
  • Lifecycle Management: Improper lifecycle management can lead to memory leaks and performance issues. Ensure dependencies are disposed of correctly by leveraging the lifecycle management features of your DI framework.
  • Tight Coupling to DI Framework: Tightly coupling your code to a specific DI framework can make it difficult to switch frameworks or test components in isolation. Use abstraction layers or interfaces to decouple your business logic from the DI framework.
  • Scattered DI Code: Avoid scattering DI code throughout your application. Centralize your DI configuration in a single location to maintain clarity and ease of maintenance.

By being aware of these pitfalls and following best practices, you can create a robust and maintainable DI setup that enhances the quality and scalability of your application.

Conclusion

Dependency Injection (DI) is a critical practice in Flutter development that promotes maintainability, testability, scalability, and reusability. By managing dependencies externally, DI enables developers to decouple components, making the codebase easier to understand, modify, and extend.

Selecting the appropriate DI method for your project is crucial for maximizing these benefits. Each DI method—Provider, GetIt, and Riverpod—offers unique advantages and trade-offs. Assess your project requirements and choose the DI method that aligns best with your goals and constraints.

Embrace the practice of Dependency Injection in your Flutter projects to build more maintainable, testable, and scalable applications. Happy coding!

>>> Follow and Contact Relia Software for more information!

  • coding
  • Mobile App Development