Docs

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,ServiceAandServiceB, whereServiceBdepends 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 aServiceProviderand instantiate the services you need.ServiceProviderwill 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 toServiceCollectionandServiceProvider, 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 aLocalStorageimplementation 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 onServiceProviderfor 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, useuseServiceoruseServiceOptionalhooks 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.