Introduction

Dependency Injection is atechnique where the construction of dependencies of a class or function isseparated from its behavior, in order to keep the codeloosely coupled.

For example, the Sequence Action authenticate in @loopback/authenticationsupports different authentication strategies (e.g. HTTP Basic Auth, OAuth2,etc.). Instead of hard-coding some sort of a lookup table to find the rightstrategy instance, the authenticate action uses dependency injection to letthe caller specify which strategy to use.

The implementation of the authenticate action is shown below.

  1. export class AuthenticateActionProvider implements Provider<AuthenticateFn> {
  2. constructor(
  3. // The provider is instantiated for Sequence constructor,
  4. // at which time we don't have information about the current
  5. // route yet. This information is needed to determine
  6. // what auth strategy should be used.
  7. // To solve this, we are injecting a getter function that will
  8. // defer resolution of the strategy until authenticate() action
  9. // is executed.
  10. @inject.getter(AuthenticationBindings.STRATEGY)
  11. readonly getStrategy: Getter<AuthenticationStrategy>,
  12. @inject.setter(AuthenticationBindings.CURRENT_USER)
  13. readonly setCurrentUser: Setter<UserProfile>,
  14. ) {}
  15. /**
  16. * @returns AuthenticateFn
  17. */
  18. value(): AuthenticateFn {
  19. return request => this.action(request);
  20. }
  21. /**
  22. * The implementation of authenticate() sequence action.
  23. * @param request - The incoming request provided by the REST layer
  24. */
  25. async action(request: Request): Promise<UserProfile | undefined> {
  26. const strategy = await this.getStrategy();
  27. if (!strategy) {
  28. // The invoked operation does not require authentication.
  29. return undefined;
  30. }
  31. const userProfile = await strategy.authenticate(request);
  32. if (!userProfile) {
  33. // important to throw a non-protocol-specific error here
  34. let error = new Error(
  35. `User profile not returned from strategy's authenticate function`,
  36. );
  37. Object.assign(error, {
  38. code: USER_PROFILE_NOT_FOUND,
  39. });
  40. throw error;
  41. }
  42. this.setCurrentUser(userProfile);
  43. return userProfile;
  44. }
  45. }

Dependency Injection makes the code easier to extend and customize, because thedependencies can be easily rewired by the application developer. It makes thecode easier to test in isolation (in a pure unit test), because the test caninject a custom version of the dependency (a mock or a stub). This is especiallyimportant when testing code interacting with external services like a databaseor an OAuth2 provider. Instead of making expensive network requests, the testcan provide a lightweight implementation returning pre-defined responses.

Configure what to inject

Now that we write a class that gets the dependencies injected, you are probablywondering where are these values going to be injected from and how to configurewhat should be injected. This part is typically handled by an IoC Container,where IoC meansInversion of Control.

In LoopBack, we use Context to keep track of all injectabledependencies.

There are several different ways for configuring the values to inject, thesimplest options is to call app.bind(key).to(value).

  1. export namespace JWTAuthenticationStrategyBindings {
  2. export const TOKEN_SECRET = BindingKey.create<string>(
  3. 'authentication.strategy.jwt.secret',
  4. );
  5. export const TOKEN_EXPIRES_IN = BindingKey.create<string>(
  6. 'authentication.strategy.jwt.expires.in.seconds',
  7. );
  8. }
  9. ...
  10. server
  11. .bind(JWTAuthenticationStrategyBindings.TOKEN_SECRET)
  12. .to('myjwts3cr3t');
  13. server
  14. .bind(JWTAuthenticationStrategyBindings.TOKEN_EXPIRES_IN)
  15. .to('600');

However, when you want to create a binding that will instantiate a class andautomatically inject required dependencies, then you need to use .toClass()method:

  1. server.bind(TokenServiceBindings.TOKEN_SERVICE).toClass(TokenService);
  2. const tokenService = await server.get(TokenServiceBindings.TOKEN_SERVICE);
  3. // tokenService is a TokenService instance

When a binding is created via .toClass(), Context will create anew instance of the class when resolving the value of this binding, injectingconstructor arguments and property values as configured via @inject decorator.

Note that the dependencies to be injected could be classes themselves, in whichcase Context will recursively instantiate these classes first,resolving their dependencies as needed.

