Skip to content

Grain call filters

Grain call filters run around Orleans calls. Use them for cross-cutting behavior such as authorization, telemetry, request metadata, and deliberate exception translation.

Filters form an asynchronous pipeline. A filter must call and await context.Invoke() to continue to the next filter and eventually the target method.

The following compiled documentation sample logs completion and failure:

public class LoggingCallFilter : IIncomingGrainCallFilter
{
private readonly ILogger<LoggingCallFilter> _logger;
public LoggingCallFilter(ILogger<LoggingCallFilter> logger)
{
_logger = logger;
}
public async Task Invoke(IIncomingGrainCallContext context)
{
try
{
await context.Invoke();
_logger.LogInformation(
"{GrainType}.{MethodName} returned value {Result}",
context.Grain.GetType(),
context.MethodName,
context.Result);
}
catch (Exception exception)
{
_logger.LogError(
exception,
"{GrainType}.{MethodName} threw an exception",
context.Grain.GetType(),
context.MethodName);
// If this exception is not re-thrown, it is considered to be
// handled by this filter.
throw;
}
}
}

Register it for the silo:

siloHostBuilder.AddIncomingGrainCallFilter<LoggingCallFilter>();

A grain class can implement IIncomingGrainCallFilter to filter calls dispatched to that grain activation without affecting other grain classes:

public sealed class MyFilteredGrain
: Grain, IMyFilteredGrain, IIncomingGrainCallFilter
{
async Task IIncomingGrainCallFilter.Invoke(
IIncomingGrainCallContext context)
{
await context.Invoke();
// Change the result of the call from 7 to 38.
if (string.Equals(
context.InterfaceMethod.Name,
nameof(IMyFilteredGrain.GetFavoriteNumber)))
{
context.Result = 38;
}
}
public Task<int> GetFavoriteNumber() => Task.FromResult(7);
}

The filter changes the result of GetFavoriteNumber from 7 to 38. A grain-level filter doesn’t need builder registration because Orleans discovers it on the grain instance. It also wraps grain extension calls dispatched through that activation, so select calls using InterfaceMethod or ImplementationMethod when the behavior isn’t intended for extensions.

A per-grain filter can enforce an application authorization policy declared by an attribute. This example evaluates an authenticated ClaimsPrincipal using an application authorization service:

[AttributeUsage(AttributeTargets.Method)]
public sealed class AuthorizeGrainCallAttribute(string policy) : Attribute
{
public string Policy { get; } = policy;
}
public interface ITrustedCallerIdentityAccessor
{
// Implement this using identity established by trusted host/filter infrastructure.
ClaimsPrincipal? Caller { get; }
}
public sealed class ApplicationAuthorizationService
{
public const string AdministratorPolicy = "Administrator";
public ValueTask<bool> AuthorizeAsync(
ClaimsPrincipal? caller,
string policy)
{
var isAuthorized = policy switch
{
AdministratorPolicy =>
caller?.Identity?.IsAuthenticated == true
&& caller.IsInRole("Administrator"),
_ => false,
};
return ValueTask.FromResult(isAuthorized);
}
}

The grain receives the trusted identity accessor and authorization service through constructor injection, then checks the attribute before continuing the call:

public sealed class MyAccessControlledGrain(
ITrustedCallerIdentityAccessor callerIdentity,
ApplicationAuthorizationService authorizationService)
: Grain, IAccessControlledGrain, IIncomingGrainCallFilter
{
async Task IIncomingGrainCallFilter.Invoke(
IIncomingGrainCallContext context)
{
var authorization = context.ImplementationMethod
.GetCustomAttribute<AuthorizeGrainCallAttribute>();
if (authorization is not null
&& !await authorizationService.AuthorizeAsync(
callerIdentity.Caller,
authorization.Policy))
{
throw new UnauthorizedAccessException(
"The caller isn't authorized to invoke this operation.");
}
await context.Invoke();
}
[AuthorizeGrainCall(ApplicationAuthorizationService.AdministratorPolicy)]
public Task<int> GetFavoriteNumber() => Task.FromResult(7);
}

The attribute is on the grain implementation method, so the filter reads it from ImplementationMethod. Put attributes on the grain interface and inspect InterfaceMethod instead if the policy is part of the public contract.

The identity accessor must be implemented and populated by trusted host or filter infrastructure after it validates the caller’s credentials. RequestContext is application metadata that callers can set, and it flows transitively into grain calls made while handling the request. Treat its values as untrusted unless trusted infrastructure sets or validates them at each trust boundary. An Orleans client can set an "isAdmin" flag or claimed user ID, so those values alone aren’t an authentication or authorization boundary. If a tamper-resistant credential is carried in request context, trusted infrastructure must still validate it before producing the authenticated principal. SourceId identifies an Orleans caller, not an authenticated end user.

See client and grain-call security for the complete boundary and enforcement model.

Outgoing filters use the same pipeline pattern:

