| name | orleans-result-pattern |
| description | Concise, version-tolerant result pattern for Microsoft Orleans 8+ grain calls. Return success/error without throwing exceptions — errors are `enum ErrorNr` + `string` message, fully Orleans-serialization-compatible, marked `[Immutable]` for zero-copy within-silo calls. Use when designing grain contracts that need non-exception flow control, when returning RFC7807 `ValidationProblemDetails` from ASP.NET Core, when avoiding `FluentResults`-style libraries that don't serialize cleanly under Orleans, or when you want implicit conversions (`return "ok"`, `return ErrorNr.NotFound`, `return (ErrorNr.NotFound, "Not found")`) and pattern-matching consumption. Generated by the `mcs-orleans-results` template, which drops `ErrorNr.cs` + `Result.cs` into the project that contains grain interfaces. |
| metadata | {"author":"https://github.com/VincentH-Net","version":"1.0","framework":"orleans","category":"grain-patterns","sources":["Modern.CSharp.Templates (mcs-orleans-results)","github.com/VincentH-Net/Orleans.Results"]} |
Orleans Result Pattern — version-tolerant, serialization-safe
Use when designing Microsoft Orleans 8+ grain contracts that need to signal failure without throwing exceptions, in a form that (a) serializes cleanly under Orleans' version-tolerant serializer, (b) integrates with ASP.NET Core minimal APIs + MVC, and (c) supports RFC7807 ValidationProblemDetails responses.
1. When to use this skill
Apply when any of these is true:
- Designing new Orleans 8+ grain methods that can fail in non-exceptional ways (not-found, validation, business-rule violations)
- Refactoring existing grain methods that throw exceptions for expected flow (
UserNotFoundException etc.) to return results instead
- Building ASP.NET Core endpoints on top of grains and wanting a single consumption pattern (switch expression on the result) that produces
Ok / NotFound / ValidationProblem
- Evaluating or replacing
FluentResults and finding it doesn't play well with Orleans serialization (requires manual annotation of every type involved)
- Wanting implicit conversions from
T, ErrorNr, (ErrorNr, message), Error, and Collection<Error> to keep return statements short
- Needing
[Flags]-enum-based tagging of validation errors so TryAsValidationErrors can produce RFC7807 output
Avoid for genuinely exceptional conditions (out-of-memory, serialization bugs, contract violations) — exceptions still belong there.
2. Why this pattern, and why this implementation
Why the result pattern at all
Using exceptions for flow control is an antipattern:
- Performance cost — see Microsoft profiling rule DA0007.
- Readability cost — an exception raised in a called method can exit the current method invisibly, hiding the expected flow. Returning values makes all flows visible at the call site.
- Analyzer support — with explicit return values, CA1806 can flag missed checks; use
_ = to express intentional discard.
Why this implementation over FluentResults etc.
Orleans 8+ requires that every type crossing a grain boundary be annotated ([GenerateSerializer] + [Id]) or serializable through another mechanism. Result implementations that allow arbitrary objects inside errors (e.g. exceptions) require open-ended work to serialize. Orleans.Results avoids this by defining an error as enum + string message only, which serializes trivially and is fully version-tolerant under Orleans 8+ guidelines.
Result, Result<T>, and Error are marked with Orleans' [Immutable] attribute, letting the silo pass references instead of deep-copying on within-silo grain calls.
3. Prerequisites
- Microsoft Orleans 8 or later (Orleans 10 recommended for new work)
Modern.CSharp.Templates installed:
dotnet new install Modern.CSharp.Templates
4. Generate the files
Run inside the project that contains grain interfaces — or inside a project that is referenced by projects containing grain interfaces. The generated Result<T> types need to be visible to both grain contracts and their callers:
dotnet new mcs-orleans-results
The template takes no parameters and drops two files into the current folder:
| File | Purpose |
|---|
ErrorNr.cs | enum ErrorNr — domain error codes; edit freely |
Result.cs | Result : ResultBase<ErrorNr> + Result<T> : ResultBase<ErrorNr, T> + Error record — wrap the library base types to avoid repeating <ErrorNr> on every method signature |
Immediate post-generation steps
- Replace the placeholder
Example namespace in both files with your project namespace.
- Add concrete error codes to the
ErrorNr enum.
- If you intend to use
TryAsValidationErrors for RFC7807 responses, make ErrorNr a [Flags] enum with a ValidationError marker — see section 7.
5. Basic usage
Define error codes
public enum ErrorNr
{
UserNotFound = 1
}
Factory helpers keep call sites terse:
static class Errors
{
public static Result.Error UserNotFound(int id) =>
new(ErrorNr.UserNotFound, $"User {id} not found");
}
Grain contract
interface ITenant : IGrainWithStringKey
{
Task<Result<string>> GetUser(int id);
}
Grain implementation
class Tenant : Grain, ITenant
{
public Task<Result<string>> GetUser(int id) => Task.FromResult<Result<string>>(
id >= 0 && id < S.Users.Count
? S.Users[id]
: Errors.UserNotFound(id)
);
}
6. Consuming results — ASP.NET Core
Minimal API
app.MapGet("minimalapis/users/{id}", async (IClusterClient client, int id)
=> await client.GetGrain<ITenant>("A").GetUser(id) switch
{
{ IsSuccess: true } r => Results.Ok(r.Value),
{ ErrorNr: ErrorNr.UserNotFound } r => Results.NotFound(r.ErrorsText),
{ } r => throw r.UnhandledErrorException()
}
);
MVC
[HttpGet("mvc/users/{id}")]
public async Task<ActionResult<string>> GetUser(int id)
=> await client.GetGrain<ITenant>("A").GetUser(id) switch
{
{ IsSuccess: true } r => Ok(r.Value),
{ ErrorNr: ErrorNr.UserNotFound } r => NotFound(r.ErrorsText),
{ } r => throw r.UnhandledErrorException()
};
Pattern anatomy
{ IsSuccess: true } r => ... — matches the success case; r.Value is the T
{ ErrorNr: ErrorNr.X } r => ... — matches a specific error code; r.ErrorsText is the joined message string
{ } r => throw r.UnhandledErrorException() — catch-all for unexpected error codes; throws a descriptive exception so API middleware can map it to 500
Each endpoint should enumerate only the error codes it specifically handles; everything else goes to the final throw.
7. Validation errors (RFC7807)
For RFC7807 ValidationProblemDetails responses, make ErrorNr a [Flags] enum with a ValidationError marker bit:
[Flags]
public enum ErrorNr
{
NoUsersAtAddress = 1,
ValidationError = 1024,
InvalidZipCode = 1 | ValidationError,
InvalidHouseNr = 2 | ValidationError,
}
Implement a grain that validates before acting:
public async Task<Result<string>> GetUsersAtAddress(string zip, string nr)
{
Collection<Result.Error> errors = new();
if (!ZipRegex().IsMatch(zip)) errors.Add(Errors.InvalidZipCode(zip));
if (!HouseNrRegex().IsMatch(nr)) errors.Add(Errors.InvalidHouseNr(nr));
if (errors.Any()) return errors;
string users = "";
if (users == "") errors.Add(Errors.NoUsersAtAddress($"{zip} {nr}"));
return errors.Any() ? errors : users;
}
Consume with TryAsValidationErrors:
Minimal API:
return result.TryAsValidationErrors(ErrorNr.ValidationError, out var validationErrors)
? Results.ValidationProblem(validationErrors)
: result switch
{
{ IsSuccess: true } r => Results.Ok(r.Value),
{ ErrorNr: ErrorNr.NoUsersAtAddress } r => Results.NotFound(r.ErrorsText),
{ } r => throw r.UnhandledErrorException()
};
MVC:
return result.TryAsValidationErrors(ErrorNr.ValidationError, out var validationErrors)
? ValidationProblem(new ValidationProblemDetails(validationErrors))
: result switch
{
{ IsSuccess: true } r => Ok(r.Value),
{ ErrorNr: ErrorNr.NoUsersAtAddress } r => NotFound(r.ErrorsText),
{ } r => throw r.UnhandledErrorException()
};
TryAsValidationErrors returns true only when the result is failed and every error in it carries the ValidationError flag. A mix of validation and non-validation errors falls through to the general switch.
8. Returning errors — every supported form
async Task<Result<string>> GetString(int i) => i switch
{
0 => "Success!",
1 => ErrorNr.NotFound,
2 => (ErrorNr.NotFound, "Not found"),
3 => new Error(ErrorNr.NotFound, "Not found"),
4 => new Collection<Error>()
};
For multiple errors sourced from an IEnumerable<Error> other than Collection<Error>, use the public constructor:
async Task<Result<string>> GetString()
{
IEnumerable<Error> errors = new HashSet<Error>();
if (errors.Any()) return new(errors);
return "Success!";
}
9. Immutability and performance
Result, Result<T>, and Error are marked [Immutable] — Orleans skips deep-copying them on within-silo grain calls.
- The wrapping result adds negligible serialization cost; additional gains come from marking your
T [Immutable] where correct.
- There is no reflection in the hot path.
10. Repo checklist after adoption
ErrorNr.cs and Result.cs live in the grain-interfaces project (or a shared project referenced by it).
- Namespace updated away from
Example.
[Flags] applied iff you use TryAsValidationErrors.
- Exception throwing removed from grain methods that have expected failure modes — replaced with explicit error returns.
- Consumer code uses the
switch-expression shape from section 6; the final catch-all throws r.UnhandledErrorException().
References