DEV Community

Cover image for Ambient Context in .NET
Hitesh
Hitesh

Posted on

Ambient Context in .NET

If you’ve worked in .NET long enough, you’ve probably seen a method signature that looks exactly like this:

Task<Order> UpdateOrderAsync(int orderId, UpdateOrderRequest request, int currentUserId);
Enter fullscreen mode Exit fullscreen mode

Notice that currentUserId at the end? That’s the culprit.

Nobody chooses which user they are when updating an order. That information is ambient. It’s just a fact about the current request. But because there’s no obvious place to store it, we end up manually passing it down a bucket brigade: from the controller, to the business service, to another helper service, and finally down to the database just to write an audit log.

Eventually, someone gets tired of the plumbing and decides to get clever. They inject IHttpContextAccessor directly into the domain service. Suddenly, your core business logic is tied to Microsoft.AspNetCore.Http. Everything seems fine, right up until a nightly background job tries to reuse that same service, hits a null HttpContext, and crashes.

We escaped this trap by stealing the concept behind IHttpContextAccessor without actually using it. It takes about 80 lines of code. Here is exactly how we did it, including answers to the two questions that always come up in code review: "Why is this registered as a Singleton?" and "How does this work when there is no HTTP request?"

The Magic Behind IHttpContextAccessor

Before we write our own, let’s look at how Microsoft does it. The trick is actually much simpler than people assume. Here is the real implementation of IHttpContextAccessor, almost word for word:

public class HttpContextAccessor : IHttpContextAccessor
{
    private static readonly AsyncLocal<HttpContextHolder> _httpContextCurrent = new();

    public HttpContext? HttpContext
    {
        get => _httpContextCurrent.Value?.Context;
        set
        {
            var holder = _httpContextCurrent.Value;
            if (holder != null)
            {
                // Clear the trapped HttpContext. It's done.
                holder.Context = null;
            }

            if (value != null)
            {
                _httpContextCurrent.Value = new HttpContextHolder { Context = value };
            }
        }
    }

    private sealed class HttpContextHolder
    {
        public HttpContext? Context;
    }
}
Enter fullscreen mode Exit fullscreen mode

Two things here do all the heavy lifting:

  1. AsyncLocal<T>: Think of this as a backpack for your thread. It understands await. Whatever you put in it flows down through the entire async call chain, even if the work hops between different background threads.
  2. The HttpContextHolder extra layer: This looks like pointless extra code, but it earns its keep. When a new execution context splits off (like a fire-and-forget Task.Run or a timer), it captures a snapshot of the current AsyncLocal value, not a live link back to you. So if we stored the context directly, cleaning up later with _httpContextCurrent.Value = null only affects our own flow. It can’t reach into the snapshot that stray task already grabbed. A long-lived capture (a static event handler, a persistent timer, a background loop) can then pin the whole request in memory for as long as it stays alive. By storing a box we can change later (the holder), we get around that. Every snapshot points at the same holder instance, so setting holder.Context = null at the end of the request empties the box for all of them at once, and the garbage collector can reclaim the request.

Notice something important: there is nothing HTTP-specific about either of those tricks. So, we just built our own version to hold what we actually cared about: the current user.

Building Our Own

We need four simple types. First, let’s define what a user looks like in our system:

public interface ICurrentUser
{
    bool IsAuthenticated { get; }
    int UserId { get; }
    string ExternalUserId { get; }
    string UserName { get; }
    string Email { get; }
    IReadOnlyList<string> Roles { get; }
}

public sealed class CurrentUser : ICurrentUser
{
    public static readonly ICurrentUser Anonymous = new CurrentUser();

    public bool IsAuthenticated { get; init; }
    public int UserId { get; init; }
    public string ExternalUserId { get; init; }
    public string UserName { get; init; }
    public string Email { get; init; }
    public IReadOnlyList<string> Roles { get; init; } = [];
}
Enter fullscreen mode Exit fullscreen mode

Notice how everything uses init. The current user is a fact about the current call, not a variable to be tweaked. If a service wants to change it, it has to replace the whole object. No silent changes allowed.

Next, we build our ambient store, stealing Microsoft’s "holder box" design:

public static class CurrentUserHolder
{
    private static readonly AsyncLocal<Holder> Current = new();

    // Notice: This never returns null! Unauthenticated calls get CurrentUser.Anonymous.
    public static ICurrentUser User => Current.Value?.Value ?? CurrentUser.Anonymous;

    public static void Set(ICurrentUser user)
    {
        ArgumentNullException.ThrowIfNull(user);

        // Empty the box the previous execution contexts are holding...
        var existing = Current.Value;
        if (existing is not null)
        {
            existing.Value = null;
        }

        // ...then hand this flow a fresh one.
        Current.Value = new Holder { Value = user };
    }

