Skip to content

Feat/generator referenced assemblies - #4

Merged
HBartosch merged 10 commits into
mainfrom
feat/generator-referenced-assemblies
Oct 1, 2026
Merged

HBartosch merged 10 commits into
mainfrom
feat/generator-referenced-assemblies

Conversation

@HBartosch

Copy link
Copy Markdown
Owner

🚀 Pull Request: Routya.Core


📋 Summary

Makes the source generator work in multiple project solutions, which it previously could not, and fixes the type conflicts that arose once it did.

Three things, in dependency order:

  1. Handlers in referenced assemblies are now discovered. The generator only ever saw handlers with a syntax tree in the compilation it was generating for. In the usual layered layout, where handlers live in an Application project and the generator runs in the Web or API project, every handler was invisible and AddGeneratedRoutya registered nothing.
  2. The generated handler lifetime is now configurable, through AddGeneratedRoutya(ServiceLifetime) and a per handler [RoutyaHandler(ServiceLifetime)] attribute. It was hardcoded to Transient with no way to change it.
  3. Only the project that composes the container generates a dispatcher. Fixing item 1 exposed a defect already shipped in 4.0.0: every project referencing the generator emitted its own Routya.Generated.IGeneratedRoutya, and they collide.

Released as 4.1.0. One behavioural change, documented under Upgrade notes in the changelog.

🔍 Related Issues (If Any)

None.

🛠️ Changes Made

  • Request/notification handler logic
  • Pipeline behavior changes
  • Dispatcher performance or safety
  • DI registration improvements
  • Unit tests added or updated
  • Benchmarks updated (if performance-related)

Benchmarks are unchanged because the dispatch path is untouched. Every change here is in generation time behaviour, registration, or documentation.

✅ Checklist

  • Code compiles and passes unit tests
  • No breaking changes introduced (or documented)
  • Docs/readme updated if needed
  • Follows Routya code style and conventions
  • Scoped vs root resolution behavior remains correct

The dispatcher conflict, in detail

Routya.Generated is a fixed, public namespace, so two assemblies in one reference chain cannot both define IGeneratedRoutya. Every project that referenced the generator and declared a handler emitted its own copy, so a layered solution produced CS0436 at every level. The compiler resolves that conflict in favour of the source declaration, which is the part that matters: a caller could silently bind to a dispatcher other than the one the application registers at startup.

This is a defect in released 4.0.0, not one introduced by this branch. Measured on a three project probe, App to Api to Tests, each declaring a handler:

released main this branch before the fix this branch now
CS0436 warnings 16 8 0
what Tests registered its own handler all four in the chain nothing

How it is resolved

Generation is gated on the compilation calling AddGeneratedRoutya. Detection is purely syntactic, which is what makes it self consistent: the method being called is itself generated, so it cannot be bound before the decision to generate has been taken.

RoutyaGenerateDispatcher overrides the decision in either direction. It ships in both build and buildTransitive, because build alone would not reach anyone installing the Routya meta package, which is how most consumers get the generator.

Two diagnostics keep the behaviour discoverable rather than silent. ROUTYA008 (Info) explains that a project with handlers generated nothing because it composes no container, which is the normal shape for a library project. ROUTYA009 (Warning) reports two assemblies in one chain both generating, and names the property that resolves it.

Who this breaks

A project that used IGeneratedRoutya but did not call AddGeneratedRoutya in that same project previously got a dispatcher anyway and now gets nothing, so those injection sites stop compiling. That shape was already producing CS0436 and could bind to the wrong dispatcher, so it was not working correctly before. Both remedies are in the changelog's Upgrade notes table.

This is why 4.1.0 rather than 4.0.1, and the argument against 5.0.0 is that the affected shape was broken rather than supported, and one line of MSBuild restores the old result.


🧪 Manual Testing Notes

Reproduced before fixing. A three project probe outside the repository, App to Api to Tests, each referencing the generator and declaring a handler. The same probe was then built against released main to establish whether the defect was pre-existing. It was.

