Newsletter Archive
Browse through our collection of past newsletters. Each edition is packed with C# and .NET insights.
Page 2 of 15 (43 editions)
IExceptionHandler: Global Error Handling Done Right
Raise your hand if you’ve ever written an exception-handling middleware in ASP.NET Core that caught Exception, logged it, and returned a ProblemDetails response. Yeah, me too. We’ve all written that middleware. We’ve all copy-pasted it between projects. ASP.NET Core now has IExceptionHandler built in, and it’s cleaner, more composable, and actually designed for this.
Here’s a custom exception handler that turns known exceptions into proper Problem Details responses:
using Microsoft.AspNetCore.Diagnostics;using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddExceptionHandler<AppExceptionHandler>();builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.MapGet("/", () =>{ throw new InvalidOperationException("Something went sideways!");});
app.MapGet("/not-found", () =>{ throw new NotFoundException("The droids you're looking for");});
app.Run();
// Custom exception for demo purposesclass NotFoundException(string item) : Exception($"Could not find: {item}");
// The exception handler: clean and focusedclass AppExceptionHandler(ILogger<AppExceptionHandler> logger) : IExceptionHandler{ public async ValueTask<bool> TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { logger.LogError(exception, "Unhandled exception occurred");
var (statusCode, title) = exception switch { NotFoundException => (StatusCodes.Status404NotFound, "Not Found"), ArgumentException => (StatusCodes.Status400BadRequest, "Bad Request"), _ => (StatusCodes.Status500InternalServerError, "Server Error") };
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails { Status = statusCode, Title = title, Detail = exception.Message, Instance = httpContext.Request.Path }, cancellationToken);
return true; // We handled it }}The beauty of IExceptionHandler is the return value. If TryHandleAsync returns true, the exception is considered handled and the pipeline stops. Return false, and the next registered handler gets a shot. This means you can chain multiple handlers (one for validation errors, one for auth failures, one as a catch-all, each focused on a single responsibility).
// Register multiple handlers: they execute in orderbuilder.Services.AddExceptionHandler<ValidationExceptionHandler>();builder.Services.AddExceptionHandler<NotFoundExceptionHandler>();builder.Services.AddExceptionHandler<FallbackExceptionHandler>();This integrates seamlessly with AddProblemDetails(), which ensures your error responses conform to RFC 9457. Your API consumers get consistent, well-structured error payloads. Your code stays clean. And you never have to write app.Use(async (ctx, next) => { try { ... } catch { ... } }) again. That middleware has earned its retirement.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
Minimal API Route Groups: Organize Your Endpoints Like a Pro
Minimal APIs are great until your Program.cs looks like a 400-line scroll of app.MapGet, app.MapPost, and app.MapDelete. At that point you start to miss controllers, not because they were better, but because they at least grouped things. Enter MapGroup(), which gives you clean organization AND endpoint filters for cross-cutting concerns, all without going back to controller-land.
Here’s how you group a set of related endpoints under a shared prefix:
var app = WebApplication.Create(args);
var todos = app.MapGroup("/api/todos") .RequireAuthorization() .AddEndpointFilter<RequestLoggingFilter>();
todos.MapGet("/", async (TodoDb db) => await db.Todos.ToListAsync());
todos.MapGet("/{id:int}", async (int id, TodoDb db) => await db.Todos.FindAsync(id) is { } todo ? Results.Ok(todo) : Results.NotFound());
todos.MapPost("/", async (Todo todo, TodoDb db) =>{ db.Todos.Add(todo); await db.SaveChangesAsync(); return Results.Created($"/api/todos/{todo.Id}", todo);});
app.Run();Notice that RequireAuthorization() and AddEndpointFilter are applied to the group, not to each individual endpoint. Every route under /api/todos automatically gets auth and logging. No repetition, no forgetting to secure that one endpoint you added at 4:57 PM on a Friday.
Endpoint filters are the secret weapon here. They’re like middleware, but scoped to specific routes. Here’s a simple one that logs request timing:
public class RequestLoggingFilter(ILogger<RequestLoggingFilter> logger) : IEndpointFilter{ public async ValueTask<object?> InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var stopwatch = Stopwatch.StartNew(); var result = await next(context); logger.LogInformation("Request completed in {Elapsed}ms", stopwatch.ElapsedMilliseconds); return result; }}You can stack filters, nest groups inside groups, and even apply different auth policies per group. It’s all the organization of controllers with all the simplicity of minimal APIs. Best of both worlds: your Program.cs will finally be something you’re proud to show in a code review.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
SearchValues<T>: Turbocharged String Scanning
Searching for characters in a string sounds like a solved problem, right? Just call IndexOfAny(char[]) and move on. But if you’re doing it in a parser, a tokenizer, or anything that processes megabytes of text, that naive approach leaves a lot of performance on the table. SearchValues<T> is .NET’s way of saying, “Give me your search set once, and I’ll vectorize the heck out of it.”
Here’s the basic setup: create a SearchValues<char> once and reuse it:
using System.Buffers;
// Create once, reuse everywherevar delimiters = SearchValues.Create([',', ';', '|', '\t', ' ']);
var input = "hello,world;this|is\ta test with spaces";
// Find the first delimitervar index = input.AsSpan().IndexOfAny(delimiters);Console.WriteLine($"First delimiter at index {index}: '{input[index]}'");
// Split manually using spans (zero allocation!)var remaining = input.AsSpan();while (remaining.Length > 0){ var delimIndex = remaining.IndexOfAny(delimiters); if (delimIndex < 0) { Console.WriteLine($"Token: \"{remaining}\""); break; }
Console.WriteLine($"Token: \"{remaining[..delimIndex]}\""); remaining = remaining[(delimIndex + 1)..];}When you pass a SearchValues<char> to IndexOfAny, the runtime picks the optimal SIMD strategy based on your hardware and the search set. Small sets might use a bitmap lookup. Larger sets might use AVX2 or ARM NEON vector comparisons. You don’t need to know the details; just know that it’s often 3-10x faster than passing a plain char[].
It also works with SearchValues<string> for multi-value string searching in .NET 9, which is fantastic for things like scanning for keywords or finding HTML tags:
var keywords = SearchValues.Create( ["async", "await", "Task", "ValueTask"], StringComparison.Ordinal);
var code = "The async method returns a Task<int> that you can await";var span = code.AsSpan();var pos = span.IndexOfAny(keywords);Console.WriteLine($"First keyword found at position {pos}: starts with '{span[pos..][..5]}'");The key insight is that SearchValues<T> is a precomputed search structure. The upfront cost of Create() is tiny, and every subsequent search benefits from the optimized representation. If you have any hot-path code doing IndexOfAny with a static set of characters or strings, this is the single easiest performance win you’ll find.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.