Newsletter Archive
Browse through our collection of past newsletters. Each edition is packed with C# and .NET insights.
Page 4 of 12 (36 editions)
Ditch the Double Lookup: CollectionsMarshal.GetValueRefOrAddDefault
Here’s a pattern every C# developer has written a hundred times for counting occurrences, aggregating values, or building up a cache:
var wordCounts = new Dictionary<string, int>();
foreach (var word in words){ if (wordCounts.TryGetValue(word, out var count)) wordCounts[word] = count + 1; else wordCounts[word] = 1;}It works. It’s clear. But it hashes the key twice on every iteration, once for TryGetValue and once for the indexer set. On a hot path with millions of entries, that adds up fast.
Since .NET 6, System.Runtime.InteropServices.CollectionsMarshal gives you a way to do this in a single lookup. The GetValueRefOrAddDefault method returns a ref directly into the dictionary’s internal storage:
using System.Runtime.InteropServices;
var wordCounts = new Dictionary<string, int>();
foreach (var word in words){ ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault( wordCounts, word, out bool exists);
// 'count' is a ref to the slot — mutate it in place count++;}That’s it. One hash, one probe, one update. If the key already existed, exists is true and count points at the current value. If it didn’t, the runtime inserts a default entry (0 for int) and count points at that new slot. Either way, count++ does the right thing.
This works beautifully for any “upsert” operation. Building a grouped lookup? Same idea:
var grouped = new Dictionary<string, List<Order>>();
foreach (var order in orders){ ref List<Order>? list = ref CollectionsMarshal.GetValueRefOrAddDefault( grouped, order.Region, out bool exists);
if (!exists) list = new List<Order>();
list!.Add(order);}No TryGetValue. No ContainsKey. No double hashing. Just a direct ref into the slot.
A couple of things to keep in mind:
- Don’t modify the dictionary while holding a ref. Adding or removing keys can resize the internal storage and invalidate your reference. Use the ref, then move on.
- It lives in
CollectionsMarshalfor a reason. This is a low-level API. It trades a bit of safety for performance. For casual code, the classicTryGetValuepattern is perfectly fine. Reach for this when profiling tells you dictionary access is a bottleneck. - It only works with
Dictionary<TKey, TValue>, notConcurrentDictionary, notFrozenDictionary, not custom implementations.
For hot-path aggregation, counting, grouping, or caching, CollectionsMarshal.GetValueRefOrAddDefault is a one-line upgrade that cuts your hashing work in half.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
Stop Mocking the Clock: TimeProvider Makes Time Testable
We’ve all been there. You write a unit test that depends on DateTime.UtcNow, and suddenly you’re building a custom IClock interface, or wrapping things in a static ambient context, or just… hoping the test doesn’t run at midnight on a leap year. TimeProvider puts all of that to rest. It’s a first-class abstraction for time, and it lives right in the base class library.
The concept is straightforward. Instead of calling DateTime.UtcNow or DateTimeOffset.UtcNow directly, you inject a TimeProvider and call GetUtcNow(). In production, you register TimeProvider.System, which just returns the real clock. In tests, you swap in a FakeTimeProvider that lets you set, freeze, and advance time whenever you want.
Let’s look at a practical example. Say you have a service that checks whether a coupon code has expired:
public class CouponService(TimeProvider timeProvider){ public bool IsValid(Coupon coupon) { var now = timeProvider.GetUtcNow(); return now < coupon.ExpiresAt; }
public async Task WaitAndExpire(Coupon coupon, CancellationToken ct) { var remaining = coupon.ExpiresAt - timeProvider.GetUtcNow(); if (remaining > TimeSpan.Zero) { await Task.Delay(remaining, timeProvider, ct); } coupon.IsActive = false; }}Notice that Task.Delay overload. It accepts a TimeProvider, so even delays become fully controllable in tests. Registering it in your DI container works the same way as any other service:
builder.Services.AddSingleton(TimeProvider.System);Now for the fun part. Install the testing companion package:
dotnet add package Microsoft.Extensions.TimeProvider.TestingAnd write tests that are completely deterministic. No flaky timing, no Thread.Sleep, no crossing your fingers:
using Microsoft.Extensions.Time.Testing;
public class CouponServiceTests{ [Fact] public void IsValid_ReturnsFalse_AfterExpiration() { // Start at a known point in time var fake = new FakeTimeProvider( new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero));
var coupon = new Coupon { ExpiresAt = fake.GetUtcNow().AddHours(2) };
var service = new CouponService(fake);
Assert.True(service.IsValid(coupon));
// Jump forward 3 hours, instantly fake.Advance(TimeSpan.FromHours(3));
Assert.False(service.IsValid(coupon)); }
[Fact] public async Task WaitAndExpire_DeactivatesCoupon_WhenTimeAdvances() { var fake = new FakeTimeProvider( new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero));
var coupon = new Coupon { ExpiresAt = fake.GetUtcNow().AddMinutes(30), IsActive = true };
var service = new CouponService(fake); var task = service.WaitAndExpire(coupon, CancellationToken.None);
// Time hasn't moved, coupon is still active Assert.True(coupon.IsActive);
// Advance past expiration fake.Advance(TimeSpan.FromMinutes(31));
await task; Assert.False(coupon.IsActive); }}
public class Coupon{ public DateTimeOffset ExpiresAt { get; set; } public bool IsActive { get; set; }}The FakeTimeProvider.Advance() call is the secret sauce. It moves the clock forward instantly, and any pending Task.Delay calls waiting on that TimeProvider will resolve right away. No real waiting, no race conditions, no flaky CI runs.
If you’re still maintaining a homegrown IClock or IDateTimeProvider interface, it might be time to retire it. TimeProvider is baked into the framework, works with Task.Delay, PeriodicTimer, and CancellationTokenSource.CancelAfter, and has a battle-tested fake ready to go. That’s one less abstraction you have to carry around.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.
Freeze Your Lookups: FrozenDictionary & FrozenSet for Hot-Path Reads
If you’ve ever built a Dictionary or HashSet at startup, say for config values, feature flags, or permission sets, and then hammered it with reads for the lifetime of your app, .NET has a gift for you: System.Collections.Frozen.
FrozenDictionary<TKey, TValue> and FrozenSet<T> are immutable, read-optimized collections. You build them once from an existing collection, and in return the runtime produces an internal layout that’s specifically tuned for the keys you gave it. The trade-off is simple: construction is slower than a normal dictionary (the runtime analyzes your keys to pick an optimal strategy), but every subsequent lookup is faster, often dramatically on hot paths.
The API couldn’t be easier. You already have the extension methods you need:
using System.Collections.Frozen;
// Built once at startup from config, a database, etc.var configMap = new Dictionary<string, string>{ ["FeatureX:Enabled"] = "true", ["FeatureX:MaxRetries"] = "3", ["Cache:SlidingExpiration"] = "00:05:00", ["Logging:MinLevel"] = "Warning"};
var enabledFeatures = new HashSet<string>{ "dark-mode", "beta-search", "new-checkout-flow"};
// One call. Every read after this is fasterFrozenDictionary<string, string> frozenConfig = configMap.ToFrozenDictionary();FrozenSet<string> frozenFeatures = enabledFeatures.ToFrozenSet();Once frozen, the read API is exactly what you’re used to with ContainsKey, TryGetValue, indexer access, and Contains. So, it’s a drop-in replacement anywhere you have a build-once, read-many collection:
string retries = frozenConfig["FeatureX:MaxRetries"];
if (frozenConfig.TryGetValue("Cache:SlidingExpiration", out var expiration)) Console.WriteLine($"Sliding expiration: {expiration}");
bool betaSearchOn = frozenFeatures.Contains("beta-search");How much faster? In a quick micro-benchmark doing 10 million lookups against a small collection, the frozen variants were roughly 2× faster than their mutable counterparts:
| Collection | 10M Lookups |
|---|---|
Dictionary<string, string> | ~90 ms |
FrozenDictionary<string, string> | ~41 ms |
HashSet<string> | ~72 ms |
FrozenSet<string> | ~30 ms |
The key thing to remember: these collections are immutable after creation. There’s no Add, Remove, or Clear. If you need to mutate, you’re back to a regular Dictionary or HashSet. But for the very common pattern of “load once, read forever”, FrozenDictionary and FrozenSet are a one-line upgrade that turns a hot path cold.
Don't miss the next tip 💧
Get a .NET tip and curated links delivered to your inbox every week.