Inside the Source Generator

What the compile-time generator produces from a [Job] declaration — tolerant serializers, DI-routed dispatch, and the registry — plus the tolerance rules that make payload evolution safe and the full BW diagnostic codes explained.


Almost none of BackWave's job machinery is discovered at runtime. Every [Job] you declare is read at compile time and turned into ordinary C# in your own assembly — code you can open, read, and step through. From one declaration the generator emits three things: an explicit serializer that tolerates payload drift, dispatch that routes a claimed job to its handler through dependency injection, and a registry that binds every job's Wire Name to both. Because that code is generated rather than reflected, the shape a payload can take is fixed at compile time, the output uses no reflection or expression trees and stays trim- and NativeAOT-clean by construction, and a malformed declaration fails your build with a precise diagnostic instead of surprising you in production. This page opens that box: what gets generated and why, the exact tolerance rules the serializers follow, and a reference walk through every BW diagnostic the generator raises.

Why generate instead of reflect#

The first payoff is startup cost and ahead-of-time compilation. There is no runtime type discovery — no assembly scan, no reflection census assembled while your host boots. Discovery happens entirely at compile time, so the work of finding every job and wiring it up is already done by the time the process starts. The generated code touches no reflection and no expression trees, which is what lets it stay trim- and NativeAOT-clean without a rundown of trimming hints. The one hand-registration escape hatch, JobRegistration.Create, is reflection-free in the same way, though it asks you to supply your own JsonTypeInfo for the payload. If you are evaluating BackWave for a trimmed or native build, Publish with Native AOT is the companion to this section.

The second payoff is wire-format discipline. Because the serializer for each payload is written out at compile time rather than assembled on the fly, what a payload can contain is knowable and testable before you ship. The set of member types the generator will serialize is closed — a fixed list, covered below — and anything outside it is a build error, not a runtime surprise. The bytes a job writes today and the bytes it will accept tomorrow are both pinned by code you can read.

The third payoff is debuggability. The generated files are real source, emitted into your compiler's generated-source output — the analyzer/generated tree your IDE exposes — as one file per job plus one file for the registry. You can set a breakpoint in a generated serializer and step through it like any other code in your solution. The exact on-disk location depends on your build settings, so reach for your IDE's generated-source view rather than a fixed path, but when you want to know precisely how a payload is written or read, the answer is code, not a black box.

The three artifacts#

The serializer: tolerant by construction#

For each payload type, the generator emits an explicit serializer built directly on System.Text.Json's low-level reader and writer primitives — straight-line Utf8JsonWriter and Utf8JsonReader code. It never calls JsonSerializer, holds no JsonTypeInfo, and uses no reflection. That hand-written read path is what funds the tolerance rules below: it can decide exactly how to treat a property it does not recognize, a property that is missing, or a value that is the wrong shape.

Those decisions are the mechanism behind safe payload evolution. Each rule maps to a compatibility guarantee it funds:

On read, when…The serializer…Which funds…
the stored bytes carry a property the reader does not recognizeskips it and moves onan older node reading a payload written by a newer one — a field added later is silently dropped, not a decode failure
a property the reader expects is absent from the bytesleaves it at the member's declared defaulta newer node reading a payload written by an older one — a field added later reads as its default on old data
a non-nullable value member holds a JSON nullleaves it at its default rather than throwingtolerance of nulls written by an earlier shape
the top-level token is not a JSON objectthrows a JsonExceptiona loud, fail-fast signal on genuinely corrupt bytes instead of a silent misread
an enum property holds a token that matches no known namethrows a JsonExceptiona renamed or removed enum member surfacing loudly rather than becoming a silent wrong value

Two of these deserve emphasis because they are stronger than most readers assume. First, a missing property does not merely fall back to default(T) — it falls back to the member's declared default. A constructor parameter written as string Region = "eu" contributes "eu" as its missing value, so a payload that omits Region decodes to "eu", not null. Second, enums are the one deliberate asymmetry: they are written as their string name, and on read an unknown name throws rather than defaulting. This is intentional. Renaming an enum member is a wire-breaking change, and the strict read makes that break visible instead of letting it degrade into a silently-wrong value. A nullable enum still accepts JSON null as "leave the default."

The tolerance rules are the engine; the how-to of using them to evolve a payload safely lives in Evolve Job Payloads.

Dispatch through dependency injection#

The second artifact is dispatch. Each generated JobRegistration carries a delegate that resolves IJobHandler<TJob> from the host's IServiceProvider and invokes HandleAsync with the decoded payload, the JobContext, and a cancellation token. If no handler is registered for that job's type, the delegate throws an InvalidOperationException naming both the missing handler and the Wire Name it was dispatching — a routing failure you see immediately rather than a job that silently goes nowhere.

Resolution happens inside a fresh dependency-injection scope opened once per Attempt: handlers, and the declaring classes behind method-form jobs, are registered as scoped services so each Attempt gets its own instances and scoped dependencies. Method-form declaring classes are registered only if the host has not already registered them, so your own registration wins when you have one. The full scope-per-Attempt story is covered in The Job Pump.

The registry and module: a compile-time census#

The third artifact is the census of every job the assembly declares, exposed as a generated static class, BackWave.Generated.BackWaveJobs, with three entry points:

  • CreateRegistrations() returns one JobRegistration per [Job], ordered by Wire Name.
  • CreateRegistry() wraps those registrations in a JobRegistry.
  • Module is a JobModule that bundles the registrations, the handler mappings, and the method-form declaring classes into a single value, so the three lists cannot drift apart.

