Skip to content

Add Symfony-aligned tagged collections, keyed defaults, and configurable autowire alias resolution - #273

Open
jmather wants to merge 2 commits into
zazoomauro:masterfrom
jmather:align/symfony-di-semantics
Open

Add Symfony-aligned tagged collections, keyed defaults, and configurable autowire alias resolution#273
jmather wants to merge 2 commits into
zazoomauro:masterfrom
jmather:align/symfony-di-semantics

Conversation

@jmather

@jmather jmather commented Aug 22, 2026

Copy link
Copy Markdown

Summary

This aligns several dependency-resolution behaviors with the Symfony DependencyInjection semantics that this project explicitly cites as an inspiration, while preserving existing NDI behavior by default.

The central distinction is cardinality: plural provider populations are valid; policy belongs to singular resolution.

This PR adds:

  • Symfony-style tagged collection projection alongside the current !tagged form;
  • aligned keyed-default cardinality semantics;
  • configurable TypeScript autowire interface-alias resolution, with current first-discovered behavior retained as the default;
  • generic invalid-alias validation under the existing compile({ validate: true }) mechanism.

Background

I have a long history with Symfony -- the framework, and the community. So when I went shopping for a proper DI container for Node, and happened across node-dependency-injection, to say I was excited... barely scratches the surface of my joy. :)

During my due diligence process, an agent noted a small concern:

One validator issue named keyed_group_no_default appears to actually inspect tags.

Which led to the first PR on my fork, but after comparing node-dependency-injection against symfony/dependency-injection for the feature surface I was accustomed to leveraging, the @tagged and autowire alignments felt worth doing. Which led to ... well ... this.

I really appreciate that this project exists. Thank you so much for making it a thing, and I hope you find these changes as useful as I do. Please let me know if you have any concerns.

Tagged collections

Existing !tagged foo behavior is preserved unchanged for compatibility: it returns an array in definition order and does not assign global meaning to arbitrary tag attributes.

An explicit reference-style form is added alongside the newer keyed-reference family:

arguments:
  - "@tagged(app.handler)"
  - "@tagged(app.handler, key)"

@tagged(tag) follows Symfony-style tagged-iterator semantics:

  • returns all tagged services;
  • honors an integer priority tag attribute;
  • preserves definition order for equal priority.

@tagged(tag, indexAttribute) additionally projects the collection as a Map, using the requested tag attribute as the member key and falling back to the service id when absent.

Arbitrary tag attributes remain consumer-owned metadata; the container does not impose universal semantics on them.

Keyed defaults

Keyed groups are plural collections and therefore do not require a default.

The invariant is now:

  • zero defaults: valid group; explicit keyed lookup and full-group lookup work;
  • one default: valid; getKeyed(group) resolves it;
  • multiple defaults: invalid/ambiguous and rejected.

This adjusts the validator behavior that inspected ordinary tags for default: true and reported keyed_group_no_default. The validator now checks keyed metadata (instead of tags) and reports keyed_group_multiple_defaults instead.

Runtime getKeyed(group) therefore requires exactly one asserted default: zero defaults remains an error for singular resolution, while multiple defaults are now rejected as ambiguous rather than resolving whichever default happens to be encountered first.

Configurable autowire alias resolution

Currently, TypeScript autowiring creates an interface alias while classes are being discovered. When multiple classes implement the same interface, the first discovered implementation wins and later implementations leave the existing alias alone.

That behavior is retained exactly as the default through a new _defaults option:

services:
  _defaults:
    autowire: true
    autowireAliasResolution: first

Supported policies are:

Policy Discovery-time behavior Late behavior
first First discovered implementation creates the alias None
first-or-unique Same legacy first-discovered behavior If that alias is missing/invalid after beforeOptimization passes and exactly one autowire-discovered implementation remains registered, repair it
unique Record candidates without creating an interface alias Create/repair the alias only when exactly one autowire-discovered implementation remains registered
unique-or-fail Record candidates without creating an interface alias Resolve a unique candidate; if a singular autowired request remains ambiguous, throw AmbiguousAutowireException
none Do not create interface aliases Do not create interface aliases

The late-resolution modes run after beforeOptimization compiler passes and before optimization begins instantiating definitions. This lets conditions, explicit aliases, binds, AutowireOverridePass, and user compiler passes clarify the graph before uniqueness is evaluated.

A valid existing alias is always treated as authoritative, regardless of whether it came from configuration, autowire, or a compiler pass.

Candidate selection remains scoped to implementations discovered by TypeScript autowiring. TypeScript interface implementation metadata does not exist on runtime JavaScript classes, so this does not pretend the generic container can infer interfaces from arbitrary definitions.

Compatibility

first is the default and preserves current behavior, including existing applications and the repository's historical autowire tests that rely on first-discovered interface selection.

The safer strategies are opt-in. This makes the feature suitable for a backward-compatible release while leaving room for a future major version to choose a stricter default if desired.

Alias integrity validation

Alias validity is a container concern, not an autowire-policy concern.

ContainerValidator now reports invalid_alias when an alias points directly to a service that is not registered. This follows current runtime lookup behavior, which resolves one alias hop and then requires a registered definition.

The check is exposed only through the existing validation surface:

await container.compile({ validate: true })

Compilation without validation preserves current behavior; an unused invalid alias is not proactively rejected and will still fail naturally if requested at runtime.

Because compile({ validate: true }) validates both before and after compilation, aliases that begin valid but are changed or invalidated by compiler passes are checked again against the compiled graph.

Tests

Regression coverage includes:

  • priority-ordered @tagged(...) collections;
  • indexed tagged collections;
  • legacy !tagged compatibility;
  • zero/one/multiple keyed-default cardinality;
  • legacy first autowire alias resolution;
  • first-or-unique repair after a compiler pass removes the originally selected implementation;
  • permissive late unique resolution;
  • strict unique-or-fail ambiguity detection;
  • explicit alias and bind resolution;
  • none disabling interface alias synthesis;
  • _defaults.autowireAliasResolution configuration loading;
  • invalid aliases with validation disabled vs. compile({ validate: true });
  • alias-to-alias validation matching current one-hop runtime lookup semantics.

The repository Build workflow passes build, Standard, and the complete test suite across Node.js 15 through 25.

Rationale

NDI's history explicitly cites Symfony as a source of inspiration and added arbitrary tag attributes in the same early release. More recently, NDI added keyed service/reference semantics. This change keeps the resulting concepts distinct:

  • tags classify plural service populations and carry consumer-owned membership metadata;
  • tag projection metadata can provide collection ordering and indexing;
  • keyed services provide explicit keyed collection identity and an optional singular convenience default;
  • aliases/binds select one implementation for a singular dependency;
  • autowire alias resolution policy controls when NDI is authorized to infer that singular alias;
  • container validation checks whether the resulting graph is internally valid.

The goal is not to port Symfony wholesale, but to preserve the same semantic boundaries using NDI's existing model and API style.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant