— Bd. I · Mai MMXXVI —
Bd. I · Mai MMXXVI $ cat ./trace.md (Bd. I)

Trace

# Magazin für Software-Entwicklung und .NET-Praxis

← Magazin 22. Mai 2026
Sprachen · 11 min

C# 13 nach sechs Monaten — was die kleinen Features in der Summe verändern

Primary Constructors aus C# 12, params Collections, Partial Properties und das neue Lock-Objekt aus C# 13 wirken einzeln unscheinbar. In der Summe verschieben sie das Schreibgefühl in mittleren und großen .NET-9-Codebasen spürbar — und stellen ein paar liebgewordene Patterns infrage.

C# 13 ist mit .NET 9 im November 2024 stabil geworden und damit zur Mai-Bilanz dieses Magazins gut sechs Monate produktiv im Einsatz. Im Vergleich zu C# 12 ist die Versionsliste kürzer und ärgert sich weniger an Rampenfeatures. Die Spezifikation in ECMA-334 hat sich um Detailerweiterungen verschoben, nicht um neue Paradigmen — und genau das macht die Bilanz interessant. Die Frage ist nicht mehr, ob Span<T> für einen Algorithmus die richtige Wahl ist, sondern wie sich die Summe der kleinen syntaktischen Verschiebungen aus C# 12 und 13 auf den Tonfall eines Codebase auswirkt. Die kurze Antwort: spürbar, aber nicht laut.

Wer im Mai 2026 eine .NET-9-Codebase pflegt, die im Sommer 2024 mit C# 11 angefangen hat, sieht dieselbe Domain in drei Sprachständen. Die ältesten Klassen tragen Konstruktoren mit Field-Assignments und readonly-Backings. Die mittleren nutzen Primary Constructors, die mit C# 12 stabil wurden. Die neuesten sind eine Mischung aus Primary Constructors, Collection Expressions und — seit C# 13 — params-Collections-Parametern, die IEnumerable<T>, Span<T> oder ReadOnlySpan<T> als Parameter akzeptieren, ohne dass der Aufrufer ein Array bauen muss. Das Ergebnis liest sich schlanker, hat aber Tücken in der Wartung.

Primary Constructors: das gewöhnungsbedürftige Verschwinden des Konstruktors

Primary Constructors für Klassen sind das C#-12-Feature, das in der ersten Adoptionsphase die meisten Diskussionen ausgelöst hat. Die Syntax ist denkbar kompakt:

// vorher (C# 11)
public sealed class InvoiceCalculator
{
    private readonly ITaxRateProvider _taxRates;
    private readonly IClock _clock;

    public InvoiceCalculator(ITaxRateProvider taxRates, IClock clock)
    {
        _taxRates = taxRates;
        _clock = clock;
    }

    public decimal Calculate(decimal net, string region) =>
        net + net * _taxRates.GetRate(region, _clock.Today);
}

// nachher (C# 12+)
public sealed class InvoiceCalculator(ITaxRateProvider taxRates, IClock clock)
{
    public decimal Calculate(decimal net, string region) =>
        net + net * taxRates.GetRate(region, clock.Today);
}

Auf den ersten Blick: weniger Boilerplate, fertig. In der Praxis sind drei Punkte zu lernen.

Erstens: Die Parameter sind keine Felder. Sie sind erfasste lokale Variablen der Klasse und werden vom Compiler in synthetisierte private Felder übersetzt, wenn sie nach dem Konstruktor verwendet werden. Das bedeutet, der Debugger und Profiler zeigen Namen wie <taxRates>P oder ähnliche compilergenerierte Symbole. Wer einen Memory-Profiler über eine Heap-Snapshot-Analyse jagt, sieht die Felder, aber nicht unter dem Namen, den der Quellcode suggeriert. Das ist nicht dramatisch, kostet aber in der ersten halben Stunde eines Tracing-Bugs Aufmerksamkeit.

Zweitens: Die Felder sind nicht readonly. Ein Primary-Constructor-Parameter kann in beliebigen Methoden der Klasse zugewiesen werden — was in der Konstruktor-Form mit explizitem readonly-Field unmöglich war. Die Empfehlung im Microsoft-Style-Guide und in der ECMA-334-Erläuterung ist, Parameter, die nicht verändert werden sollen, explizit als readonly-Field zu duplizieren:

public sealed class InvoiceCalculator(ITaxRateProvider taxRates, IClock clock)
{
    private readonly ITaxRateProvider _taxRates = taxRates;
    private readonly IClock _clock = clock;

    public decimal Calculate(decimal net, string region) =>
        net + net * _taxRates.GetRate(region, _clock.Today);
}