Verified after fixing. Zero warnings, with only the composition root generating and its dispatcher covering all three assemblies' handlers. The ROUTYA009 path and its documented remedy were both exercised, including confirming that the RoutyaGenerateDispatcher property actually reaches the compiler.

Packaging verified. dotnet pack inspected to confirm both build/ and buildTransitive/ props land in the nupkg, and that the meta package pins both dependencies at 4.1.0.

Every demo was run, not merely built. The four console demos exit zero. The generator Web API demo was exercised across all eight of its endpoints. The two lifetime claims in the README were checked against the running API: the Scoped handler behind GET /customers/{id} returned a different instance id on each of three requests, and the Singleton handler behind GET /status returned the same one each time. The .NET Framework 4.8 demo builds.

Tests. 150 passing across the three suites, 64 plus 32 plus 54. Solution builds with 0 errors and 5 warnings, all pre-existing nullable field warnings in Routya.Benchmark.

💬 Additional Context

Two problems found while checking documentation, both predating this work.

The generator README claimed internal handlers are ignored. They are not, and have not been since accessibility filtering was removed from discovery. Internal handlers are registered and dispatched normally; what an internal type cannot have is a typed member on the public IGeneratedRoutya, which is what ROUTYA005 reports.

Routya.SourceGen.Demo still used IRequest<IAsyncEnumerable<T>> for its streaming example, so the demo most people run first taught the shape 4.0.0 replaced. It is ported, and gains a StreamLoggingBehavior so its output interleaves production, observation and consumption with elapsed timings. That makes the reason the two behavior interfaces are separate visible rather than merely asserted.

Three known vulnerabilities cleared, all transitive dependencies of demo and test projects rather than anything Routya ships: Microsoft.AspNetCore.OpenApi to 10.0.12, Microsoft.Data.Sqlite to 8.0.31, and xunit to 2.9.3. dotnet list package --vulnerable --include-transitive now reports nothing across every project.

HBartosch and others added 10 commits September 29, 2026 09:37
The generator only ever saw handlers declared in the compilation it was
generating for. That is a property of the syntax provider: a handler in
another project has no syntax tree in this compilation, so it is structurally
invisible.

In the usual Clean Architecture layout the handlers live in an Application
project and the generator runs in the Web or API project, so every handler was
invisible and AddGeneratedRoutya registered nothing at all. This was the
single largest barrier to adopting the source generator in a multiple project
solution.

Handlers are now also read from the metadata of referenced assemblies, and
fed through the same descriptor and emission path as local ones, so nested
types, records, streaming, synchronous handlers and the accessibility rules
all behave identically wherever the handler lives.

Only assemblies that themselves reference Routya.Core are walked. That check
is on assembly identity and happens before any type is enumerated, so the
whole base class library is skipped without inspection and the cost stays
proportional to the number of projects actually using Routya.

Handlers that are not public in their own assembly are skipped with the new
ROUTYA006 warning, naming the handler and the assembly. Generated registration
lives in the consuming assembly, so an internal type elsewhere cannot be
referenced by it. Reporting rather than skipping silently is the same choice
made for ROUTYA005, and avoids recreating the silent-skip failure mode that
was the most expensive defect to find in the 4.0.0 work.

Also removes an early return that bailed out when the compilation contained
no handlers of its own. A thin API project holding no handlers but referencing
a project that does is precisely the case this change exists to support, and
the guard would have defeated it.

Adds six harness tests using genuinely separate compiled assemblies rather
than one source string, since a single compilation cannot reproduce the
problem. Three failed before this change. Verified end to end with a real two
project build: an API project declaring no handlers dispatched both a request
and a notification to handlers in a referenced assembly.
Reported from a real migration from Routya 1 to 4 with the source generator:
handlers became Transient with no way to configure it. The generator
hardcoded ServiceLifetime.Transient, under a comment claiming it would "look
for lifetime attributes or conventions", which was never implemented.

