Two real schemas loaded side-by-side
The bundle currently embeds three schema files:
src/FrenchExDev.Net.Traefik.Bundle/schemas/
├── traefik-v3-static.json
├── traefik-v3-file-provider.json ← v3.0 dynamic
└── traefik-v3.1-file-provider.json ← v3.1 dynamicsrc/FrenchExDev.Net.Traefik.Bundle/schemas/
├── traefik-v3-static.json
├── traefik-v3-file-provider.json ← v3.0 dynamic
└── traefik-v3.1-file-provider.json ← v3.1 dynamicThe Stage 2 merge in TraefikBundleGenerator (see Part 3) sorts the parsed schemas by version, then unions their definitions. The first time a property appears in a non-earliest version, the merge stamps firstSeen[key] = schema.Version and the emitter (via BuildVersionAttributes in TraefikBuilderHelper) tags the corresponding C# property with [SinceVersion("3.1")]. Properties that disappear in a later version get [UntilVersion("…")] (the infrastructure exists; it just hasn't fired yet because no v3 property has been removed in v3.1).
The user-visible result is TraefikSchemaVersions.g.cs, the only generated file every consumer is guaranteed to see:
// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.Traefik.Bundle;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public static class TraefikSchemaVersions
{
private static readonly global::System.Collections.Generic.List<string> _versions = new()
{
"3",
"3.1",
};
public static global::System.Collections.Generic.IReadOnlyList<string> Available => _versions;
public static string Latest => "3.1";
public static string Oldest => "3";
}
[global::System.AttributeUsage(global::System.AttributeTargets.Property | global::System.AttributeTargets.Method | global::System.AttributeTargets.Class, AllowMultiple = false)]
public sealed class SinceVersionAttribute : global::System.Attribute
{
public string Version { get; }
public SinceVersionAttribute(string version) => Version = version;
}
[global::System.AttributeUsage(global::System.AttributeTargets.Property | global::System.AttributeTargets.Method | global::System.AttributeTargets.Class, AllowMultiple = false)]
public sealed class UntilVersionAttribute : global::System.Attribute
{
public string Version { get; }
public UntilVersionAttribute(string version) => Version = version;
}// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.Traefik.Bundle;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public static class TraefikSchemaVersions
{
private static readonly global::System.Collections.Generic.List<string> _versions = new()
{
"3",
"3.1",
};
public static global::System.Collections.Generic.IReadOnlyList<string> Available => _versions;
public static string Latest => "3.1";
public static string Oldest => "3";
}
[global::System.AttributeUsage(global::System.AttributeTargets.Property | global::System.AttributeTargets.Method | global::System.AttributeTargets.Class, AllowMultiple = false)]
public sealed class SinceVersionAttribute : global::System.Attribute
{
public string Version { get; }
public SinceVersionAttribute(string version) => Version = version;
}
[global::System.AttributeUsage(global::System.AttributeTargets.Property | global::System.AttributeTargets.Method | global::System.AttributeTargets.Class, AllowMultiple = false)]
public sealed class UntilVersionAttribute : global::System.Attribute
{
public string Version { get; }
public UntilVersionAttribute(string version) => Version = version;
}That's all the version metadata the runtime needs. TraefikSchemaVersions.Latest is enough for "what version of Traefik does the typed model claim to support?", and the SinceVersion / UntilVersion attributes on individual properties are enough for a consumer to write a build-time check like "warn if my code uses a v3.1-only property and my Traefik deployment is still on v3".
The emitter for this file is also small enough to show in full — VersionMetadataEmitter.cs:
internal static class VersionMetadataEmitter
{
public static string Emit(string ns, UnifiedSchema schema)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]");
sb.AppendLine("public static class TraefikSchemaVersions");
sb.AppendLine("{");
sb.AppendLine(" private static readonly global::System.Collections.Generic.List<string> _versions = new()");
sb.AppendLine(" {");
foreach (var v in schema.Versions)
sb.AppendLine($" \"{v}\",");
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" public static global::System.Collections.Generic.IReadOnlyList<string> Available => _versions;");
sb.AppendLine();
if (schema.Versions.Count > 0)
{
sb.AppendLine($" public static string Latest => \"{schema.Versions[schema.Versions.Count - 1]}\";");
sb.AppendLine($" public static string Oldest => \"{schema.Versions[0]}\";");
}
sb.AppendLine("}");
sb.AppendLine();
// … SinceVersionAttribute / UntilVersionAttribute definitions …
return sb.ToString();
}
}internal static class VersionMetadataEmitter
{
public static string Emit(string ns, UnifiedSchema schema)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]");
sb.AppendLine("public static class TraefikSchemaVersions");
sb.AppendLine("{");
sb.AppendLine(" private static readonly global::System.Collections.Generic.List<string> _versions = new()");
sb.AppendLine(" {");
foreach (var v in schema.Versions)
sb.AppendLine($" \"{v}\",");
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" public static global::System.Collections.Generic.IReadOnlyList<string> Available => _versions;");
sb.AppendLine();
if (schema.Versions.Count > 0)
{
sb.AppendLine($" public static string Latest => \"{schema.Versions[schema.Versions.Count - 1]}\";");
sb.AppendLine($" public static string Oldest => \"{schema.Versions[0]}\";");
}
sb.AppendLine("}");
sb.AppendLine();
// … SinceVersionAttribute / UntilVersionAttribute definitions …
return sb.ToString();
}
}The whole versioning story is "sort the schemas, list them, expose the first and last". No semver parser, no comparison logic. Traefik's versioning happens to be string-orderable for what's been released so far ("3" < "3.1" lexicographically); when 3.10 ships, this will need a real version comparer. That's a known cliff and intentionally not fixed yet — overengineering versioning for a single live point release would have added complexity nobody can use.
NuGet packaging: two packages, one install
The bundle ships as two NuGet packages:
| Package | Contents | What it gives the consumer |
|---|---|---|
FrenchExDev.Net.Traefik.Bundle |
Bundle.dll + the embedded schemas |
The runtime: models, builders, serializer, schema validation |
FrenchExDev.Net.Traefik.Bundle.SourceGenerator |
The generator + the analyzer + the shared BuilderEmitter library, all under analyzers/dotnet/cs |
Build-time: code generation, TFK001/TFK004 diagnostics |
Here is the runtime package's .csproj:
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>FrenchExDev.Net.Traefik.Bundle</PackageId>
<Description>Strongly-typed Traefik v3 static + dynamic configuration: source-generated models + builders, schema-validated YAML round-trip serializer, and a discriminated-union analyzer.</Description>
<PackageTags>traefik;reverse-proxy;config;source-generator;yaml;json-schema</PackageTags>
<Authors>FrenchExDev</Authors>
</PropertyGroup><PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>FrenchExDev.Net.Traefik.Bundle</PackageId>
<Description>Strongly-typed Traefik v3 static + dynamic configuration: source-generated models + builders, schema-validated YAML round-trip serializer, and a discriminated-union analyzer.</Description>
<PackageTags>traefik;reverse-proxy;config;source-generator;yaml;json-schema</PackageTags>
<Authors>FrenchExDev</Authors>
</PropertyGroup>And the source generator package:
<PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>FrenchExDev.Net.Traefik.Bundle.SourceGenerator</PackageId>
<Description>Roslyn source generator + analyzers (TFK001, TFK004) for FrenchExDev.Net.Traefik.Bundle. Generates strongly-typed Traefik static + dynamic config models, builders, and version metadata from JSON schemas wired as AdditionalFiles.</Description>
<PackageTags>traefik;reverse-proxy;config;source-generator;roslyn;analyzer</PackageTags>
<Authors>FrenchExDev</Authors>
<DevelopmentDependency>true</DevelopmentDependency>
<IncludeBuildOutput>false</IncludeBuildOutput>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
<!-- Pack the SG and the shared BuilderEmitter Lib it depends on under
analyzers/dotnet/cs so consumers get them from a single nupkg pull. -->
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
<None Include="$(OutputPath)\FrenchExDev.Net.Builder.SourceGenerator.Lib.dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
</ItemGroup><PropertyGroup>
<IsPackable>true</IsPackable>
<PackageId>FrenchExDev.Net.Traefik.Bundle.SourceGenerator</PackageId>
<Description>Roslyn source generator + analyzers (TFK001, TFK004) for FrenchExDev.Net.Traefik.Bundle. Generates strongly-typed Traefik static + dynamic config models, builders, and version metadata from JSON schemas wired as AdditionalFiles.</Description>
<PackageTags>traefik;reverse-proxy;config;source-generator;roslyn;analyzer</PackageTags>
<Authors>FrenchExDev</Authors>
<DevelopmentDependency>true</DevelopmentDependency>
<IncludeBuildOutput>false</IncludeBuildOutput>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
<!-- Pack the SG and the shared BuilderEmitter Lib it depends on under
analyzers/dotnet/cs so consumers get them from a single nupkg pull. -->
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
<None Include="$(OutputPath)\FrenchExDev.Net.Builder.SourceGenerator.Lib.dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
</ItemGroup>Three load-bearing properties:
<DevelopmentDependency>true</DevelopmentDependency>— tells NuGet that consumers of this package don't transitively depend on it. A library that uses the bundle to generate its own typed models doesn't accidentally force its own consumers to install Roslyn at runtime.<IncludeBuildOutput>false</IncludeBuildOutput>— prevents the defaultlib/netstandard2.0/packing that would put the generator DLL where Roslyn doesn't look for it. Instead, the explicit<None Include="…" PackagePath="analyzers/dotnet/cs" />lines pack it where Roslyn does look.- The shared
BuilderEmitterlibrary is packed alongside the generator's own DLL, in the sameanalyzers/dotnet/csfolder. This is the trick that lets one nupkg install pull both pieces. Without it, consumers would have to manually referenceFrenchExDev.Net.Builder.SourceGenerator.Libin their.csprojfor the generator to load — which is exactly the kind of friction that would kill adoption.
The consumer side is the standard incantation for source generators:
<PackageReference Include="FrenchExDev.Net.Traefik.Bundle" Version="x.y.z" />
<PackageReference Include="FrenchExDev.Net.Traefik.Bundle.SourceGenerator" Version="x.y.z"
PrivateAssets="all"
ReferenceOutputAssembly="false" />
<ItemGroup>
<AdditionalFiles Include="schemas\traefik-v*.json" />
</ItemGroup><PackageReference Include="FrenchExDev.Net.Traefik.Bundle" Version="x.y.z" />
<PackageReference Include="FrenchExDev.Net.Traefik.Bundle.SourceGenerator" Version="x.y.z"
PrivateAssets="all"
ReferenceOutputAssembly="false" />
<ItemGroup>
<AdditionalFiles Include="schemas\traefik-v*.json" />
</ItemGroup>If the third block is omitted, the generator runs but emits TFK004 (see Part 7). The consumer copies the schemas into their own project — they can't be implicitly bundled because Roslyn's AdditionalFiles are project-scoped, not package-scoped.
What worked
Looking back at the architecture from Part 1, the things that paid off the most:
- The schema as the only source of truth. Adding
traefik-v3.1-file-provider.jsonto theschemas/folder was the entire upgrade work for v3.1 support. Thepriorityfield onTraefikHttpRoutermaterialized in the next build. No code changes anywhere. - Value-equal IR (Part 4). The single biggest perf win in the project. Without it, the generator runs on every keystroke regardless. With it, the generator runs only when a schema actually changes. The cost was ~150 lines of
Equals/GetHashCodeboilerplate and a unit-test discipline to keep them in sync — both of which are mechanical. - The shared
BuilderEmitterlibrary.TraefikBuilderHelperis ~170 lines.BuilderEmitteris thousands of lines, shared with the DockerCompose generator and several others. Reusing the emitter meant the Traefik bundle inherited dictionary-friendlyWithEntryPoint(string key, Action<…>)overloads, async validation, andResult<T>integration for free. - Layered defense on the flat-union pattern. Part 5 emits the type, Part 6 catches misuse at
BuildAsynctime, Part 7 catches it in the IDE. Each layer is small. Together they cover essentially every C# expression that constructs a flat union, and the user gets the right feedback at the right moment. - Schema-validated
Try*API (Part 8). OnceJsonSchema.Netwas wired in for real, the same schemas that drive code generation also vet inputs at runtime. One source of truth, two consumers. A schema bug shows up in both places identically. - Hand-rolled
CSharpCompilationanalyzer tests (Part 9). AvoidingMicrosoft.CodeAnalysis.Testingavoided every Roslyn-version-mismatch headache the project would otherwise have hit. - Two NuGet packages, with the analyzer packed alongside the generator. One install gives you the runtime, the codegen, and the IDE diagnostics. No manual package coordination.
What didn't (and the honest "next steps")
Things that are explicitly not in the code today, in priority order:
- A
CodeFixProviderfor TFK001. A fix that says "remove all but the first non-null branch" is plausible, but the correct branch to keep depends on user intent — pretending to know it would produce the wrong fix half the time. The right shape is probably an interactive fix that lets the user pick which branch to keep. Until then, the warning text alone is enough to point the user at the problem. - A
Verify-based snapshot test for.g.cs.EmitterTestspins the parts that determine whether the runtime check runs at all (the__setCountepilogue), but it doesn't pin the rest of the emitted shape. A snapshot test would catch more regressions, at the cost of the dependency-management trade-off discussed in Part 9. This is the most likely first addition. - Property-based round-trip tests via
CsCheck. A generator that produces randomTraefikDynamicConfiginstances and provesDeserialize ∘ Serialize ≡ idwould catch YAML edge cases the realistic sample doesn't reach. The hard part is writing aCsCheckgenerator that respects the flat-union "exactly one branch" invariant — a naive generator would produce TFK001-violating instances most of the time. - Custom MSBuild
.targetsto surface TFK004 earlier. Today TFK004 fires when the generator runs, which is during compilation. A.targetsfile could check the<AdditionalFiles>collection during the project's evaluation phase and surface a more eye-catching error message ("did you forget to copy the Traefik schemas?"). This is small, but the current TFK004 already catches the bug, so it's lower priority than the items above. <PackageReadmeFile>and<GeneratePackageOnBuild>on both packages. Cosmetic NuGet improvements that would make the package pages look nicer on nuget.org. Not blocking anything, just nice-to-have.- TFK002 (dangling router → service reference). Reserved ID with no implementation. The hard part is that detecting this rule cheaply across files requires a built config graph at analysis time, which today only exists at runtime inside
TraefikSerializer. A correct implementation would probably build a partial graph from the user's project's.csfiles — non-trivial, and the runtime serializer already catches it via schema validation.
A note on what isn't on this list
You will notice the list above does not include things like "rewrite the parser as a SyntaxFactory tree" or "switch the IR to records". Both would be larger churn for no measurable benefit at the current scale. The bundle generates ~200 types and ~200 builders from ~120 KB of JSON schema. Both numbers comfortably fit StringBuilder + mutable IR with structural-equality boilerplate. If the project ever has to scale to thousands of types from megabytes of schema, the trade-offs change. Today they don't.
Closing
FrenchExDev.Net.Traefik.Bundle is a small project — five src projects, two test projects, ~3,000 lines of hand-written code, ~25,000 lines of generated code, two real Traefik schema versions. It exists to prove that the "schema as the single source of truth" pattern is workable in real-world .NET, end to end: a CLI that pulls schemas from upstream, an incremental generator that turns them into compile-safe types, an analyzer that catches misuse at edit-time, a runtime serializer that round-trips YAML and JSON with schema validation, an atomic-rename file writer for the file-provider use case, and two NuGet packages that ship the whole thing without coordination headaches.
The pattern generalizes. The next FrenchExDev bundle will probably target Caddy or Envoy. The Bundle.SourceGenerator codebase is roughly 80% Traefik-shaped today, but the parts that matter — the value-equal IR, the shared BuilderEmitter, the layered defense pattern, the schema-validating Try* API, the atomic-rename file writer — are not Traefik-specific. They're the answer to "how do you turn an upstream JSON schema into a compile-safe .NET API without drift?" Traefik happens to be the first concrete instance.
← Part 9: Tests, Property Checks, and Quality Gates · ↑ Series index