Skip to main content

A Source Generator Replaced Over a Thousand Lines of Hand-Written Code

Abblix OIDC Server is our certified OpenID Connect and OAuth 2.0 library for .NET. Its architecture rests on a deliberate split: the core assembly implements the protocol - validators, grant handlers, token services - and knows OAuth 2.0 and OpenID Connect but nothing about ASP.NET Core. A separate MVC assembly adapts that core to the web framework: controllers, model binders, response formatters. The split is worth protecting: it keeps the protocol logic testable without a web host, and it leaves the door open for transports other than ASP.NET Core.

But it has a price, and the price is paid in models: everything the protocol receives has to be described twice, once on each side of the boundary. Here is what the MVC side of one such pair looked like until recently. There were many like it, one per endpoint, and every line of every one was written and maintained by hand:

// The hand-written MVC model: every property duplicated from the core,
// re-annotated for form binding, then copied back field by field.
[BindProperty(SupportsGet = true, Name = Parameters.Scope)]
[ModelBinder(typeof(SpaceSeparatedValuesBinder))]
public string[] Scope { get; init; } = [];

// ...twenty more properties...

public Core.AuthorizationRequest Map() => new()
{
Scope = Scope,
Claims = Claims,
// ...twenty more assignments...
};

Here is the same model today, in its entirety:

[GeneratedFrom(typeof(Core.AuthorizationRequest), SupportsGet = true)]
public partial record AuthorizationRequest;

The other half of that record still exists - the properties, the wire names, the binders, the validation, the projection onto the core type - but no human writes it anymore. A Roslyn incremental source generator emits it on every build, derived from the core model that was always the real source of truth.

This article walks the diff between those two snippets, one hunk at a time: what moved into the core, what was deleted outright, what refused to move - and why the most important change is the one no diff can show.

TL;DR

Abblix OIDC Server keeps its protocol logic in a transport-agnostic core assembly. The ASP.NET Core MVC layer used to mirror every core request model with a hand-written binding model: same properties, different attributes, plus a mapping method copying every field. Keeping the two hierarchies consistent was a standing tax: every new wire parameter had to be added twice and mapped once, a missed copy produced no compile error, and the only countermeasures were review attention and tests written specifically to verify the mapping.

We built an incremental source generator that produces the binding models from the core ones. The core declares the semantics of each parameter with small marker attributes - this value travels as a space-separated string, this one is an embedded JSON document, this one arrives in a named HTTP header. Each model binder declares which marker it realizes. The generator connects the two and emits the model: bound properties, binders, executable validation attributes, inherited XML documentation, and an implicit conversion back to the core type. Anything it cannot resolve fails the build. The payoff is the thing duplication never gave us: the two model hierarchies stay consistent automatically, as a property of the build rather than of anyone's discipline.

The binding model layer shrank by an order of magnitude - from well over a thousand lines to barely more than a hundred - and the mapping tests went with it: there is no hand-written mapping left to verify. The full suite - thousands of unit and end-to-end tests - runs green.

The costs were real: generated code is harder to discover, metadata symbols hide property initializers, and one model category turned out not to need generation at all - it needed deletion.

What the Two Copies Carried

The core version of each model carried System.Text.Json attributes describing the wire shape - this is how a request object's payload deserializes, and how requests round-trip through storage. The MVC version - the first snippet at the top of this article - carried ASP.NET Core binding attributes describing how the same parameters arrive from a form or query string, and a hand-written Map() method copied every property from the MVC model to the core one.

Could one set of classes have carried both? Not without giving up the split. ASP.NET Core's model binding is driven by attributes on the bound type's properties - [BindProperty] with the wire name, [ModelBinder] with the binder type, the validation attributes the MVC pipeline executes - and every one of them is a type from the ASP.NET Core assemblies. Put them on the core models, and the core now references the web framework: it no longer compiles without it, no longer runs under any other transport, and the boundary we set out to protect is gone. Inheritance does not rescue this either: binding attributes must sit on the property being bound, and a derived model cannot attach attributes to properties it inherits - it would have to redeclare every one of them, which is the same duplication wearing a different syntax. MVC's attribute-free alternative, custom binding conventions, only relocates the problem: a convention that knows every parameter's wire name and binder is the same mirror, written as registration code instead of a class.

