Skip to content

Draft: refactor: solve intitialization issues with Dependency injection

Tomas Vik (OOO back on 2024-05-09) requested to merge tv/2024-03/implement-di into main

Description

This is a POC that fixes a long-lived technical issue - the extension initialization. Curretnly it's a mess where we rely on creating singletons and calling their init methods in the extension main file. This sometimes becomes problematic when you need to carefully insert a dependency B that requires A, but it needs to be initialized before C. This involves trial and error and in the resulting procedural code the A <- B <- C dependency is not clear.

This MR is not ready for merging. It only has enough code to agree on the interfaces. I can add tests and migrate a few singletons when I know we are happy with this format.

Proposal

The Goal: Create a simple way to declaratively define how our "singletons" depend on each other and what order they should initialized in

Non-goals:

  • implement a full dependency injection framework
  • use DI for every aspect of the extension
  • Use TS decorators and other heavy-weight features
  • support multiple implementations for an interface
  • manipulating implementations (replace/remove) after container is initialized

I had a look at the two most popular libraries:

They are heavyweight for what we need.

I think our usecase is simple enough that we can create our dependency container implementation. The implementation will be between 50 and 100 LOC and the Container interface has 3 methods.

I propose the following set of interfaces:

/** Identifier of an interface. This identifier contains the interface type to aid TS with resolving the type when we call `container.get()`
 Example:
 ```ts`
  export interface ContextProvider extends Implementation {
    getContext(): vscode.ExtensionContext;
  }

  export const ContextProvider = 'ContextProvider' as InterfaceId<ContextProvider>;
  ```
*/
export type InterfaceId<T extends Implementation<any>> = string & { __type: T };

/**
 * The `Implementation` interface needs to be implemented by every object managed by the Container.
 */
export interface Implementation<TDependencies extends Implementation[] = []> {
  /** InterfaceId is used to uniquely identify an object implements the interface in the container */
  implements: InterfaceId<any>;

  /**
   * Dependencies that have to be initialized before the container can initialize this `Implementation`.
   * The expectation is that the init method needs these dependencies or this `Implementation`.
   */
  dependencies?: { readonly [K in keyof TDependencies]: InterfaceId<TDependencies[K]> };

  /** This method initialized the `Implementation`, be careful, it will hold the container initialization until it finishes. If you want to make API queries, consider using vscode.Event to notify dependencies. */
  init?: (...deps: TDependencies) => Promise<void>;
}

/** Container provides way to specify dependency hierarchy, initialize and retrieve the dependencies. */
export interface Container {
  /** Adds implementation to the container. Throws error if there is already an implementation added for the same InterfaceId */
  add(d: Implementation): void;
  /** Retrieves initialized implementation. Throws error if the InterfaceId implementation is missing or not initialized */
  get<T extends Implementation>(id: InterfaceId<T>): T;
  /** Initializes all Implementations so that for every Implementation, its dependencies are initialized beforehand */
  init(): Promise<void>;
}

The usage then looks like this:

Defining an Implementation

export interface ContextProvider extends Implementation {
  getContext(): vscode.ExtensionContext;
}

export const ContextProvider = 'ContextProvider' as InterfaceId<ContextProvider>;

export class DefaultContextProvider implements ContextProvider {
  implements = ContextProvider;

  // implementation of ContextProvider interface (the real business logic)
}

export interface AccountService extends Implementation<[ContextProvider]> {}

export class DefaultAccountService implements AccountService {
  implements = AccountService;
  dependencies = [ContextProvider] as const;

  async init(contextProvider: ContextProvider): Promise<void> {}
}

Initialization and retrieving instances

  container.add(new DefaultContextProvider(context));
  container.add(new DefaultAccountService());
  await container.init().catch(e => {
    handleError(e);
    throw e;
  });
  const accountService = container.get(AccountService)

Related Issues

Resolves #653

Edited by Tomas Vik (OOO back on 2024-05-09)

Merge request reports