Published on

@injectable in Flutter

Authors
  • avatar
    Name
    Rosa Tiara
    Twitter

Introduction

Have you ever get stuck passing the same dependencies through multiple widgets, making your code messy and hard to test? Me too! :p In Flutter, there is a term called Dependency Injection (DI) that can help with that. We can use @injectable package.

Think of DI like ordering food. Instead of cooking everything yourself (creating dependencies manually), you tell the waiter what you need, and it's delivered to you. This makes your code cleaner, more flexible, and easier to test.

In this post, we'll see how to use injectable in Flutter to manage dependencies. Let's gooo!

What is Dependency Injection in technical terms?

Dependency Injection is a design pattern where objects receive their dependencies from an external source rather than creating them internally. Let's take a look at the code example.

Without Dependency Injection

class UserService {
  final ApiClient apiClient = ApiClient(); // Creating dependency directly
  
  Future<User> getUser(String id) {
    return apiClient.get('/users/$id');
  }
}

With Dependency Injection

class UserService {
  final ApiClient apiClient;
  
  UserService(this.apiClient); // Dependency is injected
  
  Future<User> getUser(String id) {
    return apiClient.get('/users/$id');
  }
}

The second approach is better because:

  • You can easily swap ApiClient with a mock for testing
  • The UserService doesn't need to know how to create an ApiClient
  • Dependencies are explicit and clear
  • You can share the same ApiClient instance across multiple services

What is Injectable?

injectable is a code generation package for Flutter that automates the setup of dependency injection using the get_it service locator. Instead of manually registering all your dependencies, injectable generates the boilerplate code for you.

Install the package here

Setting Up Injectable

Let's set up injectable in your Flutter project!

1. Add Dependencies

Add these packages to your pubspec.yaml:

dependencies:
  get_it: ^7.6.4
  injectable: ^2.3.2

dev_dependencies:
  injectable_generator: ^2.4.1
  build_runner: ^2.4.7

2. Create the Service Locator

Create a file called injection.dart (or di.dart, service_locator.dart) in your lib folder:

lib/injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';

import 'injection.config.dart';

final getIt = GetIt.instance;

()
void configureDependencies() => getIt.init();

3. Initialize in Your App

Call configureDependencies() in your main.dart before running the app:

lib/main.dart
import 'package:flutter/material.dart';
import 'injection.dart';

void main() {
  configureDependencies(); // Initialize dependencies
  runApp(MyApp());
}

4. Generate the Code

Run the code generator:

flutter pub run build_runner build --delete-conflicting-outputs

This command generates the injection.config.dart file that contains all the registration code.

Using Injectable

Now that we have injectable set up, let's see how to use it in practice.

Basic Registration

To make a class injectable, annotate it with @injectable:


class ApiClient {
  Future<Map<String, dynamic>> get(String endpoint) async {
    // Implementation
  }
}


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

When you run the code generator, injectable automatically:

  • Registers ApiClient as a singleton (by default)
  • Registers UserService and injects ApiClient into it
  • Creates the necessary factory methods

Using Injected Dependencies

To use your injected dependencies, simply call getIt:

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

Different Registration Types

injectable supports different registration scopes:

Singleton (Default)


class DatabaseService {
  // Created once and reused everywhere
}

Lazy Singleton

()
class ExpensiveService {
  // Created only when first accessed
}

Factory

()
class TemporaryService {
  // New instance created every time it's requested
}

Environment-Specific Registration

You can register different implementations based on the environment:

('dev')

class ApiClient {
  // Development implementation
}

('prod')

class ApiClient {
  // Production implementation
}

Then set the environment when initializing:

(initializerName: 'init')
void configureDependencies() => getIt.init(environment: 'dev');

Registering Interfaces

When working with interfaces, you need to specify which implementation to use:

abstract class IStorage {
  Future<void> save(String key, String value);
}


class SharedPreferencesStorage implements IStorage {
  
  Future<void> save(String key, String value) async {
    // Implementation
  }
}

// In your injection file, register the interface:

abstract class StorageModule {
  (as: IStorage)
  SharedPreferencesStorage get storage;
}

Real Example

Let's build a complete example with a user authentication flow:

// 1. Define the API client

class ApiClient {
  final http.Client client;
  
  ApiClient(this.client);
  
  Future<Map<String, dynamic>> post(String endpoint, Map<String, dynamic> data) async {
    final response = await client.post(
      Uri.parse(endpoint),
      body: jsonEncode(data),
    );
    return jsonDecode(response.body);
  }
}

// 2. Create authentication service

