Skip to content

Latest commit

 

History

History
269 lines (206 loc) · 11.7 KB

README.md

File metadata and controls

269 lines (206 loc) · 11.7 KB

Dependency injection

This is a simple dependency injection system for TypeScript. This is a generic system that can be used in any TypeScript project. A class can be labeled as a singleton, transient or scoped. The system will automatically create the class and inject the dependencies as needed.

Table of contents

How to use

Use TypeScript's decorators to label a class. You can use the following decorators

Name Used for
Singleton Only one instance of the class will be created
Transient Every time the dependency is used, a new instance will be created
Scoped A new instance will be created for each scope

Note: There is support for scoped dependencies, however the scopes are not managed by this system. The developer must implement the scope management. An example of a scope manager for Sveltekit can be found below.

import { Singleton } from "./dependency-injection";

@Singleton
class MyService {
  // Implementation of MyService
}

After that, just import the file that contains the class and the IOC will automatically load it.

import "./services/MyService";

Injecting dependencies

Inside another class, use the Inject decorator on a property to automatically inject the dependency.

import { Inject } from "./dependency-injection";

class SomeClass {
  @Inject("MyService") private myService: IMyService;
}

Note: The string passed to the Inject decorator must match the name of the class that was labeled with the decorator.

Add support for decorators in tsconfig.json

Add the following lines to your tsconfig.json file to enable support for decorators.

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Testing dependencies

After importing all the classes used in the project, you can let the IOC container test all the dependency references. You can find an example below.

import { IOCContainer } from "./dependency-injection";
const result = IOCContainer.testDependencyReferences();

if (!result.valid) {
  console.error("Dependency resolution test failed.");
  for (const error of result.errors) {
    console.error(error);
  }
  process.exit(1);
}

Scope Manager

By default, scopes are handled the same as transient dependencies. After resolving a dependency using IOCManager#resolve, you can call IOCManager#registerScope with the resolved class instance and a method to resolve dependencies. This method will be used to resolve dependencies for the class instance.

Here is a really simple example of a scope manager. This manager does not store the class instances in the scope. For a full example, take a look at the Scope Manager example for Sveltekit.

function resolve<T>(dependency: string): T {
  // Resolve the dependency using the IOC Container
  const resolvedDependency = IOCContainer.resolve<T>(dependency);

  // We need to tell the IOC Container about the scope we just created
  // When the dependency tries to access a sub dependency, it will use the resolve method we just added
  IOCContainer.registerScope(resolvedDependency, resolve);
}

In most cases, a Scope Manager should follow the following steps.

Flowchart of a Scope Manager

Example

Example Scope Manager for Sveltekit

This is an example of a Scope Manager for Sveltekit. This manager will create a new instance of the class for each request.

hooks.server.ts

  // For every request, the method below is called. A new scope will be created for each request. After the request is handled, the scope is deleted.
  export async function handle({ resolve, event }) {
    // Create a new map to store scoped dependencies
    const scopedDependencies: Map<string, any> = new Map();

    // Add a resolve method to locals to resolve within the current scope
    event.locals.resolve = function<T>(dependency: string) {
      // First, check if the dependency is already resolved in the current scope
      if (scopedDependencies.has(dependency)) {
        return scopedDependencies.get(dependency);
      }

      // Resolve the dependency
      const resolvedDependency = IOCContainer.resolve<T>(dependency);

      // Get the injection type of the dependency
      const isScopedDependency = IOCContainer.getInjectionType(dependency) === "Scoped";

      // The dependency is scoped, we need to store the current dependency and register the scope
      if (isScopedDependency) {
        // For scoped dependencies, we need to store the current dependency and register the scope
        scopedDependencies.set(dependency, resolvedDependency);

        // Lastly, we need to tell the IOC Container about the scope we just created
        // When the dependency tries to access a sub dependency, it will use the resolve method we just added
        IOCContainer.registerScope(resolvedDependency, event.locals.resolve);
      }

      // Now, we can return the resolved dependency
      return resolvedDependency;
    }

    // Resolve the event
    const result = await resolve(event);

    // Clean up the dependency scope
    IOCContainer.cleanScope(scopedDependencies.keys());
    scopedDependencies.clear();

    // Done with the request
    return result;
  }

