Skip to main content
Version: 0.6.1

Unified Result

Sharkable provides a pluggable unified result system to ensure consistent API response formats across all endpoints.

Overview

The unified result system has three layers:

  1. IUnifiedResult — marker interface that defines the shape of a unified response (StatusCode, Data, ErrorMessage)
  2. UnifiedResult<T> — default implementation included in the library
  3. IUnifiedResultFactory — factory that creates result instances; replace it to use your own format

Default usage

// Create a result manually
var result = new UnifiedResult<string>("hello");
// { "statusCode": 200, "data": "hello", "errorMessage": null, ... }

// With status code
var error = new UnifiedResult<string>(null, "not found", HttpStatusCode.NotFound);

Extension methods

Extension methods on string? (error-based) and T? (data-based) let you return typed HTTP responses with consistent UnifiedResult<T> wrapping.

Data/error wrappers

// Wrap data in UnifiedResult<T> as IResult (200)
return data.AsOkResult();
// null → Results.Ok(), non-null → Results.Ok(UnifiedResult<T>)

// Wrap data in UnifiedResult<T> with status code (201)
return data.AsCreated(uri: "/api/items/1");
// null → 204 No Content, with uri → Results.Created(), no uri → Results.Ok()

// Wrap data in UnifiedResult<T> with status code (202)
return data.AsAccepted(uri: "/api/jobs/42");
// null → 202 No Content, with uri → Results.Accepted(), no uri → 202 + body

Error responses (200+ coverage)

return "message".AsBadRequest(); // 400 Bad Request
return "expired".AsUnauthorized(); // 401 Unauthorized
return "forbidden".AsForbidden(); // 403 Forbidden
return "not found".AsNotFound(); // 404 Not Found
return "conflict".AsConflict(); // 409 Conflict
return "not allowed".AsMethodNotAllowed(); // 405 Method Not Allowed
return "not acceptable".AsNotAcceptable(); // 406 Not Acceptable
return "gone".AsGone(); // 410 Gone
return "bad media".AsUnsupportedMediaType(); // 415 Unsupported Media Type
return "invalid".AsUnprocessableEntity(); // 422 Unprocessable Entity
return "rate limited".AsTooManyRequests(); // 429 Too Many Requests
return "server error".AsInternalServerError();// 500 Internal Server Error
return "not impl".AsNotImplemented(); // 501 Not Implemented
return "bad gateway".AsBadGateway(); // 502 Bad Gateway
return "unavailable".AsServiceUnavailable(); // 503 Service Unavailable
return "timeout".AsGatewayTimeout(); // 504 Gateway Timeout

All error extensions share the same pattern — null input returns a bare status response, non-null error wraps it in UnifiedResult<string>:

return errorString.AsNotFound(); // { "statusCode": 404, "data": null, "errorMessage": "not found", ... }
string? nullError = null;
return nullError.AsNotFound(); // bare 404, no body

Generic custom status

// Error with arbitrary HttpStatusCode
return "error".AsStatus(HttpStatusCode.FailedDependency);

// Data with arbitrary HttpStatusCode
return data.AsStatus(HttpStatusCode.ExpectationFailed, errors: "optional");

Static factories (UnifiedResult.*)

All extension methods above have equivalent static factories on the non-generic UnifiedResult class:

// 2xx Success
UnifiedResult.Ok(data, errors: null); // 200
UnifiedResult.Created(data, uri: "/items/1"); // 201
UnifiedResult.Accepted(data, uri: "/jobs/42"); // 202
UnifiedResult.NoContent(); // 204

// 4xx Client errors
UnifiedResult.BadRequest("message"); // 400
UnifiedResult.Unauthorized(); // 401
UnifiedResult.Forbidden(); // 403
UnifiedResult.NotFound("message"); // 404
UnifiedResult.MethodNotAllowed("msg"); // 405
UnifiedResult.NotAcceptable("msg"); // 406
UnifiedResult.Conflict("message"); // 409
UnifiedResult.Gone("message"); // 410
UnifiedResult.UnsupportedMediaType("msg"); // 415
UnifiedResult.UnprocessableEntity("msg"); // 422
UnifiedResult.TooManyRequests("msg"); // 429

// 5xx Server errors
UnifiedResult.InternalServerError("msg"); // 500
UnifiedResult.NotImplemented("msg"); // 501
UnifiedResult.BadGateway("msg"); // 502
UnifiedResult.ServiceUnavailable("msg"); // 503
UnifiedResult.GatewayTimeout("msg"); // 504