public class OutgoingLoggingCallFilter : IOutgoingGrainCallFilter
{
private readonly ILogger<OutgoingLoggingCallFilter> _logger;
public OutgoingLoggingCallFilter(ILogger<OutgoingLoggingCallFilter> logger)
{
_logger = logger;
}
public async Task Invoke(IOutgoingGrainCallContext context)
{
try
{
await context.Invoke();
_logger.LogInformation(
"{GrainType}.{MethodName} returned value {Result}",
context.Grain.GetType(),
context.MethodName,
context.Result);
}
catch (Exception exception)
{
_logger.LogError(
exception,
"{GrainType}.{MethodName} threw an exception",
context.Grain.GetType(),
context.MethodName);
// If this exception is not re-thrown, it is considered to be
// handled by this filter.
throw;
}
}
}

Register outgoing filters on silos or clients:

builder.AddOutgoingGrainCallFilter<OutgoingLoggingCallFilter>();

IGrainCallContext exposes:

  • SourceId and TargetId.
  • InterfaceType, InterfaceName, and MethodName.
  • InterfaceMethod.
  • Request, an IInvokable.
  • Result and Response.

Incoming contexts also expose TargetContext and ImplementationMethod; outgoing contexts expose SourceContext.

Use context.Request.GetArgumentCount(), GetArgument(index), and SetArgument(index, value) to inspect or replace arguments. Current contexts and IInvokable don’t expose an Arguments array.

The compiled delegate example demonstrates request context and result modification:

siloHostBuilder.AddIncomingGrainCallFilter(async context =>
{
// If the method being called is 'MyInterceptedMethod', then set a value
// on the RequestContext which can then be read by other filters or the grain.
if (string.Equals(
context.InterfaceMethod.Name,
nameof(IMyGrain.MyInterceptedMethod)))
{
RequestContext.Set(
"intercepted value", "this value was added by the filter");
}
await context.Invoke();
// If the grain method returned an int, set the result to double that value.
if (context.Result is int resultValue)
{
context.Result = resultValue * 2;
}
});

Changing arguments or results can violate interface expectations. Preserve declared types and apply transformations only to methods whose contract explicitly permits them.

Class-based filters are created by the dependency injection container, so they can receive IGrainFactory and make a guarded audit or telemetry call:

public sealed class AuditCallFilter(IGrainFactory grainFactory)
: IIncomingGrainCallFilter
{
public async Task Invoke(IIncomingGrainCallContext context)
{
// Exclude the audit interface so its call doesn't reenter this filter recursively.
if (context.InterfaceMethod.DeclaringType != typeof(ICallAuditGrain))
{
var auditGrain = grainFactory.GetGrain<ICallAuditGrain>(
context.TargetId.ToString());
await auditGrain.RecordCallAttempt(
context.InterfaceName,
context.MethodName);
}
await context.Invoke();
}
}

Register the filter on the silo so Orleans can construct it and supply its dependencies:

siloHostBuilder.AddIncomingGrainCallFilter<AuditCallFilter>();

The nested grain call enters the normal outgoing filter pipeline on the current silo and the incoming filter pipeline on the target silo. A silo-wide incoming filter must exclude the audit target, as the sample does, or the audit call reenters the same filter and recurses indefinitely. Use an explicit target interface, grain type, method, or marker guard, and don’t call the request’s current target from its incoming filter.

Avoid call cycles. A non-reentrant grain remains busy while its filter awaits the nested call, so a downstream grain which calls back into that activation can deadlock. If a deliberate callback is unavoidable, use AllowCallChainReentrancy only for the narrow call chain and account for interleaving; see Call-chain reentrancy. The sample records an attempt before the original call and couples audit availability to the request: audit failure prevents context.Invoke() from running. The audit and original call aren’t one transaction and aren’t guaranteed exactly once, so a record can remain if the original call later fails or application-level retries can produce duplicates. Use an awaited grain call only when that coupling is intentional; prefer logging, tracing, or decoupled telemetry for observational work.

Code before context.Invoke() runs on the way into the call. Code after it runs on successful return. Use try, catch, and finally around the awaited call to observe or transform failures.

If a filter catches an exception and doesn’t rethrow, the exception is handled. Only replace exceptions when callers understand the replacement contract. Orleans already preserves unavailable remote exception details using UnavailableExceptionFallbackException, so broad exception conversion is rarely necessary.

Incoming filters also observe grain extension calls. Outgoing filters can observe Orleans system calls in addition to application calls. Filter by interface or method when behavior isn’t intended globally.

Register silo-wide filters with AddIncomingGrainCallFilter. Class filters are singleton services created by the silo service provider, and their constructor dependencies are resolved from that provider. Grain-level filters have the grain activation’s lifetime, and the usual grain activator supplies their constructor dependencies.

The incoming pipeline runs in this order:

  1. Silo-wide filters, in registration order.
  2. The grain-level filter, if the grain instance implements IIncomingGrainCallFilter.
  3. The target grain or grain extension method.

Code before context.Invoke() follows that order. Code after the awaited call unwinds in reverse order.

Keep filters asynchronous, fast, and free of blocking calls. A filter adds latency to every call in its scope and can become a cluster-wide bottleneck.