app.d.ts

  // This is only needed to add the resolve method type definition to the event.locals object
  declare namespace App {
    interface Locals {
      resolve<T>(dependency: string): T;
    }
  }

This Scope Manager follows the following steps.

Flowchart of an example Scope Manager

API

UML of the project

Types

  • Constructor<T = {}>
    Base type for a class constructor that results in a class instance.

  • DependencyInjectionType
    The type of dependency injection. Can be "Singleton", "Transient" or "Scoped".

  • DependencyResolutionResult
    Object with two keys.

    • valid: boolean
      True if all dependency references are valid.
    • errors: string[]
      Array of possible error messages if the dependency references are not valid.

IOCContainer

Note: The only methods you would use are registerScope, cleanScope and testDependencyReferences. The other methods should only be used internally. All methods are static

  • register<T>(name: string, injectionType: DependencyInjectionType, implementation: Constructor<T>)
    Register a class with the IOC container.
    This method is automatically called when using @Singleton, @Transient or @Scoped

  • resolve<T>(token: string): T
    Resolve a dependency from the IOC container.
    Scoped and transient dependencies will be created when calling this method.
    Singletons will automatically be stored in the container.
    This method is automatically called when using @Inject
    Can throw DependencyInjectionError

  • getInjectionType(token: string): DependencyInjectionType | null
    Get the injection type of a dependency or null if the dependency is not registered.

  • registerScope(instance: any, resolve: (dependency: string) => any)
    Register a scope for a class instance.
    This method should be used in your Scope Manager

  • resolveScope<T>(instance: any, dependency: string): T | null
    Resolve a dependency within the scope of the given instance.
    This method is automatically called when using @Inject

  • cleanScope(dependencies: Iterable<string>)
    Clean up the scope of the given dependencies.
    This method should be used in your Scope Manager

  • addDependencyReference(token: string, reference: string)
    Add a reference to a dependency.
    This method is automatically called when using @Inject

  • testDependencyReferences(): DependencyResolutionResult
    Test all dependency references.
    Only call after importing all files to prevent false positives

How it works

The system relies on TypeScript's decorators. A class can be labeled to be used in Dependency Injection. When the class is labeled properly, the class is added to the IOC Container.

IOC Container

The IOC Container (Inversion Of Control) controls all the dependencies. It allows for dependencies to be registered and to be resolved. Depending on the type, the behavior of the IOC will change.

In the implementation, the IOC Container has a map of the names and implementations. Because everything within the IOC Container is static, there is always only one instance of the container.

Here is a flowchart of how the class decorator works.
Flowchart of the class decorator

Singletons

A singleton is a class that will only be created once. The same instance is used every time the dependency is resolved. Because of this, singletons can be used to store state.
When the IOC is requested to resolve a singleton, it will first look at the stored instances. If the instance is not found, a new instance will be created and stored.

Transient and Scoped

The IOC handles transient and scoped dependencies the same way. Every time the dependency is resolved, a new instance is created.

The IOC provides a method to register a scope for a class instance. This scope can then be used by the Inject decorator to resolve dependencies within the scope of the class instance. The Scope Manager is expected to store class instances and clean everything up when the scope is no longer needed.

Injecting

When a property in a class is labeled with the Inject decorator, the IOC will try to resolve the dependency when the property is accessed. The decorator will automatically call the Scope Manager when it detects a scoped dependency. The Scope Manager will then resolve the dependency within the scope.

What happens in the injector What happens when resolving a dependency
Flowchart of the property decorator Flowchart of resolving an dependency

Tokens

Because of the limitations in JavaScript, the type of a property is not known in runtime. To solve this, a token is used to identify the class. When using @Singleton, @Transient or @Scoped, the class name is used as the token. Because of this, it's not possible to have two classes with the same name. The dependency injection system will throw an error when it detects two classes with the same name.

When using @Inject, the token is the string passed to the decorator. It's also possible to create an enum as a type and use the enum as the token. This can prevent typos in the token string.

enum Tokens {
  MyService
}

class SomeClass {
  @Inject(Tokens.MyService) private myService: IMyService;
}