Hosts consume the census in one call — UseJobs(BackWaveJobs.Module) — which registers every job, its handler, and its scoped dependencies together. Because the module is generated from the same pass that emits the serializers, the registry can never fall out of sync with the code that reads and writes the bytes.

That same census is what the Job Manifest snapshots. The manifest renders one line per registration — each job's Wire Name paired with its payload type, in Wire-Name order — and a test can assert that this rendering has not changed in a breaking way. If a previously recorded Wire Name disappears or its payload type changes, the check fails; an additive change rewrites the file so the new line shows up in your pull-request diff for review. Wiring that guard into a test suite is covered in Guard Wire Compatibility.

The method form#

You can put [Job] on a method instead of a type. When you do, the generator writes both pieces for you: a payload record whose properties are the method's data parameters, and a handler shell that forwards a decoded payload to your method.

A method-form [Job] and what the generator emits
// You write:
[Job("send-welcome")]
public Task SendWelcomeAsync(string email, JobContext context, CancellationToken ct) { /* ... */ }
 
// The generator emits, into your own assembly:
public sealed record SendWelcome(string Email);
public sealed class SendWelcomeHandler : IJobHandler<SendWelcome> { /* forwards to your method */ }

This form is for small jobs you do not want to spread across a record and a separate handler class. A few shape rules apply, and violating them is a build error rather than a runtime one. The method must be public and return Task. Its data parameters come first; a JobContext and a CancellationToken are both optional and, when present, follow the data parameters. An Async suffix on the method name is trimmed when deriving the generated names, so SendWelcomeAsync yields the record SendWelcome and the handler SendWelcomeHandler. A static method is invoked statically with no instance; an instance method's generated handler constructor-injects the declaring class and forwards to it, which is why those declaring classes are registered as scoped services.

Each data parameter must be a type the generated serializer supports. The supported set is closed:

CategoryTypes
Text and flagstring, bool
Integerbyte, sbyte, short, ushort, int, uint, long, ulong
Floating-point and decimalfloat, double, decimal
Date and identityDateTime, DateTimeOffset, Guid
Enumany enum (written as its name)
OptionalNullable<T> of any supported value type above

Anything outside this set — a List<T>, an array, a TimeSpan, a char, a nested custom object — is rejected with BW0004, and a method whose shape breaks the rules above is rejected with BW0005. When a payload needs a type the generator will not serialize, hand-register that job with JobRegistration.Create and your own JsonTypeInfo. For the record (type) form, the generator maps the richest public constructor together with settable public properties; get-only computed properties are excluded from the wire shape.

The developer-facing walkthrough of declaring and enqueuing either form is in Define & Enqueue a Job.

The diagnostics#

The generator raises exactly six diagnostics, BW0001 through BW0006, all of them build errors in the BackWave category. Each rejects one specific mistake in a [Job] declaration and tells you how to fix it. In the messages below, the bracketed placeholders are filled in with your actual names at build time.

CodeWhat it rejectsMessageFix
BW0001a [Job] with an empty or whitespace Wire Name[Job] requires a non-empty Wire Name — Wire Names are mandatory and explicit, never derived from CLR namesGive the [Job] a non-empty Wire Name.
BW0002two [Job]s sharing one Wire NameWire Name '{0}' is declared by both '{1}' and '{2}' — Wire Names must be uniqueRename one of the two Wire Names.
BW0003a record [Job] with no handler in the compilationNo IJobHandler<{0}> implementation was found in this compilation for [Job] type '{0}'Add an IJobHandler<T> for the payload, or use the method form.
BW0004a payload member of an unsupported typeMember '{0}' of job payload '{1}' has type '{2}', which generated serialization does not support — register this job by hand with JobRegistration.Create and your own JsonTypeInfoUse a supported member type, or hand-register with JobRegistration.Create.
BW0005a [Job] method with the wrong shape[Job] method '{0}' must be public and return Task; data parameters come first, with optional JobContext and CancellationToken parametersMake the method public, return Task, and order its parameters correctly.
BW0006two [Job]s resolving to the same generated payload typeTwo [Job] declarations resolve to the same payload type '{0}' — payload type names and method-sugar job names must be unique within a namespaceRename one method or type so the generated payload types no longer collide.

BW0002 and BW0006 share a theme worth stating on its own: uniqueness here is a storage-identity rule, not a style preference. A Wire Name is a job's identity in storage, and the same uniqueness the generator enforces at compile time, JobRegistry also enforces at runtime — the two guards protect one invariant from both ends. The two codes catch different collisions: BW0002 fires when two jobs claim the same Wire Name, while BW0006 fires when two declarations resolve to the same generated payload type — for instance, two method-form Send() jobs in one namespace. The Wire-Name side of this is covered further in Evolve Job Payloads.

What is deliberately not generated#

The generator draws a hard line around what it will and will not produce, and the omissions are as deliberate as the output.

There are no interceptors and no in-path hooks into the generated code. BackWave's stance is that observers watch from the egress side and never sit in the execution path; a filter or interceptor pipeline around job dispatch is a permanently-rejected idea, not a missing feature. The reasoning behind that boundary lives in Extension Seams.

There are no partial-class seams for extending the serializers. The generated serialization code is marked auto-generated and offers no partial method or hook point for you to splice into. This is intentional: a payload evolves by changing the payload type — adding or removing members within the tolerance rules above — not by editing generated code. Leaving the serializer closed is what keeps the tolerance guarantees true.

And there is no runtime type discovery of any kind. The generator finds jobs through the compiler's incremental analysis at build time, never by walking a compilation or scanning assemblies at startup. That absence is precisely what makes the reflection-free, AOT-clean guarantees hold.

Where to go next#