Skip to main content

Newsletter Archive

Browse through our collection of past newsletters. Each edition is packed with C# and .NET insights.

Page 1 of 18 (52 editions)

September 13, 2026

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.

Read the full tip + 6 curated links

Don't miss the next tip 💧

Get a .NET tip and curated links delivered to your inbox every week.

September 6, 2026

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 → StoppedAsync

Practical 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

HookFires WhenUse For
StartingAsyncBefore StartAsyncPre-start validation, logging
StartedAsyncAfter ALL hosted services have startedCache warming, readiness probes
StoppingAsyncBefore StopAsyncSignal producers to stop, close writers
StoppedAsyncAfter StopAsync completesFinal 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.

Read the full tip + 7 curated links

Don't miss the next tip 💧

Get a .NET tip and curated links delivered to your inbox every week.

August 30, 2026

You’re writing a unit test and you need to verify some internal state. The field is private. Your options used to be:

  1. [InternalsVisibleTo] and make it internal (pollutes your public API design)
  2. Reflection with BindingFlags.NonPublic (slow, breaks under trimming/AOT, string-based)
  3. 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 this
public class ConnectionPool
{
private readonly List<Connection> _connections = [];
private int _activeCount;
public void Acquire() { /* ... */ _activeCount++; }
public void Release() { /* ... */ _activeCount--; }
}
// In your test project — zero-cost accessor
public 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();
var result = CallSanitize(service, " [email protected] ");
Assert.Equal("[email protected]", result);
}

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?

ReflectionUnsafeAccessor
SpeedSlow (method lookup + invoke)Zero overhead (JIT-inlined)
AOT/Trimming❌ Breaks✅ Works
Compile-time safety❌ Strings✅ Signature-checked
AllocationsYes (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.

Read the full tip + 6 curated links

Don't miss the next tip 💧

Get a .NET tip and curated links delivered to your inbox every week.