In this particular example, the class is aProvider. Providers allow you to customize theway how a value is created by the Context, possibly depending on other Contextvalues. A provider is typically bound using .toProvider() API:

  1. app
  2. .bind(AuthenticationBindings.AUTH_ACTION)
  3. .toProvider(AuthenticateActionProvider);
  4. const authenticate = await app.get(AuthenticationBindings.AUTH_ACTION);
  5. // authenticate is the function returned by provider's value() method

You can learn more about Providers inCreating Components.

Flavors of Dependency Injection

LoopBack supports three kinds of dependency injection:

  • constructor injection: the dependencies are provided as arguments of theclass constructor.
  • property injection: the dependencies are stored in instance properties afterthe class was constructed.
  • method injection: the dependencies are provided as arguments of a methodinvocation. Please note that constructor injection is a special form ofmethod injection to instantiate a class by calling its constructor.

Constructor injection

This is the most common flavor that should be your default choice.

  1. class ProductController {
  2. constructor(@inject('repositories.Product') repo) {
  3. this.repo = repo;
  4. }
  5. async list() {
  6. return this.repo.find({where: {available: true}});
  7. }
  8. }

Property injection

Property injection is usually used for optional dependencies which are notrequired for the class to function or for dependencies that have a reasonabledefault.

  1. class InfoController {
  2. @inject('logger', {optional: true})
  3. private logger = ConsoleLogger();
  4. status() {
  5. this.logger.info('Status endpoint accessed.');
  6. return {pid: process.pid};
  7. }
  8. }

Method injection

Method injection allows injection of dependencies at method invocation level.The parameters are decorated with @inject or other variants to declaredependencies as method arguments.

  1. class InfoController {
  2. greet(@inject(AuthenticationBindings.CURRENT_USER) user: UserProfile) {
  3. return `Hello, ${user.name}`;
  4. }
  5. }

Optional dependencies

Sometimes the dependencies are optional. For example, the logging level for aLogger provider can have a default value if it is not set (bound to thecontext).

To resolve an optional dependency, set optional flag to true:

  1. const ctx = new Context();
  2. await ctx.get('optional-key', {optional: true});
  3. // returns `undefined` instead of throwing an error

Here is another example showing optional dependency injection using propertieswith default values:

  1. // Optional property injection
  2. export class LoggerProvider implements Provider<Logger> {
  3. // Log writer is an optional dependency and it falls back to `logToConsole`
  4. @inject('log.writer', {optional: true})
  5. private logWriter: LogWriterFn = logToConsole;
  6. // Log level is an optional dependency with a default value `WARN`
  7. @inject('log.level', {optional: true})
  8. private logLevel: string = 'WARN';
  9. }

Optional dependencies can also be used with constructor and method injections.

An example showing optional constructor injection in action:

  1. export class LoggerProvider implements Provider<Logger> {
  2. constructor(
  3. // Log writer is an optional dependency and it falls back to `logToConsole`
  4. @inject('log.writer', {optional: true})
  5. private logWriter: LogWriterFn = logToConsole,
  6. // Log level is an optional dependency with a default value `WARN`
  7. @inject('log.level', {optional: true}) private logLevel: string = 'WARN',
  8. ) {}
  9. }

An example of optional method injection, where the prefix argument isoptional:

  1. export class MyController {
  2. greet(@inject('hello.prefix', {optional: true}) prefix: string = 'Hello') {
  3. return `${prefix}, world!`;
  4. }
  5. }

Additional inject.* decorators

There are a few special decorators from the inject namespace.

Circular dependencies

LoopBack can detect circular dependencies and report the path which leads to theproblem.

Consider the following example:

  1. import {Context, inject} from '@loopback/context';
  2. interface Developer {
  3. // Each developer belongs to a team
  4. team: Team;
  5. }
  6. interface Team {
  7. // Each team works on a project
  8. project: Project;
  9. }
  10. interface Project {
  11. // Each project has a lead developer
  12. lead: Developer;
  13. }
  14. class DeveloperImpl implements Developer {
  15. constructor(@inject('team') public team: Team) {}
  16. }
  17. class TeamImpl implements Team {
  18. constructor(@inject('project') public project: Project) {}
  19. }
  20. class ProjectImpl implements Project {
  21. constructor(@inject('lead') public lead: Developer) {}
  22. }
  23. const context = new Context();
  24. context.bind('lead').toClass(DeveloperImpl);
  25. context.bind('team').toClass(TeamImpl);
  26. context.bind('project').toClass(ProjectImpl);
  27. try {
  28. // The following call will fail
  29. context.getSync('lead');
  30. } catch (e) {
  31. console.error(e.toString());
  32. }

