Problem
A library encapsulates its implementations as internal and exposes only interfaces. The container's assembly cannot name those implementations, so it cannot construct them. The library has no way to contribute them to a consuming container without giving up the encapsulation.
What already works, and what does not
This scoping matters, because the original issue overstated the gap.
Cross-assembly modules already do the hard part. The container emits a direct, statically bound, fully-qualified call to a module's factory member (QualifiedProductionMember, AwaitenGenerator.Production.cs:652, emitted at Sources/Sources.Construction.cs:241), and a module factory's parameters resolve from the container's graph. So a library can already ship:
[Module]
[Singleton<IRoaster>(Factory = nameof(CreateRoaster))]
public static class RoasterModule
{
public static IRoaster CreateRoaster(IClock clock) => new Roaster(clock); // Roaster stays internal
}
Internal implementation, container-resolved dependency, container-owned lifetime, no InternalsVisibleTo. That works today and is tested.
The "registration silently disappears" claim is largely stale. AWT188 landed in #109 and warns when a scan match's only exposure interface is inaccessible. The remaining silence is at the type level, which is now its own bug issue.
So the real residual gap is narrow: a library with many internal implementations, or a convention-based set of them, has to hand-write one factory per type. That is mechanical, and it is exactly what a scan is for.
Design
Allow [Scan] on a [Module], and make it mean: the scan is evaluated in the module's own compilation, and expanded there into ordinary module registrations.
The library writes:
[Module]
[Scan<IPlugin>(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Singleton)]
public static partial class PluginModule;
internal sealed class Roaster(IClock clock) : IPlugin, IRoaster; // IRoaster is public, Roaster is not
The module's generator runs the scan over its own assembly, where Roaster is visible, and emits into the partial:
[Singleton<IRoaster>(Factory = nameof(__Awaiten_Create_Roaster))]
public static partial class PluginModule
{
public static IRoaster __Awaiten_Create_Roaster(IClock clock) => new Roaster(clock);
}
The consuming container does [Import(typeof(PluginModule))] and gets IRoaster, resolved as a singleton, with IClock supplied from its own graph.
Why this shape
The emitted surface is the module contract that already ships. Generator-emitted attributes are real attributes in the compiled assembly's metadata, indistinguishable from hand-written ones to a downstream generator reading moduleSymbol.GetAttributes(). The container already reads module registration attributes from metadata, and already calls module factory members cross-assembly. So the cross-assembly contract is registration attributes plus public static methods, which is what a module is today.
This is the central difference from the original proposal. There is no new ABI and no new runtime protocol, so "generated code becomes a versioned cross-assembly contract" does not apply. Version skew reduces to compatibility of attributes that are already public, versioned types in Awaiten.
[Scan] is the right trigger, and a declared registration is not. A registration names its implementation: [Singleton<Roaster, IRoaster>]. The container reads that attribute from metadata too, tries to construct the inaccessible Roaster, and hits AWT104. The generated [Singleton<IRoaster>(Factory = ...)] would then be a second registration competing for the same service, and a generator cannot retract a source-declared attribute. [Scan] has no such collision, because it names a marker, not an implementation. Nothing in [Scan<IPlugin>] mentions Roaster, so the generated registrations are purely additive.
The diagnostics land in the build that owns the types. Scan warnings are per-match (AWT182, AWT187, AWT188, AWT141, AWT138), and module diagnostics fall back to the container's [Import] location because a module may have no source (AwaitenGenerator.Collection.cs:222-225). A container-side module scan would therefore fire warnings in the consumer's build, anchored at their [Import], about types they neither own nor can fix. Evaluating in the module's compilation puts them at the library author's own source locations. The consumer just sees a module.
Rules
- Only
[Scan] self-compiles. Everything else a module declares stays exactly as it is.
- Contract accessibility, not implementation accessibility, is what binds. The exposure interface must be accessible outside the module assembly; the implementation need not be.
ScanAs.Self cannot expose an inaccessible match. The container cannot name Roaster, so Self over an internal match registers nothing nameable. Diagnostic, see below. Marker and MatchingInterface are the viable exposures for internal matches.
- Accessible matches do not need a factory. For a match whose implementation is accessible outside, emit a plain
[Singleton<Impl, IContract>] and let the container construct it directly. Only emit a factory where one is required, keeping the generated surface minimal.
- Lifetime stays container-owned. The scan's
Lifetime becomes the generated registration's lifetime, which the container honors, because it is an ordinary module registration. Nothing about scope ownership moves into the library.
v1 scope: no internal intermediate nodes
A match's constructor parameter whose type is accessible outside becomes a factory parameter, supplied by the container. A parameter whose type is internal to the module cannot be, and the module would have to wire it inline:
public static IRoaster __Awaiten_Create_Roaster(IClock clock)
=> new Roaster(new InternalCache(clock)); // threading the container-supplied param down
That is feasible, but it means the module's generator does real graph work over its internal subgraph, which is the bulk of the effort and effectively a mini-container. v1 should reject it (AWT196) and require every constructor parameter of a match to be accessible outside the module assembly.
If v2 adds it, those internal intermediate nodes get inline construction, not a container-managed lifetime. That is correct, not a gap: a node the container cannot name is the library's business, not the composition root's.
Container-side changes
Small, but not zero. The claim "the container's generator needs no changes" is not quite true.
AWT154 is repurposed, not deleted. Today it rejects [Scan] on a module. Its rationale (Diagnostics.cs:790, Collection.cs:276) is mechanical, not principled:
Assembly scanning is a container concern (it sweeps assemblies relative to the container) and is not collected from modules, so a module-declared scan would contribute nothing. This is an error rather than a warning so the scan is not silently dropped.
That reads as "we do not collect it, so we error instead of ignoring it". The stated reason, that a scan sweeps relative to the container, is about the scan's default scope, and a module-declared scan naturally defaults to the module's own assembly.
Under this design the container should skip a module's [Scan] attributes (they were already expanded) rather than error. Guard it with a generator-emitted marker attribute on the module, and keep AWT154 as the version-skew guard: the module's metadata carries a [Scan] but no expansion, meaning the library was built without the Awaiten generator, or with a version predating this feature. That must be an error, not a silent drop.
No opt-in property is needed. [Scan] on a [Module] is currently a hard error, so making it mean self-compilation breaks nothing. The module class must become partial for the generator to emit into it, which is the one new requirement on the author.
A second generator trigger. The generator currently runs on ForAttributeWithMetadataName("Awaiten.ContainerAttribute") (AwaitenGenerator.cs:32). It needs a second trigger on ModuleAttribute, emitting only for modules that declare a [Scan], so module assemblies without one generate nothing.
Prerequisite: Fallback on [Scan]
ScanAttribute has Lifetime, As, SkipUnconstructable, InAssembliesOf, NamePatterns, NamespacePatterns, Exclude. There is no Fallback.
Fallback.Warn / Fallback.Silent is the entire override story for module registrations. Without it, a self-compiled module scan drops a pile of non-overridable registrations into a consumer's root. Add Fallback to [Scan], applied to each generated registration.
This is worth doing on its own merits (a container-side scan providing overridable bulk defaults is useful today), so it could be its own issue and land first.
Diagnostics
| ID |
Severity |
Meaning |
| AWT154 |
Error |
(repurposed) an imported module's metadata declares a [Scan] with no generated expansion: built without the Awaiten generator, or with a version predating self-compiled scans |
| AWT194 |
Error |
[Scan] on a [Module] that is not partial; the generator cannot emit into it |
| AWT195 |
Warning |
a self-compiled scan match's only exposure is ScanAs.Self, but the implementation is inaccessible outside the module assembly, so nothing nameable registers |
| AWT196 |
Error |
a self-compiled scan match has a constructor parameter whose type is inaccessible outside the module assembly (v1 limitation) |
AWT193 is claimed by the inaccessible-type silent-skip issue, which should land first. Verify against open PRs before assigning.
Rejected alternatives
Run the generator in the module and emit construction code the container calls into (the original #113). This makes generated code a versioned cross-assembly ABI. Today every emission decision is private to one compilation; the moment the container calls into module-emitted construction, every future change to how Awaiten emits is potentially breaking across a NuGet boundary. The original issue listed version skew as an open question. It is the central design constraint, and the scan-expansion shape avoids it entirely by emitting the existing contract instead of a new one.
[assembly: InternalsVisibleTo]. The original issue's objections stand on their merits: it inverts the dependency (the library must name its consumers), grants the whole consuming assembly access to every internal, and is invisible at the point of use. But note it is also currently broken for constructors (see the split-out bug), so the comparison should be re-made once it works.
Collect the module's [Scan] and evaluate it container-side. Pure sugar. It saves the consumer from repeating InAssembliesOf, but the scan still evaluates in the container's compilation, where internal types are still invisible, so it does nothing for this issue. It also inherits the diagnostic-ownership problem above: per-match warnings fire in the consumer's build about types they cannot fix.
Open questions
- Silent surface growth. A library adding a matching type in a patch release silently grows every consumer's container. A module
[Singleton] addition does the same, but that is a deliberate list edit someone made once; a scan makes it automatic. Fallback.Silent blunts this without removing it. Is that acceptable, or should a self-compiled scan's expansion be visible to the consumer somehow?
- Generated member naming. Needs to be deterministic and collision-proof against the author's own members. Prefix plus a mangled type name, and a diagnostic on collision.
[Scan(InAssembliesOf = ...)] on a module. Should a module be allowed to scan assemblies other than its own? It only sees their public types, so it buys nothing over the container doing it. Probably reject in v1.
- Interaction with
AwaitenBoundaryAnalyzer. AWT134 already treats [Container] and [Module] as legitimate composition code (AwaitenBoundaryAnalyzer.cs:103-110), so no change expected, but confirm.
- Direction conflict with runtime-invisible markers.
Docs/issues/di-principles-review.md item 2 wants Awaiten's attributes to become per-assembly internal polyfills so consuming assemblies carry no Awaiten reference. A self-compiled module needs Awaiten attributes to survive into metadata for the container to read them. These two are compatible (a module is composition code, not domain code, and is expected to reference Awaiten) but the boundary between "polyfilled and invisible" and "real and readable" needs to be drawn deliberately, not by accident.
Acceptance
- A library with
internal sealed class Roaster(IClock) : IPlugin, IRoaster and [Module] [Scan<IPlugin>(As = ScanAs.MatchingInterface)] public static partial class PluginModule is imported by a container in another assembly, which resolves IRoaster and supplies IClock from its own graph. No InternalsVisibleTo.
- The library's own build reports AWT182 / AWT187 / AWT188 for its own types, at its own source locations. The consumer's build reports none of them.
- The scan's
Lifetime is honored by the consuming container; a singleton match is one instance per Root.
- The consumer overrides a match with its own registration, and
Fallback.Warn on the module scan makes the module's default drop out.
- A module assembly compiled without the Awaiten generator, whose metadata carries a
[Scan], reports AWT154 at the consumer's [Import].
- A non-
partial module with a [Scan] reports AWT194.
- A match with an
internal constructor parameter type reports AWT196.
- A module with no
[Scan] generates no source (no regression to existing cross-assembly module tests).
Problem
A library encapsulates its implementations as
internaland exposes only interfaces. The container's assembly cannot name those implementations, so it cannot construct them. The library has no way to contribute them to a consuming container without giving up the encapsulation.What already works, and what does not
This scoping matters, because the original issue overstated the gap.
Cross-assembly modules already do the hard part. The container emits a direct, statically bound, fully-qualified call to a module's factory member (
QualifiedProductionMember,AwaitenGenerator.Production.cs:652, emitted atSources/Sources.Construction.cs:241), and a module factory's parameters resolve from the container's graph. So a library can already ship:Internal implementation, container-resolved dependency, container-owned lifetime, no
InternalsVisibleTo. That works today and is tested.The "registration silently disappears" claim is largely stale. AWT188 landed in #109 and warns when a scan match's only exposure interface is inaccessible. The remaining silence is at the type level, which is now its own bug issue.
So the real residual gap is narrow: a library with many internal implementations, or a convention-based set of them, has to hand-write one factory per type. That is mechanical, and it is exactly what a scan is for.
Design
Allow
[Scan]on a[Module], and make it mean: the scan is evaluated in the module's own compilation, and expanded there into ordinary module registrations.The library writes:
The module's generator runs the scan over its own assembly, where
Roasteris visible, and emits into the partial:The consuming container does
[Import(typeof(PluginModule))]and getsIRoaster, resolved as a singleton, withIClocksupplied from its own graph.Why this shape
The emitted surface is the module contract that already ships. Generator-emitted attributes are real attributes in the compiled assembly's metadata, indistinguishable from hand-written ones to a downstream generator reading
moduleSymbol.GetAttributes(). The container already reads module registration attributes from metadata, and already calls module factory members cross-assembly. So the cross-assembly contract is registration attributes plus public static methods, which is what a module is today.This is the central difference from the original proposal. There is no new ABI and no new runtime protocol, so "generated code becomes a versioned cross-assembly contract" does not apply. Version skew reduces to compatibility of attributes that are already public, versioned types in
Awaiten.[Scan]is the right trigger, and a declared registration is not. A registration names its implementation:[Singleton<Roaster, IRoaster>]. The container reads that attribute from metadata too, tries to construct the inaccessibleRoaster, and hits AWT104. The generated[Singleton<IRoaster>(Factory = ...)]would then be a second registration competing for the same service, and a generator cannot retract a source-declared attribute.[Scan]has no such collision, because it names a marker, not an implementation. Nothing in[Scan<IPlugin>]mentionsRoaster, so the generated registrations are purely additive.The diagnostics land in the build that owns the types. Scan warnings are per-match (AWT182, AWT187, AWT188, AWT141, AWT138), and module diagnostics fall back to the container's
[Import]location because a module may have no source (AwaitenGenerator.Collection.cs:222-225). A container-side module scan would therefore fire warnings in the consumer's build, anchored at their[Import], about types they neither own nor can fix. Evaluating in the module's compilation puts them at the library author's own source locations. The consumer just sees a module.Rules
[Scan]self-compiles. Everything else a module declares stays exactly as it is.ScanAs.Selfcannot expose an inaccessible match. The container cannot nameRoaster, soSelfover an internal match registers nothing nameable. Diagnostic, see below.MarkerandMatchingInterfaceare the viable exposures for internal matches.[Singleton<Impl, IContract>]and let the container construct it directly. Only emit a factory where one is required, keeping the generated surface minimal.Lifetimebecomes the generated registration's lifetime, which the container honors, because it is an ordinary module registration. Nothing about scope ownership moves into the library.v1 scope: no internal intermediate nodes
A match's constructor parameter whose type is accessible outside becomes a factory parameter, supplied by the container. A parameter whose type is
internalto the module cannot be, and the module would have to wire it inline:That is feasible, but it means the module's generator does real graph work over its internal subgraph, which is the bulk of the effort and effectively a mini-container. v1 should reject it (AWT196) and require every constructor parameter of a match to be accessible outside the module assembly.
If v2 adds it, those internal intermediate nodes get inline construction, not a container-managed lifetime. That is correct, not a gap: a node the container cannot name is the library's business, not the composition root's.
Container-side changes
Small, but not zero. The claim "the container's generator needs no changes" is not quite true.
AWT154 is repurposed, not deleted. Today it rejects
[Scan]on a module. Its rationale (Diagnostics.cs:790,Collection.cs:276) is mechanical, not principled:That reads as "we do not collect it, so we error instead of ignoring it". The stated reason, that a scan sweeps relative to the container, is about the scan's default scope, and a module-declared scan naturally defaults to the module's own assembly.
Under this design the container should skip a module's
[Scan]attributes (they were already expanded) rather than error. Guard it with a generator-emitted marker attribute on the module, and keep AWT154 as the version-skew guard: the module's metadata carries a[Scan]but no expansion, meaning the library was built without the Awaiten generator, or with a version predating this feature. That must be an error, not a silent drop.No opt-in property is needed.
[Scan]on a[Module]is currently a hard error, so making it mean self-compilation breaks nothing. The module class must becomepartialfor the generator to emit into it, which is the one new requirement on the author.A second generator trigger. The generator currently runs on
ForAttributeWithMetadataName("Awaiten.ContainerAttribute")(AwaitenGenerator.cs:32). It needs a second trigger onModuleAttribute, emitting only for modules that declare a[Scan], so module assemblies without one generate nothing.Prerequisite:
Fallbackon[Scan]ScanAttributehasLifetime,As,SkipUnconstructable,InAssembliesOf,NamePatterns,NamespacePatterns,Exclude. There is noFallback.Fallback.Warn/Fallback.Silentis the entire override story for module registrations. Without it, a self-compiled module scan drops a pile of non-overridable registrations into a consumer's root. AddFallbackto[Scan], applied to each generated registration.This is worth doing on its own merits (a container-side scan providing overridable bulk defaults is useful today), so it could be its own issue and land first.
Diagnostics
[Scan]with no generated expansion: built without the Awaiten generator, or with a version predating self-compiled scans[Scan]on a[Module]that is notpartial; the generator cannot emit into itScanAs.Self, but the implementation is inaccessible outside the module assembly, so nothing nameable registersAWT193 is claimed by the inaccessible-type silent-skip issue, which should land first. Verify against open PRs before assigning.
Rejected alternatives
Run the generator in the module and emit construction code the container calls into (the original #113). This makes generated code a versioned cross-assembly ABI. Today every emission decision is private to one compilation; the moment the container calls into module-emitted construction, every future change to how Awaiten emits is potentially breaking across a NuGet boundary. The original issue listed version skew as an open question. It is the central design constraint, and the scan-expansion shape avoids it entirely by emitting the existing contract instead of a new one.
[assembly: InternalsVisibleTo]. The original issue's objections stand on their merits: it inverts the dependency (the library must name its consumers), grants the whole consuming assembly access to every internal, and is invisible at the point of use. But note it is also currently broken for constructors (see the split-out bug), so the comparison should be re-made once it works.Collect the module's
[Scan]and evaluate it container-side. Pure sugar. It saves the consumer from repeatingInAssembliesOf, but the scan still evaluates in the container's compilation, where internal types are still invisible, so it does nothing for this issue. It also inherits the diagnostic-ownership problem above: per-match warnings fire in the consumer's build about types they cannot fix.Open questions
[Singleton]addition does the same, but that is a deliberate list edit someone made once; a scan makes it automatic.Fallback.Silentblunts this without removing it. Is that acceptable, or should a self-compiled scan's expansion be visible to the consumer somehow?[Scan(InAssembliesOf = ...)]on a module. Should a module be allowed to scan assemblies other than its own? It only sees their public types, so it buys nothing over the container doing it. Probably reject in v1.AwaitenBoundaryAnalyzer. AWT134 already treats[Container]and[Module]as legitimate composition code (AwaitenBoundaryAnalyzer.cs:103-110), so no change expected, but confirm.Docs/issues/di-principles-review.mditem 2 wants Awaiten's attributes to become per-assemblyinternalpolyfills so consuming assemblies carry no Awaiten reference. A self-compiled module needsAwaitenattributes to survive into metadata for the container to read them. These two are compatible (a module is composition code, not domain code, and is expected to reference Awaiten) but the boundary between "polyfilled and invisible" and "real and readable" needs to be drawn deliberately, not by accident.Acceptance
internal sealed class Roaster(IClock) : IPlugin, IRoasterand[Module] [Scan<IPlugin>(As = ScanAs.MatchingInterface)] public static partial class PluginModuleis imported by a container in another assembly, which resolvesIRoasterand suppliesIClockfrom its own graph. NoInternalsVisibleTo.Lifetimeis honored by the consuming container; a singleton match is one instance perRoot.Fallback.Warnon the module scan makes the module's default drop out.[Scan], reports AWT154 at the consumer's[Import].partialmodule with a[Scan]reports AWT194.internalconstructor parameter type reports AWT196.[Scan]generates no source (no regression to existing cross-assembly module tests).