So the second set of classes was the honest price of the architecture - which made it no less tedious to maintain. Many request models. Well over a thousand lines of this, plus the tests whose only purpose was to confirm the copying was faithful. None of it wrong, all of it upkeep.

At some point you ask the obvious question: why is a human doing this? The MVC model contains no decision a machine could not derive from the core one. Its properties are the core's properties, its wire names are the core's wire names, and its mapping method is the identity function written out by hand, twenty assignments at a time.

The Consistency Tax

Adding a wire parameter to an endpoint meant touching the core model, the MVC model, and the mapping method. Miss either of the last two and nothing complains at compile time: each copy of the model is internally consistent on its own, and the type system has no idea the two are supposed to agree. It was never hard, just boring - the same three edits, endpoint after endpoint.

So agreement had to be manufactured by process, and we paid for it the way every team with a mirrored layer pays. Reviews checked model changes property against property. Tests existed whose entire assertion was that the mapping assigned every field - tests that verify no behavior, only that a human performed a mechanical task correctly. And every contributor carried the standing obligation to remember the second and third edit. The daily cost was small, but it was permanent, it grew with every endpoint, and the guarantee it bought was only ever as strong as the most recent person's attention.

To be fair, the layer was not pure duplication. The MVC attributes on its properties carried real behavior: which binder parses each parameter, which validation runs before the pipeline ever sees the value. But that knowledge was a fraction of the lines. Everything else - the properties, the wire names, the mapping - was a second copy of the truth, plus the standing obligation to keep both copies in agreement.

The Property: Mechanism Out, Semantics In

The first hunk is the property itself. Before, the binding knowledge lived in the MVC layer and named the technology outright:

// MVC layer, hand-written: names the binding technology directly.
[BindProperty(SupportsGet = true, Name = Parameters.Scope)]
[ModelBinder(typeof(SpaceSeparatedValuesBinder))]
public string[] Scope { get; init; } = [];

This was the central design question of the migration: if the MVC model is generated from the core model, where does the binding knowledge live? The core cannot reference SpaceSeparatedValuesBinder - that would drag ASP.NET Core into the assembly we keep transport-free.

The answer was to make the core declare what each parameter is, never how to bind it. A handful of empty marker attributes name the wire format:

// Core layer, after: declares the wire format, never the mechanism.
[JsonPropertyName(Parameters.Scope)]
[JsonConverter(typeof(SpaceSeparatedValuesConverter))]
[SpaceSeparatedString]
public string[] Scope { get; init; } = [];

[JsonPropertyName(Parameters.MaxAge)]
[JsonConverter(typeof(TimeSpanSecondsConverter))]
[TotalSeconds]
public TimeSpan? MaxAge { get; init; }

SpaceSeparatedString says: on the wire, this is one string with space-separated tokens. TotalSeconds says: an integer number of seconds. Neither mentions a binder, a converter, or any other mechanism. They are statements about the protocol, and they would read the same way if the transport were gRPC.

A second family of markers describes transport sources for values that are not payload parameters at all: a named HTTP request header, the parsed Authorization header, the client certificate from the TLS connection. Those let the generator handle the client authentication material - DPoP proofs, mTLS certificates - that the core marks as excluded from JSON serialization.

The Binder: It Declares Itself

The MVC side closes the loop. Each model binder states which marker it realizes:

[Binds(typeof(SpaceSeparatedStringAttribute))]
public class SpaceSeparatedValuesBinder : ModelBinderBase
{
// ...
}

