target IEnumerable<T>, List<T>, Span<T>, and ReadOnlySpan<T>.
// Before: forces boxing or manual ToList()
public void DispatchEvents(params object[] events) { ... }
// After: zero-allocation for spans, deferred-friendly for enumerables
public void DispatchEvents(params ReadOnlySpan<EventPayload> events)
{
foreach (var evt in events)
_queue.Enqueue(evt);
}
// Flexible API for heterogeneous sources
public void DispatchEvents(params IEnumerable<EventPayload> events)
{
if (events is null) return;
_queue.AddRange(events);
}
Architecture Rationale: Use ReadOnlySpan<T> for hot paths where data originates from stack-allocated buffers or arrays. Use IEnumerable<T> for public APIs where callers may pass LINQ queries or database readers. The compiler generates the appropriate overload resolution, eliminating manual materialization while preserving deferred execution semantics.
Step 2: Implement Partial Properties and Fields
Partial classes previously restricted partial to methods, types, and constructors. C# 13 extends it to properties, fields, and events, enabling clean separation between generated state and manual logic.
// Generated partial (e.g., from source generator or designer)
public partial class EventDispatcher
{
public partial string SchemaVersion { get; }
public partial int MaxRetries { get; set; }
}
// Manual partial
public partial class EventDispatcher
{
public partial string SchemaVersion => "1.3.0";
public partial int MaxRetries { get; set; } = 3;
public void Configure() => _config.Apply(MaxRetries, SchemaVersion);
}
Architecture Rationale: Partial properties eliminate backing field duplication and force explicit implementation across partial files. This pattern integrates cleanly with source generators, keeping generated contracts visible and manually implemented defaults centralized. Avoid mixing accessibility modifiers across partials; the compiler enforces consistency.
Step 3: Replace object Locks with System.Threading.Lock
The traditional lock(object) pattern allocates a reference type with synchronization block overhead. C# 13 introduces System.Threading.Lock, a ref struct optimized for stack allocation and lower contention.
// Before: heap-allocated, sync block overhead
private readonly object _syncRoot = new();
public void UpdateState()
{
lock (_syncRoot) { _state = Compute(); }
}
// After: stack-allocated, reduced header overhead
private readonly Lock _stateLock = new();
public void UpdateState()
{
lock (_stateLock) { _state = Compute(); }
}
Architecture Rationale: Lock is a ref struct, meaning it cannot be boxed, stored on the heap, or captured by async state machines. This constraint is intentional: it forces synchronous critical sections and eliminates async lock anti-patterns. Use Lock for CPU-bound mutations and short-lived I/O coordination. For async boundaries, prefer SemaphoreSlim or AsyncLock wrappers.
Step 4: Apply Raw String Literal Escape Sequences
Raw string literals ("""...""") previously required brace doubling ({{/}}) for interpolation escapes, which degraded readability. C# 13 introduces $-prefixed escaping within raw strings.
// Before: brace doubling, hard to scan
var template = """
{
"event": "{{name}}",
"metadata": {
"source": "{{source}}"
}
}
""";
// After: $-escaping, compiler-validated
var template = $$"""
{
"event": "{{name}}",
"metadata": {
"source": "{{source}}"
}
}
""";
Architecture Rationale: The $ prefix before """ enables {{ and }} to represent literal braces without doubling. The compiler validates brace counts against interpolation depth, shifting syntax errors to compile time. Use this for JSON templates, SQL scripts, and structured log formats where brace density exceeds three per line.
Pitfall Guide
-
Deferred Execution Surprises with params IEnumerable<T>
- Mistake: Assuming
params IEnumerable<T> materializes immediately. If callers pass LINQ queries, multiple enumerations trigger redundant database calls or file reads.
- Mitigation: Document enumeration behavior explicitly. Convert to
List<T> internally if mutation or multiple passes are required. Use ReadOnlySpan<T> when immediate evaluation is mandatory.
-
Lock Struct in Async Contexts
- Mistake: Attempting to capture
Lock in an async lambda or storing it in a class field that crosses await boundaries.
- Mitigation:
Lock is a ref struct. It cannot be used in async methods, iterators, or lambda captures. Restrict usage to synchronous critical sections. For async coordination, use SemaphoreSlim or AsyncReaderWriterLock.
-
Partial Property Accessibility Conflicts
- Mistake: Defining
public partial string Name { get; } in one file and private partial string Name { get; set; } in another.
- Mitigation: The compiler requires identical accessibility and signature across partials. Enforce consistency via analyzer rules. Generate only the contract; implement defaults in the manual partial.
-
Raw String Brace Count Mismatch
- Mistake: Using
$$""" but providing only {{ instead of {{{ for triple-brace literals, or vice versa.
- Mitigation: Each
$ adds one level of escape. $$ requires {{ for a literal {. Count interpolation depth before templating. Use IDE brace-matching overlays during authoring.
-
Null Handling in params Collections
- Mistake: Assuming
params IEnumerable<T> rejects null. It accepts null, which causes NullReferenceException during enumeration.
- Mitigation: Add null guards at API boundaries:
if (items is null) return;. Prefer ReadOnlySpan<T> for value-type parameters where null is impossible.
-
Mixing Lock with Traditional object Locks
- Mistake: Using
lock(_lockStruct) and lock(_objectRoot) on the same resource, causing lock ordering violations and deadlocks.
- Mitigation: Standardize on
Lock for new codebases. If migrating, wrap legacy object locks in a dedicated synchronization layer and phase out gradually. Never interleave lock types on shared state.
-
Span<T> Lifetime Violations
- Mistake: Returning a
Span<T> from a method or storing it in a class field, causing stack-escape or use-after-free.
- Mitigation:
Span<T> is stack-only. Use Memory<T> or ArraySegment<T> for heap-bound or long-lived buffers. Validate span usage with Span<T>.DangerousGetPinnableReference() only in interop scenarios.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-frequency event dispatch (<10ΞΌs latency) | params ReadOnlySpan<T> + Lock struct | Zero allocation, stack-bound synchronization, minimal contention | Low (refactor time), High ROI (GC reduction) |
| Public API accepting heterogeneous collections | params IEnumerable<T> with explicit enumeration contract | Flexible input, deferred execution support, backward compatible | Medium (documentation overhead), Low runtime cost |
| Async state coordination with await boundaries | SemaphoreSlim or AsyncLock wrapper | Lock struct is ref struct and cannot cross await | Low (minor API change), Neutral runtime impact |
| JSON/SQL templates with >3 braces per line | $$""" raw string literals | Compiler-validated escaping, eliminates brace-doubling noise | Low (syntax update), High (defect reduction) |
| Source generator + manual class split | partial properties/fields | Enforces contract consistency, removes backing field duplication | Medium (generator adjustment), Low maintenance |
Configuration Template
<!-- .csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>13</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
<PackageReference Include="System.Threading.Lock" Version="9.0.0" />
</ItemGroup>
</Project>
// Production-ready dispatcher template
using System;
using System.Collections.Generic;
using System.Threading;
public sealed class EventDispatcher : IDisposable
{
private readonly Lock _stateLock = new();
private readonly Queue<EventPayload> _queue = new();
private bool _disposed;
public partial string SchemaVersion { get; }
public partial int MaxRetries { get; set; }
public partial string SchemaVersion => "1.3.0";
public partial int MaxRetries { get; set; } = 3;
// Hot path: zero allocation
public void Dispatch(params ReadOnlySpan<EventPayload> events)
{
if (_disposed) throw new ObjectDisposedException(nameof(EventDispatcher));
lock (_stateLock)
{
foreach (var evt in events)
_queue.Enqueue(evt);
}
}
// Flexible API: deferred-friendly
public void Dispatch(params IEnumerable<EventPayload> events)
{
if (events is null) return;
if (_disposed) throw new ObjectDisposedException(nameof(EventDispatcher));
lock (_stateLock)
{
foreach (var evt in events)
_queue.Enqueue(evt);
}
}
public string FormatLogTemplate(string name, string source)
{
// C# 13 raw string escape
return $$"""
{
"event": "{{name}}",
"metadata": {
"source": "{{source}}",
"schema": "{{SchemaVersion}}",
"retries": {{MaxRetries}}
}
}
""";
}
public void Dispose()
{
_disposed = true;
}
}
public readonly record struct EventPayload(string Id, DateTime Timestamp, string Type);
Quick Start Guide
- Set Language Version: Add
<LangVersion>13</LangVersion> to your .csproj and target net9.0 or later.
- Replace Lock Primitives: Swap
private readonly object _lock = new(); with private readonly Lock _lock = new(); and verify no async captures exist.
- Convert Collection Parameters: Change
params object[] or manual ToList() bridges to params IEnumerable<T> for flexible APIs or params ReadOnlySpan<T> for hot paths.
- Update String Templates: Replace brace-doubled interpolated strings with
$$""" raw literals where brace density exceeds three per line.
- Validate & Profile: Build, run unit tests, and execute
dotnet-counters monitor --process-id <pid> to verify reduced Gen 0 allocations and lock contention metrics.