The Invisible Parameters in Your .NET Application

An application service can have a method that appears to accept two inputs while its behaviour actually depends on six. The visible inputs arrive as parameters. The remaining values come from the current HTTP request, authenticated user, tenant accessor, logging scope, trace or an AsyncLocal<T> hidden behind an injected interface. The method signature looks small, but it does not describe everything capable of changing the result.
This is ambient context: data associated with the current flow of execution and available without being passed through every method call.
.NET applications use it constantly. HttpContext, Activity.Current, logging scopes and many implementations of a current user or current tenant service all depend on ambient state. Used carefully, it keeps infrastructure concerns out of business APIs and allows trace information to follow asynchronous work naturally. Used for the wrong data, it creates invisible inputs that cross concurrency boundaries, survive longer than expected or disappear as soon as work leaves the process. The useful architectural decision is then not whether ambient context is universally good or bad. It is deciding which values may travel implicitly, how far they may travel and where they must become explicit data.
Ambient context already exists in most .NET applications
If you take an ordinary ASP.NET Core request. Authentication establishes a ClaimsPrincipal. Middleware may add a correlation identifier to a logging scope. ASP.NET Core creates an Activity for distributed tracing. A tenant resolver reads a claim or hostname and exposes the selected tenant through a scoped service. Deeper code can read all of this without the endpoint passing the values directly. Thats attractive because repeatedly forwarding operational metadata adds noise. A repository should not need a traceId parameter simply so an outgoing database activity can join the current trace. A service should not need a correlationId parameter on every method simply so its log messages can be searched together. These are cross-cutting observations about an operation rather than business inputs to it.
The position changes when an ambient value decides what the method is allowed to read or write.
public sealed class QuoteService(
ICurrentTenant currentTenant,
QuoteDbContext dbContext)
{
public Task<Quote?> GetAsync(
Guid quoteId,
CancellationToken stopToken) =>
dbContext.Quotes.SingleOrDefaultAsync(
quote =>
quote.Id == quoteId &&
quote.TenantId == currentTenant.RequiredTenantId,
stopToken);
}
The visible contract says that GetAsync depends on a quote identifier. Its result also depends on the tenant active at the moment the query is created. The tenant accessor was supplied through dependency injection, but that only makes the accessor dependency visible on the class. The tenant value remains an implicit input to the method. This may be acceptable inside a tightly controlled request scope. It becomes much harder to reason about when the same service is called by a message handler, scheduled worker, parallel operation or test that does not naturally have a current tenant.
AsyncLocal<T> follows execution rather than a thread
The runtime primitive behind many ambient context implementations is AsyncLocal<T>. Microsoft describes it as ambient data local to an asynchronous control flow. The word asynchronous is important. Older code sometimes used thread local storage for this job. That does not fit async code because a continuation can resume on a different thread after an await. AsyncLocal<T> follows the logical execution flow even when the physical thread changes. The runtime carries these values as part of ExecutionContext. The context is captured and restored across runtime-defined asynchronous points. Culture, impersonation information and AsyncLocal<T> values can therefore remain available as execution moves between thread pool threads.
private static readonly AsyncLocal<string?> Tenant = new();
Tenant.Value = "tenant-a";
await Task.Delay(10);
Console.WriteLine(Tenant.Value); // tenant-a
The value survived the await without being attached permanently to either thread involved. Thinking in terms of a logical call flow explains its behaviour more accurately than thinking in terms of thread affinity. It also explains why Task.Run does not automatically detach work from ambient state. Task.Run normally captures the caller's ExecutionContext, so the scheduled delegate can see the current AsyncLocal<T> values and Activity.Current.
Tenant.Value = "tenant-a";
await Task.Run(() =>
{
Console.WriteLine(Tenant.Value); // tenant-a
});
That propagation is useful when Task.Run is genuinely part of the current operation. Its dangerous when developers use it as an informal background queue and assume the new task starts with a clean context. ConfigureAwait(false) doesnt clear this state. It changes whether an await attempts to resume through the captured SynchronizationContext or scheduler, while AsyncLocal<T> values travel through the separate ExecutionContext. Stephen Toub's ConfigureAwait FAQ calls this out directly: ambient values continue across an await regardless of ConfigureAwait(false) unless execution-context flow has been suppressed explicitly.
The common boundaries behave differently:
An await, a new task and a new process are all asynchronous boundaries, but they do not have the same propagation semantics. Once work is written to Service Bus, Kafka, RabbitMQ or a database backed queue, the in memory execution context has ended. Trace instrumentation may inject standard trace headers into a message, but any business context required by the consumer must be part of the durable contract.
Flow does not imply safe ownership
An AsyncLocal<T> slot carries a value. It does not make the object stored in that value thread safe, immutable or private to a parallel branch. Suppose an application stores a mutable context object in an ambient slot and then starts two operations with Task.WhenAll. Both child flows can inherit a reference to the same object. If either branch mutates it, the other branch may observe that mutation. ExecutionContext propagation has worked correctly; the application has confused flowing a reference with giving each branch independent state.
public sealed class MutableOperationContext
{
public required string TenantId { get; set; }
public string? ActingUserId { get; set; }
}
private static readonly AsyncLocal<MutableOperationContext?> Current = new();
Changing ActingUserId in one parallel operation changes the shared object. Assigning a completely new value to Current.Value behaves differently because the assignment belongs to that logical execution context, but mutation inside the referenced object remains shared in the usual .NET sense.
Ambient values should therefore be immutable wherever practical.
public sealed record OperationContext(
string TenantId,
string SubjectId,
string CorrelationId,
DateTimeOffset AcceptedAtUtc);
When a branch needs different context, it can derive a new record and install it for that branch. Existing flows continue to reference the original value. The same distinction applies to HttpContext. Making it available through an asynchronous flow does not make concurrent access safe. Microsoft's ASP.NET Core guidance states that HttpContext is not thread safe and recommends copying the required data before starting parallel operations.
Dependency injection cannot make an implicit value explicit
Wrapping ambient state in an interface improves testability and limits direct framework coupling. It does not change the nature of the state.
public interface ICurrentOperationContext
{
OperationContext Required { get; }
}
public sealed class PricingService(ICurrentOperationContext current)
{
public Money ApplyDiscount(Money price)
{
return current.Required.TenantId == "preferred-partner"
? price * 0.9m
: price;
}
}
PricingService declares a dependency on the accessor, which is useful. ApplyDiscount still appears to be a function of price while its answer also changes according to the current tenant. A caller can invoke the same method with the same argument and receive a different result because unrelated code established different ambient state earlier in the flow. When context changes a business result, passing the relevant value makes the contract clearer.
public sealed class PricingService
{
public Money ApplyDiscount(
Money price,
PricingContext context)
{
return context.CustomerCategory == CustomerCategory.PreferredPartner
? price * 0.9m
: price;
}
}
public sealed record PricingContext(
CustomerCategory CustomerCategory);
There is no need to pass the complete HttpContext or a generic property bag. The method receives the smallest context that belongs to the decision it owns. This keeps the business API honest without spreading transport details through the domain. An injected accessor remains useful near framework boundaries and for infrastructure that cannot reasonably accept additional parameters. The distinction is about the value's role. A trace identifier enriches observation of a pricing decision. A customer category changes the decision itself.
Observational context and authoritative context deserve different treatment
Tracing is one of the strongest uses of ambient context. Activity.Current flows across asynchronous calls, allowing an HTTP request, database call and outgoing HTTP call to participate in the same trace without every application method accepting an Activity parameter. The current activity represents instrumentation around the work, not an instruction telling the domain which quote may be returned. Logging scopes serve a similar purpose. A scope can attach the same transaction, submission or correlation identifier to each log written within a logical operation. Microsoft's logging guidance describes a scope as grouping a set of logical operations until the scope is disposed. Passing those values through every method would add ceremony without improving the business contract.
Tenant identity, user identity, permissions, data region selection and an "act as" mode carry more authority. They can determine which database is selected, which rows are visible, whether an operation is permitted and who appears in the audit trail. An unexpected value can become a cross tenant data incident rather than a missing telemetry field.
This difference leads to a practical threshold. Observational data can usually flow for the lifetime of an in process operation. Authoritative data should be captured as an immutable value at the boundary that establishes it, passed explicitly into important business decisions and written into any durable message that needs it later. Some values occupy both categories. A subject identifier may be useful in logs and also required for authorisation. It can be present in an ambient logging scope for diagnostics while being passed explicitly to the policy or command that uses it as authority. One representation does not have to perform both jobs.
Keep HttpContext at the HTTP boundary
HttpContext is a rich transport object. It contains the request, response, connection details, user, features and services for one active request. That makes it useful in middleware and endpoints and a poor general purpose execution context for application services. Microsoft's documentation says that an HttpContext is valid only while its request is active and may be recycled after the pipeline completes. It also advises against capturing it in background work. The accessor itself carries an explicit warning: IHttpContextAccessor relies on AsyncLocal<T>, introduces ambient state and can make testing harder. A boundary can extract stable values while the request is active and create an application owned context.
app.MapPost(
"/submissions",
async (
StartSubmissionRequest request,
HttpContext httpContext,
SubmissionService service,
TimeProvider timeProvider,
CancellationToken stopToken) =>
{
var tenantId = httpContext.User.FindFirst("tenant_id")?.Value
?? throw new UnauthorizedAccessException(
"The authenticated identity has no tenant.");
var subjectId = httpContext.User.FindFirst("sub")?.Value
?? throw new UnauthorizedAccessException(
"The authenticated identity has no subject.");
var context = new OperationContext(
tenantId,
subjectId,
Activity.Current?.TraceId.ToString()
?? httpContext.TraceIdentifier,
timeProvider.GetUtcNow());
var submissionId = await service.StartAsync(
request,
context,
stopToken);
return Results.Accepted($"/submissions/{submissionId}");
});
The application service receives ordinary immutable data. It no longer needs to know that the tenant came from a claim, that the subject came from an OpenID Connect token or that the operation began as HTTP.
public sealed class SubmissionService(SubmissionDbContext dbContext)
{
public async Task<Guid> StartAsync(
StartSubmissionRequest request,
OperationContext context,
CancellationToken stopToken)
{
var submission = new Submission(
Guid.NewGuid(),
context.TenantId,
request.Reference,
context.SubjectId,
context.AcceptedAtUtc);
dbContext.Submissions.Add(submission);
await dbContext.SaveChangesAsync(stopToken);
return submission.Id;
}
}
That service can now be called from a controller, minimal API, gRPC service, message handler or integration test with the same contract. The caller is responsible for establishing trustworthy context for its own transport.
A queue starts a new execution context
An in memory task may inherit the current ExecutionContext. A durable queue never inherits it. Treating the consumer as though it is a continuation of the original request hides the security and lifetime change taking place. The message should contain the values needed to perform the work and to audit why it exists.
public sealed record ProcessSubmission(
Guid SubmissionId,
string TenantId,
string RequestedBySubjectId,
string CorrelationId,
DateTimeOffset RequestedAtUtc);
This is a snapshot, not a serialised HttpContext. Cookies, request services, a ClaimsPrincipal and bearer tokens do not belong in the command. They are tied to transport, contain more information than the worker requires and can expire or change meaning before delayed work runs. The consumer establishes a fresh processing scope and uses its own service identity to access infrastructure. It can start a new activity linked to the incoming trace information and place the correlation identifier in a logging scope. The tenant and requesting subject remain explicit inputs to the handler.
public sealed class ProcessSubmissionHandler(
SubmissionProcessor processor,
ILogger<ProcessSubmissionHandler> logger)
{
public async Task HandleAsync(
ProcessSubmission message,
CancellationToken stopToken)
{
using var logScope = logger.BeginScope(new Dictionary<string, object>
{
["TenantId"] = message.TenantId,
["CorrelationId"] = message.CorrelationId,
["SubmissionId"] = message.SubmissionId
});
await processor.ProcessAsync(
message.SubmissionId,
message.TenantId,
message.RequestedBySubjectId,
stopToken);
}
}
Capturing the requesting subject does not answer every authorisation question. Some operations are authorised when they are requested and may complete later even if the user's role changes. Other operations must re-evaluate permission immediately before the external effect. The message contract should support whichever rule the business actually uses. If permission must be current, the worker can load the subject's current entitlements and evaluate the policy again. If the original authorisation is the durable fact, the system may need to record the policy decision, policy version and relevant evidence when accepting the command. Carrying an old ClaimsPrincipal into the future only disguises that choice.
In process background work still needs an explicit hand-off
The same approach is useful before a system adopts a broker. A bounded Channel<T> and a hosted service can provide a clear in-process queue. The work item includes its context rather than relying on whatever ambient state happened to exist when the producer called WriteAsync.
public sealed record SubmissionWorkItem(
Guid SubmissionId,
OperationContext Context);
public interface ISubmissionWorkQueue
{
ValueTask EnqueueAsync(
SubmissionWorkItem workItem,
CancellationToken stopToken);
IAsyncEnumerable<SubmissionWorkItem> ReadAllAsync(
CancellationToken stopToken);
}
The worker creates a dependency-injection scope for each item and passes the captured context into the handler.
public sealed class SubmissionWorker(
ISubmissionWorkQueue queue,
IServiceScopeFactory scopeFactory) : BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken stopToken)
{
await foreach (var item in queue.ReadAllAsync(stopToken))
{
await using var scope = scopeFactory.CreateAsyncScope();
var handler = scope.ServiceProvider
.GetRequiredService<QueuedSubmissionHandler>();
await handler.HandleAsync(
item.SubmissionId,
item.Context,
stopToken);
}
}
}
This avoids two separate lifetime mistakes. The worker does not hold request-scoped services after the request finishes, and it does not depend on an ambient context whose presence is an accidental consequence of how the task was scheduled. Replacing the channel with a broker later does not change the handler's inputs.
When an ambient accessor is still useful
There are cases where passing context through every layer produces more friction than clarity. A logging enricher, audit interceptor or EF Core query filter may need the same operation level value across a large amount of infrastructure code. An application can support this while keeping the scope deliberate. The accessor should fail when required context is missing, support nested scopes and restore the previous value when a scope ends. An immutable frame avoids sharing mutable context between parallel flows.
public sealed class AmbientOperationContext : ICurrentOperationContext
{
private static readonly AsyncLocal<Frame?> Slot = new();
public OperationContext Required =>
Slot.Value?.Context
?? throw new InvalidOperationException(
"No operation context is active.");
public IDisposable Push(OperationContext context)
{
var parent = Slot.Value;
var frame = new Frame(context, parent);
Slot.Value = frame;
return new PopScope(frame, parent);
}
private sealed record Frame(
OperationContext Context,
Frame? Parent);
private sealed class PopScope(
Frame installed,
Frame? parent) : IDisposable
{
private bool disposed;
public void Dispose()
{
if (disposed)
{
return;
}
if (!ReferenceEquals(Slot.Value, installed))
{
throw new InvalidOperationException(
"Operation contexts were disposed out of order.");
}
Slot.Value = parent;
disposed = true;
}
}
}
Middleware can create the scope after authentication and tenant resolution have succeeded.
app.Use(async (httpContext, next) =>
{
var ambient = httpContext.RequestServices
.GetRequiredService<AmbientOperationContext>();
var context = CreateOperationContext(httpContext);
using (ambient.Push(context))
{
await next(httpContext);
}
});
Restoring the previous frame is important. Simply assigning null in finally breaks nested operations, such as a system level process that temporarily establishes an explicit tenant scope. Enforcing disposal order also turns corrupted scope nesting into an immediate failure rather than allowing a later call to use the wrong identity silently. An accessor like this should remain an integration mechanism, not a universal route around method parameters. A domain policy that needs SubjectId should still receive it. An audit interceptor that records the current subject for every changed entity may reasonably read it from the operation scope.
Tenant filters turn context into data access policy
EF Core global query filters are a powerful example of ambient authority. A tenant value can be attached to a DbContext, after which EF adds a tenant predicate whenever the relevant entity is queried. Microsoft's global query filter documentation demonstrates this by making the tenant ID available on the context instance. This reduces the chance of forgetting a tenant predicate in an individual LINQ query. It also means that constructing a DbContext establishes a security sensitive context for every query issued through that instance.
public sealed class SubmissionDbContext(
DbContextOptions<SubmissionDbContext> options,
ICurrentOperationContext current) : DbContext(options)
{
private readonly string tenantId = current.Required.TenantId;
public DbSet<Submission> Submissions => Set<Submission>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Submission>()
.HasQueryFilter(
"TenantFilter",
submission => submission.TenantId == tenantId);
}
}
Capturing the tenant when the context is created is safer than repeatedly consulting a mutable accessor during its lifetime. A single DbContext then has one tenant identity. Code that intentionally works across tenants should create a separate administrative path with explicit authority rather than changing the ambient tenant underneath an existing context. Query filters also need supporting controls. They can be disabled, and they do not automatically validate every raw SQL command or ensure that a newly inserted row has the correct tenant ID. Write side validation, database permissions, constraints and focused tests still contribute to tenant isolation. The query filter is one enforcement point, not a reason to make the tenant invisible everywhere else.
Suppressing flow is a specialised tool
.NET allows ExecutionContext propagation to be suppressed. This can prevent AsyncLocal<T> values from flowing into subsequently scheduled work.
Task maintenanceTask;
using (ExecutionContext.SuppressFlow())
{
maintenanceTask = Task.Run(
() => RunSystemMaintenanceAsync(stopToken),
stopToken);
}
await maintenanceTask;
The await deliberately occurs after the suppression scope has been disposed. The suppression controls context capture while the task is scheduled; it should not be left active across an await. ExecutionContext.SuppressFlow suppresses the execution context as a whole. It is not a selective way to remove only a tenant ID. Activity, culture and other ambient values may also stop flowing. Low level APIs such as ThreadPool.UnsafeQueueUserWorkItem similarly avoid normal context propagation and place responsibility for any required propagation on the caller.
This makes suppression appropriate only when the work truly requires a fresh context and the caller understands everything being removed. It does not turn fire and forget work into a reliable background processing design. Lifetime, exception handling, shutdown, dependency scopes and durability still need to be handled by a hosted service or external queue.
Tests should expose the hidden inputs
Ambient context often appears convenient in unit tests because a mock accessor can return any desired value. The more revealing tests exercise absence, nesting and concurrency. A required accessor should fail when no scope exists. Silent defaults such as an empty tenant, a system user or the first available tenant can convert a missing boundary into data access under unintended authority. Nested scopes should restore the outer context after the inner scope is disposed. Parallel operations should establish their own immutable contexts and must not change each other's values.
[Fact]
public async Task Parallel_operations_keep_separate_contexts()
{
var ambient = new AmbientOperationContext();
var first = RunAsAsync("tenant-a");
var second = RunAsAsync("tenant-b");
Assert.Equal(
new[] { "tenant-a", "tenant-b" },
await Task.WhenAll(first, second));
async Task<string> RunAsAsync(string tenantId)
{
var context = new OperationContext(
tenantId,
"subject-1",
Guid.NewGuid().ToString("N"),
new DateTimeOffset(
2026, 9, 1, 12, 0, 0, TimeSpan.Zero));
using (ambient.Push(context))
{
await Task.Yield();
return ambient.Required.TenantId;
}
}
}
Integration tests should then cross the boundaries that unit tests tend to hide. A queued command should contain the tenant and audit information required by its consumer. A message processed without required context should be rejected or quarantined rather than assigned a convenient default. A background worker should create a fresh service scope. An administrative operation that bypasses a tenant filter should require an explicit, testable route. These tests document where context is established and where it ends. That is more valuable than proving that an accessor returns the value a mock was configured to return.
Performance is real, but it is rarely the first design decision
The documentation for IHttpContextAccessor notes that AsyncLocal<T> can have a negative performance impact on asynchronous calls. Ambient values participate in execution-context capture and restoration, so creating many separate slots or changing them repeatedly in a hot path is not free. That warning deserves measurement rather than folklore. ASP.NET Core, distributed tracing and logging infrastructure already make deliberate use of execution context. A small number of operation level values set once near a request boundary will have a very different cost from a library that writes several AsyncLocal<T> values inside a tight loop.
The larger architectural risks are often correctness, lifetime and invisible authority. Once those are under control, representative benchmarks can determine whether a specific ambient-context implementation is significant for the application's workload. Removing an AsyncLocal<T> while leaving tenant selection hidden inside mutable shared state would be a poor trade.
Make context explicit when the boundary becomes real
Ambient context works best when its lifetime matches one logical in process operation and its purpose is observation or infrastructure integration. Traces, logging scopes and correlation data fit naturally because they describe the work without deciding its business outcome. Values carrying authority need a narrower design. Resolve them at a trusted boundary. Represent them as immutable application data. Pass the relevant parts into business decisions. Capture them in queued work instead of assuming a request context will survive. Decide explicitly whether delayed work uses the original authorisation decision or re-evaluates current permissions. This approach does not require adding a large context object to every method. Most methods should receive ordinary domain values. A small execution context can travel across application boundaries, while individual policies receive only the fields they actually use. Ambient access remains available for the infrastructure that benefits from it.
When reviewing a contextual value, follow its authority and lifetime. Establish who creates it, whether a parallel branch can mutate it, whether Task.Run inherits it, what happens when the request ends and how a consumer reconstructs it after a queue or process boundary. Then inspect whether changing that value merely changes what is observed or changes what the system is allowed to do. The parameters missing from a method signature still participate in the architecture. Making their boundaries deliberate is what keeps convenience from becoming invisible control flow.