Worse than simply being fixed, the two dispatch paths disagreed:

  AddRoutya          handlers default to Scoped
  AddGeneratedRoutya handlers were always Transient

So moving a project from runtime dispatch to the generator silently changed
every handler's lifetime, and nothing said so anywhere.

AddGeneratedRoutya now takes an optional ServiceLifetime, and a new
[RoutyaHandler(ServiceLifetime)] attribute overrides it for a single handler.
Handlers without the attribute are emitted as ServiceDescriptor registrations
carrying the parameter, so the choice is made at registration rather than
fixed when the code is generated. Handlers with it keep a fixed registration
that ignores the parameter.

The default stays Transient. Changing it to match the runtime path would
silently alter handler lifetimes for every existing 4.0.0 user on upgrade,
which is exactly the failure being fixed here, so aligning the defaults is a
5.0.0 discussion rather than something to slip into a minor release.

The difference is now documented in three places a reader might look: a
README section contrasting both paths, the attribute's own documentation, and
the generated AddGeneratedRoutya method's parameter comment.

Adds six generator tests covering the parameter, the attribute, the two
together, notification handlers, and an attributed handler in a referenced
assembly. Adds six runtime tests in Routya.SourceGen.Test proving the
lifetime actually takes effect rather than merely appearing in the generated
text: Transient gives a new handler per resolution, Scoped one per scope and a
new one across scopes, Singleton one for the application, and the attribute
beats the parameter.
The previous commit documented that AddRoutya defaults to Scoped while
AddGeneratedRoutya defaults to Transient, and treated that as an
inconsistency to live with. Testing it showed the difference is structural
rather than accidental, and the documentation now says why.

Measured:

  Transient handler, resolved from the root provider   works
  Scoped handler, resolved from the root provider      InvalidOperationException
  Scoped handler, resolved from a scope                works

The cause is that the runtime dispatcher creates a DI scope per dispatch and
the generated one does not. IRoutya can therefore resolve a Scoped handler
wherever it was itself resolved from, because it makes its own scope.
IGeneratedRoutya uses whatever provider it was given, so a Scoped handler
only works when the consumer resolved IGeneratedRoutya inside a scope.

Transient is therefore the correct default for the generated path, not a
compatibility compromise: it is the only lifetime that works regardless of
where the consumer resolves from. Aligning the defaults to Scoped, which was
under consideration, would break dispatching from a singleton service, a
hosted service that does not create its own scope, or a console application,
and would do so at runtime rather than at build.

The README now explains this, notes that ASP.NET Core resolves from the
request scope so Scoped is safe there, and shows how to create a scope when
dispatching outside a request.

Adds three tests recording the constraints, including that a
[RoutyaHandler(Singleton)] handler cannot depend on a Scoped service. That
one is a captive dependency caught by scope validation in Development but not
necessarily in Production, which is why the attribute documentation warns
about it rather than relying on the container to catch it.
… sample

Neither feature on this branch had a sample. Both are now shown in
Routya.WebApi.SourceGen.Demo, and they belong together: a separate handlers
project is exactly the situation where handler lifetime has to be decided.

Adds Routya.WebApi.SourceGen.Demo.Application, a class library holding
handlers, which the Web project references. That library deliberately does
NOT reference Routya.SourceGenerators. Only the consuming project runs the
generator; these handlers are found by reading the library's metadata, which
is the whole point of the change and is stated in the project file so a
reader does not have to infer it.

The Web project now calls AddGeneratedRoutya(ServiceLifetime.Scoped) with a
comment explaining why Scoped is safe in ASP.NET Core, where endpoints
resolve from the request scope, and why the default is Transient for code
dispatching outside a request.

Three endpoints, chosen so the behaviour is observable rather than asserted:

  GET  /customers/{id}            handler in the referenced assembly, returns
                                  its own instance id
  GET  /status                    handler pinned Singleton by attribute,
                                  overriding the Scoped above
  POST /customers/{id}/registered notification handled in the referenced
                                  assembly

