Newsletter Archive
Browse through our collection of past newsletters. Each edition is packed with C# and .NET insights.
Page 2 of 12 (36 editions)
Keyed Services: Resolving the Right Implementation Without Factory Hacks
You have two implementations of the same interface. Before .NET 8, you either registered a Func<string, IService> factory, built a custom resolver, or stuffed a switch into a single implementation. None of it felt right.
.NET 8 added keyed services to the built-in DI container.
The Problem
Two notification senders, one interface. Registering both means the last one wins:
builder.Services.AddSingleton<INotificationSender, EmailSender>();builder.Services.AddSingleton<INotificationSender, SmsSender>(); // this one winsThe Fix
Register each with a key:
builder.Services.AddKeyedSingleton<INotificationSender, EmailSender>("email");builder.Services.AddKeyedSingleton<INotificationSender, SmsSender>("sms");Inject the one you want with [FromKeyedServices]:
app.MapPost("/notify/email", async ( [FromKeyedServices("email")] INotificationSender sender, NotificationRequest request) =>{ await sender.SendAsync(request.To, request.Message); return Results.Ok();});It works in constructor injection too:
public class OrderProcessor( [FromKeyedServices("email")] INotificationSender emailSender, [FromKeyedServices("sms")] INotificationSender smsSender){ // Both are available, resolved by key}Dynamic Resolution
When you don’t know the key at compile time, resolve through IServiceProvider:
app.MapPost("/notify/{channel}", async (string channel, IServiceProvider sp) =>{ var sender = sp.GetKeyedService<INotificationSender>(channel); if (sender is null) return Results.BadRequest($"Unknown channel: {channel}");
return Results.Ok();});Keys can be any object, not just strings. Enums work great:
builder.Services.AddKeyedSingleton<INotificationSender, EmailSender>(Channel.Email);builder.Services.AddKeyedSingleton<INotificationSender, SmsSender>(Channel.Sms);Key Takeaway
Keyed services replace hand-rolled factories with a first-class DI concept. Register by key, inject with [FromKeyedServices], and stop writing Func<string, IService> workarounds.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
HybridCache: One API for In-Memory + Distributed Caching
Caching in ASP.NET Core has always meant choosing between IMemoryCache (fast, single-node) and IDistributedCache (shared, slower). In practice you want both: a fast L1 in-memory layer backed by shared L2 storage like Redis. But wiring them together manually means duplicate code, stampede bugs, and serialization headaches.
HybridCache in .NET 9 wraps both layers behind a single call.
Setup
dotnet add package Microsoft.Extensions.Caching.Hybridbuilder.Services.AddHybridCache(options =>{ options.DefaultEntryOptions = new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(10), // L2 (distributed) LocalCacheExpiration = TimeSpan.FromMinutes(2) // L1 (memory) };});
// Optional: add a distributed backendbuilder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");Without an IDistributedCache registration, HybridCache still works as an in-memory cache with stampede protection.
Basic Usage
app.MapGet("/products/{id:int}", async (int id, HybridCache cache) =>{ var product = await cache.GetOrCreateAsync( $"product:{id}", async cancel => await LoadProductFromDb(id, cancel));
return product is not null ? Results.Ok(product) : Results.NotFound();});First call: factory runs, result is stored in L1 and L2. Subsequent calls within the local expiration window come straight from memory without touching Redis.
Stampede Protection
This is the headline feature. If 100 concurrent requests all miss the cache for the same key, only one runs the factory. The other 99 wait for that single result. No configuration needed. It’s the default behavior.
Tag-Based Invalidation
Group related entries with tags and invalidate them in one call:
// When caching, attach a tagvar products = await cache.GetOrCreateAsync( $"products:category:{category}", async cancel => await LoadByCategory(category, cancel), tags: ["products"]);
// When data changes, evict everything tagged "products"await cache.RemoveByTagAsync("products");For single entries, RemoveAsync clears from both L1 and L2:
await cache.RemoveAsync($"product:{id}");Key Takeaway
HybridCache gives you L1 + L2 caching, stampede protection, and tag-based invalidation behind a single GetOrCreateAsync call. Stop hand-rolling two-layer cache logic.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
Server-Sent Events in ASP.NET Core Minimal APIs
You need to push real-time updates from server to browser. SignalR is the default answer, but sometimes you don’t need bidirectional communication. You just need the server to stream events to the client: a progress bar, a live feed, a notification stream.
Server-Sent Events (SSE) does exactly that. It’s a native browser API built on plain HTTP. No WebSocket upgrade, no SignalR hub, no client library.
The Basics
Set the content type to text/event-stream and write data: lines followed by double newlines:
app.MapGet("/events/progress", async (HttpContext context, CancellationToken ct) =>{ context.Response.ContentType = "text/event-stream"; context.Response.Headers.CacheControl = "no-cache";
string[] steps = ["Validating", "Processing", "Confirming"];
for (int i = 0; i < steps.Length; i++) { var json = JsonSerializer.Serialize(new { step = steps[i], percent = (i + 1) * 33 }); await context.Response.WriteAsync($"data: {json}\n\n", ct); await context.Response.Body.FlushAsync(ct); await Task.Delay(1500, ct); }});The CancellationToken fires when the client disconnects, so you stop doing work immediately.
Named Events
Add an event: line to categorize messages:
await context.Response.WriteAsync($"event: progress\ndata: {json}\n\n", ct);The client can then listen for specific event types.
The Client Side
The browser’s native EventSource API handles connection and auto-reconnection:
const source = new EventSource('/events/progress');
source.addEventListener('progress', (e) => { const data = JSON.parse(e.data); updateProgressBar(data.percent);});
source.onerror = () => console.log('Reconnecting...');Auto-reconnect is built into the spec. You get resilience for free.
Streaming with Channels
For fan-out scenarios like live dashboards, pair SSE with System.Threading.Channels:
app.MapGet("/events/orders", async (HttpContext ctx, OrderEventBus bus, CancellationToken ct) =>{ ctx.Response.ContentType = "text/event-stream"; ctx.Response.Headers.CacheControl = "no-cache";
await foreach (var evt in bus.Subscribe(ct)) { var json = JsonSerializer.Serialize(evt); await ctx.Response.WriteAsync($"data: {json}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); }});POST events into the channel from elsewhere, and every connected SSE client receives them in real time.
SSE vs. SignalR
| SSE | SignalR | |
|---|---|---|
| Direction | Server to client only | Bidirectional |
| Transport | Plain HTTP | WebSocket (with fallbacks) |
| Client | Native EventSource | Requires SignalR library |
| Auto-reconnect | Built into the spec | Built into the client |
| Binary data | Text only | Text and binary |
Choose SSE when you only need server-to-client streaming and want the simplest possible setup. Choose SignalR when you need bidirectional messaging, binary data, or group management.
Key Takeaway
SSE is the lightest way to push real-time updates from ASP.NET Core to a browser. Set text/event-stream, write data: lines, flush, and the browser handles the rest, including automatic reconnection.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.