// Generic custom status
UnifiedResult.Status(data, HttpStatusCode.FailedDependency, errors: "msg"); // arbitrary

Static factories return IResult, identical behavior to the extension methods.

Auto-wrap (default on)

EnableAutoWrap is enabled by default. All plain return values — anything not IResult or IUnifiedResult — are automatically wrapped in UnifiedResult<T>:

// No configuration needed — auto-wrap is on by default
app.MapGet("hello", () => "world");
// Response: { "statusCode": 200, "data": "world", "errorMessage": null, ... }

app.MapGet("users", (DbContext db) => db.Users.ToList());
// Response: { "statusCode": 200, "data": [...], ... }

Handlers that return IResult (e.g., Results.NotFound(), UnifiedResult.BadRequest(), .AsNotFound()) are never modified — auto-wrap skips them.

Disabling auto-wrap

Globally — in AddShark():

builder.Services.AddShark(opt =>
{
opt.EnableAutoWrap = false;
});

Per endpoint class — apply [SharkDontWrap] to the ISharkEndpoint class:

[SharkDontWrap]
public class StreamingEndpoint : ISharkEndpoint
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("download", () => new FileStreamResult(...)); // raw response, no wrapping
}
}

Per route — use .DisableAutoWrap():

app.MapGet("plain-text", () => "raw string").DisableAutoWrap();

Note: When auto-wrap is disabled, the raw return value is passed through unchanged — ensure your serializer supports the type.

Custom result format

Implement IUnifiedResult and IUnifiedResultFactory to use your own response format:

public class MyResult : IUnifiedResult
{
public int Code { get; set; }
public object? Info { get; set; }
public string? Error { get; set; }

int IUnifiedResult.StatusCode => Code;
object? IUnifiedResult.Data => Info;
string? IUnifiedResult.ErrorMessage => Error;
}

public class MyResultFactory : IUnifiedResultFactory
{
public IUnifiedResult Create(object? data, string? errorMessage, int statusCode)
=> new MyResult { Code = statusCode, Info = data, Error = errorMessage };
}

Register the factory in AddShark():

builder.Services.AddShark(opt =>
{
opt.UnifiedResultFactory = new MyResultFactory();
});

Now all exception handler responses and auto-wrap results will use MyResult format.

Important: When using a custom response format with EnableAutoWrap, the OpenAPI document transformer still generates the default UnifiedResult<T> schema shape. To make the generated OpenAPI document match your actual response structure, set WrapSchemaFactory:

builder.Services.AddShark(opt =>
{
opt.UnifiedResultFactory = new MyResultFactory();
opt.EnableAutoWrap = true;
opt.WrapSchemaFactory = (original) => new OpenApiSchema
{
Type = JsonSchemaType.Object,
Properties = new Dictionary<string, IOpenApiSchema>
{
["code"] = new OpenApiSchema { Type = JsonSchemaType.Integer },
["info"] = original,
["error"] = new OpenApiSchema { Type = JsonSchemaType.String },
},
};
});

This requires using Microsoft.OpenApi; in your Program.cs.

AOT support

Sharkable ships a Source Generator that automatically preserves UnifiedResult<T> types for all endpoint return values — no JsonSerializerContext needed:

app.MapGet("/users", () => new UserDto { Name = "Alice" });
app.MapGet("/orders", () => new OrderDto { Id = 1 });
// UnifiedResult<UserDto> + UnifiedResult<OrderDto> auto-preserved at compile time

The SG scans all MapGet/Post/Put/Patch/Delete delegates, extracts return types, and emits typeof(UnifiedResult<T>) references. Combined with the AutoCrud AOT preserver, all entity types and unified result types survive Native AOT trimming without any manual configuration.

For custom IUnifiedResultFactory implementations with custom types, register a JSON serializer context:

[JsonSerializable(typeof(MyResult))]
internal partial class AppJsonContext : JsonSerializerContext { }

builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});

ProblemDetails (RFC 7807)

Sharkable supports RFC 7807 ProblemDetails as an alternative error response format. Enable with one flag:

builder.Services.AddShark(opt =>
{
opt.UseProblemDetails = true;
});

Standard format (all error responses):

{
"type": "https://httpstatuses.com/429",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded. Please retry later.",
"instance": "/api/orders",
"traceId": "4bf92f3577b34ad00000000000000000"
}

When disabled (default), error responses use the Sharkable unified result envelope instead. UseProblemDetails affects all framework-generated errors: 400, 401, 403, 404, 409, 422, 429, 500, 503.