Skip to content

Async Patterns

Rules for asynchronous code, cancellation, time, and concurrency.

No blocking on async

Never use async void, .Result, or .Wait(). They cause deadlocks and swallow or wrap exceptions.

// WRONG
public async void OnMessage(Message m) { await HandleAsync(m); }
var result = client.GetAsync(url).Result;
task.Wait();

// RIGHT
public async Task OnMessageAsync(Message m, CancellationToken ct)
{
    await HandleAsync(m, ct);
}
var result = await client.GetAsync(url, ct);

Warning

async void is acceptable only for UI event handlers, which this codebase does not have.

CancellationToken everywhere

Accept a CancellationToken in every async method and pass it down the entire call chain.

public async Task<Invoice> LoadAsync(Guid id, CancellationToken ct)
{
    var row = await _db.Invoices.SingleAsync(i => i.Id == id, ct);
    return await _mapper.MapAsync(row, ct);
}

Tests pass TestContext.Current.CancellationToken to every call that accepts a token (analyzer xUnit1051).

[Fact]
public async Task Loads_invoice()
{
    var ct = TestContext.Current.CancellationToken;
    var invoice = await _service.LoadAsync(id, ct);
    Assert.Equal(id, invoice.Id);
}

TimeProvider, not DateTime

Inject TimeProvider. Never read DateTime.Now or DateTime.UtcNow directly. This makes time-dependent logic deterministic in tests.

public sealed class ExpiryChecker
{
    private readonly TimeProvider _clock;

    public ExpiryChecker(TimeProvider clock)
    {
        _clock = clock;
    }

    public bool IsExpired(DateTimeOffset expiresAt) => _clock.GetUtcNow() >= expiresAt;
}

In tests, use FakeTimeProvider and call Advance(...) instead of sleeping.

No ContinueWith in actor code

Task.ContinueWith() bypasses the actor's threading guarantees and may run on any thread. Use PipeTo with explicit success and failure mappings instead.

// WRONG
_client.FetchAsync(url).ContinueWith(t => new Fetched(t.Result));

// RIGHT
_client.FetchAsync(url, ct).PipeTo(
    Self,
    success: body => new Fetched(body),
    failure: ex => new FetchFailed(ex));

Tip

Define your own XxxFailed(Exception Cause) message rather than relying on framework failure wrappers.

ConfigureAwait

Follow the project default. Do not sprinkle ConfigureAwait(false) on individual awaits.

  • Application code (ASP.NET Core, workers, actors) has no synchronization context, so it has no effect.
  • Libraries intended for general reuse may set it consistently, enforced by an analyzer, never ad hoc.

Structured concurrency

Prefer higher-level primitives over manual Task management.

Need Use
Producer/consumer, buffering, back-pressure System.Threading.Channels
Stateful entities, serialized access Actors
Parallel I/O with a bound Parallel.ForEachAsync with MaxDegreeOfParallelism
var channel = Channel.CreateBounded<Job>(capacity: 100);

await foreach (var job in channel.Reader.ReadAllAsync(ct))
{
    await ProcessAsync(job, ct);
}

Warning

Avoid fire-and-forget Task.Run and unobserved tasks. Every task must be awaited, piped, or owned by a component that observes its failure.