Newsletter Archive
Browse through our collection of past newsletters. Each edition is packed with C# and .NET insights.
Page 1 of 18 (52 editions)
You write a guard clause. It throws. The exception message says “Value cannot be null. Parameter name: input.” Cool. But what was the input? What expression did the caller pass? Was it user.Email? config.GetSection("Auth").Value? You don’t know without digging through the stack trace.
[CallerArgumentExpression] captures the literal source text of an argument at compile time. Your error messages suddenly quote the exact code that violated the precondition.
The Basics
using System.Runtime.CompilerServices;
public static class Guard{ public static void NotNull<T>( T? value, [CallerArgumentExpression(nameof(value))] string? expression = null) where T : class { if (value is null) throw new ArgumentNullException(expression, $"'{expression}' must not be null."); }
public static void MustBePositive( int value, [CallerArgumentExpression(nameof(value))] string? expression = null) { if (value <= 0) throw new ArgumentOutOfRangeException(expression, $"'{expression}' must be positive, but was {value}."); }}What Callers See
var order = GetOrder();Guard.NotNull(order.Customer);// Throws: "'order.Customer' must not be null."
Guard.MustBePositive(order.LineItems.Count - discountItems);// Throws: "'order.LineItems.Count - discountItems' must be positive, but was -2."The compiler fills in the expression order.Customer or order.LineItems.Count - discountItems as a string literal at the call site. Zero runtime cost — it’s baked into the compiled IL.
Custom Preconditions
This really shines for domain-specific validation:
public static class Require{ public static void That( bool condition, [CallerArgumentExpression(nameof(condition))] string? expression = null) { if (!condition) throw new InvalidOperationException( $"Precondition failed: {expression}"); }}Require.That(user.Age >= 18);// Throws: "Precondition failed: user.Age >= 18"
Require.That(startDate < endDate);// Throws: "Precondition failed: startDate < endDate"
Require.That(retryCount <= maxRetries);// Throws: "Precondition failed: retryCount <= maxRetries"No more writing custom error messages for every single check. The expression is the message.
How It Works
The attribute tells the compiler: “Take whatever source text the caller passed for the parameter named X, and pass it as a string to this parameter.” It’s resolved at compile time, so there’s no reflection, no Expression trees, no runtime parsing. The string is just… there.
Key Takeaway
[CallerArgumentExpression] makes guard clauses self-documenting with zero effort. Your exception messages quote the exact failing expression from the caller’s source code. No more guessing what “Parameter ‘value’ was invalid” actually means at 2 AM.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
BackgroundService gives you one method: ExecuteAsync. It starts when the host starts and runs until the host shuts down. Simple. But what if you need to warm a cache after the app is fully ready to receive requests? Or flush a queue before connections start draining? ExecuteAsync fires during startup — you can’t distinguish between “the host is starting” and “the host is fully started and ready.”
.NET 8 introduced IHostedLifecycleService, which adds four lifecycle hooks around the existing Start/Stop flow:
The Lifecycle
StartingAsync → StartAsync → StartedAsync ↓ (app is running) ↓StoppingAsync → StopAsync → StoppedAsyncPractical Example: Cache Warming + Graceful Drain
public class OrderProcessingService( IServiceScopeFactory scopeFactory, Channel<Order> orderChannel, ILogger<OrderProcessingService> logger) : IHostedLifecycleService{ private Task? _processingTask; private CancellationTokenSource? _cts;
// Called BEFORE the host marks itself as started public Task StartingAsync(CancellationToken ct) { logger.LogInformation("Preparing order processor..."); return Task.CompletedTask; }
public Task StartAsync(CancellationToken ct) { _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); _processingTask = ProcessOrdersAsync(_cts.Token); return Task.CompletedTask; }
// Called AFTER the host is fully started (Kestrel is listening, etc.) public async Task StartedAsync(CancellationToken ct) { logger.LogInformation("Host is ready. Warming the product cache...");
using var scope = scopeFactory.CreateScope(); var cache = scope.ServiceProvider.GetRequiredService<IProductCache>(); await cache.WarmAsync(ct);
logger.LogInformation("Cache warmed. Ready to process orders."); }
// Called BEFORE StopAsync — signal your work to wind down public Task StoppingAsync(CancellationToken ct) { logger.LogInformation("Shutdown signal received. Completing channel..."); orderChannel.Writer.Complete(); return Task.CompletedTask; }
public async Task StopAsync(CancellationToken ct) { if (_processingTask is not null) { _cts?.Cancel(); await _processingTask; } }
// Called AFTER StopAsync — final cleanup public Task StoppedAsync(CancellationToken ct) { logger.LogInformation("Order processor fully stopped. All items flushed."); _cts?.Dispose(); return Task.CompletedTask; }
private async Task ProcessOrdersAsync(CancellationToken ct) { await foreach (var order in orderChannel.Reader.ReadAllAsync(ct)) { await ProcessOrderAsync(order); } }
private Task ProcessOrderAsync(Order order) { logger.LogInformation("Processed order {Id}", order.Id); return Task.CompletedTask; }}Registration
builder.Services.AddHostedService<OrderProcessingService>();That’s it. The DI container sees it implements IHostedLifecycleService and calls the lifecycle methods at the right time.
When to Use Which Hook
| Hook | Fires When | Use For |
|---|---|---|
StartingAsync | Before StartAsync | Pre-start validation, logging |
StartedAsync | After ALL hosted services have started | Cache warming, readiness probes |
StoppingAsync | Before StopAsync | Signal producers to stop, close writers |
StoppedAsync | After StopAsync completes | Final cleanup, metrics flush |
Key Takeaway
IHostedLifecycleService gives you four hooks around the start/stop lifecycle so you can separate “begin starting” from “fully ready” and “begin stopping” from “fully stopped.” Use StartedAsync for work that needs the full app running, and StoppingAsync to gracefully drain before shutdown.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
You’re writing a unit test and you need to verify some internal state. The field is private. Your options used to be:
[InternalsVisibleTo]and make itinternal(pollutes your public API design)- Reflection with
BindingFlags.NonPublic(slow, breaks under trimming/AOT, string-based) - Add a test-only public method (yuck)
.NET 8 introduced [UnsafeAccessor], which gives you a direct, zero-overhead, AOT-compatible way to access private fields and methods through a compiler-generated extern method.
Accessing a Private Field
using System.Runtime.CompilerServices;
// The class under test — you don't control thispublic class ConnectionPool{ private readonly List<Connection> _connections = []; private int _activeCount;
public void Acquire() { /* ... */ _activeCount++; } public void Release() { /* ... */ _activeCount--; }}
// In your test project — zero-cost accessorpublic partial class ConnectionPoolTests{ [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_activeCount")] private static extern ref int GetActiveCount(ConnectionPool pool);
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_connections")] private static extern ref List<Connection> GetConnections(ConnectionPool pool);
[Fact] public void Acquire_IncrementsActiveCount() { var pool = new ConnectionPool();
pool.Acquire(); pool.Acquire();
Assert.Equal(2, GetActiveCount(pool)); }
[Fact] public void Release_DecrementsActiveCount() { var pool = new ConnectionPool(); pool.Acquire();
pool.Release();
Assert.Equal(0, GetActiveCount(pool)); }}No reflection. No BindingFlags. No string-based property lookup that breaks when you rename the field. The JIT resolves the access at compile time and inlines it — it’s literally as fast as accessing the field directly.
Calling a Private Method
public class EmailService{ private string SanitizeAddress(string email) => email.Trim().ToLowerInvariant();}
// Test accessor[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "SanitizeAddress")]private static extern string CallSanitize(EmailService service, string email);
[Fact]public void SanitizeAddress_TrimsAndLowercases(){ var service = new EmailService();
}Accessing a Private Static Field
public class RateLimiter{ private static int _globalRequestCount;}
[UnsafeAccessor(UnsafeAccessorKind.StaticField, Name = "_globalRequestCount")]private static extern ref int GetGlobalCount(RateLimiter? _);
[Fact]public void Test_GlobalCount(){ // Pass null for static accessors — the instance isn't used ref int count = ref GetGlobalCount(null); Assert.Equal(0, count);}Why “Unsafe”?
The name is honest: you’re bypassing encapsulation. The runtime won’t stop you from reading or writing private state. This is explicitly a testing and tooling escape hatch, not something for production business logic. Use it the way you’d use reflection — to verify internal state in tests, to build diagnostic tools, or to interop with legacy code you can’t modify.
Why Not Just Use Reflection?
| Reflection | UnsafeAccessor | |
|---|---|---|
| Speed | Slow (method lookup + invoke) | Zero overhead (JIT-inlined) |
| AOT/Trimming | ❌ Breaks | ✅ Works |
| Compile-time safety | ❌ Strings | ✅ Signature-checked |
| Allocations | Yes (boxing, object[]) | None |
Key Takeaway
[UnsafeAccessor] gives you direct, zero-cost, trim-safe access to private fields and methods. Use it in tests to verify internal state without compromising your public API design. It’s reflection’s speed and AOT limitations — solved.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.