Das hebt einen Teil des Boilerplate-Gewinns wieder auf — verbessert aber die Lesbarkeit für Teams, die mit Java-Records oder Kotlin-Konstruktorparametern eingearbeitet sind und die Erwartung „Konstruktorparameter sind unveränderlich” mitbringen.

Drittens: Primary Constructors interagieren mit Vererbung schräg. base(...)-Aufrufe gehen in die Klassendeklaration selbst (public sealed class InvoiceCalculator(...) : BaseCalculator(taxRates)), nicht in einen separaten Konstruktorkörper. Das ist bei mehr als zwei abgeleiteten Ebenen schwer lesbar und im Code-Review-Workflow oft die Stelle, an der die Diskussion „lassen wir die alten Konstruktoren” eskaliert.

In den drei Codebasen, die ich für diesen Beitrag durchgesehen habe (zwei interne ERP-nahe .NET-9-Projekte, ein OSS-Repository für eine Document-DB-Engine), liegt die Adoptionsrate von Primary Constructors für Klassen zwischen 40 und 70 Prozent. Records und Struct-Records sind durchgängig auf Primary-Form, Klassen gemischt — und das Muster zeigt sich klar: Service-Klassen mit Dependency Injection bekommen Primary Constructors, Domain-Entities und Aggregat-Roots bleiben überwiegend auf der klassischen Form. Die Begründung der Teams ist konsistent: Domain-Logik will explizite Invariantenprüfung im Konstruktor, und die ist im Primary-Constructor-Stil syntaktisch unhandlich.

params Collections: das stille Detail mit großem Performance-Schwung

Der Schritt von C# 12 zu C# 13 hat im Sprachbereich ein Detail mitgebracht, das in der Roadmap kaum jemand auf der Watchlist hatte: params akzeptiert ab C# 13 nicht mehr nur Arrays, sondern jeden Collection-Type, der vom Compiler als Target von Collection Expressions erkannt wird. Konkret: IEnumerable<T>, IReadOnlyList<T>, List<T>, Span<T>, ReadOnlySpan<T> und alle anderen Typen mit passender Builder-Annotation.

// vorher (C# 12 und früher)
public static int Sum(params int[] values)
{
    var total = 0;
    foreach (var v in values) total += v;
    return total;
}

// nachher (C# 13)
public static int Sum(params ReadOnlySpan<int> values)
{
    var total = 0;
    foreach (var v in values) total += v;
    return total;
}

Der Aufruf Sum(1, 2, 3, 4) sieht identisch aus. Der entscheidende Unterschied liegt in der CIL-Ebene: Die C#-12-Variante alloziert pro Aufruf ein int[4] auf dem Heap. Die C#-13-Variante mit ReadOnlySpan<int> legt die vier Werte auf den Stack des Aufrufers (stackalloc im Compiler-Hintergrund) und reicht den Span weiter. In hot paths — Telemetrie-Aufrufe, Logging-Format-Builder, Parser-Inner-Loops — sind das in den von mir vermessenen Benchmarks 30 bis 60 Prozent weniger GC-Druck.

Die Empfehlung der .NET-Runtime-Team-Engineering-Blog-Beiträge und der Korrespondenz auf dem dotnet/runtime-GitHub-Repository ist eindeutig: Für neue API-Designs ist params ReadOnlySpan<T> der Default, params T[] der Legacy-Fallback. Bestehende APIs werden mit Overloads ergänzt, die die Span-Form anbieten — siehe etwa string.Concat, string.Format, Console.WriteLine und viele Logger-APIs, die in .NET 9 / .NET 10 Preview entsprechende Overloads bekommen haben.

Eine Nebenwirkung in der Migration: Wer eine Methode mit params T[] auf params ReadOnlySpan<T> umstellt, muss prüfen, ob Aufrufer den Array-Wert weiterverwenden — etwa var args = new[] { 1, 2, 3 }; Sum(args); SaveSomewhere(args);. Die Span-Form akzeptiert das Array zwar implizit, aber das ist eine subtile Lebensdauer-Frage. Die Roslyn-Analyzer warnen in den meisten Fällen; in legacy code paths gibt es leise Verhaltensänderungen, die in Code-Reviews aufmerksam zu prüfen sind.

Partial Properties: das Feature für die Source-Generatoren-Welt

Partial Properties in C# 13 sind der weniger spektakuläre Nachzügler zu Partial Methods und Partial Classes. Eine Property kann jetzt in einer Partial-Class-Datei deklariert und in einer anderen implementiert werden:

// File A: Domain.Generated.cs (von Source Generator erzeugt)
public partial class CustomerView
{
    public partial string DisplayName { get; }
}

// File B: Domain.cs (handgeschrieben)
public partial class CustomerView
{
    public partial string DisplayName => $"{LastName}, {FirstName}";
}