The generator carries no mapping table. It scans the compiling assembly for these declarations and builds the marker-to-binder map on the fly. Adding a new wire format means adding a marker in the core and a binder with a Binds declaration in the MVC layer - the generator itself never changes.

This mirrors a convention the codebase already follows elsewhere: when an extension point dispatches on a wire discriminator, the implementations self-declare their key in DI registrations. The instance owns its name; no central registry to keep in sync.

The Mapping: Twenty Assignments Become One Operator

The before side of this hunk is the Map() method from the top of the article - the identity function written out by hand:

public Core.AuthorizationRequest Map() => new()
{
Scope = Scope,
Claims = Claims,
// ...twenty more assignments...
};

The after side is emitted by the generator as the other half of the three-line stub: every bound property with its wire name lifted from the core's JsonPropertyName, the binder resolved through the marker, validation attributes translated to their executable MVC counterparts, XML documentation inherited from the core property, and the projection onto the core type - a Map() method plus an implicit conversion operator that delegates to it:

/// <inheritdoc cref="Abblix.Oidc.Server.Model.AuthorizationRequest.Scope"/>
[global::Microsoft.AspNetCore.Mvc.BindProperty(SupportsGet = true, Name = "scope")]
[global::Microsoft.AspNetCore.Mvc.ModelBinder(typeof(global::Abblix.Oidc.Server.Mvc.Binders.SpaceSeparatedValuesBinder))]
public string[] Scope { get; init; } = [];

The pairing turned out to be more than a stylistic preference. The explicit method is discoverable - it shows up in completion lists and reads naturally in tests - while the implicit operator means a controller simply assigns the bound model to a core-typed local and the conversion happens at the assignment. There is no method call to forget, and only one projection body to maintain: the operator is a one-line delegate to the method.

The Hunk That Fails the Build

A hand-maintained mirror can only fail silently; the generated one was designed to fail loudly. The generator reports build errors for every situation that would otherwise be a silent drop: a wire-format marker no binder declares, a bound property without a wire name, an unrecognized marker on a property excluded from the payload. The last one doubles as a rename guard - if someone renames a marker attribute in the core, the generator stops recognizing it and the build breaks instead of a parameter quietly vanishing from an endpoint.

What the Diff Will Not Show

Some of the most consequential changes never appear in a property-by-property diff.

The Model That Was Deleted, Not Generated

The generator reads the core models through compilation symbols - effectively through the compiled assembly's metadata. Metadata does not carry property initializers. public string[] Scope { get; init; } = []; looks, through symbols, identical to a property with no default at all.

For form-bound request models this barely matters; the generator special-cases non-nullable arrays. But the client registration model is different: it is a JSON document with meaningful defaults baked into initializers - the default response type, the default authentication method, the default subject type. A generated mirror would silently lose all of them, and the implicit operator would then overwrite the core's defaults with nulls.

We went back and forth on this until the right answer turned out to be the simplest one: for a JSON-bound endpoint, the mirror should not exist. The core model already carries complete System.Text.Json metadata - it is the wire contract. The controller now binds the body straight into the core type and adds the one thing the body cannot carry:

public async Task<ActionResult> RegisterClientAsync(
[FromServices] IRegisterClientHandler handler,
[FromServices] IRegisterClientResponseFormatter formatter,
[FromBody] Core.ClientRegistrationRequest request,
[FromHeader(Name = HttpRequestHeaders.Authorization)] AuthenticationHeaderValue? authorizationHeader)
{
var clientRegistrationRequest = request with { AuthorizationHeader = authorizationHeader };
// ...
}

That single decision deleted a hand-written model of several hundred lines and the entire question of how to preserve its defaults.

The Model That Refused to Move

One model refused to fit, and the refusal was informative. The client management endpoints of RFC 7592 identify the client through the URL path - /register/{clientId} - with a registration access token in the Authorization header. The little model binding those two values is not a transport mirror of any core type; it is a narrow projection specific to those routes, and the client identifier's source is a routing concept the core deliberately does not know about. Inventing a route marker in the core would have smuggled URL topology into the assembly we keep transport-free.

