aurelia / aurelia

Aurelia 2, a standards-based, front-end framework designed for high-performing, ambitious applications.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

RFC: Enhancements for Overriding Registrations in Aurelia DI Container

Vheissu opened this issue · comments

💬 RFC

In the current implementation of Aurelia's dependency injection (DI) container, registering a new value under an existing key does not replace the original value but adds to it. This behaviour can lead to unintended side effects, such as users being surprised to find multiple instances registered under the same key (especially during testing). This RFC proposes multiple solutions to this issue by allowing more explicit control over registration overrides.

The need to override dependencies arises primarily in tests, where it might be desirable to stub/mock dependencies.

Current behaviour

In the current implementation of Aurelia 2's dependency injection (DI) container, registering a value under a key does not replace any existing value associated with that key. Instead, it adds to the list of values registered under that key. This behaviour allows for multiple instances to be associated with the same key, which can be useful in some scenarios but may lead to unintended side effects in others.

When you register multiple instances under the same key, the DI container maintains all these instances. You can then retrieve these instances using methods like getAll or all. Here’s a brief overview of how this works:

const container = DI.createContainer();
const instance1 = new MyService();
const instance2 = new MyService();

container.register(Registration.instance('myKey', instance1));
container.register(Registration.instance('myKey', instance2));

// Retrieve all instances registered under 'myKey'
const instances = container.getAll('myKey');
console.log(instances); // Will contain two instances of MyService[]

In this example, both instance1 and instance2 are registered under the key 'myKey'. When you call getAll('myKey'), you get an array containing both instances.

// In a class constructor
@inject(all('myKey'))
class MyClass {
  constructor(public instances: MyService[]) {}
}

// Later in the code
const container = DI.createContainer();
const instance1 = new MyService();
const instance2 = new MyService();

container.register(Registration.instance('myKey', instance1));
container.register(Registration.instance('myKey', instance2));

const myClassInstance = container.get(MyService);
console.log(myClassInstance.instances); // Will contain two instances of MyService[]

Here, the all('myKey') usage in the @inject decorator ensures that instances will be an array containing all instances registered under 'myKey'.

Proposed Enhancements

Enhancement 1: Override Option in Registration Methods

Extend the current registration methods to accept an options parameter with an override flag. When this flag is true, the registration will replace any value associated with the key.

const container = DI.createContainer();
const instance1 = new MyService();
const instance2 = new MyService();

container.register(Registration.instance('myKey', instance1));
console.log(container.get('myKey')); // Outputs: first registered instance

container.register(Registration.instance('myKey', instance2, { override: true }));
console.log(container.get('myKey')); // Outputs: second registered instance (no array)

It would look similar to other registration methods like singleton:

