Head First Dependency Injection
In this advanced article, we introduce a dependency injection library used internally in AFFiNE. It’s important to note that AFFiNE has not fully adopted the dependency injection pattern described here yet. This article won’t cover “how to add xxx feature in AFFiNE.” For that, please refer to other documents.
To meet the modularization needs of AFFiNE, we implemented a dependency injection library with the following features:
-
Comprehensive TypeScript type checking
-
Support for multi-layer scope
-
Lazy instantiation by default
-
Supports optional and variant dependency injections
-
Facilitates writing unit tests
Basic Usage
First, create a top-level dependency injection container,ServiceCollection
:
const services = new ServiceCollection()
Then, implement two services,ServiceA
andServiceB
, whereServiceB
depends onServiceA
:
class ServiceA {
}
class ServiceB {
serviceA: ServiceA
constructor(serviceA: ServiceA) {
this.serviceA = serviceA
}
}
Next, register these two services inServiceCollection
:
services.add(ServiceA)
services.add(ServiceB, [ServiceA]) // [ServiceA] declares the dependency on ServiceA
Finally, create aServiceProvider
and instantiate the services you need.ServiceProvider
will automatically construct services based on the declared dependencies:
const provider = services.provider() // Create ServiceProvider
const instance = provider.get(ServiceB) // Instantiate ServiceB
expect(instance instanceof ServiceB).toBe(true)
expect(instance.serviceA instanceof ServiceA).toBe(true)
Dependency injection can be divided into two phases: registration and instantiation, corresponding toServiceCollection
andServiceProvider
, respectively.
Registering Services
There are two methods to register services onServiceCollection
:
const services = new ServiceCollection()
services.add(SomeClass, [deps...])
services.addImpl(SomeIdentifier, Impl, [deps...])
We will introduce the detailed usage of these two methods.
Using add
The simplest way to register a service is by using add
to register a JS class as a service:
services.add(SomeClass, [deps...])
// ^ JS class ^ Array of dependencies, matching the constructor parameters of the class
// If the JS class has no dependencies, you can omit it:
services.add(SomeClass)
Using ServiceIdentifier
A ServiceIdentifier
is used to identify a type of service, allowing you to reference one or more services without knowing the specific implementation. This achieves inversion of control.
Example: Defining an identifier named Storage
// define a interface
interface Storage {
get(key: string): string | null;
set(key: string, value: string): void;
}
// create a identifier
const Storage = createIdentifier<Storage>('Storage');
identifier usually corresponds to a TypeScript interface. Highly recommend to use the interface name as the identifier name, so that it is easy to understand. and it is legal to do so in TypeScript.
Then, write aLocalStorage
implementation forStorage
:
class LocalStorage implements Storage {
get(key: string): string | null {
return localStorage.getItem(key);
}
set(key: string, value: string): void {
localStorage.setItem(key, value);
}
}
Register LocalStorage as an implementation of Storage
services.addImpl(Storage, LocalStorage);
// If LocalStorage has dependencies:
services.addImpl(Storage, LocalStorage, [deps...]);
// ^ dependencies
In your code, you just need to declare a dependency on the Storage Identifier:
class SomeService {
constructor(private storage: Storage) {}
dowork() {
this.storage.set('foo', 'bar')
}
}
services.add(SomeService, [Storage])
^ dependency on Storage, LocalStorage will be passed in
With identifier:
-
You can easily replace the implementation of a
Storage
without changing the code that uses it. -
You can easily mock a
Storage
for testing.
Variants
Sometimes, you might want to register multiple implementations for the same interface.
For example, you can register both LocalStorage
and SessionStorage
for Storage
,
and use them in same time.
In this case, you can use variant
to distinguish them.
const Storage = createIdentifier<Storage>('Storage');
const LocalStorage = Storage('local');
const SessionStorage = Storage('session');
services.addImpl(LocalStorage, LocalStorageImpl);
services.addImpl(SessionStorage, SessionStorageImpl);
In your code, service can depend on one or more variants:
class StorageManager {
constructor(
oneStorage: Storage,
manyStorage: Storage[]
)
}
services.add(StorageManager, [
Storage("local"),
// ^^^ Declare a dependency on the "local" variant of `Storage`
[Storage]
// ^^^ Declare dependencies on all variants of `Storage`
])
Value as Service
You can also register any JS Value/Object as an implementation of an identifier:
services.addImpl(Storage, {
get: (key) => localStorage.getItem(key),
set: (key, value) => localStorage.setItem(key, value)
})
Using Factory Functions
You can use factory functions for dynamic dependency items. Example: dynamically getting UserService
based on login status:
It is not recommended to implement side effects in factory functions because it is difficult to predict when they will run.
services.addImpl(Storage, (provider) => { // use provider to get other services
const settings = provider.get(Setting);
let savePath = settings.logined ?
provider.get(UserService).userDir :
settings.defaultSaveDir;
return new LocalStorage(savePath);
})
Bulk Registration of Services
As the number of services in the application increases, we recommend defining a configure function in each module for bulk service registration. For example:
// infra/services.ts
function configureInfra(services: ServiceCollection) {
services.add(Storage);
services.add(Settings);
...
}
// web/services.ts
function configureWebInfra(services: ServiceCollection) {
services.add(WebStorage)
services.add(WebNotification)
...
}
// business/services.ts
function configureBusinessServices(services: ServiceCollection) {
services.add(SomeFeatureService)
...
}
Finally, we call the configure functions at the program entry:
// web/main.ts
const services = new ServiceCollection();
configureInfra(services);
configureWebInfra(services);
configureBusinessServices(services);
Bulk registration improves readability and testability.
Overriding Definitions
By default, a service can only be defined once, and attempting to define it again will cause a DuplicateServiceDefinitionError
.
When writing tests, we may need to overwrite some services, and this is when the override
method can be used.
const services = new ServiceCollection();
configureBusinessServices(services); // <- Bulk register some services
// Override Storage with MockStorage
services.override(Storage, MockStorage)
// Or with dependencies
services.override(Storage, MockStorage, [...deps])
// Also override JS class
services.override(OldClass, NewClass, [...deps])
Instantiating Services
After registering all required services inServiceCollection
, create aServiceProvider
:
const services = new ServiceCollection();
services.add(...) // Register some services
const provider = services.provider() // Create ServiceProvider
Instantiate services withprovider.get
:
// Instantiate a class service
const classInstance = provider.get(ClassA)
// Can also implementation of an identifier
const identifierImpl = provider.get(identifier)
By using provider.getAll
, you can instantiate all variants at once
const storages = provider.getAll(Storage) // Instantiates all Storage
// ^ Returns a Map<VariantName, Storage>
Dynamic Dependencies
Services can directly depend onServiceProvider
for dynamic service retrieval, allowing for service lazy loading and avoiding circular references:
class ServiceA {
doA() {
}
}
class ServiceB {
constructor(private serviceProvider: ServiceProvider) {
}
dowork() {
const a = this.serviceProvider.get(ServiceA) // ServiceA is instantiated here
a.doA()
}
}
services.add(ServiceA)
services.add(ServiceB, [ServiceProvider])
Scope
The concept of “scope” in dependency injection is pivotal for managing the lifecycle and visibility of a service instance.
In AFFiNE, we support a multi-layered scope structure, such as Root -> Workspace -> Page.
The Root Scope is the default and is always present, we need to declare Workspace and Page scopes separately.
To declare a scope, usecreateScope
:
const WorkspaceScope = createScope('Workspace')
// PageScope inherits from WorkspaceScope
const PageScope = createScope('Page', WorkspaceScope)
// ^ Parent scope
This allows us to specify the scope of a service before declaring it:
services
.add(RootService)
.scope(WorkspaceScope)
.add(WorkspaceService)
.scope(PageScope)
.add(PageService);
During the instantiation, each scope requires its own provider:
// Default root scope, contains only RootService
const rootProvider = services.provider()
// Contains WorkspaceService
const workspaceProvider = services
.provider(
WorkspaceScope,
// ^ Scope of the Provider
rootProvider
// ^ Parent scope's Provider
)
// Scopes can also have multiple instances
const pageProvider1 = services.provider(PageProvider, workspaceProvider)
const pageProvider2 = services.provider(PageProvider, workspaceProvider)
Since all service instances are stored within the provider, to release a scope and its instances, simply stop referencing that scope in your code. The JavaScript engine will garbage collect (GC) all instances within that scope.
Regarding dependency relations, services in a child scope can depend on services in a parent scope, but not the other way around. Within the same scope, there are no restrictions:
services
.add(RootService)
.scope(WorkspaceScope)
.add(WorkspaceService, [RootService])
// ^ Can depend on services in the parent scope
.add(WorkspaceService, [PageService])
// ^ Error: Cannot depend on a child scope
.scope(PageScope)
.add(PageService);
React Integration
To integrate dependency injection into a React application, begin by adding a ServiceProviderContext.Provider
at the top level of your application and pass in the ServiceProvider
you’re using.
const services = new ServiceCollection()
services.add(...)
const provider = services.provider()
<ServiceProviderContext.Provider value={provider}>
<YouComponent>
</ServiceProviderContext.Provider>
Within your components, useuseService
oruseServiceOptional
hooks to obtain instances of services.
const a = useService(ServiceA)
const maybeB = useServiceOptional(ServiceB)
The useService
hook fetches a required service instance, ensuring the service is available. In contrast, useServiceOptional
is used for services that might not be available or necessary.