Der unmittelbare Anwendungsfall sind Source Generators, die etwa für ein DTO oder ein Mapping ein Set von Properties anlegen, aber dem Entwickler die Implementierung individueller Properties zur Anpassung überlassen. Vor C# 13 musste man das über Partial Methods oder über kompliziertere Patterns mit virtuellen Properties lösen — beides war nicht ideal.

In der Praxis sehen wir Partial Properties vor allem in zwei Kontexten. Erstens in EF-Core-9-Codebasen, wo der EF-Core-Code-Generator inzwischen Partial Properties für berechnete Spalten erzeugen kann, deren Implementierung der Entwickler überschreibt. Zweitens in JSON-Serialisierungs-Source-Generatoren, etwa in System.Text.Json-Custom-Setups, wo bestimmte Properties eine Konvertierungs-Logik brauchen, die der Generator nicht kennen kann.

Der Sprach-Theorie nach sind Partial Properties auch ein Schritt in Richtung der seit Jahren diskutierten Trennung zwischen Property-Deklaration und -Implementierung — vergleichbar mit C++-Header-Files oder Java-Interfaces mit Default-Methoden. In der C#-Welt bleibt das ein begrenztes Werkzeug, weil die Sprache Interface-Properties bereits seit C# 8 mit Default-Implementierung kennt. Wer in C# 13 anfängt, Domain-Logik in Partial Properties zu zerlegen, ohne dass ein Source Generator beteiligt ist, baut sich vermutlich ein Wartungsproblem.

Das Lock-Objekt-Pattern: ABI-relevant, leise hinzugekommen

Das technisch interessanteste C#-13-Detail ist das neue System.Threading.Lock-Objekt — eine kleine Klasse, die als Drop-in-Replacement für object-basierte Locks dient und gegenüber dem klassischen lock(syncRoot) einen niedrigeren Overhead hat. Der Compiler erkennt lock-Statements auf Lock-Instanzen und übersetzt sie nicht in Monitor.Enter / Monitor.Exit, sondern in spezialisierte Methoden des Lock-Typs.

// vorher
public class Cache
{
    private readonly object _syncRoot = new();
    private Dictionary<string, byte[]> _data = new();

    public void Set(string key, byte[] value)
    {
        lock (_syncRoot)
        {
            _data[key] = value;
        }
    }
}

// nachher (C# 13)
public class Cache
{
    private readonly Lock _syncRoot = new();
    private Dictionary<string, byte[]> _data = new();

    public void Set(string key, byte[] value)
    {
        lock (_syncRoot)
        {
            _data[key] = value;
        }
    }
}

Im Quelltext fast identisch, im Detail interessant. Die Lock-Klasse erlaubt explizit den Scope-basierten Zugriff:

public void Set(string key, byte[] value)
{
    using (_syncRoot.EnterScope())
    {
        _data[key] = value;
    }
}