    public static void Clear()
    {
        var existing = Current.Value;
        if (existing is not null)
        {
            existing.Value = null;
        }
    }

    private sealed class Holder
    {
        public ICurrentUser Value;
    }
}
Enter fullscreen mode Exit fullscreen mode

We made one deliberate change from Microsoft’s design: our accessor never returns null. Dealing with nullable contexts means writing ?. everywhere. In our system, an unauthenticated call isn’t an "absent" user; it’s an "anonymous" one. This means CurrentUser.Anonymous is the floor, and calling accessor.CurrentUser.UserId is always safe.

Finally, we create the clean, injectable window for our application code to use:

public interface ICurrentUserAccessor
{
    ICurrentUser CurrentUser { get; }
}

public sealed class CurrentUserAccessor : ICurrentUserAccessor
{
    public ICurrentUser CurrentUser => CurrentUserHolder.User;
}
Enter fullscreen mode Exit fullscreen mode

This is get-only. We don’t want random services overriding the current user. Writing to the store goes through CurrentUserHolder.Set, which should only ever be called by the entry points of your app.

We register it once in our DI container:

services.AddSingleton<ICurrentUserAccessor, CurrentUserAccessor>();
Enter fullscreen mode Exit fullscreen mode

And this is where code reviews usually grind to a halt. "Wait, why is this a Singleton?"

The Singleton Controversy

The temptation is to register the user as a scoped service. I’ve written this exact code myself:

// The tempting version. Don't do this.
services.AddScoped<ICurrentUser>(sp =>
{
    var http = sp.GetRequiredService<IHttpContextAccessor>();
    return MapFromPrincipal(http.HttpContext?.User);
});
Enter fullscreen mode Exit fullscreen mode

It looks gorgeous. You can just inject ICurrentUser into your constructor without dealing with accessors. But it will break your app in four distinct ways:

  1. Singletons can’t use it: If you have a singleton service (like an EF Core Interceptor, a cache, or a background worker) that needs to know the current user, it will crash. The DI container will yell at you about a "captive dependency": you’ve trapped a short-lived scoped object inside a long-lived singleton.
  2. It freezes in time: A scoped instance is created once per scope and cached. If it gets resolved before your authentication middleware has figured out who the user is, every other service in that request will receive an "Anonymous" user. Silently.
  3. Scopes aren’t always what you think: We treat "Scoped" and "Per HTTP Request" as the same thing because ASP.NET Core does that for us. But in a background worker, there might only be one DI scope for the entire lifetime of the process!

The "Window" Analogy

Look at how ASP.NET Core registers its own accessor:

services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
Enter fullscreen mode Exit fullscreen mode

It’s a singleton! This isn’t a mistake. The key is separating the data from the object that hands you the data.

Think of ICurrentUserAccessor as a window. The window itself never changes (it’s a Singleton). It has no state, no fields, and holds no memory. But the scenery outside the window (AsyncLocal) changes depending on exactly when you look through it.

By injecting a singleton window, you get hold of a value provider. You ask it for the answer at the exact moment you need it, ensuring you always get the right user, no matter which DI scope you happen to be sitting in.

Setting the User

The current user is only ever written at entry points. Everywhere else just reads it.

In an HTTP API, that entry point is a middleware registered right after authentication:

public class CurrentUserMiddleware
{
    private readonly RequestDelegate _next;

    public CurrentUserMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext httpContext, IUserService userService)
    {
        CurrentUserHolder.Set(await ResolveAsync(httpContext.User, userService));

        try
        {
            await _next(httpContext);
        }
        finally
        {
            CurrentUserHolder.Clear(); // Empty the box!
        }
    }

    // (ResolveAsync left out to keep this short. It just maps ClaimsPrincipal to our CurrentUser)
}

Enter fullscreen mode Exit fullscreen mode

What about Background Jobs?

This is where the pattern really shines. What if a background queue consumer needs to process a job on behalf of a user?

The publisher simply stamps the user’s ID onto the message envelope before sending it. When the consumer picks it up, it sets up the ambient context:

public sealed class CurrentUserConsumeFilter<T> : IConsumeFilter<T>
{
    public async Task OnConsumeAsync(MessageEnvelope<T> envelope, ConsumeDelegate next)
    {
        // For automated background tasks, we use a dedicated system account
        CurrentUserHolder.Set(SystemPrincipals.BackgroundWorker);

        try
        {
            await next();
        }
        finally
        {
            CurrentUserHolder.Clear();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

From this point on, the message handler (and every service it calls) sees the exact same ICurrentUserAccessor it would see in a web request. Your business logic never has to know if it was triggered by an HTTP call or a RabbitMQ message.

The Massive Payoff: Automatic Audit Columns

If this sounds like a lot of theory, here is the feature that will make you fall in love with it.

Let’s say your database tables have standard audit columns (CreatedBy, ModifiedBy). We can use an EF Core Interceptor to fill these in automatically, globally, without ever writing it in a service again.

public sealed class AuditInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserAccessor _accessor;   // Safely capture the singleton!

    public AuditInterceptor(ICurrentUserAccessor accessor) => _accessor = accessor;

    private void Stamp(DbContext context)
    {
        if (context is null) return;

        // Because this runs right when SaveChanges is called, it always gets the correct, current user.
        var userId = _accessor.CurrentUser.UserId;
        var now = DateTime.UtcNow;

        foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Entity.CreatedBy = userId;
                entry.Entity.CreatedOnUtc = now;
            }
            if (entry.State == EntityState.Added || entry.State == EntityState.Modified)
            {
                entry.Entity.ModifiedBy = userId;
                entry.Entity.ModifiedOnUtc = now;
            }
        }
    }

    // (Wire this up to SavingChanges and SavingChangesAsync)
}
Enter fullscreen mode Exit fullscreen mode

Because our ICurrentUserAccessor is a Singleton and works across HTTP threads and background workers, this interceptor functions perfectly everywhere. No developer ever has to remember to set ModifiedBy = currentUserId again, which means no developer can forget.

(Bonus: You can use this exact same pattern to auto-tag your Serilog logs via custom Enrichers, so every single log is automatically stamped with the active user’s ID!)

A Few Real-World Gotchas

If you’re going to use this, keep these sharp edges in mind:

  • AsyncLocal flows down, not up: If you set the user deep inside a child method, the parent method won’t see it. Always call Set() at the absolute highest point of your operation (middleware, top of the cron loop, etc.).
  • Always Clear() in a finally block: This is what prevents memory leaks in long-lived applications. Don’t skip it.
  • Don’t treat it like a junk drawer: This pattern is for ambient data (like the user). Don’t start stuffing CurrentOrderId or IsUserHavingAGoodDay in there. If you need a new ambient scope (like a Tenant ID), build a separate, dedicated accessor for it.

One Pattern, Many Uses

Once this is in your toolbox, you start spotting places for it everywhere. The user is just the first example. Anything that is a fact about the current operation, rather than a real input to your methods, is a good fit.

The one I reach for most is the correlation ID. When a request kicks off, you generate a short ID and drop it in an ambient holder. Every log line inside that request can pick it up on its own, so you can trace one user’s journey through a noisy log file without threading an ID through every method.

The real win is that it survives process boundaries. Your publisher stamps the correlation ID onto the message envelope (exactly like we did with the user), the consumer reads it back on the other side and sets its own ambient holder, and suddenly one ID ties together the HTTP call, the queue message, and the three services that handled it downstream. Debugging a distributed system stops feeling like guesswork.

A few other things that fit the same mold:

  • Tenant ID in a multi-tenant app, so every query is scoped to the right customer without passing it through every layer.
  • Request culture or locale, so formatting and translations just work wherever you are in the call chain.
  • Feature flag context, so a deep-down service can check "is this experiment on for this user?" without being handed the whole context.

The recipe is always the same: a get-only accessor (the window), an AsyncLocal holder behind it (the scenery), and a Set() / Clear() pair at your entry points. Build a separate, dedicated accessor for each kind of data, resist the urge to cram them all into one, and you get the same clean, leak-free behavior every time.

Was it worth it?

Absolutely. By adding four simple classes and one DI registration, we entirely deleted int currentUserId from hundreds of method signatures. Audit logs went from a manual chore that was constantly forgotten, to an automated guarantee that works flawlessly in both web apps and background workers.

Sometimes framework abstractions like IHttpContextAccessor are 80% of what you need, but the missing 20% makes them unusable. But if you peek underneath the hood and steal the pattern instead of the class, you can solve massive architectural headaches in less than a hundred lines of code.

Top comments (1)

Collapse
 
iqtechsolutions profile image
Ivan Rossouw

One boundary I’d add is to separate attribution from authorization. An ambient UserId is useful for audit stamps within one operation, but a queued job should not treat captured roles or claims as durable authority; persist the stable actor ID on the envelope for attribution, then authorize the consumer’s current execution principal or policy instead of trusting serialized claims. I’d also consider making Set return an IDisposable scope so nested identity changes restore the previous context instead of Clear erasing an outer value: using var _ = CurrentUserScope.Push(user);. The regression test I’d want is two concurrent requests plus a nested system-identity operation and a Task.Run captured before cleanup, asserting correct restoration and no cross-request bleed.