class AuthService {
  final ApiClient apiClient;
  final SecureStorage storage;
  
  AuthService(this.apiClient, this.storage);
  
  Future<bool> login(String email, String password) async {
    final response = await apiClient.post('/auth/login', {
      'email': email,
      'password': password,
    });
    
    if (response['success'] == true) {
      await storage.save('token', response['token']);
      return true;
    }
    return false;
  }
}

// 3. Use in your widget
class LoginScreen extends StatefulWidget {
  
  _LoginScreenState createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _authService = getIt<AuthService>();
  
  Future<void> _handleLogin() async {
    final success = await _authService.login(
      _emailController.text,
      _passwordController.text,
    );
    
    if (success) {
      Navigator.pushReplacementNamed(context, '/home');
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Login failed')),
      );
    }
  }
  
  
  Widget build(BuildContext context) {
    // Build login UI
  }
}

Best Practices

1. Organize Your Dependencies

Group related dependencies in modules:


abstract class NetworkModule {
  
  http.Client get httpClient => http.Client();
  
  
  Dio get dio => Dio();
}

2. Use Interfaces for Abstraction

Always program to interfaces, not implementations:

abstract class IUserRepository {
  Future<User> getUser(String id);
}


class UserRepository implements IUserRepository {
  // Implementation
}

3. Register Dependencies Early

Initialize dependencies in main() before runApp():

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  configureDependencies();
  runApp(MyApp());
}

4. Use Named Parameters for Clarity

When a class has multiple dependencies, use named parameters:


class ComplexService {
  final ApiClient apiClient;
  final DatabaseService database;
  final CacheService cache;
  
  ComplexService({
    required this.apiClient,
    required this.database,
    required this.cache,
  });
}

5. Keep Registration Simple

Let injectable handle the registration automatically. Only use manual registration when necessary.


Common Pitfalls and Solutions

Pitfall 1: Forgetting to Run Code Generation

Problem: You add @injectable but forget to run build_runner, causing runtime errors.

Solution: Always run flutter pub run build_runner build --delete-conflicting-outputs after adding new injectable classes.

Pitfall 2: Circular Dependencies

Problem: Two classes depend on each other, causing initialization issues.


class ServiceA {
  final ServiceB serviceB;
  ServiceA(this.serviceB);
}


class ServiceB {
  final ServiceA serviceA; // Circular dependency!
  ServiceB(this.serviceA);
}

Solution: Refactor to break the cycle, often by introducing an interface or a third service:

abstract class IServiceA {
  void doSomething();
}


class ServiceA implements IServiceA {
  final ServiceB serviceB;
  ServiceA(this.serviceB);
}


class ServiceB {
  final IServiceA serviceA; // Use interface instead
  ServiceB(this.serviceA);
}

Pitfall 3: Not Using LazySingleton for Heavy Objects

Problem: Creating expensive objects immediately, even if they're never used.

Solution: Use @LazySingleton() for services that are expensive to create:

()
class DatabaseService {
  DatabaseService() {
    // Expensive initialization
  }
}

Pitfall 4: Accessing getIt Before Initialization

Problem: Trying to use getIt before calling configureDependencies().

Solution: Always initialize dependencies in main() before using them.


Testing with Injectable

One of the biggest benefits of dependency injection is easier testing. You can easily swap real implementations with mocks:

// In your test file
void main() {
  setUp(() {
    // Reset getIt before each test
    getIt.reset();
    
    // Register mock implementations
    getIt.registerLazySingleton<ApiClient>(() => MockApiClient());
    getIt.registerLazySingleton<UserService>(() => UserService(getIt()));
  });
  
  test('should get user successfully', () async {
    final userService = getIt<UserService>();
    final user = await userService.getUser('123');
    expect(user.id, '123');
  });
}

Takeaways

Dependency injection with injectable makes your Flutter code:

  1. More Testable: Easy to swap real implementations with mocks
  2. More Maintainable: Clear dependencies and separation of concerns
  3. More Flexible: Easy to change implementations without modifying dependent classes
  4. Less Coupled: Classes don't need to know how to create their dependencies

Best Practices Checklist

Before implementing injectable in your project, remember:

  • Have you added all necessary dependencies to pubspec.yaml?
  • Did you create the injection.dart file with @InjectableInit()?
  • Are you initializing dependencies in main() before runApp()?
  • Have you run build_runner to generate the configuration?
  • Are you using interfaces for better abstraction?
  • Have you organized related dependencies into modules?

Got questions about injectable or dependency injection in Flutter? Drop them in the comments below!

Hope this helps and happy learning! 🥳