🏗️ Consistent API Architecture: The Unified Response & Exception Wrapper
In a professional API, consistency is one of the most valuable architectural principles.
If your frontend has to handle different JSON structures for each endpoint — and different formats for errors — your codebase quickly becomes difficult to maintain and error-prone.
By implementing a Unified Response Wrapper, we ensure every request follows a predictable response structure — whether the result is:
- 200 OK
- 400 Bad Request
- 404 Not Found
- 500 Internal Server Error
All responses are wrapped inside a standardized JSON "Envelope Pattern".
📜 Standardized Response Contract
Every response from the API follows a strict JSON schema.
✅ Success Response
{
"success": true,
"traceId": "0HMNK92L8S1A",
"data": {
"id": 1,
"name": "Blue Shirt"
}
}
❌ Error Response
{
"success": false,
"traceId": "0HMNK92L8S1A",
"error": {
"code": 404,
"message": "Product Not Found"
}
}
This structure ensures that client applications can always expect the same response format.
🛠️ Implementation: ResponseWrapperMiddleware
This middleware captures outgoing responses and ensures they are wrapped in the standardized envelope.
It also acts as a global exception handler, guaranteeing consistent error responses even when unexpected exceptions occur.
The middleware works by temporarily replacing the response stream with a memory stream. Once the downstream pipeline completes, the response is formatted and written back to the original stream.
using System.Text.Json;
namespace ProductApi.Middleware;
public class ResponseWrapperMiddleware(RequestDelegate next)
{
private readonly RequestDelegate _next = next;
public async Task InvokeAsync(HttpContext context)
{
var originalBodyStream = context.Response.Body;
using var newBodyStream = new MemoryStream();
context.Response.Body = newBodyStream;
try
{
await _next(context);
}
catch (Exception ex)
{
context.Response.StatusCode = 500;
await WrapErrorAsync(context, ex.Message, originalBodyStream);
return;
}
context.Response.Body.Seek(0, SeekOrigin.Begin);
var bodyText = await new StreamReader(context.Response.Body)
.ReadToEndAsync();
context.Response.Body.Seek(0, SeekOrigin.Begin);
if (!context.Response.HasStarted)
{
// Case A: Success responses
if (!string.IsNullOrWhiteSpace(bodyText)
&& context.Response.ContentType?.Contains("application/json") == true
&& context.Response.StatusCode < 400)
{
await WrapSuccessAsync(context, bodyText, originalBodyStream);
}
// Case B: Error responses
else if (context.Response.StatusCode >= 400)
{
string message =
string.IsNullOrWhiteSpace(bodyText)
? GetDefaultMessage(context)
: bodyText;
await WrapErrorAsync(context, message, originalBodyStream);
}
// Case C: Pass-through responses (files, redirects)
else
{
context.Response.Body = originalBodyStream;
await newBodyStream.CopyToAsync(originalBodyStream);
}
}
}
private async Task WrapSuccessAsync(
HttpContext context,
string bodyText,
Stream originalStream)
{
using var jsonDoc = JsonDocument.Parse(bodyText);
var wrapped = new
{
success = true,
traceId = context.TraceIdentifier,
data = jsonDoc.RootElement
};
context.Response.Body = originalStream;
await context.Response.WriteAsync(
JsonSerializer.Serialize(wrapped)
);
}
private async Task WrapErrorAsync(
HttpContext context,
string message,
Stream originalStream)
{
var wrappedError = new
{
success = false,
traceId = context.TraceIdentifier,
error = new
{
code = context.Response.StatusCode,
message
}
};
context.Response.Body = originalStream;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(
JsonSerializer.Serialize(wrappedError)
);
}
private string GetDefaultMessage(HttpContext context)
=> context.Response.StatusCode switch
{
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
500 => "Internal Server Error",
_ => "Unexpected error"
};
}
🛡️ Why This Architecture is a Game Changer
1️⃣ Traceability via traceId
Each response includes context.TraceIdentifier, allowing developers to correlate client errors with server logs.
When a user reports an issue, they can provide the TraceId, enabling quick lookup in:
- Serilog
- ELK Stack
- Application Insights
- CloudWatch
2️⃣ Simplified Frontend Interceptors
Frontend applications no longer need complex error parsing logic.
A single interceptor can handle all API responses:
api.interceptors.response.use(response => {
if (response.data.success) {
return response.data.data;
}
showToast(response.data.error.message);
return Promise.reject(response.data.error);
});
3️⃣ Global Exception Handling
The middleware acts as a centralized error handler.
Unhandled exceptions from controllers, services, or repositories are automatically converted into structured JSON responses.
This prevents:
- stack trace exposure
- unformatted error responses
- inconsistent error handling logic
📊 Architecture Benefits
| Feature | Benefit |
|---|---|
| Unified Response Envelope | Predictable structure for all clients |
| TraceId Injection | Easy debugging and log correlation |
| Error Masking | Prevents sensitive internal details from leaking |
| Automatic Status Mapping | Standardized error messages |
| Centralized Exception Handling | Cleaner controllers and services |
🏁 Final Thoughts
A professional API is a predictable API.
By combining:
- Input Sanitization
- Content Security Policy
- Unified Response Wrapper
you ensure that your application is:
- secure when receiving data
- consistent when returning data
- easy to integrate for frontend teams
app.UseMiddleware<ResponseWrapperMiddleware>();
so that all responses and exceptions are captured consistently.
Comments
Post a Comment