Dependency injection (DI)
Last updated
Was this helpful?
Last updated
Was this helpful?
Dependency injection is a powerful tool that enables Aurelia and its component model and can assist in building SOLID modular architectures in any object-oriented environment. In Aurelia, dependency injection underpins much of what you do in the framework when working with components and resources.
To learn about dependency injection and why you should use it, consult our section.
To declare a dependency on a plain class, you can use one of three techniques, depending on what JavaScript/TypeScript features you want to use.
You can declare your desired injected dependencies by adding a inject
static property to your class. The inject
property should reference an array of items to be injected, where each item in the array corresponds to a constructor argument.
In this case, we have a class designed to import files. The importer uses a file system abstraction (FileReader) and a logging abstraction (Logger) to do its job.
The importer declares these in its inject
array and then creates a constructor with the matching inputs. When the DI container instantiates the importer, it will locate or instantiate its dependencies and supply them to the constructor at runtime.
The order is important when using the static injection method. The order of dependencies in the array is how they will be passed through to the constructor.
Decorators are an upcoming feature of EcmaScript and an experimental feature in TypeScript. If you want to use decorators in your project, you must first opt into them by adding "experimentalDecorators": true
in the compilerOptions
section of your tsconfig.json
file. Once enabled, you can import the @inject
decorator from this library and use it to declare injected dependencies. Here's the same example from above, written with a decorator:
This decorator creates a static inject
property on the decorated class, with an array whose items correspond to the decorator's arguments. It's just a bit of syntax sugar over the base implementation. You could even write an app-specific decorator to accomplish the same result if desired.
If you have already opted into using TypeScript and decorators, you have an additional option: decorator metadata. The TypeScript compiler can emit metadata about the types of the constructor parameters automatically as part of the build process if a decorator is present. If this metadata is present, the DI container can use it instead of an explicit injection list.
To enable this feature for the compiler, add "emitDecoratorMetadata": true
in the compilerOptions
section of your tsconfig.json
file. With that in place, you can write the above code like this:
When the inject
decorator specifies no arguments; the DI container will attempt to look up the metadata that the TypeScript compiler has associated with the importer class. This approach is nice since it allows the constructor of the class to be the single source of truth for the information on what to inject.
Besides the experimental nature of this feature, there are some drawbacks to be aware of:
You can only use classes for the types of your constructor parameters. Interfaces don't exist at runtime, so using them, in this case, will result in missing metadata for the type at runtime. Using a symbol or other value won't work either. This constraint tends to be fairly limited in practice.
You cannot use custom resolvers. See the section below to learn about resolvers like all
and lazy
. Since these provide additional information to the DI about resolving the dependency, they cannot stand in as a type for the TypeScript compiler to store in metadata.
If you have either of the above scenarios, you'll want to use an explicit dependency list through the decorator or the static property.
An application typically has a single root-level DI container. To create a root container, call the DI.createContainer()
method:
Once you have a root-level container, you'll typically configure it with any services and then resolve your composition root, allowing you to kick off instantiation.
The DI container uses a technique called auto-registration. This means that if a class requests another class to be injected, and the requested class is not already registered with the container, the container will automatically register the class as a singleton, create the instance, and return it to the requestor.
Auto-registration works if everything is a singleton and you're using classes for dependencies everywhere and not using any interfaces or objects directly. That's very rarely the case, though. Additionally, you may want to declare the behavior of your services upfront, to make the code more understandable for other engineers. To do this, the DI container provides a registration API.
The primary API for registration is called register
, and it can be used in combination with the Registration
DSL. Here's an example:
With each registration, we provide a key and a value. The key is what consumers will use to request the dependency. The value is what the DI container will provide. The type of value you configure here depends on the registration type.
Below is a list of available options, along with explanations:
Registration.instance(key: any, value: any): IRegistration
- Creates a registration for an existing object instance with the container so that the specified key can look it up.
Registration.singleton(key: any, value: Function): IRegistration
- Creates a registration for a singleton. The value is a class that will be instantiated when first requested. The DI container will retain a reference to the created instance and will return the same instance to all subsequent requestors.
Registration.transient(key: any, value: Function): IRegistration
- Creates a registration for a transient instance. The value is a class that will be instantiated whenever requested. The container does not retain a reference to the created instance.
Registration.callback(key: any, value: ResolveCallback): IRegistration
- Creates a registration that enables custom instantiation and lifetime behavior. ResolveCallback
is defined as follows:
Provide a function with the above signature, and the container will invoke you each time it needs to resolve an instance. Note that the resolver
that is provided as the third parameter is the resolver associated with your factory. As such, should your resolver need to store the state used across requests, it can store that state on the resolver instance itself.
Registration.alias(originalKey: any, aliasKey: any): IRegistration
- Creates a registration that allows access to a previous registration via an additional name. The originalKey
is the key used in the original registration. The aliasKey
is the 2nd (or 3rd, 4th, etc.) key that you also want to be able to resolve the same behavior.
You will notice that each of these helper methods returns an IRegistration
implementation. The register
method of the container can handle anything that implements this interface, so you can create your own registration implementations. Here's what that interface looks like:
In most cases, the resolution will happen automatically through constructor injection. However, you'll typically need to manually resolve the root service to kick the whole thing off. Other scenarios may come up that require manual resolution. To resolve from a container, call the get
API on the container.
Here's an example:
If there are multiple different implementations for the same key, then you can resolve all of them with the getAll
API:
When using DI on other platforms, one would typically register services with an interface. However, TypeScript interfaces only exist at compile-time, not runtime, and the JavaScript language doesn't (currently) support interfaces. As a result, using an interface for the registration key cannot work. However, a feature of the TypeScript language lets you get around this limitation. Because TypeScript can merge definitions, it's possible to create an interface and a symbol with the same name. Here's an example:
When this technique is used, TypeScript, based on usage, can determine in which scenarios you want to use the interface and in which you want to use the Symbol. So, it can completely understand this code:
Warning You cannot use decorator metadata in this case because the TypeScript compiler cannot understand that it should encode the variable value into the metadata in place of the interface (which gets erased).
Because this is such a handy compiler capability, the DI API provides a helper method with some benefits. We can optionally rework the above code to use this API like so:
This is slightly more verbose, but it has the advantage that the symbol created by the DI.createInterface()
API "captures" the generic parameter, allowing it to work seamlessly with the get
APIs of the container providing strong return types when manually resolving with these symbols.
In many front-end scenarios, it's common to have a single default implementation of your interface, which you provide "out of the box". To facilitate this scenario, the DI.createInterface()
method accepts a callback that you can use to associate a default implementation with your interface.
The consumer who sets up the container can still specify their own interface implementation at runtime. Still, if one is not provided, it will fall back to the default implementation specified through this method. This allows the container to use auto-registration with the symbols created by the DI.createInterface()
helper.
The callback that creates the default registration. This API looks nearly identical to the Registration
DSL described above.
Note: For a deeper exploration of how to implement custom registrations and resolvers, that handles all the registration scenarios listed above.