Custom Response Builders
If the default response envelope doesn’t match your API contract, you can replace it entirely by implementing IResponseBuilder and/or IErrorResponseBuilder.
When to Use Custom Builders
- Different envelope structure — Your API contract requires a specific JSON shape
- Legacy API compatibility — Matching an existing API format
- Third-party integration — Conforming to partner/client requirements
- Minimalist responses — Removing metadata or simplifying the envelope
Make sure you understand ApiResult and RequestDescriptor before proceeding. The following examples build on these concepts.
IResponseBuilder
Controls the shape of success responses (2xx, 3xx status codes).
public interface IResponseBuilder
{
/// <summary>
/// Determines if the value is already a wrapped response.
/// </summary>
bool IsWrappedResponse(object? value);
/// <summary>
/// Builds the response envelope.
/// </summary>
object BuildResponse(ApiResult apiResult, RequestDescriptor request);
}
Implementing IResponseBuilder
using AspNetConventions.Core.Abstractions.Contracts;
using AspNetConventions.Http.Models;
using AspNetConventions.Http.Services;
public class MyResponseBuilder : IResponseBuilder
{
public bool IsWrappedResponse(object? value) => false;
public object BuildResponse(ApiResult apiResult, RequestDescriptor request)
{
return new
{
success = apiResult.IsSuccess,
code = (int)apiResult.StatusCode,
type = apiResult.Type,
message = apiResult.Message,
data = apiResult.GetValue(),
};
}
}
IErrorResponseBuilder
Controls the shape of error responses (4xx, 5xx status codes).
public interface IErrorResponseBuilder
{
/// <summary>
/// Determines if the value is already a wrapped error response.
/// </summary>
bool IsWrappedResponse(object? value);
/// <summary>
/// Builds the error response envelope.
/// </summary>
object BuildResponse(ApiResult apiResult, Exception? exception, RequestDescriptor request);
}
Implementing IErrorResponseBuilder
using AspNetConventions.Core.Abstractions.Contracts;
using AspNetConventions.Http.Models;
using AspNetConventions.Http.Services;
public class MyErrorResponseBuilder : IErrorResponseBuilder
{
public bool IsWrappedResponse(object? value) => false;
public object BuildResponse(
ApiResult apiResult,
Exception? exception,
RequestDescriptor request)
{
return new
{
success = false,
code = (int)apiResult.StatusCode,
type = apiResult.Type,
message = apiResult.Message,
errors = apiResult.GetValue(), // Validation errors, etc.
};
}
}
The exception parameter is the original exception (if one was thrown). Use it for logging or debugging, but avoid exposing details in production responses.
Registering Custom Builders
Register your custom builders in the options configuration:
builder.Services.AddControllers()
.AddAspNetConventions(options =>
{
options.Response.ResponseBuilder = new MyResponseBuilder();
options.Response.ErrorResponseBuilder = new MyErrorResponseBuilder();
});
You can replace one or both builders independently. If you only set ResponseBuilder, the default error builder is kept, and vice versa.
Preventing Double-Wrapping
The IsWrappedResponse method prevents your response from being wrapped twice. This is important when:
- Your service layer returns pre-wrapped responses
- You have a custom response type that’s already in the correct format
- You want certain responses to bypass wrapping entirely
// Your custom wrapper type
public class MyApiResponse<T>
{
public bool Success { get; set; }
public int Code { get; set; }
public T? Data { get; set; }
}
// In your builder
public bool IsWrappedResponse(object? value)
{
// Check if value is already your wrapper type
return value?.GetType().IsGenericType == true
&& value.GetType().GetGenericTypeDefinition() == typeof(MyApiResponse<>);
}
When IsWrappedResponse returns true:
BuildResponseis not called- The value is serialized as-is
- No envelope is added
Example
Flat Envelope
A minimal, flat response structure:
public class FlatResponseBuilder : IResponseBuilder
{
public bool IsWrappedResponse(object? value) => false;
public object BuildResponse(ApiResult apiResult, RequestDescriptor request)
{
return new
{
ok = true,
code = (int)apiResult.StatusCode,
payload = apiResult.GetValue()
};
}
}
public class FlatErrorResponseBuilder : IErrorResponseBuilder
{
public bool IsWrappedResponse(object? value) => false;
public object BuildResponse(
ApiResult apiResult,
Exception? exception,
RequestDescriptor request)
{
return new
{
ok = false,
code = (int)apiResult.StatusCode,
error = apiResult.Type,
message = apiResult.Message
};
}
}
Success response:
{ "ok": true, "code": 200, "payload": { "userId": 1 } }
Error response:
{ "ok": false, "code": 404, "error": "NOT_FOUND", "message": "User not found." }
HATEOAS-Style Links
Adding hypermedia links to responses:
public class HateoasResponseBuilder : IResponseBuilder
{
public bool IsWrappedResponse(object? value) => false;
public object BuildResponse(ApiResult apiResult, RequestDescriptor request)
{
var links = new Dictionary<string, string>
{
["self"] = request.Path
};
// Add pagination links if available
if (apiResult.Pagination is not null)
{
var pagination = apiResult.Pagination;
if (pagination.Links.NextPageUrl is not null)
links["next"] = pagination.Links.NextPageUrl;
if (pagination.Links.PreviousPageUrl is not null)
links["prev"] = pagination.Links.PreviousPageUrl;
}
return new
{
data = apiResult.GetValue(),
status = (int)apiResult.StatusCode,
_links = links
};
}
}
Response:
{
"data": { "id": 1, "name": "John" },
"status": 200,
"_links": {
"self": "/api/users/1"
}
}
RFC 7807 Problem Details
Conforming to the Problem Details specification:
public class ProblemDetailsErrorBuilder : IErrorResponseBuilder
{
private readonly string _baseUrl;
public ProblemDetailsErrorBuilder(string baseUrl)
{
_baseUrl = baseUrl;
}
public bool IsWrappedResponse(object? value) => false;
public object BuildResponse(
ApiResult apiResult,
Exception? exception,
RequestDescriptor request)
{
return new
{
type = $"{_baseUrl}/errors/{apiResult.Type?.ToLowerInvariant()}",
title = GetTitle(apiResult.StatusCode),
status = (int)apiResult.StatusCode,
detail = apiResult.Message,
instance = request.Path,
traceId = request.TraceId
};
}
private static string GetTitle(HttpStatusCode statusCode) => statusCode switch
{
HttpStatusCode.BadRequest => "Bad Request",
HttpStatusCode.Unauthorized => "Unauthorized",
HttpStatusCode.Forbidden => "Forbidden",
HttpStatusCode.NotFound => "Not Found",
HttpStatusCode.Conflict => "Conflict",
HttpStatusCode.InternalServerError => "Internal Server Error",
_ => "Error"
};
}
Response:
{
"type": "https://api.example.com/errors/not_found",
"title": "Not Found",
"status": 404,
"detail": "User with ID 123 was not found.",
"instance": "/api/users/123",
"traceId": "00-abc123..."
}
Conditional Wrapping
Wrap only certain response types:
public class ConditionalResponseBuilder : IResponseBuilder
{
public bool IsWrappedResponse(object? value)
{
// Don't wrap primitive types or strings
if (value is null) return true;
var type = value.GetType();
return type.IsPrimitive || type == typeof(string);
}
public object BuildResponse(ApiResult apiResult, RequestDescriptor request)
{
return new
{
status = "success",
data = apiResult.GetValue(),
metadata = new
{
timestamp = DateTime.UtcNow,
path = request.Path
}
};
}
}
Cursor-Based Pagination
Cursor-based pagination is suited for large datasets, real-time feeds, or any collection where offset pagination breaks down (deep pages, shifting data). Instead of pageNumber / pageSize, the client receives an opaque nextCursor token and passes it back on the next request.
Because AspNetConventions only includes offset-based pagination natively, cursor responses require a custom builder.
Step 1 — Define a carrier type:
public sealed class CursorPage<T>
{
public IEnumerable<T> Items { get; init; } = [];
public string? NextCursor { get; init; } // null = last page
public bool HasMore => NextCursor is not null;
public int Count { get; init; }
}
Step 2 — Implement the builder:
using AspNetConventions.Core.Abstractions.Contracts;
using AspNetConventions.Http.Models;
using AspNetConventions.Http.Services;
public class CursorResponseBuilder : IResponseBuilder
{
public bool IsWrappedResponse(object? value) => false;
public object BuildResponse(ApiResult apiResult, RequestDescriptor request)
{
var value = apiResult.GetValue();
// Shape cursor pages differently from regular responses
if (value is not null)
{
var valueType = value.GetType();
if (valueType.IsGenericType &&
valueType.GetGenericTypeDefinition() == typeof(CursorPage<>))
{
dynamic page = value;
return new
{
data = page.Items,
pagination = new
{
count = page.Count,
hasMore = page.HasMore,
nextCursor = page.NextCursor
}
};
}
}
// Fall back to a standard envelope for non-cursor responses
return new
{
data = value,
success = apiResult.IsSuccess,
code = (int)apiResult.StatusCode
};
}
}
Step 3 — Register:
builder.Services.AddControllers()
.AddAspNetConventions(options =>
{
options.Response.ResponseBuilder = new CursorResponseBuilder();
});
Step 4 — Use in a controller:
[HttpGet]
public ApiResult<CursorPage<OrderDto>> GetOrders(
[FromQuery] string? cursor,
[FromQuery] int limit = 20)
{
// Decode the cursor into a position in your dataset
var afterId = DecodeCursor(cursor);
var items = _db.Orders
.Where(o => afterId == null || o.Id > afterId)
.OrderBy(o => o.Id)
.Take(limit + 1) // fetch one extra to detect HasMore
.Select(OrderDto.FromEntity)
.ToList();
var hasMore = items.Count > limit;
var page = items.Take(limit).ToList();
var nextCursor = hasMore ? EncodeCursor(page.Last().Id) : null;
return ApiResults.Ok(new CursorPage<OrderDto>
{
Items = page,
NextCursor = nextCursor,
Count = page.Count
});
}
private static int? DecodeCursor(string? cursor) =>
cursor is null ? null : int.Parse(
System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(cursor)));
private static string EncodeCursor(int id) =>
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(id.ToString()));
First page (GET /api/orders?limit=2):
{
"data": [
{ "id": 1, "total": 49.99 },
{ "id": 2, "total": 129.00 }
],
"pagination": {
"count": 2,
"hasMore": true,
"nextCursor": "Mw=="
}
}
Last page (GET /api/orders?cursor=Mw==&limit=2):
{
"data": [
{ "id": 3, "total": 19.99 }
],
"pagination": {
"count": 1,
"hasMore": false,
"nextCursor": null
}
}
The example uses Base64 over a plain integer for illustration. In production, encode whatever opaque value marks the position in your dataset — a timestamp, a composite key, or an encrypted value. Clients should treat it as a black box.
Using Dependency Injection
For builders that need dependencies, register them with DI:
public class LoggingResponseBuilder : IResponseBuilder
{
private readonly ILogger<LoggingResponseBuilder> _logger;
public LoggingResponseBuilder(ILogger<LoggingResponseBuilder> logger)
{
_logger = logger;
}
public bool IsWrappedResponse(object? value) => false;
public object BuildResponse(ApiResult apiResult, RequestDescriptor request)
{
_logger.LogInformation(
"Response: {Method} {Path} → {StatusCode}",
request.Method, request.Path, (int)apiResult.StatusCode);
return new
{
success = apiResult.IsSuccess,
code = (int)apiResult.StatusCode,
data = apiResult.GetValue()
};
}
}
// Registration
builder.Services.AddSingleton<IResponseBuilder, LoggingResponseBuilder>();
builder.Services.AddControllers()
.AddAspNetConventions(options =>
{
// Builder will be resolved from DI
options.Response.ResponseBuilder =
builder.Services.BuildServiceProvider()
.GetRequiredService<IResponseBuilder>();
});
Best Practices
- Keep builders simple — Focus on structure transformation, not business logic
- Use consistent naming — Match your API documentation and client expectations
- Handle nulls gracefully —
ApiResult.GetValue()may returnnull - Don’t expose exceptions — Use
exceptionfor logging only in production - Test both success and error paths — Ensure both builders produce valid JSON
- Document your envelope — Update API documentation to reflect custom shapes