Force object instantiation after binding (DI/inversify)

Hey Community

I try to create an instance of a service object right after binding. It might be an XY Problem so I`ll try to describe what i want to achieve.

I have an AssignmentModelService on the browser side which acts as a presentation model. This service communicates with the node backend and emits events on state changes. Widgets register listeners and update the presentation. Nothing special, I guess. Now it´s the case that I have event listeners which do not belong to any of the widgets. For this purpose I created an CommonEventHandlerService:

// my-extension/src/browser/common-event-handler/common-event-handler-service.ts
@injectable()
export class CommonEventHandlerService {

  @inject(WorkspaceService)
  protected readonly workspaceService: WorkspaceService;
  @inject(EditorManager)
  protected readonly editorManager: EditorManager;
  @inject(AssignmentModelService)
  private readonly assignmentModelService: AssignmentModelService;

  @postConstruct()
  protected init(): void {
    // Register AssignmentModelChangeEvent listener
    this.assignmentModelService.onDidStateChange(event => {
      event.assignmentDidChange && this.onAssignmentDidChange();
    });
  }

  private async onAssignmentDidChange(): Promise<void> {
    // In case of an assignment change all open editors for the previous assignment
    // must be closed. After that an editor window is opened for all loaded 'src' files.
    if (this.workspaceService.opened) {
      if (this.editorManager.all.length > 0) {
        await this.editorManager.closeAll({ save: false });
      }
      const workspaceRoot = this.workspaceService.workspace!.resource.path.toString();
      const srcFiles = this.assignmentModelService.getFilesByType('src');
      srcFiles.forEach(file =>
        this.editorManager.open(new URI([workspaceRoot, file.path].join('/')))
      );
    }
  }
}

I bind it as usual…

// my-extension/src/browser/my-extension-frontend-module.ts
export default new ContainerModule(bind => {
  ...
  bind(CommonEventHandlerService).toSelf().inSingletonScope();
  ...
});

but unlike my other classes I don’t see the need for a Contribution class and nothing is requiring a reference to my CommonEventHandlerService. Therefor inversify is never creating an instance when resolving the dependencies.

I think of this as a pretty common usecase but I “did my research” and I am struggling to find any people having the same problem. Which, of course, makes me think that I am getting something (about DI) fundamentally wrong?!

Other people using inversify force the instantiation by calling get() on the DI container, but I did not find a way to access the container itself from my extension.

container.bind(MyClass).toSelf();
const myClass = container.get(MyClass);

I would highly appreciate any hints or thoughts on this,
thank you in advance,
Leo

Current workaround

Everything does work as expected if I create a CommonEventHandlerContribution which only holds a reference to the CommonEventHandlerService. But this does not seem right to me and also Typescript is complaining then:

... /common-event-handler-contribution.ts(6,14): error TS2559: Type 'CommonEventHandlerContribution' has no properties in common with type 'FrontendApplicationContribution'.
... /common-event-handler-contribution.ts(8,20): error TS6133: 'commonEventHandlerService' is declared but its value is never read.
// my-extension/src/browser/common-event-handler/common-event-handler-contribution.ts
@injectable()
export class CommonEventHandlerContribution implements FrontendApplicationContribution {
  @inject(CommonEventHandlerService)
  private readonly commonEventHandlerService: CommonEventHandlerService;
}
// my-extension/src/browser/my-extension-frontend-module.ts
export default new ContainerModule(bind => {
  ...
  bind(CommonEventHandlerService).toSelf().inSingletonScope();
  bind(CommonEventHandlerContribution).toSelf().inSingletonScope();
  bind(FrontendApplicationContribution).toService(CommonEventHandlerContribution);
  ...
});

Hey @ozfox,

About the TypeScript error: As the error suggests, the class has nothing in common with it’s interface, so it’s a useless interface implementation. You can still successfully bind it to a FrontendApplicationContribution though, which might be a valid solution (you can just remove the implements FrontendApplicationContribution from the class definition).

As for the actual issue at hand: Since the main reason for DI is to get object instances out of containers, it really seems like it would abuse the design pattern if you where to forcefully instantiate an unused object. Having the class that performs the postConstruct a FrontendApplicationContribution seems reasonable to me, you could even actually use the initialize method from the interface to perform the callback registration.

I think most devs using Theia would simply register their callbacks in a common FrontendContribution where they also register menus, commands, etc. without worrying too much about separation of concerns.

As Mark said, using a FrontendApplicationContribution is a good solution to execute logic on startup such as event wiring.

I don’t know about timing though, so maybe instead of executing the wiring in the post constructor you could execute it in the onStart or initialize lifecycle method?

We actually did something similar to what you’ve done in the main repo:

Thank you very much for your clarifications! I knew I was barking up the wrong tree…

Seeing something-contribution.ts files with SomeThingContribution classes implementing AnotherContribution interfaces somehow made me think this is the only correct way to do this from an architectural perspective. I did not think about implementing FrontendApplicationContribution directly. It works perfectly fine now.

Even if both of you helped me out I need to select a solution and I decided for @msujew’s answer as it addresses my ambiguities regarding DI.

For the sake of completeness:

@injectable()
export class CommonEventHandlerService implements FrontendApplicationContribution {

  @inject(WorkspaceService)
  protected readonly workspaceService: WorkspaceService;
  @inject(EditorManager)
  protected readonly editorManager: EditorManager;
  @inject(AssignmentModelService)
  private readonly assignmentModelService: AssignmentModelService;

  initialize(): void {
    this.assignmentModelService.onDidStateChange(event => {
      event.assignmentDidChange && this.onAssignmentDidChange();
    });
  }

  ...
}
bind(CommonEventHandlerService).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(CommonEventHandlerService);
1 Like