A lightweight, flexible, and high-performance dependency injection and service location library for Dart and Flutter.
Version 2 of the library introduces the groundbreaking async locking feature for singletons, a feature that's set to revolutionize the way you handle asynchronous initialization in Dart and Flutter! ioc_container is the only known container that offers this feature.
Containers and service locators give you an easy way to lazily create the dependencies that your app requires. As your app grows in complexity, you will find that static variables or global factories start to become cumbersome and error-prone. Containers give you a consistent approach to managing the lifespan of your dependencies and make it easy to replace services with mocks for testing. ioc_container embraces the Dependency Injection pattern, and offers an approach that is standard across programming languages and frameworks. The implementation of this approach transcends Dart or Flutter. It is a proven and reliable method employed by developers across various technologies for well over a decade.
Dependency Injection (DI) allows you to decouple concrete classes from the rest of your application. Your code can depend on abstractions instead of concrete classes. It allows you to easily swap out implementations without changing your code. This is great for testing, and it makes your code more flexible. You can use test doubles in your tests, so they run quickly and reliably.
Imagine a scenario where you need to initialize a service, like Firebase, connect to a database, or perhaps fetch some initial configuration data. These operations are asynchronous, and in a complex app, there's always a risk of inadvertently initializing the service multiple times, leading to redundant operations, wasted resources, and potential bugs.
Enter async locking: With this feature, you can perform your async initialization with the confidence that it will only ever run once. No matter how many times you request the service, the initialization logic is executed just a single time. This is not just about efficiency; it's about ensuring the consistency and reliability of your services.
Version 2 brings this powerful new feature. This is perfect for initializing Firebase, connecting to a database, or any other async initialization work. You can initialize anywhere in your code and not worry that it might happen again. Furthermore, the singleton never gets added to the container until the initialization completes successfully. This means that you can retry as many times as necessary without the container holding on to a service in an invalid state.
Notice that this example calls the initialization method three times. However, it doesn't run the work three times. It only runs once. The first call to getAsync()
starts the initialization work. The second and third calls to getAsync()
wait for the initialization to complete.
import 'dart:async';
import 'package:ioc_container/ioc_container.dart';
class ConfigurationService {
Map<String, String>? _configData;
int initCount = 0;
Future<void> initialize() async {
print('Fetching configuration data from remote server...');
// Simulate network delay
await Future<void>.delayed(const Duration(seconds: 2));
_configData = {
'apiEndpoint': 'https://api.example.com',
'apiKey': '1234567890',
};
print('Configuration data fetched!');
initCount++;
}
String get apiEndpoint => _configData!['apiEndpoint']!;
String get apiKey => _configData!['apiKey']!;
}
void main() async {
final builder = IocContainerBuilder()
..addSingletonAsync((container) async {
final service = ConfigurationService();
await service.initialize();
return service;
});
final container = builder.toContainer();
final stopwatch = Stopwatch()..start();
// Multiple parts of the application trying to initialize the service
// simultaneously
final services = await Future.wait([
container.getAsync<ConfigurationService>(),
container.getAsync<ConfigurationService>(),
container.getAsync<ConfigurationService>(),
]);
stopwatch.stop();
print('API Endpoint: ${services.first.apiEndpoint}');
print('API Key: ${services.first.apiKey}');
print('Milliseconds spent: ${stopwatch.elapsedMilliseconds}');
print('Init Count: ${services.first.initCount}');
}
You can do initialization work when instantiating an instance of your service. Use addAsync()
or addSingletonAsync()
to register the services. When you need an instance, call the getAsync()
method instead of get()
.
Check out the retry package to add resiliency to your app. Check out the Flutter example that displays a progress indicator until the initialization completes successfully.
This library makes it easy to
- Easily replace services with mocks for testing
- Configure the lifecycle of your services for singleton (one per app) or transient (always fresh)
- Access factories for other services from any factory
- Perform async initialization work inside the factories
- Create a scope for a set of services that you can dispose of together
- Perform lazy initialization of services
- It's standard. It aims at being a standard dependency injector so anyone who understands DI can use this library.
This library is objectively fast and holds up to comparable libraries in terms of performance. These benchmarks are currently out of data for v2 beta but new benchmarks and performance options are coming.
The source code is a fraction of the size of similar libraries and has no dependencies. According to codecov, it weighs in at 81 lines of code, which makes it the lightest container I know about. It is stable and has 100% test coverage. At least three apps in the stores use this library in production.
Most importantly, it has no external dependencies so you don't have to worry about it pulling down packages you don't need.
You can copy/paste it anywhere, including Dartpad (as long as you follow the license), and it's simple enough to understand and change if you find an issue. Global factories get complicated when you need to manage the lifecycle of your services or replace services for testing. This library solves that problem.
Run this command:
With Dart:
$ dart pub add ioc_container
With Flutter:
$ flutter pub add ioc_container
This will add a line like this to your package's pubspec.yaml
(and run an implicit dart pub get):
dependencies:
ioc_container: ^2.0.0-beta ## Or, latest version
This example registers a singleton and two transient dependencies to the container.
import 'package:ioc_container/ioc_container.dart';
// These are some example services
class AuthenticationService {
String login(String username, String password) {
// Implement your authentication logic here
return 'Logged in';
}
}
class UserService {
final AuthenticationService _authenticationService;
UserService(this._authenticationService);
String getUserDetails() {
// Implement your user details retrieval logic here
return 'User Details';
}
}
class ProductService {
List<String> getProducts() {
// Implement your product retrieval logic here
return ['Product 1', 'Product 2', 'Product 3'];
}
}
void main() {
// Create a container builder and register your services
final builder = IocContainerBuilder()
//The app only has one AuthenticationService for the lifespan of the app (Singleton)
..addSingleton((container) => AuthenticationService())
//We create a new UserService/ProductService for each usage
..add((container) => UserService(
//This is shorthand for container.get<AuthenticationService>()
container<AuthenticationService>()
))
..add((container) => ProductService());
// Build the container
final container = builder.toContainer();
// Retrieve your services from the container
final authService = container<AuthenticationService>();
final userService = container<UserService>();
final productService = container<ProductService>();
// Use the services
print(authService.login('user', 'password'));
print(userService.getUserDetails());
print(productService.getProducts());
}
We define the services: AuthenticationService
, UserService
, and ProductService
. Then, we create an IocContainerBuilder
and register these services using addSingleton()
and add()
methods. Finally, we build the container and retrieve the services to use them in our application like this: container<ProductService>()
.
You can use ioc_container as a service locator by declaring a global instance and using it anywhere. This is a good alternative to get_it. You can access it inside or outside the widget tree. Or, you can use the flutter_ioc_container package to add your container to the widget tree as an InheritedWidget
. This is a good alternative to Provider, which can get complicated when you need to manage the lifecycle of your services or replace services for testing.
Here is a Flutter example that uses a container as a service locator. You can also see the Flutter pub dev example app here.
import 'package:flutter/material.dart';
import 'package:ioc_container/ioc_container.dart';
class NotificationService {
void sendEmail(String email, String message) {
// Implement your email sending logic here
print('Email sent to $email: $message');
}
}
class OrderService {
void placeOrder(String item, int quantity, String email) {
final notificationService = serviceLocator<NotificationService>();
// Implement your order placement logic here
print('Order placed for $quantity x $item');
notificationService.sendEmail(
email, 'Order confirmation for $quantity x $item');
}
}
class InventoryService {
List<String> getAvailableItems() {
// Implement your inventory retrieval logic here
return ['Item 1', 'Item 2', 'Item 3'];
}
}
// Create a builder so we can replace dependencies later
final IocContainerBuilder builder = IocContainerBuilder(allowOverrides: true)
..addSingleton((container) => NotificationService())
..add((container) => OrderService())
..addSingleton((container) => InventoryService());
// Create a global service locator instance
late final IocContainer serviceLocator;
void main() {
serviceLocator = builder.toContainer();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
//It's safe to use the service locator here in a StatelessWidget
//because the InventoryService is a singleton
final inventoryService = serviceLocator<InventoryService>();
final availableItems = inventoryService.getAvailableItems();
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'IoC Container Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: Scaffold(
appBar: AppBar(title: const Text('IoC Container Demo')),
body: ListView.builder(
itemCount: availableItems.length,
itemBuilder: (context, index) {
final item = availableItems[index];
return ListTile(
title: Text(item),
trailing: ElevatedButton(
onPressed: () {
serviceLocator<OrderService>()
.placeOrder(item, 1, '[email protected]');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Order placed for $item')),
);
},
child: const Text('Order'),
),
);
},
),
),
);
}
}
The Flutter app above defines three services (NotificationService
, OrderService
, and InventoryService
) and registers them in the container using the builder
. We create the serviceLocator
to access these services as needed in the application. In the StatelessWidget
MyApp
, we use the InventoryService
to retrieve available items, and the OrderService
to place an order, which in turn uses the NotificationService
to send an email.
Check out the Flutter widget tests for the example app
You might require scoping and disposal when working with dependencies that require proper cleanup. Scoping refers to limiting the lifespan of resources or objects to a specific block of code or function. This prevents unintended access or manipulation. Disposal ensures that we properly release resources or objects after we use them. This can be important for memory management to prevent resource leaks but is often not necessary for common Dart and Flutter objects that the garbage collector will destroy for you.
A scoped container does not create more than one object instance of each registration. Even if you get the service twice, the same instance will be returned. This example demonstrates a typical case where you may need to dispose of a database connection.
import 'package:ioc_container/ioc_container.dart';
class DatabaseConnection {
final String connectionString;
DatabaseConnection(this.connectionString);
void open() {
print('Opening database connection');
}
void close() {
print('Closing database connection');
}
}
class UserRepository {
final DatabaseConnection _databaseConnection;
UserRepository(this._databaseConnection);
List<String> getUsers() {
_databaseConnection.open();
print('Fetching users from the database');
return ['User 1', 'User 2'];
}
void dispose() {
_databaseConnection.close();
}
}
void main() async {
final builder = IocContainerBuilder()
..add((container) => DatabaseConnection('my-connection-string'))
..add<UserRepository>(
(container) => UserRepository(container<DatabaseConnection>()),
dispose: (userRepository) => userRepository.dispose(),
);
final container = builder.toContainer();
// Create a scope and use UserRepository within the scope
final scope = container.scoped();
final userRepository = scope<UserRepository>();
print(userRepository.getUsers());
// Dispose the scope, which will close the database connection
await scope.dispose();
}
This example above defines a DatabaseConnection
class that represents a connection to a database, and a UserRepository
class that uses the DatabaseConnection
to fetch user data. We use the container to manage the lifecycle of these services. We create an IocContainerBuilder
to register the DatabaseConnection
and UserRepository
. We specify a dispose
function for the UserRepository
that will close the database connection when we dispose of the scope.
The main function creates a scope to retrieve the UserRepository
from the scoped container. We fetch the user data and then dispose of the scope. Disposing of the scope will invoke the dispose()
function for UserRepository
, which in turn closes the DatabaseConnection.
Note: all services in the scoped container exist for the lifespan of the scope. They act in a way that is similar to singletons, but when we call dispose()
on the scope, it calls dispose()
on each service registration.
import 'package:ioc_container/ioc_container.dart';
class DatabaseService {
DatabaseService(this.connectionString);
final String connectionString;
Future<DatabaseService> init() async {
// Simulate async initialization, such as connecting to the database.
await Future<void>.delayed(const Duration(milliseconds: 1500));
print('DatabaseService initialized');
return this;
}
}
class UserService {
UserService(this._dbService);
final DatabaseService _dbService;
Future<UserService> init() async {
// Simulate async initialization, such as fetching user data.
await Future<void>.delayed(const Duration(milliseconds: 1500));
print('UserService initialized');
return this;
}
}
void main() async {
final builder = IocContainerBuilder()
..addSingletonAsync(
(container) async => DatabaseService('connection_string').init(),
)
..addSingletonAsync(
(container) async =>
UserService(await container.getAsync<DatabaseService>()).init(),
);
final container = builder.toContainer();
print('Waiting for services to initialize at...${DateTime.now()}');
final userService = await container.getAsync<UserService>();
print('Got initialized service at at...${DateTime.now()}');
// Use the userService instance for your application logic.
}
The example above uses a container to manage async initialization for two services: DatabaseService
and UserService
. It simulates time-consuming initialization tasks for each service. It uses addSingletonAsync()
to register the services. When the getAsync()
call completes, the app can use the UserService
instance because the initialization is complete.
We compose the container with a builder. You can replace services in the builder if the allowOverrides
flag is set to true. This is useful for testing. Expose the builder in a location where the tests can access it, add new mock/fake registrations, and call toContainer()
to get the container with test doubles.
import 'package:flutter/material.dart';
import 'package:ioc_container/ioc_container.dart';
abstract class AuthService {
Future<bool> authenticate(String username, String password);
}
class RealAuthService implements AuthService {
@override
Future<bool> authenticate(String username, String password) async {
// Your real authentication logic here.
return username == 'bob' && password == '123';
}
}
//We declare the builder and container as top level variables here just to make
//the example clearer
final builder = IocContainerBuilder(allowOverrides: true)
..addSingleton<AuthService>((container) => RealAuthService());
late IocContainer container;
void main() {
container = builder.toContainer();
runApp(const AppRoot());
}
class AppRoot extends StatelessWidget {
const AppRoot({
super.key,
});
@override
Widget build(BuildContext context) => const MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: LoginScreen(),
),
);
}
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final usernameController = TextEditingController();
final passwordController = TextEditingController();
@override
Widget build(BuildContext context) => Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: usernameController,
decoration: const InputDecoration(labelText: 'Username'),
),
TextField(
controller: passwordController,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
),
TextButton(
onPressed: () async {
final success = await container<AuthService>().authenticate(
usernameController.text,
passwordController.text,
);
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(success ? 'Welcome' : 'Error'),
content: Text(
success ? 'Login Successful' : 'Invalid credentials',
),
),
);
},
child: const Text('Login'),
),
],
),
),
);
}
The code above defines a simple Flutter app with a login screen that uses an IoC container to manage its dependencies. The app has an AuthService
to authenticate users, with the RealAuthService
registered in the container. This is how we can mock the dependencies and replace the RealAuthService
with MockAuthService
in our tests.
import 'package:flutter/material.dart';
import 'package:flutter_application_9/main.dart';
import 'package:flutter_test/flutter_test.dart';
class MockAuthService implements AuthService {
@override
Future<bool> authenticate(String username, String password) async =>
username == 'test' && password == '1234';
}
void main() {
setUp(
() {
builder.addSingleton<AuthService>((container) => MockAuthService());
container = builder.toContainer();
},
);
testWidgets('Test LoginScreen with MockAuthService', (tester) async {
await tester.pumpWidget(const AppRoot());
// Enter correct credentials
await tester.enterText(find.byType(TextField).at(0), 'test');
await tester.enterText(find.byType(TextField).at(1), '1234');
// Find and tap the Login button
final loginButton = find.widgetWithText(TextButton, 'Login');
await tester.tap(loginButton);
await tester.pumpAndSettle();
// Find the AlertDialog
final alertDialog = find.byType(AlertDialog);
// Check if the AlertDialog is present
expect(alertDialog, findsOneWidget);
// Check if the AlertDialog displays the expected success message
final errorMessage = find.text('Login Successful');
expect(errorMessage, findsOneWidget);
});
testWidgets('Invalid login scenario', (tester) async {
await tester.pumpWidget(const AppRoot());
// Enter invalid credentials
await tester.enterText(find.byType(TextField).at(0), 'wrong_user');
await tester.enterText(find.byType(TextField).at(1), 'wrong_password');
// Find and tap the Login button
final loginButton = find.widgetWithText(TextButton, 'Login');
await tester.tap(loginButton);
await tester.pumpAndSettle();
// Find the AlertDialog
final alertDialog = find.byType(AlertDialog);
// Check if the AlertDialog is present
expect(alertDialog, findsOneWidget);
// Check if the AlertDialog displays the expected error message
final errorMessage = find.text('Invalid credentials');
expect(errorMessage, findsOneWidget);
});
}
These tests validate the login functionality of the app with fake authentication services. One test checks for a successful login scenario, ensuring the "Login Successful" message is displayed. The other test examines the invalid login scenario, verifying that the "Invalid credentials" error message appears.
Check out the Flutter widget tests for the example app
ioc_container makes accessing, initializing, and testing Firebase easy. Configure Firebase with the official documentation, and make sure your pubspec.yaml
has these dependencies.
- ioc_container
- firebase_core
- firebase_auth
- cloud_firestore
Add this file
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:ioc_container/ioc_container.dart';
///Extensions for wiring up FlutterFire. This adds
///[FirebaseApp], [FirebaseAuth], and [FirebaseFirestore] as singletons
extension FlutterFireExtensions on IocContainerBuilder {
void addFirebase() {
//These factories are all async because we need to ensure that Firebase is initialized
addSingletonAsync(
(container) {
//This is typically done at the start of the main() function.
//Be aware that this is being done to ensure that the Flutter engine is initialized before Firebase and never occurs twice
WidgetsFlutterBinding.ensureInitialized();
return Firebase.initializeApp(
options: container.get<FirebaseOptions>(),
);
},
);
addSingletonAsync(
(container) async => FirebaseAuth.instanceFor(
app: await container.getAsync<FirebaseApp>(),
),
);
addSingletonAsync(
(container) async => FirebaseFirestore.instanceFor(
app: await container.getAsync<FirebaseApp>(),
),
);
}
}
Call addFirebase()
on your builder to add the factories to your composition and add your FirebaseOptions
.
IocContainerBuilder compose() => IocContainerBuilder(allowOverrides: true)
..addFirebase()
//You must add your own FirebaseOptions to the composition
..addSingleton<FirebaseOptions>((container) => DefaultOptions(
apiKey: apiKey,
appId: appId,
projectId: projectId,
));
You can now get any Firebase dependencies from the container like this and be sure that it is initialized.
final firebaseFirestore = await container.getAsync<FirebaseFirestore>();
Replace the dependencies with fakes or mocks in your tests like this.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:example_2/main.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_test/flutter_test.dart';
import '../firebase.dart';
void main() {
testWidgets('Testing with Firebase', (WidgetTester tester) async {
final builder = compose();
//TODO: Create mocks for Firebase or use a library like firestore_fakes to
//mock the dependencies
var fakeFirebaseFirestore = FirebaseFirestoreFake();
//TODO: Put fake data in fakeFirebaseFirestore here. The app will consume it.
builder
..addSingletonAsync<FirebaseAuth>((container) async => MockFirebaseAuth())
..addSingletonAsync<FirebaseFirestore>(
(container) async => fakeFirebaseFirestore);
await tester.pumpWidget(MyApp(container: builder.toContainer()));
//TODO: Put your tests here
});
}
If you have any further issues, see the FlutterFire documentation.
This library takes inspiration from DI in .NET MAUI and ASP .NET Core. You register your dependencies with the IocContainerBuilder
which is a bit like IServiceCollection
in ASP.NET Core. Then you build it with the toContainer()
method, which is like the BuildServiceProvider()
method in ASP.NET Core. DI is an established pattern on which the whole .NET ecosystem and many other ecosystems depend. This library does not reinvent the wheel, it just makes it easy to use in Flutter and Dart.
Much of the functionality comes from extension methods. Extension methods are better because they don't pollute the core public interface. It is very easy to implement your own IocContainer
because it only has 4 properties. You can add as many extension methods as you need. The library doesn't come with extensions that are not necessary.
For example, in version V1, there was a addSingletonService
extension. This was removed in V2 because it is not necessary, but you can easily add it back for backwards compatibility or convenience. This is the extension:
///Add a singleton service to the container.
void addSingletonService<T>(T service) => addServiceDefinition(
ServiceDefinition<T>(
(container) => service,
isSingleton: true,
),
);
You may need to use keys to store multiple instances of the same type. You can use extensions to implement this functionality. This example demonstrates how to use extensions to add keyed services to ioc_container
import 'package:ioc_container/ioc_container.dart';
import 'package:test/test.dart';
///Example service
class BigService {
final String name;
BigService(this.name);
Future<void> callApi() => Future<void>.delayed(Duration(seconds: 1));
///We can check equality by the name(key)
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is BigService && other.name == name;
}
@override
int get hashCode => name.hashCode;
}
///These give us the functionality to add or
///access a service by key
extension KeyedExtensions on IocContainer {
T? keyedService<T>(String key) => get<Map<String, T>>()[key];
void setServiceByKey<T>(String key, T service) =>
get<Map<String, T>>()[key] = service;
}
void main() {
test('Keyed Services', () {
var count = 0;
final builder = (IocContainerBuilder()
..add((container) {
//Increments the name (key) of the service so they are unique
//Uuid would be better
count++;
var bigService = BigService(count.toString());
bigService;
container.setServiceByKey(count.toString(), bigService);
return bigService;
})
..addSingleton(
(container) => <String, BigService>{},
));
final container = builder.toContainer();
final bigContainerOne = container<BigService>();
final bigContainerTwo = container<BigService>();
final bigContainerThree = container<BigService>();
//Verifies the three names
expect(bigContainerOne.name, '1');
expect(bigContainerTwo.name, '2');
expect(bigContainerThree.name, '3');
//Verifies you can access these by key
expect(container.keyedService<BigService>(bigContainerOne.name),
bigContainerOne);
expect(container.keyedService<BigService>(bigContainerTwo.name),
bigContainerTwo);
expect(container.keyedService<BigService>(bigContainerThree.name),
bigContainerThree);
});
}