The emitter
TraefikModelClassEmitter is the function that turns a UnifiedDefinition into a .g.cs partial class. It is intentionally a StringBuilder, not a SyntaxFactory tree — Roslyn's syntax APIs are far more verbose than the schema warrants, and the output shape is regular enough that string concatenation is faster to read and faster to maintain. Here is the standard-class path (the discriminated-union path is shown below):
private static string EmitClass(string ns, string className, string? description,
List<UnifiedProperty> properties, string? sinceVersion, string? untilVersion)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
if (description is not null)
{
sb.AppendLine("/// <summary>");
sb.AppendLine($"/// {EscapeXml(description)}");
sb.AppendLine("/// </summary>");
}
EmitVersionAttributes(sb, sinceVersion, untilVersion, "");
sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]");
sb.AppendLine($"public partial class {className}");
sb.AppendLine("{");
foreach (var up in properties)
{
var prop = up.Property;
var type = TraefikNamingHelper.MapCSharpType(prop);
if (prop.Description is not null)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine($" /// {EscapeXml(prop.Description)}");
sb.AppendLine(" /// </summary>");
}
EmitVersionAttributes(sb, up);
if (prop.IsDeprecated)
sb.AppendLine(" [global::System.Obsolete(\"Deprecated.\")]");
sb.AppendLine($" public {type} {prop.CSharpName} {{ get; set; }}");
sb.AppendLine();
}
sb.AppendLine("}");
return sb.ToString();
}private static string EmitClass(string ns, string className, string? description,
List<UnifiedProperty> properties, string? sinceVersion, string? untilVersion)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
if (description is not null)
{
sb.AppendLine("/// <summary>");
sb.AppendLine($"/// {EscapeXml(description)}");
sb.AppendLine("/// </summary>");
}
EmitVersionAttributes(sb, sinceVersion, untilVersion, "");
sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]");
sb.AppendLine($"public partial class {className}");
sb.AppendLine("{");
foreach (var up in properties)
{
var prop = up.Property;
var type = TraefikNamingHelper.MapCSharpType(prop);
if (prop.Description is not null)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine($" /// {EscapeXml(prop.Description)}");
sb.AppendLine(" /// </summary>");
}
EmitVersionAttributes(sb, up);
if (prop.IsDeprecated)
sb.AppendLine(" [global::System.Obsolete(\"Deprecated.\")]");
sb.AppendLine($" public {type} {prop.CSharpName} {{ get; set; }}");
sb.AppendLine();
}
sb.AppendLine("}");
return sb.ToString();
}Three things on every emitted class:
public partial— so consumers can extend the generated class with hand-written members in another file. This is critical for the small number of cases where the schema can't express a constraint that you want to enforce in C#.[ExcludeFromCodeCoverage]— generated code is not interesting to coverage. The emitter itself is covered byEmitterTests(see Part 9); the output is just data./// <summary>lifted fromprop.Description— the JSON schema'sdescriptionfield becomes XML doc that shows in IntelliSense.EscapeXmlcollapses newlines and escapes<,>,&so multi-line descriptions don't break the build.
The output: TraefikAddPrefixMiddleware.g.cs
The simplest possible case — a Traefik middleware with one string property — produces this:
// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.Traefik.Bundle;
/// <summary>
/// The AddPrefix middleware updates the URL Path of the request before forwarding it.
/// </summary>
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class TraefikAddPrefixMiddleware
{
/// <summary>
/// prefix is the string to add before the current path in the requested URL. It should include the leading slash (/).
/// </summary>
public string? Prefix { get; set; }
}// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.Traefik.Bundle;
/// <summary>
/// The AddPrefix middleware updates the URL Path of the request before forwarding it.
/// </summary>
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class TraefikAddPrefixMiddleware
{
/// <summary>
/// prefix is the string to add before the current path in the requested URL. It should include the leading slash (/).
/// </summary>
public string? Prefix { get; set; }
}Both <summary> blocks were lifted directly from the JSON schema's description fields. No translation, no rewrite. The IntelliSense the user gets when they type new TraefikAddPrefixMiddleware { is whatever the Traefik docs team wrote.
The flat discriminated-union pattern
The interesting case is TraefikHttpMiddleware, which the JSON schema models as a oneOf over 25 different middleware types (AddPrefix, BasicAuth, RateLimit, StripPrefix, …). The schema says: a middleware is one of these 25 things. The C# language has no native way to express that — record-based discriminated unions are still in proposal form.
The generator's compromise: emit a flat class with one nullable property per branch, plus a marker attribute the analyzer can find:
private static string EmitDiscriminatedClass(string ns, string className,
string? description, List<DiscriminatedBranch> branches)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
if (description is not null)
{
sb.AppendLine("/// <summary>");
sb.AppendLine($"/// {EscapeXml(description)}");
sb.AppendLine("/// </summary>");
}
sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]");
sb.AppendLine("[global::FrenchExDev.Net.Traefik.Bundle.Attributes.TraefikDiscriminatedUnion]");
sb.AppendLine($"public partial class {className}");
sb.AppendLine("{");
foreach (var branch in branches)
{
var propName = TraefikNamingHelper.ToPascalCase(branch.PropertyName);
var refClassName = TraefikNamingHelper.DefinitionToClassName(branch.RefName);
sb.AppendLine($" public {refClassName}? {propName} {{ get; set; }}");
sb.AppendLine();
}
sb.AppendLine("}");
return sb.ToString();
}private static string EmitDiscriminatedClass(string ns, string className,
string? description, List<DiscriminatedBranch> branches)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
if (description is not null)
{
sb.AppendLine("/// <summary>");
sb.AppendLine($"/// {EscapeXml(description)}");
sb.AppendLine("/// </summary>");
}
sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]");
sb.AppendLine("[global::FrenchExDev.Net.Traefik.Bundle.Attributes.TraefikDiscriminatedUnion]");
sb.AppendLine($"public partial class {className}");
sb.AppendLine("{");
foreach (var branch in branches)
{
var propName = TraefikNamingHelper.ToPascalCase(branch.PropertyName);
var refClassName = TraefikNamingHelper.DefinitionToClassName(branch.RefName);
sb.AppendLine($" public {refClassName}? {propName} {{ get; set; }}");
sb.AppendLine();
}
sb.AppendLine("}");
return sb.ToString();
}The [TraefikDiscriminatedUnion] attribute is the entire reason the runtime check in Part 6 and the IDE-time analyzer in Part 7 can find these classes. It's emitted on every oneOf definition and on nothing else. The analyzer literally checks for that one attribute by full metadata name.
The output: TraefikHttpMiddleware.g.cs
The 25-branch dynamic-config middleware union expands to:
// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.Traefik.Bundle;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
[global::FrenchExDev.Net.Traefik.Bundle.Attributes.TraefikDiscriminatedUnion]
public partial class TraefikHttpMiddleware
{
public TraefikAddPrefixMiddleware? AddPrefix { get; set; }
public TraefikBasicAuthMiddleware? BasicAuth { get; set; }
public TraefikBufferingMiddleware? Buffering { get; set; }
public TraefikChainMiddleware? Chain { get; set; }
public TraefikCircuitBreakerMiddleware? CircuitBreaker { get; set; }
public TraefikCompressMiddleware? Compress { get; set; }
public TraefikContentTypeMiddleware? ContentType { get; set; }
public TraefikDigestAuthMiddleware? DigestAuth { get; set; }
public TraefikErrorsMiddleware? Errors { get; set; }
public TraefikForwardAuthMiddleware? ForwardAuth { get; set; }
public TraefikGrpcWebMiddleware? GrpcWeb { get; set; }
public TraefikHeadersMiddleware? Headers { get; set; }
public TraefikIpWhiteListMiddleware? IpWhiteList { get; set; }
public TraefikIpAllowListMiddleware? IpAllowList { get; set; }
public TraefikInFlightReqMiddleware? InFlightReq { get; set; }
public TraefikPassTLSClientCertMiddleware? PassTLSClientCert { get; set; }
public TraefikPluginMiddleware? Plugin { get; set; }
public TraefikRateLimitMiddleware? RateLimit { get; set; }
public TraefikRedirectRegexMiddleware? RedirectRegex { get; set; }
public TraefikRedirectSchemeMiddleware? RedirectScheme { get; set; }
public TraefikReplacePathMiddleware? ReplacePath { get; set; }
public TraefikReplacePathRegexMiddleware? ReplacePathRegex { get; set; }
public TraefikRetryMiddleware? Retry { get; set; }
public TraefikStripPrefixMiddleware? StripPrefix { get; set; }
public TraefikStripPrefixRegexMiddleware? StripPrefixRegex { get; set; }
}// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.Traefik.Bundle;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
[global::FrenchExDev.Net.Traefik.Bundle.Attributes.TraefikDiscriminatedUnion]
public partial class TraefikHttpMiddleware
{
public TraefikAddPrefixMiddleware? AddPrefix { get; set; }
public TraefikBasicAuthMiddleware? BasicAuth { get; set; }
public TraefikBufferingMiddleware? Buffering { get; set; }
public TraefikChainMiddleware? Chain { get; set; }
public TraefikCircuitBreakerMiddleware? CircuitBreaker { get; set; }
public TraefikCompressMiddleware? Compress { get; set; }
public TraefikContentTypeMiddleware? ContentType { get; set; }
public TraefikDigestAuthMiddleware? DigestAuth { get; set; }
public TraefikErrorsMiddleware? Errors { get; set; }
public TraefikForwardAuthMiddleware? ForwardAuth { get; set; }
public TraefikGrpcWebMiddleware? GrpcWeb { get; set; }
public TraefikHeadersMiddleware? Headers { get; set; }
public TraefikIpWhiteListMiddleware? IpWhiteList { get; set; }
public TraefikIpAllowListMiddleware? IpAllowList { get; set; }
public TraefikInFlightReqMiddleware? InFlightReq { get; set; }
public TraefikPassTLSClientCertMiddleware? PassTLSClientCert { get; set; }
public TraefikPluginMiddleware? Plugin { get; set; }
public TraefikRateLimitMiddleware? RateLimit { get; set; }
public TraefikRedirectRegexMiddleware? RedirectRegex { get; set; }
public TraefikRedirectSchemeMiddleware? RedirectScheme { get; set; }
public TraefikReplacePathMiddleware? ReplacePath { get; set; }
public TraefikReplacePathRegexMiddleware? ReplacePathRegex { get; set; }
public TraefikRetryMiddleware? Retry { get; set; }
public TraefikStripPrefixMiddleware? StripPrefix { get; set; }
public TraefikStripPrefixRegexMiddleware? StripPrefixRegex { get; set; }
}This shape has one important property: it round-trips through YAML and JSON for free. YamlDotNet sees a class with 25 nullable properties; whichever one is set on the inbound side gets serialized, the others are omitted (because of OmitNull). On the outbound side, the deserializer fills in whichever property the YAML tag maps to. No custom converter, no [JsonConverter], no discriminator field.
The trade-off is that the type TraefikHttpMiddleware does not enforce "exactly one branch is set". The C# compiler will happily let you write:
new TraefikHttpMiddleware
{
AddPrefix = new() { Prefix = "/v1" },
BasicAuth = new() { Users = new() { "..." } }, // ← WRONG: two branches
}new TraefikHttpMiddleware
{
AddPrefix = new() { Prefix = "/v1" },
BasicAuth = new() { Users = new() { "..." } }, // ← WRONG: two branches
}Traefik will silently reject this configuration at startup (or worse, pick one and ignore the other). The next two chapters close that gap from two angles:
- Part 6 — the matching
TraefikHttpMiddlewareBuilder.g.csincludes a generated__setCount == 1epilogue that failsBuildAsyncif you populate more than one branch. - Part 7 —
DiscriminatedUnionAnalyzerraises TFK001 in the IDE the moment you write the offending object initializer, because the type carries the[TraefikDiscriminatedUnion]attribute.
The flat shape stays. The defenses are layered on top.
The static config root, for contrast
Just so you can see what a non-discriminated definition looks like, here is TraefikStaticConfig.g.cs — the root of traefik.yml:
// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.Traefik.Bundle;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class TraefikStaticConfig
{
public TraefikTypesAccessLog? AccessLog { get; set; }
public TraefikStaticAPI? Api { get; set; }
public global::System.Collections.Generic.Dictionary<string, TraefikStaticCertificateResolver>? CertificatesResolvers { get; set; }
public TraefikStaticCore? Core { get; set; }
public global::System.Collections.Generic.Dictionary<string, TraefikStaticEntryPoint>? EntryPoints { get; set; }
public TraefikStaticExperimental? Experimental { get; set; }
public TraefikStaticGlobal? Global { get; set; }
public TraefikTypesHostResolverConfig? HostResolver { get; set; }
public TraefikTypesTraefikLog? Log { get; set; }
public TraefikTypesMetrics? Metrics { get; set; }
public TraefikPingHandler? Ping { get; set; }
public TraefikStaticProviders? Providers { get; set; }
public TraefikStaticServersTransport? ServersTransport { get; set; }
public TraefikStaticSpiffeClientConfig? Spiffe { get; set; }
public TraefikStaticTCPServersTransport? TcpServersTransport { get; set; }
public TraefikStaticTracing? Tracing { get; set; }
}// <auto-generated/>
#nullable enable
namespace FrenchExDev.Net.Traefik.Bundle;
[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public partial class TraefikStaticConfig
{
public TraefikTypesAccessLog? AccessLog { get; set; }
public TraefikStaticAPI? Api { get; set; }
public global::System.Collections.Generic.Dictionary<string, TraefikStaticCertificateResolver>? CertificatesResolvers { get; set; }
public TraefikStaticCore? Core { get; set; }
public global::System.Collections.Generic.Dictionary<string, TraefikStaticEntryPoint>? EntryPoints { get; set; }
public TraefikStaticExperimental? Experimental { get; set; }
public TraefikStaticGlobal? Global { get; set; }
public TraefikTypesHostResolverConfig? HostResolver { get; set; }
public TraefikTypesTraefikLog? Log { get; set; }
public TraefikTypesMetrics? Metrics { get; set; }
public TraefikPingHandler? Ping { get; set; }
public TraefikStaticProviders? Providers { get; set; }
public TraefikStaticServersTransport? ServersTransport { get; set; }
public TraefikStaticSpiffeClientConfig? Spiffe { get; set; }
public TraefikStaticTCPServersTransport? TcpServersTransport { get; set; }
public TraefikStaticTracing? Tracing { get; set; }
}No discriminator attribute, no __setCount. Just 16 nullable properties — every Traefik static-config section in one place. Note the two Dictionary<string, …> properties for CertificatesResolvers and EntryPoints; these are the cases where the builder in Part 6 emits its dictionary-friendly WithEntryPoint(string key, Action<…>) overloads.