Advanced C# Features Every Developer Should Master

Forget the days when C# was just Microsoft’s enterprise answer to Java. Modern C# is an absolute powerhouse. It has evolved into a highly expressive, ridiculously performant language that strips away boilerplate, locks down type safety, and unlocks system-level patterns we used to reserve for C and Rust.
I am genuinely excited about what this compiler lets us get away with right now. We're going to explore the advanced features that will fundamentally upgrade how you architect production code day-to-day. Let's drop the fluff and dive straight into the deep end—starting with a massive upgrade to how you evaluate complex data.
Pattern Matching Beyond the Basics
Pattern matching has grown from a simple switch enhancement into a powerful declarative system for decomposing and testing data. If you're still writing if-else chains with type checks and casts, you're leaving clarity on the table.
Property Patterns with Nested Matching
public record Order(
Guid Id,
Customer Customer,
List<OrderLine> Lines,
ShippingInfo? Shipping);
public record Customer(string Name, Address Address, CustomerTier Tier);
public record Address(string City, string Country);
public record ShippingInfo(DateTime ShippedAt, string Carrier);
public enum CustomerTier { Bronze, Silver, Gold, Platinum }
public decimal CalculateDiscount(Order order) => order switch
{
{ Customer.Tier: CustomerTier.Platinum } => 0.30m,
{ Customer.Tier: CustomerTier.Gold, Lines.Count: > 10 } => 0.25m,
{ Customer.Tier: CustomerTier.Gold } => 0.20m,
{ Customer.Address.Country: "US", Lines.Count: > 5 } => 0.15m,
{ Shipping: not null } => 0.05m, // repeat customers already shipping
_ => 0.00m
};Notice how we can reach into nested properties, compare against constants, and use relational patterns like > 10 — all without a single as cast or null check.
List Patterns (C# 11)
public string ClassifyReading(int[] values) => values switch
{
[] => "No data",
[var single] => $"Single reading: {single}",
[var first, var second] => $"Delta: {second - first}",
[.. var middle, var last] when last > middle.Average() => "Trending up",
[var first, .., var last] => $"Range: {first} to {last}",
_ => "Complex pattern"
};
// Deconstruct arrays into head/tail (functional style)
public int SumRecursively(ReadOnlySpan<int> values) => values switch
{
[] => 0,
[var head, .. var tail] => head + SumRecursively(tail)
};The .. slice pattern is a game-changer for working with arrays and spans declaratively.
Ref Structs, Spans, and Stack-Allocated Performance
If you're allocating arrays on the heap for short-lived operations, Span<T> and stackalloc can eliminate that pressure entirely.
Zero-Allocation Parsing with Span
public static class CsvParser
{
public static List<(string Name, int Age)> Parse(ReadOnlySpan<char> csv)
{
var results = new List<(string, int)>();
foreach (var line in csv.EnumerateLines())
{
var separator = line.IndexOf(',');
if (separator < 0) continue;
var nameSpan = line[..separator];
var ageSpan = line[(separator + 1)..];
if (int.TryParse(ageSpan, out var age))
{
results.Add((nameSpan.ToString(), age));
}
}
return results;
}
// Stack-allocate buffer for temporary work
public static void ProcessLargeData()
{
Span<char> buffer = stackalloc char[256];
for (int i = 0; i < 1000; i++)
{
i.TryFormat(buffer, out var charsWritten);
var slice = buffer[..charsWritten];
// Process slice without any heap allocation
}
}
}Ref Returns for Zero-Copy Access
public ref struct Matrix2D
{
private readonly Span<float> _data;
private readonly int _stride;
public Matrix2D(Span<float> data, int stride)
{
_data = data;
_stride = stride;
}
public ref float this[int row, int col] => ref _data[row * _stride + col];
public ref float GetDiagonal(int index) => ref _data[index * _stride + index];
}
// Usage: modify in place without copying the entire struct
Span<float> stackBuffer = stackalloc float[100];
var matrix = new Matrix2D(stackBuffer, 10);
matrix[3, 4] = 42f;
matrix.GetDiagonal(2) = 99f;Key rule: ref struct cannot escape to the heap — no boxing, no capturing in lambdas, no storing in classes. This is what makes it safe.
Source Generators: Compile-Time Metaprogramming
Source Generators let you produce C# code at compile time based on your existing code. No reflection at runtime, no IL weaving at build time — just pure, inspectable C#.
Auto-Registration Generator
[AttributeUsage(AttributeTargets.Class)]
public class AutoRegisterAttribute : Attribute { }
// The generator will find all types with [AutoRegister]
// and produce a registration method[Generator]
public class AutoRegisterGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var provider = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: (node, _) => node is ClassDeclarationSyntax,
transform: (ctx, _) => GetTypeInfo(ctx))
.Where(static info => info is not null);
context.RegisterSourceOutput(provider, (spc, info) =>
{
var source = $$"""
namespace AutoRegistration;
public static class ServiceRegistry
{
public static void RegisterAll(IServiceCollection services)
{
{{string.Join("\n", info!.AllTypes.Select(t =>
$" services.AddTransient<{t.InterfaceName}, {t.ImplementationName}>();"))}}
}
}
""";
spc.AddSource("ServiceRegistry.g.cs", source);
});
}
private static TypeInfo? GetTypeInfo(GeneratorSyntaxContext ctx)
{
var classDecl = (ClassDeclarationSyntax)ctx.Node;
var symbol = ctx.SemanticModel.GetDeclaredSymbol(classDecl) as INamedTypeSymbol;
if (symbol?.GetAttributes().Any(a =>
a.AttributeClass?.ToDisplayString() == "AutoRegisterAttribute") != true)
return null;
var iface = symbol.AllInterfaces.FirstOrDefault();
return new TypeInfo(iface?.ToDisplayString()!, symbol.ToDisplayString());
}
private record TypeInfo(string InterfaceName, string ImplementationName);
}Now any class decorated with [AutoRegister] automatically gets wired up — zero reflection, zero runtime cost, full IntelliSense on the generated code.
Record Types and With-Expressions for Immutable Data
Records give you value-based equality, non-destructive mutation, and concise syntax — the holy trinity for domain modeling.
Advanced Record Patterns
// Positional record with custom validation
public record Money(decimal Amount, string Currency)
{
public decimal Amount { get; init; } = Amount >= 0
? Amount
: throw new ArgumentException("Amount cannot be negative");
public string Currency { get; init; } = Currency?.ToUpperInvariant()
?? throw new ArgumentNullException(nameof(Currency));
// Custom with-expression logic via init-only setters
public Money WithAmount(decimal newAmount) => this with { Amount = newAmount };
// Operator overloading that returns new instances
public static Money operator +(Money left, Money right) =>
left.Currency != right.Currency
? throw new InvalidOperationException("Currency mismatch")
: new Money(left.Amount + right.Amount, left.Currency);
// Deconstruct for pattern matching
public void Deconstruct(out decimal amount, out string currency)
{
amount = Amount;
currency = Currency;
}
}
// Record struct for performance-critical value types
public readonly record struct Point(double X, double Y)
{
public double DistanceTo(Point other) =>
Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
public static Point Origin { get; } = new(0, 0);
}Nested With-Expressions (C# 12 Primary Constructors + With)
public record Project(string Name, ProjectConfig Config, List<string> Tags);
public record ProjectConfig(string Environment, int MaxRetries, bool EnableTelemetry);
var project = new Project("Api", new ProjectConfig("prod", 3, true), ["api", "v2"]);
// Non-destructive update of a nested record
var updated = project with
{
Config = project.Config with { MaxRetries = 5, EnableTelemetry = false }
};Async Streams and Channels
IAsyncEnumerable<T> is the async counterpart to IEnumerable<T> — perfect when data arrives over time.
Backpressure-Aware Producer/Consumer with Channels
public class DataPipeline
{
private readonly Channel<RawEvent> _input = Channel.CreateBounded<RawEvent>(
new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = false
});
private readonly Channel<ProcessedEvent> _output = Channel.CreateUnbounded<ProcessedEvent>();
public async IAsyncEnumerable<ProcessedEvent> ConsumeAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var evt in _output.Reader.ReadAllAsync(ct))
{
yield return evt;
}
}
public async Task ProduceAsync(IAsyncEnumerable<RawEvent> source, CancellationToken ct)
{
await foreach (var raw in source.WithCancellation(ct))
{
var processed = await TransformAsync(raw, ct);
await _output.Writer.WriteAsync(processed, ct);
}
_output.Writer.Complete();
}
private async ValueTask<ProcessedEvent> TransformAsync(RawEvent raw, CancellationToken ct)
{
// Simulate enrichment
await Task.Delay(10, ct);
return new ProcessedEvent(raw.Id, raw.Payload.ToUpperInvariant(), DateTime.UtcNow);
}
}Chunked Async Processing
public static async IAsyncEnumerable<T[]> ChunkAsync<T>(
this IAsyncEnumerable<T> source,
int chunkSize,
[EnumeratorCancellation] CancellationToken ct = default)
{
var buffer = new List<T>(chunkSize);
await foreach (var item in source.WithCancellation(ct))
{
buffer.Add(item);
if (buffer.Count >= chunkSize)
{
yield return buffer.ToArray();
buffer.Clear();
}
}
if (buffer.Count > 0)
yield return buffer.ToArray();
}
// Usage: batch API calls efficiently
await foreach (var batch in events.ChunkAsync(50, ct))
{
await apiClient.SendBatchAsync(batch, ct);
}Static Abstract Members in Interfaces (C# 11)
This feature unlocks generic math and zero-instance patterns. You can now define contracts that require static methods — no more factory workarounds.
Generic Math Operations
public interface INumeric<TSelf> :
IAdditionOperators<TSelf, TSelf, TSelf>,
ISubtractionOperators<TSelf, TSelf, TSelf>,
IMultiplyOperators<TSelf, TSelf, TSelf>,
IComparisonOperators<TSelf, TSelf, bool>
where TSelf : INumeric<TSelf>
{
static abstract TSelf Zero { get; }
static abstract TSelf One { get; }
}
public static T Sum<T>(this IEnumerable<T> values) where T : INumeric<T>
{
var result = T.Zero;
foreach (var v in values)
result = result + v;
return result;
}
public static T Average<T>(this IEnumerable<T> values) where T : INumeric<T>
{
var sum = T.Zero;
var count = T.Zero;
foreach (var v in values)
{
sum = sum + v;
count = count + T.One;
}
return sum / count; // Requires IDivisionOperators too
}
// Works with int, double, decimal, float, half, Int128...
var numbers = new[] { 1, 2, 3, 4, 5 };
Console.WriteLine(numbers.Sum()); // 15
Console.WriteLine(numbers.Average()); // 3Interface-Based Factory Pattern
public interface IEntity<TSelf> where TSelf : IEntity<TSelf>
{
Guid Id { get; }
// No instance needed to create — call TSelf.Create() directly
static abstract TSelf Create(Guid id);
// Validate without an instance
static abstract bool IsValid(TSelf entity);
}
public record User(Guid Id, string Name) : IEntity<User>
{
public static User Create(Guid id) => new(id, "New User");
public static bool IsValid(User entity) =>
entity.Id != Guid.Empty && !string.IsNullOrWhiteSpace(entity.Name);
}
// Generic factory — no reflection, no Activator.CreateInstance
public static T CreateEntity<T>() where T : IEntity<T> => T.Create(Guid.NewGuid());Required Members and Init Setters
These two features combine to create immutable objects with mandatory configuration — the best of both worlds between constructors and object initializers.
public class HttpClientConfig
{
public required Uri BaseAddress { get; init; }
public required TimeSpan Timeout { get; init; }
// Optional with sensible defaults
public Dictionary<string, string> DefaultHeaders { get; init; } = new();
public HttpMessageHandler? Handler { get; init; }
public bool AutoRetry { get; init; } = true;
public int MaxRetries { get; init; } = 3;
// Validation via init accessor
private int _maxRetries;
public int MaxRetries
{
get => _maxRetries;
init => _maxRetries = value is >= 0 and <= 10
? value
: throw new ArgumentOutOfRangeException(nameof(value));
}
}
// Compiler enforces required members — won't compile without them
var config = new HttpClientConfig
{
BaseAddress = new Uri("https://api.example.com"),
Timeout = TimeSpan.FromSeconds(30),
MaxRetries = 5 // Validated at initialization time
};Caller Information Attributes for Diagnostics
Stop passing method names and file paths manually. The compiler injects them for you.
public static class AuditLog
{
public static void Log(
string message,
[CallerMemberName] string member = "",
[CallerFilePath] string file = "",
[CallerLineNumber] int line = 0)
{
var timestamp = DateTime.UtcNow.ToString("O");
var fileName = Path.GetFileName(file);
Console.WriteLine($"[{timestamp}] {fileName}:{line} ({member}) — {message}");
}
// Interceptor-style guard that knows where it was called
public static T GuardNotNull<T>(
T? value,
[CallerArgumentExpression(nameof(value))] string expr = "",
[CallerMemberName] string caller = "") where T : class
{
if (value is null)
throw new ArgumentNullException(expr, $"Null check failed in {caller}");
return value;
}
}
public class OrderService
{
public void ProcessOrder(Order? order)
{
// The expression "order" is automatically captured
var safeOrder = AuditLog.GuardNotNull(order);
// Log knows it was called from ProcessOrder in OrderService.cs:42
AuditLog.Log($"Processing order {safeOrder.Id}");
}
}[CallerArgumentExpression] is particularly powerful — it captures the source code expression passed to a parameter, enabling better assertions and validation messages without string duplication.
Unsafe Code and Function Pointers
When you need C-level performance, C# lets you drop down to pointer manipulation without leaving the language.
public static class FastMemcpy
{
// Function pointer — no delegate allocation, direct call
private delegate void MemCpyDelegate(nint dest, nint src, nint count);
private static readonly MemCpyDelegate _memcpy = LoadMemcpy();
private static MemCpyDelegate LoadMemcpy()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var kernel32 = NativeLibrary.Load("kernel32.dll");
return Marshal.GetDelegateForFunctionPointer<MemCpyDelegate>(
NativeLibrary.GetExport(kernel32, "RtlMoveMemory"));
}
var libc = NativeLibrary.Load("libc");
return Marshal.GetDelegateForFunctionPointer<MemCpyDelegate>(
NativeLibrary.GetExport(libc, "memcpy"));
}
public static unsafe void CopyUnmanaged<T>(T* source, T* destination, int count)
where T : unmanaged
{
var byteCount = sizeof(T) * count;
_memcpy((nint)destination, (nint)source, byteCount);
}
}
// Function pointers for hot paths — zero delegate overhead
public static class SortStrategies
{
public static unsafe void Sort<T>(Span<T> span, delegate* managed<T, T, int> comparer)
{
// Direct function pointer call — no virtual dispatch, no delegate invocation
for (int i = 0; i < span.Length - 1; i++)
for (int j = i + 1; j < span.Length; j++)
{
if (comparer(span[i], span[j]) > 0)
(span[i], span[j]) = (span[j], span[i]);
}
}
}
// Usage
public static int CompareInt(int a, int b) => a.CompareTo(b);
unsafe
{
delegate* managed<int, int, int> ptr = &CompareInt;
SortStrategies.Sort<int>(stackalloc int[] { 3, 1, 2 }, ptr);
}Primary Constructors (C# 12) in Practice
Primary constructors aren't just syntactic sugar for records — they work on regular classes and structs, and the parameters are available throughout the type's body.
public class Repository<TEntity>(DbContext context, ILogger<Repository<TEntity>> logger)
: IRepository<TEntity> where TEntity : class
{
// Parameters are captured and available in all methods
// No boilerplate field + constructor assignment needed
public async Task<TEntity?> FindByIdAsync(Guid id)
{
logger.LogInformation("Finding {Entity} with id {Id}", typeof(TEntity).Name, id);
return await context.Set<TEntity>().FindAsync(id);
}
public async Task<List<TEntity>> GetAllAsync(int page = 1, int pageSize = 50)
{
logger.LogDebug("Getting page {Page} of {EntityType}", page, typeof(TEntity).Name);
return await context.Set<TEntity>()
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
}
// Works with structs too — perfect for small service objects
public readonly struct CurrencyConverter(ICurrencyApi api, decimal margin)
{
public async Task<decimal> ConvertAsync(string from, string to, decimal amount)
{
var rate = await api.GetRateAsync(from, to);
return amount * rate * (1 + margin);
}
}Collection Expressions and Spread (C# 12)
A unified syntax for initializing all collection types, with the .. spread operator for concatenation.
// Same syntax works for arrays, lists, spans, and any collection with Add()
int[] array = [1, 2, 3];
List<string> list = ["hello", "world"];
Span<double> span = [1.0, 2.0, 3.0];
HashSet<string> set = ["a", "b", "c"];
// Spread operator for concatenation
var defaults = [0, -1, int.MaxValue];
var userValues = GetValues();
var combined = [.. defaults, .. userValues, 42]; // int[]
// Works with different collection types — automatic conversion
var headers = new Dictionary<string, string>
{
["Content-Type"] = "application/json"
};
var authHeaders = GetAuthHeaders();
var allHeaders = new Dictionary<string, string>
{
["X-Request-Id"] = Guid.NewGuid().ToString(),
.. headers,
.. authHeaders
};Key Takeaways
Feature | Primary Benefit | When to Use |
|---|---|---|
Pattern Matching | Declarative data decomposition | Complex conditionals, type hierarchies |
Span<T> / ref struct | Zero-allocation performance | Hot paths, parsing, buffers |
Source Generators | Compile-time metaprogramming | Eliminating reflection, auto-registration |
Records | Immutable value types with equality | DTOs, domain models, messages |
IAsyncEnumerable | Streaming async data | Real-time feeds, paginated APIs |
Static abstract interface members | Generic math, zero-instance patterns | Numeric abstractions, type-level factories |
Required + init | Mandatory immutable config | Configuration objects, builders |
Primary constructors | Eliminate boilerplate | Services with injected dependencies |
Function pointers | Zero-overhead dispatch | Interop, extremely hot paths |
Here is where the real magic happens: combining these features into a cohesive architecture.
Individually, they are great quality-of-life improvements. Together? They are a cheat code for high-performance systems. Imagine a Source Generator emitting immutable record types with required init properties, which your application streams in real-time via IAsyncEnumerable while parsing the incoming payload with zero-allocation ReadOnlySpan<char>.
That isn't just clean code—that is modern C# running at absolute peak performance with virtually zero overhead.
Don't try to boil the ocean. Pick one burning bottleneck in your current hot path, apply one of these patterns to crush it, and watch how naturally the rest of your architecture elevates to match.