Verified by running it:

  /customers/1  instanceId 9aa237b0 then 22d7018b   Scoped, new per request
  /status       instance 394fd660 both times        Singleton, never changes
  POST          HTTP 202, and the Application assembly handler logged

So the referenced assembly discovery, the lifetime parameter and the
attribute override are all demonstrated working in a real ASP.NET Core
application rather than only in tests.
Reported from a consumer: the generated dispatcher carried only the
// <auto-generated/> header and no attributes, so coverlet's attribute based
exclusion did not recognise it.

The header is not the same thing. Roslyn reads it to skip analyzers on the
file, but tooling outside the compiler does not, and coverage tools key off
attributes instead. The result was that every consumer's coverage figure was
dragged down by generated dispatch code they never wrote and cannot
meaningfully test.

All three generated types are now attributed:

  GeneratedRoutya            GeneratedCode + ExcludeFromCodeCoverage
  RoutyaGeneratedExtensions  GeneratedCode + ExcludeFromCodeCoverage
  IGeneratedRoutya           GeneratedCode only, an interface has no code to
                             exclude from coverage

GeneratedCode names the tool and carries the generator's assembly version, so
a consumer can see which version produced the file. Both attributes are fully
qualified, consistent with the rest of the emitted code, so a consumer
declaring their own GeneratedCodeAttribute cannot break the build. There is a
test for exactly that.

Checked the rest of what the generator contributes to a consuming assembly:
it adds two files and three public types, and all three are now covered.
Nothing else is emitted.

The helper appends to the StringBuilder rather than composing lines itself,
because Environment.NewLine is banned inside analyzers by RS1035.
…ntainer

Routya.Generated is a fixed, public namespace, so two assemblies in one
reference chain cannot both define IGeneratedRoutya. The generator emitted
into every project that referenced it and declared a handler, so in a layered
solution the Application, API and test projects each emitted a copy and the
build filled with CS0436 type conflicts. The compiler resolves such a conflict
in favour of the source declaration, so a caller could silently bind to a
dispatcher other than the one the application registers at startup.

Generation is now gated on the compilation calling AddGeneratedRoutya.
Detection is purely syntactic, which is what makes it self consistent: the
method being called is itself generated, so it cannot be bound before the
decision to generate has been taken. Projects that already call it need no
change.

The RoutyaGenerateDispatcher MSBuild property overrides the decision in either
direction, shipped through both build and buildTransitive so it reaches
consumers of the Routya meta package as well as direct consumers.

Two diagnostics make the behaviour discoverable rather than silent. ROUTYA008
(Info) explains that a project with handlers generated nothing because it never
composes a container, which is the normal shape for a library project.
ROUTYA009 (Warning) reports two assemblies in one chain both generating, and
names the property that resolves it.

Verified on a three project probe, App to Api to Tests: 16 CS0436 warnings
before, none after, with only the composition root generating and its
dispatcher covering the handlers of all three assemblies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three advisories, all in transitive dependencies of demo and test projects
rather than in anything Routya ships, resolved by bumping the direct reference
that pulls each one in. All stay on the same major version.

  Microsoft.AspNetCore.OpenApi  10.0.0 to 10.0.12  in Routya.WebApi.Demo
    brought Microsoft.OpenApi 2.0.0, GHSA-v5pm-xwqc-g5wc, high.
    Now resolves 2.12.0.

  Microsoft.Data.Sqlite  8.0.0 to 8.0.31  in Routya.SourceGen.DatabaseDemo
    brought SQLitePCLRaw.lib.e_sqlite3 2.1.6, GHSA-2m69-gcr7-jv3q, high.
    Now resolves 2.1.12.

  xunit  2.5.3 to 2.9.3, and xunit.runner.visualstudio 2.5.3 to 2.8.2,
  across all four test projects. xunit 2.5.3 depended on NETStandard.Library
  1.6.1, which brought System.Net.Http 4.3.0, GHSA-7jgj-8wvc-jh57, and
  System.Text.RegularExpressions 4.3.0, GHSA-cmhx-cq75-c4mj, both high.