Das ist nützlich in async-nahen Szenarien, in denen lock-Statements nicht über await hinweg gehalten werden dürfen (CS9216, neu in C# 13) — der Scope-Stil zwingt die explizite Lifetime-Verwaltung.

Die ABI-Implikation ist nicht offensichtlich. Wer eine bestehende public-Klasse mit object-Lock auf Lock-Lock umstellt und das Lock-Feld ist public oder protected, ändert die Binärsignatur — Konsumenten der Assembly, die auf das Feld zugreifen (selten, aber möglich), brechen. Die Empfehlung der Roslyn-Analyzer ist: Lock-Felder grundsätzlich private und das Refactoring auf Lock an dieser Stelle ohne Sorge durchführen. Public-API-Locks sind ohnehin ein Anti-Pattern.

Collection Expressions als Brücke

Aus C# 12 ist die syntaktische Verbindung zwischen Array-Initializer und IEnumerable<T> zu einem einheitlichen Collection-Expression-Stil gewachsen:

int[] xs = [1, 2, 3];
List<int> ys = [1, 2, 3, ..xs];
Span<int> zs = [1, 2, 3];
ReadOnlySpan<int> ws = [1, 2, 3];

Der ..-Spread-Operator ist hier der Hebel: Wer aus mehreren Quellen eine neue Collection bauen will, schreibt das in einer Zeile. In Kombination mit params Collections aus C# 13 reduziert das die Häufigkeit von .ToList()- und .ToArray()-Aufrufen in der Kette spürbar — und damit auch die Anzahl von Heap-Allokationen.

In den vermessenen Codebasen ist die Mischung aus Collection Expressions und Span-basierten params der einzige Sprach-Hebel, der einen messbaren Performance-Effekt ohne Algorithmus-Änderung erzeugt hat. Die Größenordnung in Mid-Tier-Web-API-Workloads: 5 bis 12 Prozent weniger Gen-0-GC pro Request — bei Codebasen, die das ohne Hot-Path-Refactoring konsequent nutzen.

Implicit Index Access und die neue Escape-Sequenz

Zwei kleine Detail-Features in C# 13, die in der Diskussion kaum vorkamen, aber in spezifischen Kontexten wertvoll sind: der implizite Index-Access innerhalb von Objekt-Initialisierern und die neue Escape-Sequenz \e für das ESC-Zeichen (ASCII 0x1B).

// Implicit Index Access (C# 13)
var buffer = new Buffer
{
    Items = { [^1] = 42 } // Wert am letzten Index überschreiben
};

// Neue Escape-Sequenz (C# 13)
Console.WriteLine("\e[31mFehler\e[0m");

Der zweite Punkt ist für jeden Console-Application-Code, der ANSI-Escape-Sequenzen für Farbausgabe nutzt, eine kleine Komfort-Erleichterung. Vor C# 13 musste man ... oder (char)0x1B + "[31m..." schreiben — beides funktioniert, ist aber nicht schön. Im Vergleich zu Java, das diese Escape-Sequenz nie standardisiert hat, oder zu Rust, das sie ebenfalls als \x1B lässt, ist C# damit pragmatisch.

Die Summe der Effekte

Einzeln sind die C#-13-Features kleine Schritte. In der Summe verändern sie das Schreibgefühl in einer .NET-9-Codebase in drei Punkten.

Erstens, weniger Allokationen für gleichen Code. Wer mit params ReadOnlySpan<T> und Collection Expressions konsequent neu schreibt, hat in API-nahen Kontexten messbar weniger GC-Druck. Das ist kein Quantensprung, aber der Effekt summiert sich über eine ganze Service-Schicht.

Zweitens, kürzere Klassen-Header. Primary Constructors verschieben den Visual-Anker einer Service-Klasse von „eigener Konstruktorblock” zu „Class-Signatur als Vertrag”. In Code-Reviews wird die Aufmerksamkeit auf die richtigen Stellen gelenkt: nicht mehr auf das Verschieben von Konstruktorparametern in Felder, sondern auf die tatsächliche Methodenlogik.

Drittens, subtile Brüche im mentalen Modell. Die Trennung zwischen Konstruktorparameter und Field, die in C# über 20 Jahre stabil war, ist mit Primary Constructors aufgeweicht. Das ist gewöhnungsbedürftig, vor allem für Teams, die regelmäßig zwischen C#, Java und Kotlin wechseln und in jeder Sprache eine andere Konstruktor-Semantik mitbringen. Die Diskussion in Code-Reviews ist hier — sechs Monate nach .NET 9 — noch nicht überall ausgewertet.

Die ECMA-334-Spezifikation bleibt die verlässliche Referenz für die Detailfragen. Wer mit dem Sprachstand aus C# 11 in eine C#-13-Codebase einsteigt, verbringt etwa zwei Wochen damit, die neuen Patterns einzuordnen — und stellt danach fest, dass die Tonart nicht radikal anders ist. Das ist genau das Bild, das die kleinen Versionssprünge der Sprache seit C# 10 zeichnen: Konsolidierung, nicht Revolution.

Was in C# 14 / .NET 10 in der Preview steckt

Die .NET-10-Preview, die seit Februar 2026 verfügbar ist, deutet auf weitere kleine Verschiebungen hin. Field Keywords für Properties (get => field, set => field = value) sind im Preview und versprechen, dass Auto-Properties mit einfacher Validierungslogik ohne Backing-Field auskommen. Extension Types (Discussion-State, noch nicht final) sind die strukturierte Antwort auf das, was Extension Methods nur halb leisten konnten. Beide Features sind im Mai 2026 noch nicht stable — die Preview-Zyklen sind aber öffentlich, und der Tonfall in den Sprache-Design-Notes auf GitHub deutet auf einen frühen RTM hin (Q4 2026 für .NET 10 LTS).

Für die Praxis 2026 heißt das: C# 13 wird in den nächsten 12 Monaten die Referenz bleiben, an der sich neue Codebasen orientieren. Die Umstellung von C# 12 auf C# 13 ist sanft, die Umstellung von älteren Versionen lohnt sich — vor allem wegen der params-Collections und der Lock-Klasse. Wer 2026 noch auf C# 10 oder älter sitzt, lebt nicht falsch, aber er verzichtet auf die kleinen Verbesserungen, die sich in der Summe lohnen. Die Migration ist in den meisten Fällen ein Tag Arbeit pro Service — und ein Schritt, den die Roadmap der eigenen Codebase ohnehin früher oder später einplant.


Ressort: Sprachen