It stays hand-written, with a remark explaining why. Three categories emerged, each justified by the nature of its transport: form-bound models are generated, JSON-bound models do not exist as mirrors at all, and route-bound projections are written by hand.

The Code Nobody Wrote

Two smaller traps came from the same root: symbols read from metadata are not symbols read from source. A record's compiler-synthesized EqualityContract property is flagged as implicitly declared when you analyze source, but not when you analyze a referenced assembly - the generator initially tried to bind it as a wire parameter. And an attribute constructor with an optional parameter that the source never spelled out arrives from metadata with the default value folded into an explicit constructor argument; render that back into generated code and you produce a call that does not match the mirror attribute's constructor. The generator now trims trailing arguments equal to their parameter's declared default, restoring the attribute as it was written.

The Annotation Stricter Than the Protocol

Translating validation attributes produced the most instructive failure: dozens of end-to-end tests rejected pushed authorization requests the moment the generated authorization request model went in. The failing check was the https-scheme requirement on request_uri.

The annotation was stricter than the protocol. With Pushed Authorization Requests in the picture, request_uri legitimately carries either an https URL or the urn:ietf:params:oauth:request_uri: value PAR hands to the client - and the only thing both have in common is being absolute URIs. We relaxed the core annotation to plain absolute-URI and let the request-object fetchers enforce scheme rules per source, where the distinction actually lives.

The episode sharpened a rule for declarative validation: annotate only what holds for every flow that touches the parameter. Anything flow-specific belongs to runtime logic, because an attribute cannot know which flow it is in.

Closing the Diff

The binding model layer collapsed by an order of magnitude: many hand-written models became a handful of three-line stubs, one deleted file, and one deliberately hand-written projection. The mapping methods disappeared, and the tests that existed only to verify them disappeared with them. The generator itself is a few hundred lines, written once, covering every current and future model.

The numbers matter less than the new invariant: the two model hierarchies now stay consistent automatically, with no effort spent on keeping them so. A wire parameter added to a core model appears in the MVC layer on the next build, documented, validated, and mapped - there is no second place to forget. The review attention, the mapping tests, the discipline of the second and third edit: all of that is gone, because the build now guarantees consistency instead of a process we run on people.

And the markers earned their keep beyond generation. The core models now document their own wire semantics in a machine-checkable form. A reader sees [SpaceSeparatedString] and [TotalSeconds] and knows the wire shape without opening the converter.

Principles That Travel

Declare semantics, not mechanisms. The core says space-separated string; only the MVC layer knows what binder that implies. The moment a shared declaration names a technology, the layering is gone. Markers that survive a hypothetical transport swap are the ones worth having.

Let implementations self-declare. The generator owns no table mapping markers to binders. Each binder claims its marker; the generator discovers the claims. Extending the system never means editing the generator.

Static value lists only for spec-fixed sets. If a set of allowed values is defined by host configuration or DI registrations - grant types, response types, signing algorithms - a compile-time allow-list duplicates a runtime check and will eventually contradict it. Reserve declarative lists for sets the specification itself froze.

Make the generator fail the build for anything it half-understands. Every silent skip in a code generator is a future debugging session. In a mirrored model layer the natural failure mode is the silent drop; the generator's job was to convert that entire class into compile errors, and the diagnostics took a quarter of its code.

The Obligation That Never Expires

We set out to stop paying for the same model twice: once in writing it, and forever after in keeping it aligned with its twin through reviews and tests. That is the part I keep coming back to. Duplication does not just cost the lines it occupies - it manufactures an obligation, and the obligation never expires until the duplication does.

If your codebase mirrors models across a layer boundary by hand, add up what keeping the mirrors honest costs you per year: the mapping code, the tests that verify nothing but copying, the reviewer minutes spent comparing property lists. Then ask what any of that buys you that a build step could not guarantee outright.