dotnet list package --vulnerable --include-transitive now reports nothing
across every project in the repository. The solution build drops from seven
warnings to five, the remainder being pre-existing nullable field warnings in
Routya.Benchmark. All 150 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Version stamped in Routya.Core, Routya.SourceGenerators and Routya.Package,
with the integration test's default RoutyaVersion brought up from a stale
3.1.0 to match.

CHANGELOG gains a dated 4.1.0 section. The dispatcher ownership change moves
out of Fixed into its own Changed heading with upgrade notes, because it can
break one project shape and a minor version leans on those notes to carry it.

The source generator README carried two claims this release makes false. It
stated that handlers in referenced libraries are not discovered, which is now
the headline feature, and that internal handlers are ignored, which has not
been true since accessibility filtering was removed from discovery. Internal
handlers are registered and dispatched normally; what an internal type cannot
have is a typed member on the public IGeneratedRoutya interface, which is what
ROUTYA005 reports. Both sections rewritten, and the dispatcher ownership rules
and handler lifetimes documented there for the first time.

Root README gains a 4.1.0 upgrade note above the existing 4.0.0 one, and its
install snippets move to 4.1.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four demo projects exercise the source generator but the example tables listed
only two, so Routya.SourceGen.DatabaseDemo and the
Routya.WebApi.SourceGen.Demo.Application class library were invisible to anyone
reading the packages on NuGet. Both are now listed in the generator and meta
package READMEs, and the root README gains a console demos section covering all
six console projects across the two dispatch paths.

Descriptions were checked against the source rather than carried over.
Routya.SourceGen.Demo was described as basic request and notification dispatch
when it also carries an open generic logging pipeline behavior and a streaming
example. The meta package tables are now split by dispatch path, since that
package covers both.

The Web API demo's endpoint table listed five of its eight routes. The three
missing ones are the lifetime endpoints the prose immediately above the table
already described, so the table contradicted its own section.

One accuracy note added rather than silently fixed: the streaming example in
Routya.SourceGen.Demo predates 4.0.0 and still uses IRequest<IAsyncEnumerable<T>>
rather than the first class IStreamRequest<T> and CreateStream API. Both Web API
demos use the current API, so the note points readers there.

Every relative and GitHub link in the three READMEs verified against the
directories on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routya.SourceGen.Demo still used IRequest<IAsyncEnumerable<T>> for its
streaming example, so the demo most people run first taught the shape 4.0.0
replaced. It now uses IStreamRequest<T>, IStreamRequestHandler and
CreateStream.

The demo also gains a StreamLoggingBehavior<TRequest, TResponse>, which is the
part that makes the difference legible rather than merely asserted. A request
behavior wrapped around a sequence returning handler completes before a single
item exists, whereas a stream behavior wraps the enumeration. The demo's output
now interleaves the handler producing a chunk, the behavior observing it with
an elapsed timing, and the consumer receiving it, so running it shows why the
two behavior interfaces are separate.

Every demo was run rather than only built. The four console demos exit zero.
The generator Web API demo was exercised across all eight of its endpoints, and
the two lifetime claims in the README were checked against it: the Scoped
handler behind GET /customers/{id} returns a different instance id on every
request, and the Singleton handler behind GET /status returns the same one.
The .NET Framework 4.8 demo builds.

Documentation updated to match, and the note added in the previous commit
warning readers away from this demo's streaming example is removed, since it no
longer applies. The streaming section of the root README now points at the demo
as a runnable reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HBartosch
HBartosch merged commit 4bd86d7 into main Oct 1, 2026
5 checks passed
@HBartosch
HBartosch deleted the feat/generator-referenced-assemblies branch October 1, 2026 06:13
@HBartosch
HBartosch restored the feat/generator-referenced-assemblies branch October 1, 2026 06:46
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