Universal Dependency Injection
Thomas Burleson
Thomas Burleson is a Principal Architect and Technical Lead specializing in FE Web solutions using React and Angular.
Dependency injection is a solution in which a system supplies a target 1…n dependencies from external sources; rather than requiring the target to create those dependencies itself.
Dependencies are services, objects, functions, or values that a class (or factory, function) needs to perform its function.
Most developers think DI is only useful for testing with mocks, spys, and stubs. The true value of DI, however, is its ability to decouple origination (configuration, construction, and caching) from destination usages.
When we say injected, we mean used-by! The best way to provide injected instances is via the constructor (or function arguments). So when we say injected, we mean “passed in as constructor arguments”.
In our illustration above, we presented a `ContactsFacade`; which could be implemented in two ways:
Traditional Scenario
Consider the following coupling of components:
Without DI systems, developers interested in using an instance of ContactsFacade must 1st prepare instances of ContactsService and ContactsStore. Those instances are passed as construction arguments to ContactsFacade.
After construction, developers interested in sharing the same instance of a service or data model must consider how those instances will be cached, shared, and accessed.
This manual process is not required when a proper DI system is available.
Traditional DI in React
React provides a Context as a way to pass data through the component tree without having to pass props down manually at every level. This allows deep child components to easily lookup services (using Context.Consumer )… services that were previously provided higher in the DOM tree (using Context.Provider).
IMO, such constructs ^ also clutter the HTML markup and the DOM tree.
`useContext()` is another approach that provides programmatic lookups to services registered higher up the view hierarchy.
Lee Warrick recently wrote an article that discusses “Context API’s Dirty Little Secret”. His article emphasizes why the Context should not be used.
Seasoned developers may even consider Higher-Order Functions (HoF) to encapsulate configuration information (or services) that will be needed for future (deep-tree) use.
Unfortunately, none of these provide a true dependency injection infrastructure nor address the true intent of DI.
- These approaches do not address the goals of automating construction and decoupling construction dependencies.
- These solutions do not address the issues of how to test with mocks, etc.
A true dependency injection (DI) system easily solves these issues!
Universal DI: Requirements
A robust DI system allows provides the following features to developers.
Question: “How can we build a framework-agnostic DI system that can be used in React or raw JavaScript/TypeScript applications?”
A more universal solution will require the following features:
- Support construction & lookups using tokens
- Configure the DI system for custom application requirements
- Provide an easy way to access singleton (shared) instances
- Provide an easy way to create a non-shared, localized instance
- Provide an easy way to override/replace existing configurations
- Provide an easy way to extend existing configurations
Exploring the DI-Flow
Dependency injection uses a lookup process to determine HOW to create an object.
Using the concept of Provider(s) featured and documented in Angular, we can implement a platform-independent approach:
The Token (<token>) itself is simply an identifier object used to lookup the cached object instance. In most cases, the token is a specific Class. Sometimes, however, a class reference is not possible. The <token> could be a
- Class
- string (not unique)
- InjectionToken
Developers should use an InjectionToken whenever the type you are injecting is not reified (does not have a runtime representation); such as when injecting an interface, callable type, array or parameterized type.
Above ^ we see that the <token> is used as a lookup for the associated factory methods that will be used to create those instance… and the token is also used as a lookup for an existing instance (which may be internally cached as a singleton).
Developers should note the deps:any[] options that allow each factory to define subsequent dependencies that are needed for proper construction. (soon we will see how this is used)
Custom Configuring DI (for your needs):
If we consider [again] the ContactsFacade dependency tree:
This construction process is non-trivial and requires a tree-like instantiation process. So how can one easily configure construction relationships to allow programmatic DI that can:
- easily be used at any view level
- supports singleton instances
- supports override (non-singleton) instances
- supports multiple DI providers
We can easily build a custom injector instance using the makeInjector() factory. Below, we registered a set (1…n) of Contact Provider configurations to create a custom Contact injector.
The best part of the custom injector is that its on-demand (lazy) feature. When a developer uses `injector.get(<token>)`, the cache registry is first checked to see if the instance exists. If not, only then will the required factories be called to create and cache the instance.
DI with React Components
Using our custom injectors in the View layer is trivial: we import and then use the `injector.get(<token>)` syntax!
From the perspective of the ContactsList view component ^, the view is not concerned with the’how’, ‘when’, or ‘where’ the ContactsService was constructed or cached.
Instead the view component is only interested in the USE of the ContactsService API.
Using `useInjectorHook()`
Our use of the DI features in the View layer can be simplified even more if we build a custom Injection hook using the useInjectorHook() API (see line 11):
Now - within the View layer- the DI code is super simple (see line 14):
Consider an additional requirement to test the view component in isolation. This means testing will need to inject a fake or mock service ContactsService into the ContactsList view component.
With DI, it is super easy to replace a ‘real’ provider with a ‘mock’ provider:
With this replacement on lines 10–13 above, the existing Provider configurations (defined in ./contacts) will be overridden.
Future requests for an injected ContactsService instance will then deliver only a mock ContactsService.
Considerations
The astute reader will realize that both of the following are required to build custom injectors:
- `makeInjector()`, and
- `import {injector} from '….'`
These two (2) manual steps are required since a DI system has not been architected within React like it is in Angular!
Fortunately, this DI solution that can be used in any view component, at any view-depth, or within any (non-view) service! The manual part is a small investment of effort that yields huge ROI with true dependency injection.
Using the Library
Install the DI features into your project using:
npm install @mindspace-io/utils --save
To configure your own custom injector, just just import the DI features:
import { makeInjector, DependendecyInjector, InjectionToken } from '@mindspace-io/utils';
Don’t forget to build a custom hook to make DI lookups super easy!