dart_ddi 0.15.0
dart_ddi: ^0.15.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.
Required Dependencies: You can use the requires parameter to ensure dependencies are registered and ready before instance creation. Validation occurs during register().
Note:
-
Interceptor.onDisposeandPreDisposemixin 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.
Required Dependencies: You can use the requires parameter to ensure dependencies are registered and ready before instance creation. Validation occurs during getWith() or getAsyncWith().
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.
Required Dependencies: You can use the requires parameter to ensure dependencies are registered before instance creation. Validation occurs during getWith() or getAsyncWith().
Note:
-
Disposefunctions,Interceptor.onDisposeandPreDisposemixin are not supported. -
PreDestroymixins are not supported. UseInterceptor.onDestroyinstead.
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.
Required Dependencies: You can use the requires parameter to ensure dependencies are registered and ready before instance creation. Validation occurs during register().
Note:
-
Interceptor.onDisposeandPreDisposemixin 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> {...}
DDIBaseFactory now also requires canDestroy so the container can enforce destroy rules consistently (including contextual destroy operations).
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>();
Context Management #
Contexts let you isolate registrations without creating a separate DDI container for every scenario.
Important: Both DDI.instance and DDI.newInstance() support contexts through a strategy-based context engine.
Use createContext(...) and destroyContext(...) for explicit context lifecycle.
Context Strategy #
DDI.newInstance()uses the exportedDDIDefaultStrategynamed-context strategy.- You can pass a custom strategy with
DDI.newInstance(contextStrategy: myStrategy). - Context lifecycle is managed explicitly.
Explicit Context Operations #
Most registration and resolution APIs accept a context: parameter:
final ddi = DDI.newInstance();
await ddi.application<ApiClient>(
ApiClient.new,
context: 'request-A',
);
final client = ddi.getWith<ApiClient, Object>(context: 'request-A');
For named contexts in the default strategy, create them first:
final ddi = DDI.newInstance();
ddi.createContext('request-A');
await ddi.application<ApiClient>(ApiClient.new, context: 'request-A');
If you try to register with an explicit context: that does not exist, a ContextNotFoundException is thrown.
This is especially useful when:
- You need to resolve a bean from a specific context while another context is active.
- You want to register the same qualifier in multiple isolated registries.
- You want to destroy or dispose only the contextual instance of a bean.
Context Lifecycle API #
You can manage named contexts explicitly:
ddi.createContext('feature');
final exists = ddi.contextExists('feature'); // true
await ddi.destroyContext('feature');
destroyContext(...) behavior:
- Destroys using normal
ddi.destroy(...)rules (scope-aware). - Runs from deepest child context to parent context.
- Validates the full context tree before starting destruction to avoid partial cleanup.
- If any factory in that tree has
canDestroy: false, it throws and keeps all contexts/factories untouched. - While destruction is running, write operations targeting those contexts are blocked (for example,
registerandcreateContext). - Reentrant
destroyContext(...)calls for a context already being destroyed are treated as no-op.
Context Freeze API #
You can lock a context to prevent mutating operations while keeping reads available:
ddi.createContext('checkout');
await ddi.application<PaymentService>(
PaymentService.new,
context: 'checkout',
);
ddi.freezeContext('checkout');
final frozen = ddi.isContextFrozen('checkout'); // true
// Throws ContextFrozenException:
await ddi.application<FraudService>(
FraudService.new,
context: 'checkout',
);
ddi.unfreezeContext('checkout');
await ddi.application<FraudService>(
FraudService.new,
context: 'checkout',
);
freezeContext(...) behavior:
- Blocks context mutations such as
register,addDecorator,addInterceptor,addChildrenModules,destroy,dispose,destroyByType, anddisposeByType. - Does not block read APIs like
get,getAsync,isRegistered,isReady, andisFuture. - Throws
ContextFrozenExceptionwhen a blocked operation is attempted.
unfreezeContext(...) behavior:
- Removes the lock for that specific context.
- Other contexts keep their own frozen/unfrozen state.
currentContext #
The currentContext property exposes a token for the currently active context:
final contextToken = ddi.currentContext;
This token always contains a valid context object, including the root context.
It can be reused in explicit context: operations when needed.
Contextual Instance Wrapper #
getInstance<T>() captures the current context at the time the wrapper is created, or an explicit context: when one is provided. This means the Instance<T> keeps resolving against the same contextual registry instead of following whatever context becomes active later.
getInstance<T>() itself does not force resolution. It creates a handle that can be checked later with isResolvable() and resolved later with get() or getAsync().
ddi.createContext('feature-context');
await ddi.application<MyService>(
MyService.new,
context: 'feature-context',
);
final instance = ddi.getInstance<MyService>();
final service = instance.get();
service.doSomething();
await ddi.destroyContext('feature-context');
This behavior is particularly useful for contextual modules and delayed resolution flows.
Context With DDIModule #
DDIModule also supports contextual registrations through contextQualifier.
By default, module children are registered through DDI.instance. If a module should use an isolated container, override ddiContainer explicitly. If you override contextQualifier, every singleton, application, dependent, object, and register call made through the module is forwarded with that explicit context.
For the default registry strategy, this also creates and keeps the module context alive while the module exists. This allows the module to register the same qualifier as the root registry without collisions.
class FeatureModule with DDIModule {
@override
Object? get contextQualifier => moduleQualifier;
@override
Future<void> onPostConstruct() async {
await application<ApiClient>(ApiClient.new);
await object<String>('feature-context', qualifier: 'featureName');
}
}
This is useful when:
- A module should keep its children isolated from root registrations.
- The same qualifier must exist both globally and inside the module.
- A module should expose contextual
Instance<T>wrappers that continue resolving from the module registry.
When a contextual module is destroyed, its contextual children are destroyed using the same module context.
Selectors With Explicit Context #
Selectors also support explicit contextual resolution:
await ddi.application<PaymentService>(
CreditCardPaymentService.new,
qualifier: 'credit-card',
context: 'checkout',
selector: (value) => value == 'credit-card',
);
final paymentService = ddi.getWith<PaymentService, Object>(
select: 'credit-card',
context: 'checkout',
);
When context: is provided, selector lookup is performed against that specific context.
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.
Instance Wrapper #
The Instance<BeanT> wrapper provides a programmatic way to access beans. It allows you to check if a bean is resolvable, get instances, and destroy them programmatically.
Instance Wrapper Overview #
The Instance wrapper is obtained using the getInstance method and provides the following capabilities:
isResolvable(): Checks if the bean is currently resolvable from the captured context.get<ParameterT>({ParameterT? parameter}): Gets the bean instance synchronously, optionally with parameters.getAsync<ParameterT>({ParameterT? parameter}): Gets the bean instance asynchronously, optionally with parameters.destroy(): Destroys the bean instance if it exists and can be destroyed.dispose(): Disposes of the bean instance if it exists.
Important characteristics:
getInstance<T>()captures the current context when the wrapper is created, unless an explicitcontext:is provided.getInstance<T>()does not throw just because the bean is not registered yet.get()andgetAsync()use the same resolution strategy asddi.get()andddi.getAsync().
Basic Usage Example
// Register a service
ddi.dependent<MyService>(MyService.new);
// Get an Instance wrapper
final instance = ddi.getInstance<MyService>();
// Check if resolvable
if (instance.isResolvable()) {
final service = instance.get();
service.doSomething();
await instance.destroy();
}
Example with Qualifier
// Register multiple instances with qualifiers
ddi.dependent<MyService>(MyService.new, qualifier: 'service1');
ddi.dependent<MyService>(MyService.new, qualifier: 'service2');
// Get Instance wrapper for specific qualifier
final instance1 = ddi.getInstance<MyService>(qualifier: 'service1');
final instance2 = ddi.getInstance<MyService>(qualifier: 'service2');
final service1 = instance1.get();
final service2 = instance2.get();
Instance Cache and Weak Reference #
The Instance wrapper supports two optional parameters that control how instances are stored and managed:
cache: Iftrue, maintains a strong reference to the instance (caching). This prevents the instance from being garbage collected while the Instance wrapper exists.useWeakReference: Iftrue, maintains a weak reference to the instance. This allows the instance to be garbage collected if no other strong references exist.
Important: If both useWeakReference and cache are true, cache takes precedence (strong reference is maintained).
When cache or useWeakReference is enabled, the wrapper first reuses the stored value and only goes back to DDI when needed. This keeps repeated instance.get() calls fast.
⚠️ Memory Management Warning: When using cache: true, the Instance wrapper maintains a strong reference to the cached instance, which can lead to memory leaks if not properly managed. Always call destroy() or dispose() on the Instance wrapper when you're done using it to release the cached instance and prevent memory leaks.
Example with Cache
// Register a service
ddi.dependent<MyService>(MyService.new);
// Get Instance wrapper with cache enabled
final instance = ddi.getInstance<MyService>(cache: true);
// First get - instance is created and cached
final service1 = instance.get();
// Second get - returns cached instance (same reference)
final service2 = instance.get();
expect(service1, same(service2)); // true
// Important: Always destroy the Instance wrapper when done to prevent memory leaks
await instance.destroy();
Example with Weak Reference
// Register a service
ddi.dependent<MyService>(MyService.new);
// Get Instance wrapper with weak reference
final instance = ddi.getInstance<MyService>(useWeakReference: true);
// Get instance - stored as weak reference
final service1 = instance.get();
// If no other strong references exist, instance may be GC collected
// Next get will recreate the instance if it was collected
final service2 = instance.get();
Example: Converting WeakReference to Strong Reference
// Register ApplicationScope with WeakReference
ddi.application<MyService>(
MyService.new,
useWeakReference: true,
);
// Get Instance wrapper with cache = true
// This converts the WeakReference to a Strong reference
final instance = ddi.getInstance<MyService>(cache: true);
// Instance maintains strong reference, preventing GC
final service1 = instance.get();
final service2 = instance.get();
expect(service1, same(service2)); // true
Instance Interceptor and Decorator Behavior #
The Instance wrapper interacts with interceptors and decorators in a specific way:
When Instance.cache = true or Instance.useWeakReference = true: #
- Interceptors
onGet: Called only once (when instance is first retrieved and cached/stored). - Decorators: Applied only once (during instance creation).
When ApplicationScope.useWeakReference = true (without Instance cache): #
- Interceptors
onGet: Called every time. - Decorators: Applied during instance creation and everytime the GC collect the instance.
Example with Interceptors #
// Register interceptor
ddi.singleton<TrackingInterceptor>(TrackingInterceptor.new);
// Register service with interceptor
ddi.application<MyService>(
MyService.new,
interceptors: {TrackingInterceptor},
);
// Get Instance wrapper with cache
final instance = ddi.getInstance<MyService>(cache: true);
// First get - interceptor.onGet is called once
final service1 = instance.get();
// Second get - interceptor.onGet is NOT called again (instance is cached)
final service2 = instance.get();
final interceptor = ddi.get<TrackingInterceptor>();
expect(interceptor.getCallCount, equals(1)); // Only called once
Instance Use Cases #
Lazy Initialization #
// Get Instance wrapper without forcing immediate resolution
final instance = ddi.getInstance<MyService>();
// Service is created only when get() is called
if (someCondition) {
final service = instance.get();
service.doSomething();
}
Conditional Usage #
final instance = ddi.getInstance<MyService>();
// Check if service is available before using
if (instance.isResolvable()) {
final service = instance.get();
service.doSomething();
} else {
// Handle case when service is not registered yet
print('Service not available');
}
Programmatic Lifecycle Management #
final instance = ddi.getInstance<MyService>();
// Use the service
final service = instance.get();
service.doSomething();
// Dispose when done
await instance.dispose();
// Or destroy completely
await instance.destroy();
⚠️ Important: If you used cache: true when creating the Instance wrapper, it's especially important to call destroy() or dispose() to release the cached instance and prevent memory leaks.
Working with Parameters #
// Register service that accepts parameters
ddi.dependent<MyService>(
(String config) => MyService(config),
);
// Get Instance wrapper
final instance = ddi.getInstance<MyService>();
// Get instance with parameter
final service1 = instance.get(parameter: 'config1');
final service2 = instance.get(parameter: 'config2');
// Different parameters create different instances
expect(service1, isNot(same(service2))); // true
// With cache, only the first retrieved instance is cached
// ⚠️ Important: Cache does NOT differentiate between parameters
// The cached instance will ALWAYS be returned, regardless of parameters
final instanceWithCache = ddi.getInstance<MyService>(cache: true);
final service3 = instanceWithCache.get(parameter: 'config1');
// service3 is now cached (created with 'config1')
// If you use the same parameter again, returns the cached instance
final service4 = instanceWithCache.get(parameter: 'config1');
expect(service3, same(service4)); // true - same cached instance
// ⚠️ Even if you use a different parameter, it STILL returns the cached instance
// The parameter is IGNORED when cache is enabled and an instance is already cached
final service5 = instanceWithCache.get(parameter: 'config2');
// service5 is the SAME instance as service3 (cached instance, parameter ignored)
expect(service3, same(service5)); // true - same cached instance
expect(service4, same(service5)); // true - same cached instance
// The cached instance (created with 'config1') is always returned
// regardless of what parameter you pass
final service6 = instanceWithCache.get(parameter: 'config2');
expect(service5, same(service6)); // true - same cached instance
expect(service3, same(service6)); // true - same cached instance
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();
final isolatedDdi = DDI.newInstance();
MyService.new.builder.asApplication(ddiInstance: isolatedDdi);
In this example:
MyService.new.is the default constructor of the class (e.g.,() => MyService())..builderdefines the parameters for the instance ofMyService..asApplication()defines the scope of the factory to create a new instance ofMyServiceand register the factory in thedart_ddisystem.ddiInstance:optionally routes helper registration to a non-globalDDIcontainer.- Builder helpers expose the registration controls that are safe with their concrete inferred type, including
context,priority,requires, anduseWeakReferencefor application scope.
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>();
Auto Inject #
DDI supports inject as a shortcut to auto-resolve constructor/function parameters by type.
This dependencies are resolved automatically from the container instead of being manually passed on registration.
// Register using scope shortcuts
await ddi.singleton(MyService.new.inject().call);
MyService.new.inject().asApplication();
// Register using explicit factory
await ddi.register(
factory: ApplicationFactory(
builder: MyService.new.inject(),
),
);
// Resolve from a non-global DDI instance
final isolatedDdi = DDI.newInstance();
await isolatedDdi.singleton(
MyService.new.inject(isolatedDdi).call,
);
await MyService.new.inject(isolatedDdi).asApplication(
ddiInstance: isolatedDdi,
);
inject() creates a zero-argument producer internally. It resolves each parameter from the global DDI instance by default; pass a DDI instance to inject(ddiInstance) when the producer must resolve dependencies from another container.
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.
Super-types or Interfaces: You cannot use the shortcut builder (MyService.new.builder.asApplication()) with super-types or interfaces. This limitation exists because the builder function only recognizes the implementation class, not the super-type or interface.
Decorators and Interceptors: Decorators are safe on the builder shortcuts, but interceptors are intentionally not exposed there. The shortcut locks the bean type to the concrete builder return type; if an interceptor for a super-type replaces H with another valid G implementation such as I, a concrete H registration would fail with IncompatibleInterceptorResultException. Use an explicitly typed registration instead, for example ddi.register<G>(factory: ApplicationFactory<G>(builder: H.new.builder, interceptors: {MyInterceptor})).
Lazy vs. Eager Injection: Eager Injection occurs when dependencies are resolved during instance creation, such as with MyService.new.inject(), builders/constructors with typed parameters (for example (A a, B b, C c) => MyService(a, b, c)), or manual constructor wiring like () => MyService(ddi.get(), ddi.get()). Lazy Injection defers resolution until needed, using DDIInject / DDIInjectAsync, late + ddi.get(), or Instance<T> (ddi.getInstance<T>()) for programmatic lazy access.
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.singleton<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");
Alias Behavior #
Aliases allow a single bean registration to be resolved through multiple identifiers, while still preserving a single primary qualifier for lifecycle operations (destroy, dispose, and context cleanup). This is useful when the same instance must be reachable by interface type, legacy qualifier names, or feature-specific keys without duplicating registrations.
Current behavior:
- One instance can have multiple aliases.
- One alias can point to multiple instances.
- When
register<T>(..., qualifier: someQualifier)is used andsomeQualifier != T, DDI automatically addsTas an alias for that registration. - Auto-alias is ignored for
Object,Future,FutureOr,Stream,List,Set,Map,Iterable, anddynamic.
Ambiguous alias resolution:
- If
get<T>()orgetAsync<T>()resolves through an alias that matches more than one qualifier in the same context, DDI throwsAmbiguousAliasException. - The exception includes the alias and all matched qualifiers.
Alias resolution with priority:
- When an alias matches multiple registrations in the same context, DDI now uses
priorityto pick a winner. - Lower value means higher priority (
priority: 1wins overpriority: 10). nullpriority is always sorted to the end.- If the best priority ties (or all priorities are
nulland ambiguous), DDI throwsAmbiguousAliasException.
await ddi.application<PaymentService>(
CreditCardPaymentService.new,
qualifier: 'creditCard',
priority: 10,
);
await ddi.application<PaymentService>(
PayPalPaymentService.new,
qualifier: 'paypal',
priority: 1,
);
// Resolves PayPalPaymentService (priority 1 wins)
ddi.get<PaymentService>();
getByType and destroyByType with aliases:
getByType<T>()considers both factory type and alias membership while searching.- It keeps returning primary qualifiers for compatibility.
destroyByType<T>()also considers aliases and removes all matching registrations by their primary qualifiers.
Use Cases for Qualifiers #
Configuration Variations
When there are multiple configurations for a service, such as different API endpoints or connection settings.
ddi.singleton<ApiService>(() => ApiService("endpointA"), qualifier: "endpointA");
ddi.singleton<ApiService>(() => ApiService("endpointB"), qualifier: "endpointB");
Feature Flags
When different instances are required based on feature flags or runtime conditions.
ddi.singleton<FeatureService>(() => FeatureService(enabled: true), qualifier: "enabled");
ddi.singleton<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.singleton<PlatformService>(AndroidService.new, qualifier: "android");
ddi.singleton<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.singleton<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.singleton<MyService>(
MyServiceAndroid.new,
canRegister: () {
return Platform.isAndroid && MyUserService.isAdmin();
},
);
ddi.singleton<MyService>(
MyServiceIos.new,
canRegister: () {
return Platform.isIOS && MyUserService.isAdmin();
},
);
ddi.singleton<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.
In contextual mode, a non-destroyable instance also blocks destroyContext(...) for that context tree.
Example Usage:
// Register an Application instance that is indestructible
ddi.application<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.application<PaymentService>(
CreditCardPaymentService.new,
qualifier: 'creditCard',
selector: (paymentMethod) => paymentMethod == 'creditCard',
);
// Registering PayPalPaymentService with a selector condition
ddi.application<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);
}
Required Dependencies #
The requires parameter allows you to explicitly declare which qualifiers or types must be registered before an instance can be created. This ensures that all necessary dependencies are available and ready before the factory attempts to create an instance.
Behavior:
- Singleton/Object: Validation occurs during
register(). If a dependency is not ready, it will be created automatically. - Application/Dependent: Validation occurs during
getWith()orgetAsyncWith(). For Application scope, dependencies are created if not ready. - If a required dependency is not registered, a
MissingDependenciesExceptionwill be thrown.
Example Usage:
// Register dependencies first
ddi.singleton<Database>(Database.new);
ddi.application<Logger>(Logger.new);
// Register a service that requires Database and Logger
ddi.application<MyService>(
MyService.new,
requires: {Database, Logger},
);
// MyService will only be created after Database and Logger are ready
final service = ddi.get<MyService>();
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.application<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;
MyClassName(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.singleton<MyClassName>(
() => MyClassName('DDI Example'),
);
// Destroying the instance (removing it from the container).
await ddi.destroy<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
singleton(() => Database('main_database'), qualifier: 'mainDatabase');
application(() => Logger(), qualifier: 'appLogger');
object('https://api.example.com', qualifier: 'apiUrl');
// Register a service with required dependencies
application(
() => ApiService(ddiContainer.get(qualifier: 'apiUrl')),
qualifier: 'apiService',
requires: {'mainDatabase', 'appLogger'},
);
dependent(() => TransientService(), qualifier: 'transientService');
}
}
Contextual Modules
If a module should isolate its internal registrations, override contextQualifier.
class ContextualAppModule with DDIModule {
@override
Object? get contextQualifier => moduleQualifier;
@override
Future<void> onPostConstruct() async {
await application<Logger>(Logger.new);
await application<ApiService>(
() => ApiService(ddiContainer.get<Logger>(context: contextQualifier)),
);
}
}
With this setup:
- Module children are registered in the module context instead of the root registry.
- The same bean type or qualifier can exist both globally and inside the module without collisions.
destroy()anddispose()keep using the module context for its children.- Module helper methods mirror the scope shortcuts: all support
requires, andapplication()also supportsuseWeakReference.
If the module itself belongs to an isolated DDI container, override ddiContainer
as well:
class IsolatedAppModule with DDIModule {
IsolatedAppModule(this._ddi);
final DDI _ddi;
@override
DDI get ddiContainer => _ddi;
}
DDIInject and DDIInjectAsync Mixins #
The DDIInject and DDIInjectAsync 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.
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();
}
}
Context Behavior Matrix #
The table below summarizes how each relevant API behaves with context.
| Method | Explicit context: supported? |
Automatic fallback? | Behavior without explicit context: |
Notes |
|---|---|---|---|---|
currentContext |
N/A | N/A | Returns the active context token | Active context is persistent until another context is activated or destroyed |
createContext(context) |
N/A | N/A | Creates/activates a named context | For default strategy, context must not already exist; also sets it as active |
destroyContext(context) |
N/A | N/A | Destroys the context tree (deepest to parent) | Blocked when any factory in tree has canDestroy: false; if active context is destroyed, it falls back to parent/root |
freezeContext(context) / unfreezeContext(context) / isContextFrozen(context) |
N/A | N/A | Operates on the named context | Frozen context blocks mutating operations only |
register(..., context: ...) and scope helpers (singleton, application, dependent, object) |
Yes | No | Registers in current context | With explicit context:, context must exist (ContextNotFoundException otherwise) |
getWith(..., context: ...) |
Yes | Yes | Resolves from current context | Fallback uses parent/root chain depending strategy |
getAsyncWith(..., context: ...) |
Yes | Yes | Resolves from current context | Fallback uses parent/root chain depending strategy |
get(...) / getAsync(...) |
Yes | Yes | Resolves from current context | Explicit context: resolves from that context and its fallback chain |
isRegistered(..., context: ...) |
Yes | Only when context is omitted |
Checks current context, then fallback when applicable | Explicit context: stays scoped to that context |
isReady(..., context: ...) / isFuture(..., context: ...) |
Yes | Only when context is omitted |
Checks current context, then fallback when applicable | Explicit context: stays scoped to that context |
getByType<T>(context?) |
Yes | No | Returns qualifiers from current context entries when omitted | Never falls back; explicit context is strict |
destroy(..., context: ...) |
Yes | Only when context is omitted |
Destroys in current context, with fallback when applicable | Explicit context: does not fallback |
destroyByType<T>(context?) |
Yes (positional) | No | Uses current context when omitted | With explicit context, affects only that context |
dispose(..., context: ...) |
Yes | Only when context is omitted |
Disposes in current context, with fallback when applicable | Explicit context: does not fallback |
disposeByType<T>(context?) |
Yes | No | Disposes entries from current context only when omitted | Never falls back; explicit context is strict |
addDecorator<T>(..., qualifier, context?) |
Yes | Explicit context: No. Omitted context: Yes |
Resolves target bean from current context lookup | Use explicit context to target one registry only |
addInterceptor<T>(..., qualifier, context?) |
Yes | Explicit context: No. Omitted context: Yes |
Resolves target bean from current context lookup | Use explicit context to target one registry only |
addChildrenModules<T>(..., qualifier, context?) |
Yes | Explicit context: No. Omitted context: Yes |
Resolves target module from current context lookup | Use explicit context to target one registry only |
getChildren<T>(qualifier, context?) |
Yes | Explicit context: No. Omitted context: Yes |
Resolves from current context lookup | Use explicit context for strict contextual read |
getInstance<T>(...) |
Yes | N/A (captures context token) | Captures current context at wrapper creation time | With context:, captures that context explicitly; later wrapper calls resolve against the captured context |