const container = DI.createContainer();
container.register(Registration.singleton('myKey', MyService);
console.log(container.get('myKey')); // Outputs: an instance of MyService()

container.register(Registration.singleton('myKey', MyOtherClass, { override: true }));
console.log(container.get('myKey')); // Outputs: an instance of MyOtherClass

Enhancement 2: Explicit Deregistration Method

Introduce an explicit method to deregister a resolver associated with a key. This allows users to remove existing registrations before registering a new one.

const container = DI.createContainer();
const instance1 = new MyService();
const instance2 = new MyService();

container.register(Registration.instance('myKey', instance1));
console.log(container.get('myKey')); // Outputs an instance of MyService

container.deregister('myKey');
container.register(Registration.instance('myKey', instance2));
console.log(container.get('myKey')); // Outputs an instance of MyService

Enhancement 3: Combined Approach

Combine the override option and explicit deregistration method to provide flexible and explicit control over registrations. Users can override directly during registration or deregister first and then register.

const container = DI.createContainer();
const instance1 = new MyService();
const instance2 = new MyService();

container.register(Registration.instance('myKey', instance1));
console.log(container.get('myKey')); // Outputs an instance of MyService

container.register(Registration.instance('myKey', instance2, { override: true }));
console.log(container.get('myKey')); // Outputs an instance of MyService

// and/or

container.register(Registration.instance('myKey', instance1));
console.log(container.get('myKey')); // Outputs an instance of MyService

container.deregister('myKey');
container.register(Registration.instance('myKey', instance1));
console.log(container.get('myKey')); // Outputs an instance of MyService


## Potential Problems

### Problem 1: Unintended Overwrites

There is a concern that users might unintentionally overwrite existing registrations, leading to unexpected behaviour in the application.

**Mitigation:**

Using the override flag requires deliberate action. It makes it clear that the registration will replace any existing value.
- Documentation should clearly explain the behaviour and use cases for the override option.

### Problem 2: Complexity of Managing Registrations

Introducing an explicit deregistration method adds complexity to managing the lifecycle of dependencies in the DI container.

**Mitigation:**

- Provide clear documentation and examples on how to use the deregistration method effectively.
- Encourage best practices for managing registrations and deregistrations.

### Problem 3: Inconsistent Behaviour Across Applications

Different developers might choose different approaches (override vs. deregister and register), leading to consistency in managing dependencies across applications.

**Mitigation:**

- Standardise the recommended approach in the Aurelia documentation.
- Provide guidelines and code samples to ensure consistent usage across projects.

Thanks @Vheissu for drafting the detailed RFC!
I have question regarding the enhancement 1 and 3. From the examples provided, the difference is not clear to me. Can you elaborate on that?

My personal opinion is that we opt for the 2nd alternative. Replacing a registered component in a DI, should be a conscious decision due to its potentially disruptive/destructive nature. Hence, I think that it should not be made extra user-friendly.

On a different note, I got a bit stuck by this:

The need to override dependencies arises primarily in tests, where it might be desirable to stub/mock dependencies.

So far, the existing DI infrastructure worked pretty well for me to supply those overrides from the tests. Would you mind sharing some example code to demonstrate where exactly the existing infra lacks?

@Sayan751 That was my bad on the example. I missed the deregister method. Enhancement 3 proposes implementing a deregister method and supporting a configurable override: true flag.

Enhancement 2 is where I am naturally leading, too. It would be the least destructive change to DI. And it would be intentional what deregister does, especially if someone uses it and wonders why dependencies are not present.

There is a workaround currently where you can use getAll or all and I added in a last resolver which will get the last dependency if there are multiple registered under the same key. However, this arose from a use-case that goes something like this:

Say you have feature flags as enums:

export enum FeatureFlags {
  flagOne = 'valueone',
  flagTwo = 'valuetwo',
  flagThree = 'valuethree',
}

You then register those feature flags inside of some bootstrap code:

container.register(Registration.instance(FeatureFlags.flagOne, false));

You can inject that feature flag in your code:

static inject = [FeatureFlags.flagOne]

Inside your test you want to override the value:

bootstrap(() => {
    container.register(Registration.instance(FeatureFlags.flagOne, true));
});

This will add the value, not override the original feature flag value. Because it's the same container (bootstrap logic stuff is happening outside of the test and abstracted away because there's some globally repeated test stuff).

@Vheissu Thanks for the clarification and providing the example use cases. For handling configuration options, there might be an easier way of dealing with configuration options. The following example is inspired by the options pattern in .netcore.

The idea is that there is one configuration source (can be more, though) and there is a configuration manager. This looks like below:

// this is loosely the schema of the configuration source, say config.json
interface ConfigurationOptions {
  features: string[]; // turn this into an array of literal type if that is what you needed
}

export const IConfigurationManager = DI.createInterface<IConfigurationManager>('IConfigurationManager', x => x.singleton(ConfigurationManager));
export type IConfigurationManager = ConfigurationManager;

export class ConfigurationManager<TConfig extends ConfigurationOptions = ConfigurationOptions> {
  private _systemConfigs: TConfig | undefined = undefined;
  
  public setConfiguration(systemConfigs: TConfig): void {
    systemConfigs.features = systemConfigs.features ?? [];
    this._systemConfigs = systemConfigs;
  }

  public isFeatureEnabled(featureName: string): boolean {
    return features.includes(featureName); // cache the result if you want
  }
}

Then you can simply inject the IConfigurationManager in your class and check if a certain feature flag is enabled or not. IN that way, you don't need to override any container registrations from tests, and you can rather provide the ConfigurationManager#setConfiguration with a different set of configuration options. Starting from this and creating a TC like with-feature="flagone" might be a trivial step.

<!-- the element-one will only be rendered if the flagone feature is on.
<element-one with-feature="flagone"></element-one>

Implementing the IContainer#deregister for this purpose seems like an overkill TBH. On a different note, this might be a good opportunity to implement something like the options pattern in .netcore for Au2, to offer something standard to manage application configuration options. 😉

Something like a flag/feature manager sounds like an excellence built in 💯 , we can have it in the addons package, or core is also fine too.

For the ability to undo a resolver, I quite agree that it is an overkill for this scenario, though there' cases where we need it in the framework ourselves, for example, hot reload where we also need to remove the old resource resolver to register the new one. That said, we don't have to do it now if we want to implement the suggestion from @Sayan751 .

My vote is to add the options pattern to the kernel package. As it is expected to be a low level infra, although it will be a public API. The advantage will be that inside the core package we can use that to better transport the configuration options in different parts of the core components.

Resolved by #1981