Newsletter Archive
Browse through our collection of past newsletters. Each edition is packed with C# and .NET insights.
Page 1 of 15 (43 editions)
System.Threading.Channels: The Async Pipeline Primitive
You have an API endpoint that receives work. You have a background service that processes it. You need a thread-safe, async-friendly way to pass items between them without blocking threads or losing data. BlockingCollection<T> blocks. ConcurrentQueue<T> requires polling. Channel<T> does exactly what you want: async writes, async reads, optional backpressure, and zero thread blocking.
The Pattern
using System.Threading.Channels;
// Create a bounded channel: if 100 items are queued, writers waitvar channel = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(100){ FullMode = BoundedChannelFullMode.Wait, // writers await until space is available SingleReader = true // optimization hint for single consumer});The Producer (API Endpoint)
app.MapPost("/ingest", async (WorkItem item, Channel<WorkItem> channel) =>{ await channel.Writer.WriteAsync(item); return Results.Accepted();});WriteAsync completes instantly if there’s space. If the channel is full, it asynchronously waits — no thread is blocked, no request is lost.
The Consumer (Background Service)
public class WorkItemProcessor( Channel<WorkItem> channel, ILogger<WorkItemProcessor> logger) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var item in channel.Reader.ReadAllAsync(stoppingToken)) { try { logger.LogInformation("Processing {Id}", item.Id); await ProcessAsync(item, stoppingToken); } catch (Exception ex) { logger.LogError(ex, "Failed to process {Id}", item.Id); } }
logger.LogInformation("Channel closed. Shutting down."); }
private async Task ProcessAsync(WorkItem item, CancellationToken ct) { // Simulate work await Task.Delay(100, ct); }}ReadAllAsync returns an IAsyncEnumerable that yields items as they arrive. When the writer calls channel.Writer.Complete(), the loop exits gracefully.
Registration
var channel = Channel.CreateBounded<WorkItem>(100);
builder.Services.AddSingleton(channel);builder.Services.AddSingleton(channel.Reader);builder.Services.AddSingleton(channel.Writer);builder.Services.AddHostedService<WorkItemProcessor>();Bounded vs. Unbounded
| Type | Behavior | Use When |
|---|---|---|
CreateBounded<T>(n) | Writers wait when full | You need backpressure (most cases) |
CreateUnbounded<T>() | Never blocks writers | You trust producers won’t overwhelm memory |
When the Channel is Full
BoundedChannelFullMode controls what happens:
FullMode = BoundedChannelFullMode.Wait // Writer awaits (default, safest)FullMode = BoundedChannelFullMode.DropOldest // Discard oldest item, write new oneFullMode = BoundedChannelFullMode.DropNewest // Discard the item being writtenFullMode = BoundedChannelFullMode.DropWrite // Same as DropNewestKey Takeaway
Channel<T> is the async-native producer/consumer primitive. Bounded channels give you backpressure without blocking threads. Pair it with a BackgroundService consumer and you have a robust in-process pipeline in about 30 lines. If you’re using ConcurrentQueue + polling or BlockingCollection + thread waste, this is the upgrade.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
WinUI 3 + CommunityToolkit.Mvvm: Desktop MVVM Without the Boilerplate
Building a WinUI 3 desktop app with proper MVVM used to mean writing mountains of INotifyPropertyChanged plumbing and ICommand implementations. The CommunityToolkit.Mvvm source generators change everything: you write a few attributes, and the compiler generates all the notification and command wiring for you.
Let’s build a simple counter app. First, the ViewModel (notice how little code there is):
using CommunityToolkit.Mvvm.ComponentModel;using CommunityToolkit.Mvvm.Input;
namespace CounterApp.ViewModels;
public partial class MainViewModel : ObservableObject{ [ObservableProperty] private int _count;
[ObservableProperty] private string _message = "Click the button!";
[RelayCommand] private void Increment() { Count++; Message = Count switch { < 10 => $"Count is {Count}. Keep going!", < 50 => $"Count is {Count}. You're on a roll! 🔥", _ => $"Count is {Count}. Okay, you can stop now. 😅" }; }
[RelayCommand] private void Reset() { Count = 0; Message = "Reset! Ready for another round?"; }}[ObservableProperty] on a field generates a public property with OnPropertyChanged calls baked in. [RelayCommand] on a method generates an IRelayCommand property (e.g., IncrementCommand) that you can bind directly in XAML. Zero boilerplate.
Here’s the XAML that binds to it using x:Bind:
<Window x:Class="CounterApp.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:CounterApp.ViewModels">
<Grid HorizontalAlignment="Center" VerticalAlignment="Center" RowSpacing="16"> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{x:Bind ViewModel.Message, Mode=OneWay}" FontSize="24" TextAlignment="Center"/>
<Button Grid.Row="1" Content="Increment" Command="{x:Bind ViewModel.IncrementCommand}" HorizontalAlignment="Center"/>
<Button Grid.Row="2" Content="Reset" Command="{x:Bind ViewModel.ResetCommand}" HorizontalAlignment="Center"/> </Grid></Window>And the code-behind is just wiring up the ViewModel:
using Microsoft.UI.Xaml;using CounterApp.ViewModels;
namespace CounterApp;
public sealed partial class MainWindow : Window{ public MainViewModel ViewModel { get; } = new();
public MainWindow() { InitializeComponent(); }}That’s a fully functional, properly architected MVVM app in about 50 lines total. The source generators handle all the PropertyChanged plumbing, command creation, and even CanExecute logic if you need it. Your ViewModels stay focused on behavior, not infrastructure. Welcome to the future of Windows desktop development.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
params ReadOnlySpan: The Allocation-Free Params
Every time you call a method with params T[], the compiler sneaks in a brand-new array allocation behind your back. One call? No big deal. Thousands of calls on a hot path? You’re basically running a charity for the garbage collector. C# 13 finally lets you swap that T[] for ReadOnlySpan<T>, and the results are chef’s kiss.
The change is laughably simple. Just update your method signature:
// Before: every call allocates an arraypublic static int Sum(params int[] numbers){ var total = 0; foreach (var n in numbers) total += n; return total;}
// After: zero allocations, stack-allocated spanpublic static int Sum(params ReadOnlySpan<int> numbers){ var total = 0; foreach (var n in numbers) total += n; return total;}Callers don’t change at all. Sum(1, 2, 3) compiles exactly the same way. But under the hood, the compiler now stack-allocates the arguments into an inline array and wraps them in a ReadOnlySpan<T>. No heap. No GC pressure. No drama.
This shines anywhere you have variadic helpers that get called frequently: logging, formatting, math utilities, builder patterns, you name it. And because ReadOnlySpan<T> is a supertype of arrays anyway, existing callers that pass an explicit int[] still work. It’s a free upgrade.
// All of these just work:Sum(1, 2, 3); // inline span, zero allocSum([10, 20, 30]); // collection expression, zero allocSum(myArray); // existing array, still compilesThe takeaway? If you own a params method that lives anywhere near a hot path, slap ReadOnlySpan<T> on it and let the runtime do the rest. Your GC will send you a thank-you card.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.