deliver immediate production value without disrupting existing contracts.
Step 1: Replace Array-Based params with Collection-Agnostic Signatures
C# 13 expands params beyond arrays. Any type implementing GetEnumerator() can now serve as the target. This enables zero-allocation spans and direct list passing.
// C# 12: Forces array allocation
public void ProcessEvents(params string[] events) { ... }
// C# 13: Accepts spans, lists, or arrays without boxing
public void ProcessEvents(params ReadOnlySpan<string> events) { ... }
// Usage remains identical, but runtime behavior changes
ProcessEvents("login", "checkout", "payment"); // Stack-only span
ProcessEvents(new List<string> { "login", "checkout" }); // Direct enumeration
Architecture Rationale: Use ReadOnlySpan<T> for hot paths where input originates from stack or pooled buffers. Use IEnumerable<T> or List<T> when the caller already owns a collection. Avoid mixing params with async methods that capture the span, as span lifetime is stack-bound.
Step 2: Implement ref Partial Methods for Code Generation Hot Paths
Partial methods previously restricted ref parameters and return types. C# 13 lifts this, enabling zero-copy data flow between generated and handwritten code.
// Generated.cs (auto-created by source generator)
public partial class PipelineContext
{
public partial ref byte GetBufferReference();
}
// Handwritten.cs (developer-maintained)
public partial class PipelineContext
{
private byte[] _buffer = ArrayPool<byte>.Shared.Rent(1024);
public partial ref byte GetBufferReference() => ref _buffer[0];
}
Architecture Rationale: Reserve ref partials for performance-critical boundaries (e.g., serialization, network I/O, game loops). The compiler enforces signature parity; if the implementation is missing, the call site is elided entirely, preventing null reference exceptions.
Step 3: Apply Default Parameters to Lambda Expressions
Inline delegates now support default values, reducing delegate factory boilerplate.
// C# 12
Func<int, int, int> add = (a, b) => a + b;
var addFive = add(5, 0);
// C# 13
Func<int, int, int> add = (int a = 0, int b = 0) => a + b;
var addFive = add(5); // b defaults to 0
Architecture Rationale: Use default lambda parameters only for simple, deterministic defaults. Avoid complex expressions in defaults; they execute at invocation, not definition. This feature shines in event routing, configuration builders, and test factories.
Step 4: Enforce required Members Across Inheritance Hierarchies
C# 13 strengthens required member enforcement. Omitted required members now trigger compiler warnings in derived types, and serialization frameworks receive explicit hints.
public abstract class CommandBase
{
public required string CorrelationId { get; init; }
public required DateTime Timestamp { get; init; }
}
public class CreateUserCommand : CommandBase
{
public required string Email { get; init; }
public required string PasswordHash { get; init; }
// Compiler warns if CorrelationId or Timestamp are not initialized
}
Architecture Rationale: Pair required members with init setters for immutable DTOs. Configure System.Text.Json with JsonSerializerOptions.Default to respect required attributes during deserialization. This eliminates defensive null checks and contract validation middleware.
Pitfall Guide
-
Assuming params collections are always allocation-free
params collections eliminate array allocation, but generic constraints or boxing can reintroduce heap pressure. Always verify the concrete type passed at the call site. Use ReadOnlySpan<T> for stack-only scenarios; avoid IEnumerable<T> when the underlying source is a LINQ query that materializes.
-
Misaligning ref partial method signatures
The compiler treats missing ref partial implementations as elided calls, not errors. If the signature drifts between generated and handwritten files, the call silently disappears. Enforce signature parity with Roslyn analyzers and CI validation.
-
Using default lambda parameters with expression trees
Expression<Func<...>> does not support default parameters. The compiler cannot translate defaults into expression nodes. If you need defaults in LINQ providers or ORM query builders, use wrapper methods instead.
-
Breaking JSON deserialization with required members
System.Text.Json respects required attributes, but older converters or custom JsonConverter<T> implementations ignore them. Always validate deserialization paths. Add [JsonRequired] explicitly if using third-party serializers.
-
Capturing ReadOnlySpan<T> in async state machines
Spans cannot cross await boundaries. Passing a params ReadOnlySpan<T> into an async method and storing it in a field or closure causes compile errors or undefined behavior. Materialize to Memory<T> or ArraySegment<T> before crossing async boundaries.
-
Overusing ref partials in business logic layers
ref partials bypass safety checks for performance. Applying them to domain services or validation pipelines introduces stack corruption risks and makes debugging nearly impossible. Restrict usage to infrastructure, I/O, and serialization boundaries.
-
Ignoring inheritance warnings for required members
C# 13 warns when derived types omit base required members, but developers often suppress warnings with #pragma disable. This defeats the safety guarantee. Configure .editorconfig to treat CS9069 as an error in production builds.
Best Practices from Production:
- Profile allocation hotspots before applying
params collections. Not all call sites benefit equally.
- Use
ref partials only when source generators or T4 templates produce the declaration. Manual partials defeat the purpose.
- Combine
required members with record types for compile-time immutability guarantees.
- Enable
NullableReferenceTypes alongside C# 13 features to catch lifetime and nullability mismatches early.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-throughput API gateway processing event streams | params ReadOnlySpan<T> | Eliminates array allocation per request, reduces Gen 0 pressure | Low (syntax change only) |
| Auto-generated serialization/deserialization layer | ref partial methods | Zero-copy buffer access, compiler-enforced signature parity | Medium (requires source generator alignment) |
| Configuration builders with optional overrides | Default lambda parameters | Reduces overload count, improves readability | Low |
| Domain DTOs with strict initialization contracts | required members + init setters | Compile-time enforcement, eliminates runtime validation middleware | Low |
| Async service processing user input | Memory<T> or ArraySegment<T> instead of ReadOnlySpan<T> | Spans cannot cross await boundaries; Memory is heap-safe | Medium (requires buffer pooling strategy) |
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" PrivateAssets="all" />
<PackageReference Include="System.Text.Json" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<EditorConfigFiles Remove=".editorconfig" />
<AdditionalFiles Include=".editorconfig" />
</ItemGroup>
</Project>
# .editorconfig
[*.cs]
# Treat missing required members as build errors
dotnet_diagnostic.CS9069.severity = error
# Enforce span lifetime rules
dotnet_diagnostic.CS8352.severity = error
dotnet_diagnostic.CS8353.severity = error
# Warn on partial signature drift
dotnet_diagnostic.CS8795.severity = warning
Quick Start Guide
- Upgrade project language version: Set
<LangVersion>13</LangVersion> in .csproj and restore packages. Ensure .NET 9 SDK is installed locally and in CI.
- Enable analyzers: Add
Microsoft.CodeAnalysis.NetAnalyzers v9.0+ and configure .editorconfig to treat CS9069 (required member omission) and CS8352 (span capture) as errors.
- Refactor top 3 allocation hotspots: Replace
params T[] with params ReadOnlySpan<T> in I/O and event processing methods. Validate with dotnet-counters or BenchmarkDotNet.
- Introduce
required members: Convert critical DTOs to use required init properties. Run serialization tests to confirm framework compatibility.
- Deploy and monitor: Ship to staging, monitor Gen 0 collection frequency and p99 latency. Roll back only if analyzer warnings surface unhandled span lifetimes or partial signature mismatches.
C# 13 shifts the performance and safety baseline without requiring architectural overhaul. Teams that treat compiler features as infrastructure components—not syntax sugar—extract measurable throughput gains and eliminate entire categories of runtime validation. The features are production-ready; the adoption strategy determines the ROI.