Problem Description
The core issue is that once a service is resolved in a container, it gets locked to the value that was provided at the time of resolution. When child containers override that value, the previously resolved service in the parent container still uses the old value, ignoring the override in the child. This results in unexpected behavior when working with container hierarchies.
Consider the following example:
const parent = Container.providesValue("value", 1).provides(
Injectable("service", ["value"], (value: number) => value)
);
const child = parent.providesValue("value", 2);
expect(child.get("service")).toBe(2); // OK!
Here, the expectation is that the overridden value (2) is used by service in the child container. However, if we trigger the factory functions before providing the override, the behavior changes:
const parent = Container.providesValue("value", 1).provides(
Injectable("service", ["value"], (value: number) => value)
);
parent.get("service"); // trigger container factory functions
const child = parent.providesValue("value", 2);
expect(child.get("service")).toBe(2); // FAIL! The value is 1
Once service is resolved in the parent, the child container inherits the cached version, which is locked to value: 1. This leads to confusing and inconsistent behavior, especially when working with complex container hierarchies.
However, the library already provides a solution for this issue through the Container.copy() method. This method allows you to scope specific services to the child container, ensuring that those services are re-instantiated with the new values in the child. For example:
const parent = Container.providesValue("value", 1).provides(
Injectable("service", ["value"], (value: number) => value)
);
parent.get("service"); // trigger container factory functions
const child = parent.copy(["service"]).providesValue("value", 2);
expect(child.get("service")).toBe(2); // OK!
While Container.copy() allows scoping services to child containers, it relies on developers remembering to use it, which can introduce complexity and potential errors. It can feel excessive for simple value overrides, leading to unnecessary duplication of services and inefficiencies when only the value, not the service instance, needs to change.
What Other DI Libraries Do
- typed-inject: Seals the service to the value that was provided at the time of its registration. This means overrides in child containers are ignored once the service is resolved.
- inversify: Throws an error when trying to register the same service twice. It provides a dedicated method (
rebind) to explicitly override a service.
Possible Solutions
This issue could be addressed at the API level. Below are several potential options:
1. Keep the Old Behavior
Maintain the current behavior, where services are locked to the value at the time of resolution. The downside is that this can lead to unpredictable container behavior, making it harder to track down bugs.
Pros:
- No breaking changes.
- Simple and consistent with the current system.
Cons:
- Can result in unpredictable behavior with nested containers.
- Difficult to debug and can lead to issues when overrides are expected to work.
2. Adopt Typed-Inject Behavior
Lock the service to the value that was provided at the time of its initial registration. This avoids any confusion caused by value overrides in child containers.
Pros:
- Consistent behavior for services, no ambiguity about what value is being used.
Cons:
- Ignores the user's intent when providing overrides in child containers.
- May introduce breaking changes, as it deviates from the current behavior.
- Confusion may still exist, as users might expect child containers to override dependencies.
3. Allow Explicit Control Over Reevaluation (My Preferred Solution)
Introduce overrides* methods that allow service overrides with explicit control over whether dependencies should be reevaluated. Developers would pass named constants such as "reevaluate" or "retain" to indicate whether the dependent services should be updated or left untouched.
Default Behavior: The default behavior should be to reevaluate dependencies when a value is overridden, as this aligns with the most intuitive expectation—overriding a value should update dependent services. If a developer explicitly wants to retain the old behavior, they can pass "retain" to prevent reevaluation.
Another option is to keep provides*() methods, but require developers to provide "reevaluate" | "retain" value if the service is already registered. If they try to use provides*() methods for already registered services without the scoping switch, they will get a compilation (and runtime) error.
Example:
const parent = Container.providesValue("value", 1).provides(
Injectable("service", ["value"], (value: number) => value)
);
parent.get("service"); // Triggers resolution of "service" with value 1
// In the child container, override the "value" and specify to reevaluate dependencies
const child = parent.overridesValue("value", 2, "reevaluate");
//. const child = parent.providesValue("value", 2, "reevaluate"); // alternative
expect(child.get("service")).toBe(2); // Reevaluates "service" with the new value 2
In this example, "reevaluate" is passed to ensure that services depending on the overridden "value" are reevaluated.
Example with No Reevaluation:
const child = parent.overridesValue("value", 2, "retain");
expect(child.get("service")).toBe(1); // Still returns the service resolved with the original value 1
Pros:
- Provides explicit control over whether to reevaluate dependent services.
- Defaulting to reevaluation ensures consistency and avoids accidental use of stale values.
- The API is more readable, using named constants like
"reevaluate" and "retain" rather than relying on true or false.
Cons:
- Adds a slight learning curve as developers need to understand when to use
"reevaluate" vs "retain".
- While the default behavior is intuitive, it might still introduce performance overhead in large applications where reevaluating services is costly.
4. Forbid Service Overriding Entirely
The simplest solution is to forbid service overrides entirely. This would avoid ambiguity but also limits flexibility.
Pros:
- Forces clear design patterns, preventing unintended side effects.
- Simplifies container management by removing the possibility of value overrides.
Cons:
- Lacks flexibility. Some users may need the ability to override services in child containers.
- Could break existing functionality where overrides are currently used.
5. Introduce a "Sealed" State (Statically Typed Language Inspiration)
Introduce a "sealed" state for containers. Until a container is sealed, users can provide new values and services. Once sealed, the container cannot be modified, but services can be resolved. Child containers could be created from sealed containers but would not inherit any resolved services.
const c1Unsealed = Container.providesValue("v", 1).providesFactory("f", ["v"] as const, (v) => v);
// Services cannot be resolved in unsealed containers
// c1Unsealed.get("f"); // This will fail
const c1 = c1Unsealed.seal();
// Now services can be resolved
expect(c1.get("f")).toBe(1);
const c2 = Container.fromSealed(c1).providesValue("v", 2).seal();
expect(c2.get("f")).toBe(2);
Pros:
- Predictable, as services cannot be resolved until the container is sealed.
- Avoids caching-related issues, as services are not locked before sealing.
- Makes the container lifecycle more explicit and manageable.
Cons:
- Significant API change.
- Could introduce more rigidity into container usage.
- Adds complexity by requiring containers to be sealed before use.
Trade-Offs Between Solutions
| Solution |
Pros |
Cons |
| 1. Keep Old Behavior and Use copy |
No breaking changes, explicit scoping |
Requires users to remember to use copy, prone to human error |
| 2. Typed-Inject Behavior |
Predictable, always tied to registration values |
Ignores user intent, breaking change |
| 3. Explicit Control Over Reevaluation |
Flexible, user intent explicit, backward-compatible |
API complexity, cognitive load |
| 4. Forbid Service Overriding |
Clear design patterns, simpler containers |
Inflexible, users may need overrides |
| 5. Introduce "Sealed" State |
Predictable lifecycle, avoids caching issues |
Significant API change, more complexity |
Conclusion
By allowing explicit control over reevaluation (option 3), we strike a balance between flexibility and predictability. Users can specify their intent clearly, and it minimizes ambiguity without introducing significant breaking changes. However, for existing users, leveraging the copy method (option 1) offers an immediate, backward-compatible solution that can address the issue today.
Problem Description
The core issue is that once a service is resolved in a container, it gets locked to the value that was provided at the time of resolution. When child containers override that value, the previously resolved service in the parent container still uses the old value, ignoring the override in the child. This results in unexpected behavior when working with container hierarchies.
Consider the following example:
Here, the expectation is that the overridden value (
2) is used byservicein the child container. However, if we trigger the factory functions before providing the override, the behavior changes:Once
serviceis resolved in the parent, the child container inherits the cached version, which is locked tovalue: 1. This leads to confusing and inconsistent behavior, especially when working with complex container hierarchies.However, the library already provides a solution for this issue through the
Container.copy()method. This method allows you to scope specific services to the child container, ensuring that those services are re-instantiated with the new values in the child. For example:While
Container.copy()allows scoping services to child containers, it relies on developers remembering to use it, which can introduce complexity and potential errors. It can feel excessive for simple value overrides, leading to unnecessary duplication of services and inefficiencies when only the value, not the service instance, needs to change.What Other DI Libraries Do
rebind) to explicitly override a service.Possible Solutions
This issue could be addressed at the API level. Below are several potential options:
1. Keep the Old Behavior
Maintain the current behavior, where services are locked to the value at the time of resolution. The downside is that this can lead to unpredictable container behavior, making it harder to track down bugs.
Pros:
Cons:
2. Adopt Typed-Inject Behavior
Lock the service to the value that was provided at the time of its initial registration. This avoids any confusion caused by value overrides in child containers.
Pros:
Cons:
3. Allow Explicit Control Over Reevaluation (My Preferred Solution)
Introduce
overrides*methods that allow service overrides with explicit control over whether dependencies should be reevaluated. Developers would pass named constants such as"reevaluate"or"retain"to indicate whether the dependent services should be updated or left untouched.Default Behavior: The default behavior should be to reevaluate dependencies when a value is overridden, as this aligns with the most intuitive expectation—overriding a value should update dependent services. If a developer explicitly wants to retain the old behavior, they can pass
"retain"to prevent reevaluation.Another option is to keep
provides*()methods, but require developers to provide"reevaluate" | "retain"value if the service is already registered. If they try to useprovides*()methods for already registered services without the scoping switch, they will get a compilation (and runtime) error.Example:
In this example,
"reevaluate"is passed to ensure that services depending on the overridden"value"are reevaluated.Example with No Reevaluation:
Pros:
"reevaluate"and"retain"rather than relying ontrueorfalse.Cons:
"reevaluate"vs"retain".4. Forbid Service Overriding Entirely
The simplest solution is to forbid service overrides entirely. This would avoid ambiguity but also limits flexibility.
Pros:
Cons:
5. Introduce a "Sealed" State (Statically Typed Language Inspiration)
Introduce a "sealed" state for containers. Until a container is sealed, users can provide new values and services. Once sealed, the container cannot be modified, but services can be resolved. Child containers could be created from sealed containers but would not inherit any resolved services.
Pros:
Cons:
Trade-Offs Between Solutions
Conclusion
By allowing explicit control over reevaluation (option 3), we strike a balance between flexibility and predictability. Users can specify their intent clearly, and it minimizes ambiguity without introducing significant breaking changes. However, for existing users, leveraging the copy method (option 1) offers an immediate, backward-compatible solution that can address the issue today.