When the user attempts to resolve “lead” binding, LoopBack detects a circulardependency and prints the following error:

  1. Error: Circular dependency detected:
  2. lead --> @DeveloperImpl.constructor[0] -->
  3. team --> @TeamImpl.constructor[0] -->
  4. project --> @ProjectImpl.constructor[0] -->
  5. lead

Dependency injection for bindings with different scopes

Contexts can form a chain and bindings can be registered at different levels.The binding scope controls not only how bound values are cached, but also howits dependencies are resolved.

Let’s take a look at the following example:

binding-scopes

The corresponding code is:

  1. import {inject, Context, BindingScope} from '@loopback/context';
  2. import {RestBindings} from '@loopback/rest';
  3. interface Logger() {
  4. log(message: string);
  5. }
  6. class PingController {
  7. constructor(@inject('logger') private logger: Logger) {}
  8. }
  9. class MyService {
  10. constructor(@inject('logger') private logger: Logger) {}
  11. }
  12. class ServerLogger implements Logger {
  13. log(message: string) {
  14. console.log('server: %s', message);
  15. }
  16. }
  17. class RequestLogger implements Logger {
  18. // Inject the http request
  19. constructor(@inject(RestBindings.Http.REQUEST) private req: Request) {}
  20. log(message: string) {
  21. console.log('%s: %s', this.req.url, message);
  22. }
  23. }
  24. const appCtx = new Context('application');
  25. appCtx
  26. .bind('controllers.PingController')
  27. .toClass(PingController)
  28. .inScope(BindingScope.TRANSIENT);
  29. const serverCtx = new Context(appCtx, 'server');
  30. serverCtx
  31. .bind('my-service')
  32. .toClass(MyService)
  33. .inScope(BindingScope.SINGLETON);
  34. serverCtx.bind('logger').toClass(ServerLogger);

Please note that my-service is a SINGLETON for the server context subtreeand it expects a logger to be injected.

Now we create a new context per request:

  1. const requestCtx = new Context(serverCtx, 'request');
  2. requestCtx.bind('logger').toClass(RequestLogger);
  3. const myService = await requestCtx.get<MyService>('my-service');
  4. // myService.logger should be an instance of `ServerLogger` instead of `RequestLogger`
  5. requestCtx.close();
  6. // myService survives as it's a singleton

Dependency injection for bindings in SINGLETON scope is resolved using theowner context instead of the current one. This is needed to ensure that resolvedsingleton bindings won’t have dependencies from descendant contexts, which canbe closed before the owner context. The singleton cannot have danglingreferences to values from the child context.

The story is different for PingController as its binding scope is TRANSIENT.

  1. const requestCtx = new Context(serverCtx, 'request');
  2. requestCtx.bind('logger').toClass(RequestLogger);
  3. const pingController = await requestCtx.get<PingController>(
  4. 'controllers.PingController',
  5. );
  6. // pingController.logger should be an instance of `RequestLogger` instead of `ServerLogger`

A new instance of PingController is created for each invocation ofawait requestCtx.get<PingController>('controllers.PingController') and itslogger is injected to an instance of RequestLogger so that it can loginformation (such as url or request-id) for the request.

The following table illustrates how bindings and their dependencies areresolved.

CodeBinding ScopeResolution ContextOwner ContextCache ContextDependency
requestCtx.get(‘my-service’)SINGLETONrequestCtxserverCtxserverCtxlogger -> ServerLogger
serverCtx.get(‘my-service’)SINGLETONserverCtxserverCtxserverCtxlogger -> ServerLogger
requestCtx.get(‘controllers.PingController’)TRANSIENTrequestCtxappCtxN/Alogger -> RequestLogger

Additional resources