Goodbye Switch, Hello Static Polymorphism
C# Domain Models Made Easy and Extensible

I’m an experienced software developer and technical leader working in the software industry since 2004. My interests are in software architecture, functional programming, web development, DevOps, cloud computing, Agile, and much more.
Introduction
For years, C# developers have juggled extensibility and type-safety, especially when mapping external inputs to domain types. The classic solutions - switch statements, reflection-based factories, attributes - often leave you with brittle code that grows harder to maintain with each new requirement. But C# 11 introduces a major upgrade: static abstract members in interfaces, bringing type-safe static polymorphism to the language. For Domain-Driven Design (DDD) scenarios, this means you can finally model pluggable, pattern-driven registries in a truly type-safe and discoverable way.
Let’s explore this by building a robust voucher system, where codes can be either exact matches or pattern-based, and see how static abstract members and modern C# patterns work together to produce clean, extensible code.
Static Duck Typing
Duck typing (from dynamic languages like Python) means:
“If it walks like a duck and quacks like a duck, treat it as a duck.”
In static duck typing (enabled by static abstract members in C# 11), the compiler checks at compile time that your type has the required static members.
So, when you constrain a generic type parameter with an interface containing static abstract members, the compiler ensures that any type you use actually implements those static methods/properties/operators.
Extensible Factory Logic in Domain Modeling
Imagine you’re designing a voucher system for a digital platform. Some voucher codes are fixed - think "BASIC" or "PREMIUM". Others are more dynamic, like codes prefixed with "SER-" or suffixed with "-BIZ". The challenge is mapping untrusted input strings to strong, intention-revealing types, all while making it easy for the business to add new voucher types and patterns as the platform evolves.
How do you map these strings to strong domain types? Classic approaches always end up as a compromise:
If you use enums, you’re stuck the moment someone requests “prefix-based codes.”
If you use a switch statement, you have to update it everywhere, risking bugs as you grow.
If you use reflection or attributes, you pay with runtime errors, fragile logic, and awkward onboarding for every new developer who joins the team.
The key challenge: how do you keep your code both open for extension and type-safe, while also minimizing the risk of human error when introducing new types?
Building a Type-Safe, Pluggable Voucher Registry
With static abstract members, suddenly the impossible is easy. Let’s build a voucher system as an example - no reflection, no runtime registry, no leaky base classes. All the logic for each voucher type lives on the type itself. Let’s look at the code:
Core Implementation
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
public interface IVoucherExact
{
// "Static duck typing": implementors must expose a static property Code.
static abstract string Code { get; }
}
public interface IVoucherPattern
{
// Lower number = higher priority
static abstract int Priority { get; }
// Recognize strings and construct a Voucher if it matches the pattern.
static abstract bool TryParse(string s, out Voucher result);
}
public abstract record Voucher(string Code)
{
private static readonly Dictionary<string, Func<Voucher>> _factories =
new(StringComparer.OrdinalIgnoreCase);
private static readonly List<(int Priority, Func<string, Voucher?> Parse)> _parsers = [];
// Register exact-code voucher types. No reflection, no strings scattered.
private static void Register<T>() where T : Voucher, IVoucherExact, new()
=> _factories[T.Code] = static () => new T();
// Register pattern voucher types.
private static void RegisterPattern<T>() where T : Voucher, IVoucherPattern
=> _parsers.Add((T.Priority, static s => T.TryParse(s, out var v) ? v : null));
// Bootstrap once
[ModuleInitializer]
internal static void Init()
{
// Exact-code vouchers
Register<Basic>();
Register<Premium>();
Register<Student>();
// Pattern vouchers
RegisterPattern<Series>(); // SER-123
RegisterPattern<Business>(); // ...-B2B
// Ensure deterministic pattern order
_parsers.Sort((a, b) => a.Priority.CompareTo(b.Priority));
}
public static Voucher From(string raw)
{
// 1) Pattern types first
foreach (var (_, parse) in _parsers)
{
var v = parse(raw);
if (v is not null) return v;
}
// 2) Exact matches
return _factories.TryGetValue(raw, out var factory)
? factory()
: new Other(raw);
}
private sealed record Other : Voucher
{
public Other(string raw) : base(raw) {}
}
// === Exact-code vouchers: single source of truth (Code lives on the type) ===
public sealed record Basic() : Voucher(Code), IVoucherExact { public new static string Code => "BASIC"; }
public sealed record Premium() : Voucher(Code), IVoucherExact { public new static string Code => "PREMIUM"; }
public sealed record Student() : Voucher(Code), IVoucherExact { public new static string Code => "STUDENT"; }
// === Pattern 1: "SER-<number>" => SeriesVoucher ===
public sealed record Series(int Number)
: Voucher($"SER-{Number}"), IVoucherPattern
{
public static int Priority => 0;
public static bool TryParse(string s, out Voucher result)
{
result = null!;
if (!s.StartsWith("SER-", StringComparison.OrdinalIgnoreCase)) return false;
if (!int.TryParse(s.AsSpan(4), out var n)) return false;
result = new Series(n);
return true;
}
}
// === Pattern 2: suffix decorator "...-BIZ" wrapping any inner voucher ===
public sealed record Business(Voucher Inner)
: Voucher($"{Inner.Code}-BIZ"), IVoucherPattern
{
public static int Priority => 10;
public static bool TryParse(string s, out Voucher result)
{
result = null!;
const string suffix = "-BIZ";
if (!s.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) return false;
var innerRaw = s[..^suffix.Length];
result = new Business(From(innerRaw)); // recurse into full logic
return true;
}
}
public override string ToString() => Code;
}
Prove It Works
Now, let's see how we could use it, and the best way to demonstrate it is to write tests:
using Xunit;
public static class VoucherTests
{
[Fact]
public static void Exact_match_basic_creates_Basic_type()
{
var v = Voucher.From("BASIC");
Assert.IsType<Voucher.Basic>(v);
Assert.Equal("BASIC", v.Code);
}
[Fact]
public static void Exact_match_is_case_insensitive()
{
var v = Voucher.From("premium");
Assert.IsType<Voucher.Premium>(v);
Assert.Equal("PREMIUM", v.Code);
}
[Fact]
public static void Prefix_pattern_series_parses_id()
{
var v = Voucher.From("SER-123");
var series = Assert.IsType<Voucher.Series>(v);
Assert.Equal(123, series.Number);
Assert.Equal("SER-123", series.Code);
}
[Fact]
public static void Suffix_pattern_business_wraps_inner_exact_basic()
{
var v = Voucher.From("BASIC-BIZ");
var biz = Assert.IsType<Voucher.Business>(v);
Assert.IsType<Voucher.Basic>(biz.Inner);
Assert.Equal("BASIC-BIZ", biz.Code);
}
[Fact]
public static void Unknown_code_falls_back_to_raw_other()
{
var v = Voucher.From("SOMETHING_ELSE");
// It's not a known type; we can only assert it wasn't parsed to a known one.
Assert.NotNull(v);
Assert.Equal("SOMETHING_ELSE", v.Code);
Assert.NotEqual("BASIC", v.Code);
Assert.NotEqual("PREMIUM", v.Code);
}
}
How It Works
Static Duck Typing
The real breakthrough here is the idea of “static duck typing.” Our interfaces, like IVoucherExact and IVoucherPattern, don’t just declare instance methods. They require certain static members to exist. If you implement IVoucherExact, the compiler will slap your wrist if you forget to declare a public static property Code.
Factory Registration
Look how we register voucher types:
private static void Register<T>() where T : Voucher, IVoucherExact, new()
=> _factories[T.Code] = static () => new T();
No more hard-coding string keys or scanning assemblies for types. The type itself is the source of truth. If you want to add a new exact-match voucher, you just create a new record that implements IVoucherExact, and register it once in the Init() method (or use auto-registration code).
Pattern-Based Polymorphism
Not all vouchers are simple one-to-one mappings. Some follow patterns, like “starts with SER-” or “ends with -BIZ.” Here, static abstract members shine again: each type that wants to parse patterns implements IVoucherPattern, providing its own TryParse logic and a Priority to control evaluation order. When you call Voucher.From(raw), it tries pattern matchers in order, then falls back to exact matches, and finally, if nothing fits, creates a generic “Other” voucher.
The kicker? If your business wants a new pattern, you just add a type with its own TryParse and registration line.
Extensibility
Imagine adding a new voucher tomorrow:
public sealed record EmployeeVoucher(string StaffId)
: Voucher($"EMP-{StaffId}"), IVoucherPattern
{
public static int Priority => 5;
public static bool TryParse(string s, out Voucher result) { ... }
}
Implement the interface, register in Init(), done. Your architecture doesn’t have to care what crazy codes the marketing team invents next month.
How to Improve
While reflection-based auto-registration is pragmatic and easy to implement, it does have trade-offs: it still involves runtime type scanning and may not catch every edge case at compile-time. You could start with manual registration like in example and move to auto-registration when you have necessity or If you want to push automation even further, C# Source Generators can be used to generate registration code at build-time, offering pure compile-time safety. This approach, however, requires setting up an additional generator project and is best suited for larger or library-grade codebases.
What’s the Catch With Old Approaches?
Before static abstract members, you’d be forced into runtime solutions: reflection, dynamic, attributes, or endless registry boilerplate. You couldn’t force a type to provide a static method or property, so you’d end up with code like typeof(T).GetProperty("Code"), or worse - having to cast everything back and forth just to call something that should be statically known.
This gets fragile fast. Forget a registration, misspell a code, or mistype an attribute? Runtime error, not a compile error. And onboarding new types is a risky process - does this code even get picked up by the system? Did you forget to wire it in two different places?
With static abstract members, C# brings this discipline to the language level. If you forget something, it won’t even build.
Summary
Static abstract members in C# 11 have opened up a new, type-safe way to build extensible factories and registries for DDD patterns. By leveraging static contracts and reflection-based auto-registration, you can build systems that are both easy to extend and safe to maintain. Adding new domain types is straightforward - just declare your record and implement the necessary interface.
While full compile-time automation is possible with source generators, for many teams a simple reflection scan at startup provides the flexibility and ease of extension needed for fast-moving projects. This approach keeps domain logic close to the types themselves and reduces the overhead of maintaining large registries as your system grows.
Static abstract members, combined with smart registration, make it practical to design pluggable, future-proof domain models in modern C# - without sacrificing clarity or type-safety.



