dart_ddi 0.12.0
dart_ddi: ^0.12.0 copied to clipboard
A Dependency Injection package, with Qualifier, Decorators, Interceptors and more. Inspired by Java CDI.
Dart Dependency Injection (DDI) Package #
Overview #
The Dart Dependency Injection (DDI) package is a robust and flexible dependency injection mechanism. It facilitates the management of object instances and their lifecycles by introducing different scopes and customization options. This documentation aims to provide an in-depth understanding of DDI's core concepts, usage, and features.
🚀 Contribute to the DDI by sharing your ideas, feedback, or practical examples.
See this example to get started with DDI.
Packages #
- Flutter DDI - This package is designed to facilitate the dependency injection process in your Flutter application.
Projects #
- Budgetopia - An intuitive personal finance app that helps users track expenses.
- Perfumei - A simple mobile app about perfumes. Built using DDI and Cubit.
- Clinicas - A project for a mobile, desktop and web application about Attendance Rank. Built using Signal and Flutter DDI to enable route-based dependency injection management.
Summary
Core Concepts #
Scopes #
The Dart Dependency Injection (DDI) package supports various scopes for efficient management of object instances. Each scope determines how instances are created, reused, and destroyed throughout the application lifecycle. Below are detailed characteristics of each scope, along with recommendations, use cases, and considerations for potential issues.
Singleton #
This scope creates a single instance during registration and reuses it in all subsequent requests.
Recommendation
: Suitable for objects that need to be globally shared across the application, maintaining a single instance.
Use Case
: Sharing a configuration manager, a logging service, or a global state manager.
Note
:
-
Interceptor.onDispose
andPreDispose
mixin are not supported. You can just destroy the instance. -
If you call dispose, only the Application children will be disposed.
Application #
Generates an instance when first used and reuses it for all subsequent requests during the application's execution.
Recommendation
: Indicated for objects that need to be created only once per application and shared across different parts of the code.
Use Case
: Managing application-level resources, such as a network client or a global configuration.
Note
: PreDispose
and PreDestroy
mixins will only be called if the instance is in use. Use Interceptor
if you want to call them regardless.
Dependent #
Produces a new instance every time it is requested, ensuring independence and uniqueness.
Recommendation
: Useful for objects that should remain independent and different in each context or request.
Use Case
: Creating instances of transient objects like data repositories or request handlers.
Note
:
-
Dispose
functions,Interceptor.onDispose
andPreDispose
mixin are not supported. -
PreDestroy
mixins are not supported. UseInterceptor.onDestroy
instead.
Object #
Registers an Object in the Object Scope, ensuring it is created once and shared throughout the entire application, working like Singleton.
Recommendation
: Suitable for objects that are stateless or have shared state across the entire application.
Use Case
: Application or device properties, like platform or dark mode settings, where the object's state needs to be consistent across the entire application.
Note
:
-
Interceptor.onDispose
andPreDispose
mixin are not supported. You can just destroy the instance. -
If you call dispose, only the Application children will be disposed.
Custom Scopes #
The DDI package provides a flexible architecture that allows you to create custom scopes by extending the DDIBaseFactory
abstract class. This enables you to implement specialized lifecycle management strategies tailored to your specific application needs.
Creating Custom Scopes #
To create a custom scope, you need to extend DDIBaseFactory<BeanT>
and implement the required methods:
class CustomScopeFactory<BeanT extends Object> extends DDIBaseFactory<BeanT> {...}
Using Custom Scopes #
Once you've created a custom scope factory, you can register it using the standard register
method:
// Register your custom scope
await ddi.register<MyService>(
factory: CustomScopeFactory<MyService>(
builder: MyService.new.builder,
canDestroy: true,
decorators: [(instance) => ModifiedService(instance)],
interceptors: {MyInterceptor()},
),
);
// Retrieve the instance
final service = ddi.get<MyService>();
Zone Management #
Run in a new Zone, making possible to register specific instances in a different context.
T runInZone<T>(String name, T Function() body);
This method creates a new Dart Zone with its own isolated registry of beans. This allows you to register and manage instances in a separate context without affecting the global DDI container. When the zone completes, all registered instances in that zone are automatically destroyed.
Use cases:
- Testing scenarios where you need isolated instances
- Temporary registrations that shouldn't persist
- Scoped dependency injection for specific operations
- Avoiding conflicts between different parts of the application
Example:
final result = ddi.runInZone('test-zone', () {
// Register instances specific to this zone
ddi.registerSingleton<TestService>(TestService.new);
// Use the zone-specific instance
final service = ddi.get<TestService>();
return service.process();
});
// Zone instances are automatically destroyed here
Common Considerations: #
Unique Registration
: Ensure that the instance to be registered is unique for a specific type or use qualifiers to enable the registration of multiple instances of the same type.
Memory Management
: Be aware of memory implications for long-lived objects, especially in the Singleton and Object scopes.
Nested Instances
: Avoid unintentional coupling by carefully managing instances within larger-scoped objects.
const and Modifiers
: Take into account the impact of const and other class modifiers on the behavior of instances within different scopes.
Factories #
Encapsulate the instantiation logic, providing a better way to define how and when objects are created. They use a builder function to manage the creation process, providing flexibility and control over the instances.
How Factories Work #
When you register a factory, you provide a builder function that defines how the instance will be constructed. This builder can take parameters, enabling the factory to customize the creation process based on the specific needs of the application. Depending on the specified scope (e.g., singleton or application), the factory can either create a new instance each time it is requested or return the same instance for subsequent requests.
Example Registration
MyService.new.builder.asApplication();
In this example:
MyService.new.
is the default constructor of the class (e.g.,() => MyService()
)..builder
defines the parameters for the instance ofMyService
..asApplication()
defines the scope of the factory to create a new instance ofMyService
and register the factory in thedart_ddi
system.
Use Cases for Factories #
Asynchronous Creation
Factories support asynchronous creation, which is useful when initialization requires asynchronous tasks, such as data fetching.
DDI.instance.register(
factory: ApplicationFactory<MyApiService>(
builder: () async {
final data = await getApiData();
return MyApiService(data);
}.builder,
),
);
Custom Parameters #
Factories can define parameters for builders, allowing for more flexible object creation based on runtime conditions. This also enables automatic injection of Beans
into factories.
// Registering the factory
DDI.instance.register(
factory: ApplicationFactory(
builder: (RecordParameter parameter) {
return ServiceWithParameter(parameter);
}.builder,
),
);
DDI.instance.register(
factory: ApplicationFactory(
builder: (MyDatabase database, UserService userService) {
return ServiceAutoInject(database, userService);
}.builder,
),
);
// Retrieving the instances
ddi.getWith<ServiceWithParameter, RecordParameter>(parameter: parameter);
ddi.get<ServiceAutoInject>();
Considerations #
Singleton Scope:
The Singleton Scope can only be created with auto-inject. If you attempt to create a singleton with custom objects, a BeanNotFoundException
will be thrown.
Supertypes or Interfaces:
You cannot use the shortcut builder (MyService.new.builder.asApplication()
) with supertypes or interfaces. This limitation exists because the builder function only recognizes the implementation class, not the supertype or interface.
Decorators and Interceptors:
It is highly recommended to register the factory using factory: CustomFactory(...)
. This approach handles type inference more effectively.
Lazy vs. Eager Injection:
Eager Injection occurs when you inject beans using auto-inject functionality or manually via constructors. For lazy injection, you can use the DDIInject
mixin or define the variable as late
(e.g., late final ServiceAutoInject serviceAutoInject = ddi.get()
).
Qualifiers #
Qualifiers are used to differentiate instances of the same type, enabling you to identify and retrieve specific instances. In scenarios where multiple instances coexist, qualifiers serve as optional labels or identifiers associated with the registration and retrieval of instances.
How Qualifiers Work #
When registering an instance, you can provide a qualifier as part of the registration process. This qualifier acts as metadata associated with the instance and can later be used during retrieval to specify which instance is needed.
Example Registration with Qualifier
ddi.registerSingleton<MyService>(MyService.new, qualifier: "specialInstance");
Retrieval with Qualifiers #
During retrieval, if multiple instances of the same type exist, you can use the associated qualifier to specify the desired instance. But remember, if you register using a qualifier, you should retrieve with a qualifier.
Example Retrieval with Qualifier
MyService specialInstance = ddi.get<MyService>(qualifier: "specialInstance");
Use Cases for Qualifiers #
Configuration Variations
When there are multiple configurations for a service, such as different API endpoints or connection settings.
ddi.registerSingleton<ApiService>(() => ApiService("endpointA"), qualifier: "endpointA");
ddi.registerSingleton<ApiService>(() => ApiService("endpointB"), qualifier: "endpointB");
Feature Flags
When different instances are required based on feature flags or runtime conditions.
ddi.registerSingleton<FeatureService>(() => FeatureService(enabled: true), qualifier: "enabled");
ddi.registerSingleton<FeatureService>(() => FeatureService(enabled: false), qualifier: "disabled");
Platform-Specific Implementations
In scenarios where platform-specific implementations are required, such as different services for Android and iOS, qualifiers can be employed to distinguish between the platform-specific instances.
ddi.registerSingleton<PlatformService>(AndroidService.new, qualifier: "android");
ddi.registerSingleton<PlatformService>(iOSService.new, qualifier: "ios");
Considerations #
Consistent Usage:
Maintain consistent usage of qualifiers throughout the codebase to ensure clarity and avoid confusion.
Avoid Overuse:
While qualifiers offer powerful customization, avoid overusing them to keep the codebase clean and maintainable.
Type Identifiers:
Qualifiers are often implemented using string-based identifiers, which may introduce issues such as typos or potential naming conflicts. To mitigate these concerns, it is highly recommended to utilize enums or constants.
Extra Customization #
The DDI package provides features for customizing the lifecycle of registered instances. These features include decorators
, interceptor
, canRegister
and canDestroy
.
Decorators #
Decorators provide a way to modify or enhance the behavior of an instance before it is returned. Each decorator is a function that takes the existing instance and returns a modified instance. Multiple decorators can be applied, and they are executed in the order they are specified during registration.
Example Usage:
class ModifiedMyService extends MyService {
ModifiedMyService(MyService instance) {
super.value = instance.value.toUpperCase();
}
}
ddi.registerSingleton<MyService>(
MyService.new,
decorators: [
(existingInstance) => ModifiedMyService(existingInstance),
// Additional decorators can be added as needed.
],
);
Interceptor #
The Interceptor provides control over the instantiation, retrieval, destruction, and disposal of instances managed by the DDI package. By creating a custom class that extends DDIInterceptor
, you can inject custom logic at various stages of the instance's lifecycle.
Interceptor Methods #
onCreate #
- Invoked after instance creation and before Decorators and PostConstruct mixin.
- Execute custom logic, Customize or replace the instance by returning a modified instance.
onGet #
- Invoked when retrieving an instance.
- Customize the behavior of the retrieved instance before it is returned.
- If you change any value, the next time you get this instance, it will be applied again. Be aware that this can lead to unexpected behavior.
onDestroy #
- Invoked when an instance is being destroyed.
- Allows customization of the instance destruction process.
onDispose #
- Invoked during the disposal of an instance.
- Provides an opportunity for customization before releasing resources or performing cleanup.
Example Usage
class CustomInterceptor<BeanT> extends DDIInterceptor<BeanT> {
@override
BeanT onCreate(BeanT instance) {
// Logic to customize or replace instance creation.
return CustomizedInstance();
}
@override
BeanT onGet(BeanT instance) {
// Logic to customize the behavior of the retrieved instance.
return ModifiedInstance(instance);
}
@override
void onDestroy(BeanT instance) {
// Logic to perform cleanup during instance destruction.
// This method is optional and can be overridden as needed.
}
@override
void onDispose(BeanT instance) {
// Logic to release resources or perform custom cleanup during instance disposal.
// This method is optional and can be overridden as needed.
}
}
CanRegister #
The canRegister parameter is a boolean function that determines whether an instance should be registered. It provides conditional registration based on a specified condition. This is particularly useful for ensuring that only a single instance is registered, preventing issues with duplicated instances.
Example Usage:
ddi.registerSingleton<MyService>(
MyServiceAndroid.new,
canRegister: () {
return Platform.isAndroid && MyUserService.isAdmin();
},
);
ddi.registerSingleton<MyService>(
MyServiceIos.new,
canRegister: () {
return Platform.isIOS && MyUserService.isAdmin();
},
);
ddi.registerSingleton<MyService>(
MyServiceDefault.new,
canRegister: () {
return !MyUserService.isAdmin();
},
);
CanDestroy #
The canDestroy parameter, is optional and can be set to false if you want to make the registered instance indestructible. When set to false, the instance cannot be removed using the destroy
or destroyByType
methods.
Example Usage:
// Register an Application instance that is indestructible
ddi.registerApplication<MyService>(
MyService.new,
canDestroy: false,
);
Selector #
The selector
parameter allows for conditional selection when retrieving an instance, providing a way to determine which instance should be used based on specific criteria. The first instance that matches true
will be selected; if no instance matches, a BeanNotFoundException
will be thrown. The selector requires registration with an interface type, making it particularly useful in scenarios where multiple instances of the same type are registered, but only one needs to be chosen dynamically at runtime based on context.
Example Usage:
void main() {
// Registering CreditCardPaymentService with a selector condition
ddi.registerApplication<PaymentService>(
CreditCardPaymentService.new,
qualifier: 'creditCard',
selector: (paymentMethod) => paymentMethod == 'creditCard',
);
// Registering PayPalPaymentService with a selector condition
ddi.registerApplication<PaymentService>(
PayPalPaymentService.new,
qualifier: 'paypal',
selector: (paymentMethod) => paymentMethod == 'paypal',
);
// Runtime value to determine the payment method
const selectedPaymentMethod = 'paypal'; // Could also be 'creditCard'
// Retrieve the appropriate PaymentService based on the selector condition
late final paymentService = ddi.get<PaymentService>(
select: selectedPaymentMethod,
);
// Process a payment with the selected service
paymentService.processPayment(100.0);
}
Modules #
Modules offer a convenient way to modularize and organize dependency injection configuration in your application. Through the use of the addChildModules
and addChildrenModules
methods, you can add and configure specific modules, grouping related dependencies and facilitating management of your dependency injection container.
When you execute dispose
or destroy
for a module, they will be executed for all its children.
Adding a Class #
To add a single class to a module to your dependency injection container, you can use the addChildModules
method.
child
: This refers to the type or qualifier of the subclasses that will be part of the module. Note that these are not instances, but rather types or qualifiers.qualifier
(optional): This parameter refers to the main class type of the module. It is optional and is used as a qualifier if needed.
// Adding a single module with an optional specific qualifier.
ddi.addChildModules<MyModule>(
child: MySubmoduleType,
qualifier: 'MyModule',
);
Adding Multiple Classes #
To add multiple classes to a module at once, you can utilize the addChildrenModules
method.
child
: This refers to the type or qualifier of the subclasses that will be part of the module. Note that these are not instances, but rather types or qualifiers.qualifier
(optional): This parameter refers to the main class type of the module. It is optional and is used as a qualifier if needed.
// Adding multiple modules at once.
ddi.addChildrenModules<MyModule>(
child: [MySubmoduleType1, MySubmoduleType2],
qualifier: 'MyModule',
);
With these methods, you can modularize your dependency injection configuration, which can be especially useful in larger applications with complex instance management requirements.
Register With Children Parameter #
The children
parameter is designed to receive types or qualifiers. This parameter allows you to register multiple classes under a single parent module.
// Adding multiple modules at once.
ddi.registerApplication<ParentModule>(
() => ParentModule(),
children: [
ChildModule,
OtherModule,
'ChildModuleQualifier',
'OtherModuleQualifier'
],
);
Mixins #
Post Construct Mixin #
The PostConstruct
mixin has been added to provide the ability to execute specific rules after the construction of an instance of the class using it. Its primary purpose is to offer an extension point for additional logic that needs to be executed immediately after an object is created.
By including the PostConstruct mixin in a class and implementing the onPostConstruct()
method, you can ensure that this custom logic is automatically executed right after the instantiation of the class.
Example Usage:
class MyClass with PostConstruct {
final String name;
MyClass(this.name);
@override
void onPostConstruct() {
// Custom logic to be executed after construction.
print('Instance of MyClass has been successfully constructed.');
print('Name: $_name');
}
}
Pre Destroy Mixin #
The PreDestroy
mixin has been created to provide a mechanism for executing specific actions just before an object is destroyed. This mixin serves as a counterpart to the PostConstruct mixin, allowing to define custom cleanup logic that needs to be performed before an object's lifecycle ends.
Example Usage:
class MyClassName with PreDestroy {
final String name;
MyMyClassNameClass(this.name);
@override
void onPreDestroy() {
// Custom cleanup logic to be executed before destruction.
print('Instance of MyClassName is about to be destroyed.');
print('Performing cleanup for $name');
}
}
void main() {
// Registering an instance of MyClassName
ddi.registerSingleton<MyClassName>(
() => MyClassName('DDI Example'),
);
// Destroying the instance (removing it from the container).
ddi.remove<MyClassName>();
// Output:
// Instance of MyClassName is about to be destroyed.
// Performing cleanup for DDI Example
}
Pre Dispose Mixin #
The PreDispose
mixin extends the lifecycle management capabilities, allowing custom logic to be executed before an instance is disposed.
Example Usage:
class MyClass with PreDispose {
final String name;
MyClass(this.name);
@override
void onPreDispose() {
// Custom cleanup logic to be executed before disposal.
print('Instance of MyClass is about to be disposed.');
print('Performing cleanup for $name');
}
}
DDIModule Mixin #
The DDIModule
mixin provides a convenient way to organize and manage your dependency injection configuration within your Dart application. By implementing this mixin in your module classes, you can easily register instances with different scopes and dependencies using the provided methods.
Example Usage:
// Define a module using the DDIModule mixin
class AppModule with DDIModule {
@override
void onPostConstruct() {
// Registering instances with different scopes
registerSingleton(() => Database('main_database'), qualifier: 'mainDatabase');
registerApplication(() => Logger(), qualifier: 'appLogger');
registerObject('https://api.example.com', qualifier: 'apiUrl');
registerDependent(() => ApiService(inject.get(qualifier: 'apiUrl')), qualifier: 'dependentApiService');
}
}
DDIInject
, DDIInjectAsync
and DDIComponentInject
Mixins #
The DDIInject
, DDIInjectAsync
and DDIComponentInject
mixins are designed to facilitate dependency injection of an instance into your classes. They provide a convenient method to obtain an instance of a specific type from the dependency injection container.
The DDIInject
mixin allows for synchronous injection of an instance and DDIInjectAsync
mixin allows for asynchronous injection. Both define an instance
property that will be initialized with the InjectType
instance obtained.
The DDIComponentInject
allows injecting a specific instance using a module as a selector.
Example Usage:
class MyController with DDIInject<MyService> {
void businessLogic() {
instance.runSomething();
}
}
class MyAsyncController with DDIInjectAsync<MyService> {
Future<void> businessLogic() async {
final myInstance = await instance;
myInstance.runSomething();
}
}
class MyController with DDIComponentInject<MyComponent, MyModule> {
void businessLogic() {
instance.runSomething();
}
}