Draft: refactor: solve intitialization issues with Dependency injection
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