🏗️ 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
🛡️ Architect Tip Register this middleware early in your pipeline:
app.UseMiddleware<ResponseWrapperMiddleware>();
so that all responses and exceptions are captured consistently.

Comments

Popular posts from this blog

Promises in Angular

Debouncing & Throttling in RxJS: Optimizing API Calls and User Interactions

Comprehensive Guide to C# and .NET Core OOP Concepts and Language Features