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 15 (43 editions)

July 12, 2026

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 wait
var 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

TypeBehaviorUse When
CreateBounded<T>(n)Writers wait when fullYou need backpressure (most cases)
CreateUnbounded<T>()Never blocks writersYou 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 one
FullMode = BoundedChannelFullMode.DropNewest // Discard the item being written
FullMode = BoundedChannelFullMode.DropWrite // Same as DropNewest

Key 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.

Read the full tip + 8 curated links

Don't miss the next tip 💧

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

July 5, 2026

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.

Read the full tip + 8 curated links

Don't miss the next tip 💧

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

June 28, 2026

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 array
public static int Sum(params int[] numbers)
{
var total = 0;
foreach (var n in numbers) total += n;
return total;
}
// After: zero allocations, stack-allocated span
public 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 alloc
Sum([10, 20, 30]); // collection expression, zero alloc
Sum(myArray); // existing array, still compiles

The 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.

Read the full tip + 9 curated links

Don't miss the next tip 💧

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