Customize Orleans serialization code generation
Orleans generates the proxy and request types which implement grain calls. Advanced libraries can extend that generated RPC surface by associating a return type with a custom invokable request base. The return type defines the application-facing calling model, while the request base defines how the generated request enters the Orleans runtime and how the target result becomes a response.
Use this extension point when a library needs a calling abstraction with explicit semantics beyond Task, ValueTask, or IAsyncEnumerable<T>. The library owns the abstraction’s completion, cancellation, failure, lifetime, allocation, and concurrency contract.
For a complete application, see the custom grain-call return type entry in the samples catalog.
Define an awaitable return type
Section titled “Define an awaitable return type”The following GrainCall<T> is task-backed. Calling a generated proxy starts one Orleans request immediately. The returned value can be awaited multiple times, caches its terminal result, and propagates remote failures through the task.
[InvokableBaseType( typeof(GrainReference), typeof(GrainCall<>), typeof(GrainCallRequest<>))]public sealed class GrainCall<T>{ private readonly Task<T?> _task;
private GrainCall(Task<T?> task) => _task = task;
public bool IsCompleted => _task.IsCompleted;
public Task<T?> AsTask() => _task;
public TaskAwaiter<T?> GetAwaiter() => _task.GetAwaiter();
public static GrainCall<T> FromResult(T? value) => new(Task.FromResult(value));
internal static GrainCall<T> FromInvocation(ValueTask<T?> invocation) => new(invocation.AsTask());}InvokableBaseTypeAttribute registers the open generic family for proxies derived from GrainReference. For every GrainCall<T> method, the generator closes GrainCallRequest<T> with the same T.
Adapt the generated request
Section titled “Adapt the generated request”The request base performs two jobs:
- On the caller, its initializer submits the generated request through IGrainReferenceRuntime and returns the application-facing
GrainCall<T>. - On the target, its Invoke implementation awaits the value returned by the grain implementation and creates a Response.
[SerializerTransparent][ReturnValueProxy(nameof(InitializeRequest))]public abstract class GrainCallRequest<T> : RequestBase{ [NonSerialized] private readonly IGrainReferenceRuntime _runtime;
[GeneratedActivatorConstructor] protected GrainCallRequest(IGrainReferenceRuntime runtime) => _runtime = runtime;
public GrainCall<T> InitializeRequest(GrainReference proxy) => GrainCall<T>.FromInvocation( _runtime.InvokeMethodAsync<T>(proxy, this, Options));
public sealed override ValueTask<Response> Invoke() { try { return CompleteAsync(InvokeInner()); } catch (Exception exception) { return ValueTask.FromResult(Response.FromException(exception)); } }
private static async ValueTask<Response> CompleteAsync(GrainCall<T> call) { try { return Response.FromResult(await call); } catch (Exception exception) { return Response.FromException(exception); } }
protected abstract GrainCall<T> InvokeInner();}ReturnValueProxyAttribute tells the generated proxy to return request.InitializeRequest(this). The initializer is therefore the handoff point which starts the operation or creates the application-facing adapter. Orleans validates that overload resolution selects an accessible, concrete, non-generic instance method with one by-value parameter accepting the generated proxy and a result implicitly convertible to the grain method’s declared return type.
GeneratedActivatorConstructorAttribute selects a dependency-injected constructor for generated request activation. A parameterless constructor also works when the request base needs no services. Mark runtime-only fields with NonSerializedAttribute; generated argument fields remain the serialized request payload.
Use the return type in a grain contract
Section titled “Use the return type in a grain contract”Both the interface and implementation use the custom return type. The generated request overrides InvokeInner with that same signature, while the request base determines how its value is completed and transported.
public interface ICalculatorGrain : IGrainWithStringKey{ GrainCall<int> Add(int left, int right);
GrainCall<int> Fail(string message);}
public sealed class CalculatorGrain : Grain, ICalculatorGrain{ public GrainCall<int> Add(int left, int right) => GrainCall<int>.FromResult(left + right);
public GrainCall<int> Fail(string message) => throw new InvalidOperationException(message);}The sample’s contract is:
- Completion: the proxy initializer submits one request immediately, and the task completes when the Orleans response arrives.
- Failure: synchronous grain failures and failures produced while awaiting
GrainCall<T>become Orleans exception responses and are rethrown to the caller. - Cancellation: a
CancellationTokengrain argument participates in Orleans cooperative call cancellation. The sample wrapper adds no independent cancellation source. - Lifetime: the runtime owns the generated request after submission. The wrapper retains the task representing that invocation.
- Concurrency: the task-backed wrapper supports multiple awaiters observing the same terminal result. It represents one invocation and never resubmits it.
Custom adapters can implement other policies, including lazy submission, streaming, or subscriptions. Specify those policies as part of the public return type contract and account for grain activation lifetime, disposal, backpressure, and abandoned consumers.
Registration locations and precedence
Section titled “Registration locations and precedence”InvokableBaseTypeAttribute can appear in four places:
| Registration location | Purpose |
|---|---|
| An attribute type applied to a grain method | Select behavior for methods carrying that attribute. |
| The return type | Define the normal adapter owned by that return-type library. |
| An assembly | Connect a return type and proxy base owned by independent libraries. |
| A proxy base through DefaultInvokableBaseTypeAttribute | Define the proxy’s built-in return families. |
Resolution runs in two passes. Orleans first considers exact constructed return-type matches, then open-generic matches. Within each pass, precedence is method attribute, return type, assembly registration, and proxy default. Consequently, an exact assembly registration has priority over an open-generic method registration.
Every registration is scoped to the proxy base’s original generic definition. A mapping for GrainReference does not affect another proxy hierarchy. An assembly registration can add a mapping, including one supplied by a referenced adapter assembly, and cannot replace a proxy’s built-in default mapping.
Identical registrations coalesce. Distinct invokable bases at the same winning location produce a deterministic build diagnostic ordered by type and assembly identity. This keeps reference ordering from changing generated behavior.
Exact and open-generic mappings
Section titled “Exact and open-generic mappings”An exact mapping associates one constructed return type, such as GrainCall<int>, with one request base. It overrides an open mapping for GrainCall<>.
An open-generic return mapping requires an open-generic request base with the same arity. Orleans closes the request base with the return type arguments and verifies every generic constraint. A closed request base cannot serve an open return family.
Use an exact mapping for a specialized protocol or optimization. Keep the open mapping as the family-wide contract so new constructed return types receive consistent behavior.
Validation requirements
Section titled “Validation requirements”The generator validates a selected request base in the consuming compilation:
- It is an accessible, non-static, non-sealed class.
- Its generic arity matches the open return type and the constructed type arguments satisfy its constraints.
- A generated derived request can invoke an accessible parameterless constructor, including an unambiguous optional or
paramsconstructor, or an accessible constructor marked with GeneratedActivatorConstructorAttribute. - A dependency-injected constructor has by-value parameters and binds unambiguously through the generated derived constructor.
- A ReturnValueProxyAttribute initializer binds from the generated proxy type and returns the declared custom return type.
Treat these diagnostics as extension-contract failures. They identify the registration site so the library can correct its published mapping.
Cross-assembly libraries
Section titled “Cross-assembly libraries”An adapter package can register types from independent assemblies:
[assembly: InvokableBaseType( typeof(GrainReference), typeof(Serialization.GrainCall<>), typeof(Serialization.GrainCallRequest<>))]The consuming project must reference the return-type owner, proxy owner, and adapter assembly. The generator reads assembly attributes from source and referenced assemblies, then validates accessibility and binding in the consuming compilation. Public types and members give every consumer the same mapping; internal members require an explicit friend-assembly relationship with each generated proxy assembly.
Publish the return type, request base, registration, and any required serializer metadata as one versioned compatibility unit. Validate consumers which generate proxies in a different assembly and deployments where callers and silos run adjacent package versions.
Identity, serialization, and compatibility
Section titled “Identity, serialization, and compatibility”The generated request type is the serialized message body. Orleans gives it a compound identity containing the invocation marker, proxy identity, grain interface type, and method identity. Method identity comes from IdAttribute, AliasAttribute, or a deterministic signature hash. Explicit aliases preserve identity when CLR names change.
The custom request base controls invocation behavior, while generated members hold method arguments and target dispatch metadata. Keep serialized member IDs and aliases stable, and keep argument and result types compatible during rolling upgrades. Changing a mapping can change the generated request’s base behavior across caller and silo versions even when the grain method signature is unchanged.
The same Orleans.Serialization codecs, copiers, activators, and converters process generated request arguments and response values. Review the serialization and code-generation internals before publishing an adapter library.
Related customization surfaces
Section titled “Related customization surfaces”- Customize serialization with IGeneralizedCodec, IGeneralizedCopier, and type filters.
- Configure serialization and register codecs, copiers, activators, converters, or external serializers through ISerializerBuilder.
- Declare immutable types with ImmutableAttribute.
- Generate serializers and stable identities with GenerateSerializerAttribute, IdAttribute, and AliasAttribute.
- Inspect or modify generated request arguments in grain call filters.
- Use generated request metadata for scheduling.
