Skip to main content

A Common API Response Wrapper: Status, Message, Data – Web API | Lesson 80

Published / updated

Introduction​

The SMS API now has eleven controllers. A student comes back as a student object, subjects as a list, and errors as ProblemDetails (Lesson 74). Each shape is correct, but each one is different.

The front-end team builds the parent app and the office screens. They asked for one rule: every answer should look the same, so one piece of their code can read all of them. This lesson builds that rule.

By the end of this lesson, you will be able to:

  • Explain what a response wrapper (an "envelope") is, and why client teams ask for one.
  • Build ApiResponse with one set of common methods: Ok and Fail.
  • Make every error path use it: rules, validation, unknown routes, and crashes.
  • Keep the HTTP status codes honest while wrapping the body.
  • Choose between a wrapper and ProblemDetails for a project.

Why this matters​

Without one shape, a client must guess. Is there a title, a detail, or a message? Is the student at the top level, or inside something?

Every new endpoint then means new parsing code in every app.

With one shape, the app checks status, shows message, and reads data. Errors also carry a traceId. When a parent reports a problem, support can find the exact request in the server's log.

The concept​

A response wrapper is like a parcel service's box. Every box has the same label (status), the same note card (message), and the item inside (data). A returned parcel has a label and a note, but no item.

Example: the two shapes​

A success always has status, message, and data:

Success
{ "status": "success", "message": "Student found.", "data": { "admissionNumber": "ADM-2017-0003", "fullName": "Kabir Singh" } }

An error has status, message, and a traceId. When the SMS has a number for the problem, it adds errorCode. It never has data:

Error
{ "status": "error", "message": "No student has admission number ADM-1999-0001.", "errorCode": 50030, "traceId": "00-dc38…-00" }
PropertySuccessError
status"success""error"
messagea short sentencewhat went wrong, ready to show
errorCodeleft outthe SMS error number, when there is one
errorsleft outfield errors, only for 400
traceIdleft outthe request's id, also in the log
datathe DTO, list, or pagenever

Example: the status code still matters​

The wrapper changes the body, not the HTTP status code. A missing student is still 404, a broken rule still 422, and a new student still 201 with a Location header.

Browsers, proxies, monitoring tools, and retry logic all read the status code, never your JSON. An API that answers 200 with "status": "error" inside hides every failure from them.

Example: one common method​

Nobody builds the wrapper by hand. Every controller and error handler calls one of three static methods:

MethodGivesUsed for
ApiResponse.Ok(data, message)ApiResponse<T> with datareads, creates, updates
ApiResponse.Ok(message)ApiResponse, no datadeletes
ApiResponse.Fail(message, errorCode, errors)ApiResponse, no dataevery error

The trick is two classes. The plain ApiResponse holds what every answer has, and ApiResponse<T> adds Data. The Fail method returns the plain class, so an error cannot carry data even by mistake.

Example: wrapper or ProblemDetails?​

Lesson 74 used ProblemDetails, the error format from the web standard RFC 9457. Both designs are good. They suit different readers:

QuestionProblemDetailsA wrapper like ApiResponse
Who defines the shape?A public standardYour team
Success bodiesPlain dataAlways inside data
Tools that understand itGateways, generated clients, many librariesOnly your own clients
Best fitPublic and partner APIsOne team's own web and mobile apps

The SMS has one set of readers: its own office screens and parent app. Their developers asked for one shape, so the SMS uses a wrapper from this lesson on.

Some rules stay the same in both designs: send the real status code, never send exception details to the client, and describe the shapes in the OpenAPI document.

Apply it to the SMS​

Step 1: ApiResponse​

src/SchoolApp.Api/Models/ApiResponse.cs
using System.Diagnostics;
using System.Text.Json.Serialization;

namespace SchoolApp.Api.Models;

// Lesson 80: one shape for every answer the API gives, so a client reads every response the same way.
// Success: { "status": "success", "message": "Student found.", "data": { ... } }
// Error: { "status": "error", "message": "No student has ...", "errorCode": 50030, "traceId": "..." }
// An error is a plain ApiResponse: it has no data property at all. Success with data is ApiResponse<T>.
public class ApiResponse
{
public const string Success = "success";
public const string Error = "error";

public string Status { get; init; } = Success;

public string Message { get; init; } = "";

// The SMS error number from sms-reference.md, such as 50030; left out when there is none.
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? ErrorCode { get; init; }

// Validation problems: each field name with its messages. Only on 400 responses.
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IDictionary<string, string[]>? Errors { get; init; }

// Matches the server's log entry for this request, so support can find what went wrong.
// Fail fills it in: Activity.Current is the request's trace, which ASP.NET Core starts for every request.
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TraceId { get; init; }

// The common methods. Every controller and error handler builds its response through these.
public static ApiResponse<T> Ok<T>(T data, string message = "OK")
{
return new ApiResponse<T> { Message = message, Data = data };
}

// Success with nothing to send back, such as after a delete.
public static ApiResponse Ok(string message)
{
return new ApiResponse { Message = message };
}

public static ApiResponse Fail(string message, int? errorCode = null, IDictionary<string, string[]>? errors = null)
{
return new ApiResponse
{
Status = Error,
Message = message,
ErrorCode = errorCode,
Errors = errors,
TraceId = Activity.Current?.Id
};
}
}

// A successful response that carries data: the student, the list, the page, and so on.
public class ApiResponse<T> : ApiResponse
{
// Without the order, System.Text.Json writes a derived class's own properties first.
[JsonPropertyOrder(1)]
public T? Data { get; init; }
}

Two details matter. The attribute JsonIgnore(WhenWritingNull) leaves out empty properties, so a success has no errorCode: null noise. The attribute JsonPropertyOrder(1) puts data last; without it, System.Text.Json writes a derived class's own properties first.

Fail fills in the traceId by itself from Activity.Current, the trace ASP.NET Core starts for each request. No caller has to remember it.

Step 2: controllers use the common methods​

Each action now returns ApiResponse<T>. Missing records answer with NotFound(ApiResponse.Fail(...)):

Show src/SchoolApp.Api/Controllers/StudentsController.cs
src/SchoolApp.Api/Controllers/StudentsController.cs
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SchoolApp.Api.Models;
using SchoolApp.Api.Options;
using SchoolApp.Core.Common;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Services;

namespace SchoolApp.Api.Controllers;

// Lesson 68: the first SMS endpoint backed by SchoolDb. The controller only translates HTTP:
// the route value goes into StudentService, and the result comes back as 200 or 404.
// Lesson 71: /// comments and Produces... attributes describe the endpoint in the OpenAPI document.
// Lesson 72: a paged list at GET /api/students, with page and pageSize from the query string.
// Lesson 73: POST /api/students admits a student.
// Lesson 75: search and class filters, PUT to update, and DELETE to remove (soft delete).
// Lesson 80: every answer is an ApiResponse. A missing student is a 404 with error code 50030.
[ApiController]
[Route("api/students")]
[Produces("application/json")]
[ProducesErrorResponseType(typeof(ApiResponse))] // Lesson 80: errors are documented as ApiResponse
public class StudentsController : ControllerBase
{
private readonly StudentService _students;
private readonly IStudentQueries _queries;
private readonly SchoolOptions _school;

// ASP.NET Core passes in these services for each request (dependency injection, Lesson 69).
public StudentsController(StudentService students, IStudentQueries queries, IOptions<SchoolOptions> school)
{
_students = students;
_queries = queries;
_school = school.Value;
}

// GET /api/students?search=Singh&classId=10&page=2&pageSize=20
/// <summary>Gets one page of students, sorted by last name, first name, and Id.</summary>
/// <param name="search">An admission number, or the start of a first or last name.</param>
/// <param name="classId">Only students enrolled in this class (1 to 10) in the current academic year.</param>
/// <param name="page">The page number, starting at 1.</param>
/// <param name="pageSize">Students per page, 1 to 100. Leave it out for the school's default.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="200">The page, with the total count for page numbers.</response>
/// <response code="400">The page or page size is out of range.</response>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ApiResponse<PagedResult<StudentDto>>> GetPage(
[FromQuery, StringLength(50)] string? search = null,
[FromQuery, Range(1, 10)] int? classId = null,
[FromQuery, Range(1, int.MaxValue)] int page = 1,
[FromQuery, Range(1, Paging.MaxPageSize)] int? pageSize = null,
CancellationToken cancellationToken = default)
{
StudentFilter filter = new StudentFilter(search, classId);
PagedResult<StudentDto> result = await _queries.GetPageAsync(filter, page, pageSize ?? _school.DefaultPageSize, cancellationToken);
return ApiResponse.Ok(result, $"{result.TotalCount} students found.");
}

// GET /api/students/ADM-2017-0003
// ActionResult<ApiResponse<StudentDto>>: either the wrapped student (200 OK) or another result, such as 404.
/// <summary>Gets one student by admission number.</summary>
/// <param name="admissionNumber">The student's admission number, such as ADM-2017-0003.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="200">The student.</response>
/// <response code="404">No student has this admission number.</response>
[HttpGet("{admissionNumber}")]
[ProducesResponseType(StatusCodes.Status200OK)] // the body type comes from ActionResult<ApiResponse<StudentDto>>
[ProducesResponseType(StatusCodes.Status404NotFound)] // the body is an ApiResponse (see Program.cs)
public async Task<ActionResult<ApiResponse<StudentDto>>> GetByAdmissionNumber(string admissionNumber, CancellationToken cancellationToken)
{
StudentDto? student = await _students.FindAsync(admissionNumber, cancellationToken);
if (student is null)
{
return NotFound(StudentNotFound(admissionNumber));
}
return ApiResponse.Ok(student, "Student found."); // converted to Ok(...)
}

// POST /api/students, with a CreateStudentRequest as JSON in the body.
// Lesson 73: [ApiController] reads the body (model binding) and checks its rules (validation)
// before this method runs. An invalid request gets 400 and never reaches it.
/// <summary>Admits a new student.</summary>
/// <param name="request">The new student's details.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="201">The student was admitted; Location gives the new student's address.</response>
/// <response code="400">The request is invalid; errors lists each problem.</response>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ApiResponse<StudentDto>>> Admit(CreateStudentRequest request, CancellationToken cancellationToken)
{
StudentDto admitted = await _students.AdmitAsync(request, cancellationToken);

// 201 Created, a Location header pointing at GET /api/students/{admissionNumber}, and the student.
return CreatedAtAction(nameof(GetByAdmissionNumber), new { admissionNumber = admitted.AdmissionNumber },
ApiResponse.Ok(admitted, "Student admitted."));
}

// PUT /api/students/ADM-2017-0003: replaces the details a client may change.
/// <summary>Updates a student's names, address, and status.</summary>
/// <param name="admissionNumber">The student to update.</param>
/// <param name="request">The new values; every field must be sent, as PUT replaces them all.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="200">The student after the update.</response>
/// <response code="400">The request is invalid.</response>
/// <response code="404">No student has this admission number.</response>
[HttpPut("{admissionNumber}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ApiResponse<StudentDto>>> Update(string admissionNumber, UpdateStudentRequest request,
CancellationToken cancellationToken)
{
StudentDto? updated = await _students.UpdateAsync(admissionNumber, request, cancellationToken);
if (updated is null)
{
return NotFound(StudentNotFound(admissionNumber));
}
return ApiResponse.Ok(updated, "Student updated.");
}

// DELETE /api/students/ADM-2017-0003: a soft delete; the row stays in SchoolDb.
/// <summary>Removes a student. The record is kept, marked deleted, and no longer returned.</summary>
/// <param name="admissionNumber">The student to remove.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="200">The student was removed.</response>
/// <response code="404">No student has this admission number, or it was already removed.</response>
// Lesson 80: 200 with a message instead of 204, because a 204 response can never have a body.
[HttpDelete("{admissionNumber}")]
[ProducesResponseType<ApiResponse>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ApiResponse>> Remove(string admissionNumber, CancellationToken cancellationToken)
{
bool removed = await _students.RemoveAsync(admissionNumber, cancellationToken);
if (!removed)
{
return NotFound(StudentNotFound(admissionNumber));
}
return ApiResponse.Ok($"Student {admissionNumber} was removed.");
}

// One message for every "no such student" answer, with the SMS error number for it.
private static ApiResponse StudentNotFound(string admissionNumber)
{
return ApiResponse.Fail($"No student has admission number {admissionNumber}.", 50030);
}
}

Three changes are worth noticing:

  • CreatedAtAction(...) still sends 201 and Location; only the body is wrapped.
  • DELETE now answers 200 with a message. A 204 No Content response can never have a body, so it cannot carry the wrapper.
  • [ProducesErrorResponseType(typeof(ApiResponse))] on each controller tells the OpenAPI document that errors are ApiResponse, not ProblemDetails.

The other ten controllers changed the same way. Every list says how many items it holds, such as "6 subjects.".

Step 3: every error path​

An error can reach the client in four ways. Each one needs the wrapper, or the client sees two shapes again.

PathExampleHandled by
A broken school ruleSunday attendanceSchoolRuleExceptionHandler
Any other exceptiona bug, a lost databaseUnhandledExceptionHandler
An invalid requestan empty first nameInvalidModelStateResponseFactory
An empty error responsean unknown routeUseStatusCodePages

The rule handler from Lesson 74 keeps its status mapping and now writes ApiResponse.Fail:

Show src/SchoolApp.Api/ErrorHandling/SchoolRuleExceptionHandler.cs
src/SchoolApp.Api/ErrorHandling/SchoolRuleExceptionHandler.cs
using Microsoft.AspNetCore.Diagnostics;
using SchoolApp.Api.Models;
using SchoolApp.Core.Exceptions;

namespace SchoolApp.Api.ErrorHandling;

// Lesson 74: turns a SchoolRuleException from Core into the right HTTP answer, with the SMS error number.
// Controllers stay free of try/catch: they let the exception travel, and this class answers.
// Lesson 80: the answer is an ApiResponse instead of a ProblemDetails; the status codes do not change.
public class SchoolRuleExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (exception is not SchoolRuleException rule)
{
return false; // not ours: UnhandledExceptionHandler answers 500
}

httpContext.Response.StatusCode = StatusFor(rule.ErrorNumber);
await httpContext.Response.WriteAsJsonAsync(ApiResponse.Fail(rule.Message, rule.ErrorNumber), cancellationToken);
return true;
}

// The error numbers from sms-reference.md, grouped by what the client did wrong.
private static int StatusFor(int errorNumber) => errorNumber switch
{
50010 or 50030 or 50031 => StatusCodes.Status404NotFound, // invoice, student, section
50014 or 50020 or 50021 or 50033 or 50034 => StatusCodes.Status409Conflict,
// ^ changed meanwhile; duplicates; already enrolled; section full (Lesson 78)
_ => StatusCodes.Status422UnprocessableEntity // 50011 to 50013 and other rules
};
}

The new safety net answers every other exception with a 500. The client never sees the exception's text:

src/SchoolApp.Api/ErrorHandling/UnhandledExceptionHandler.cs
using Microsoft.AspNetCore.Diagnostics;
using SchoolApp.Api.Models;

namespace SchoolApp.Api.ErrorHandling;

// Lesson 80: the last safety net. Any exception nobody else handled becomes a 500 ApiResponse.
// The client never sees the exception's text or stack trace; the log has them, under the same traceId.
public class UnhandledExceptionHandler : IExceptionHandler
{
private readonly ILogger<UnhandledExceptionHandler> _logger;

public UnhandledExceptionHandler(ILogger<UnhandledExceptionHandler> logger)
{
_logger = logger;
}

public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
ApiResponse response = ApiResponse.Fail("Something went wrong on our side. Please try again later.");

// Since .NET 10, the exception middleware stays silent when a handler returns true,
// so this handler writes the log entry itself. The traceId links it to the client's answer.
_logger.LogError(exception, "Unhandled exception for {Method} {Path}, traceId {TraceId}",
httpContext.Request.Method, httpContext.Request.Path, response.TraceId);

httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(response, cancellationToken);
return true;
}
}

It writes the log entry itself. In .NET 10, the exception middleware stays silent when a handler returns true.

We tested it: without the LogError line, the crash left no trace in the log at all. The client would have a traceId that matches nothing.

Validation and empty responses are set up in one small class:

src/SchoolApp.Api/ErrorHandling/ApiResponseSetup.cs
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using SchoolApp.Api.Models;

namespace SchoolApp.Api.ErrorHandling;

// Lesson 80: the two error paths that do not go through an exception, now answering with ApiResponse.
public static class ApiResponseSetup
{
// Validation (Lesson 73). [ApiController] still checks every request before the action runs;
// this factory only decides what the 400 looks like.
public static void UseApiResponseForValidation(ApiBehaviorOptions options)
{
options.InvalidModelStateResponseFactory = context =>
{
Dictionary<string, string[]> errors = context.ModelState
.Where(entry => entry.Value is { Errors.Count: > 0 })
.ToDictionary(entry => entry.Key,
entry => entry.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
return new BadRequestObjectResult(ApiResponse.Fail("One or more fields are invalid.", errors: errors));
};

// Without this, a bare NotFound() would still be turned into a ProblemDetails.
// Now it stays empty, and the status code page below gives it an ApiResponse body.
options.SuppressMapClientErrors = true;
}

// Errors with an empty body: an unknown route (404), a wrong HTTP method (405), a bare NotFound().
public static async Task WriteStatusCodePage(StatusCodeContext context)
{
HttpResponse response = context.HttpContext.Response;
HttpRequest request = context.HttpContext.Request;
string message = response.StatusCode == StatusCodes.Status404NotFound
? $"Nothing was found at {request.Method} {request.Path}."
: ReasonPhrases.GetReasonPhrase(response.StatusCode) + ".";
await response.WriteAsJsonAsync(ApiResponse.Fail(message));
}
}

SuppressMapClientErrors = true matters. Without it, a bare NotFound() would still become a ProblemDetails, before the status code page could wrap it.

Step 4: wire it up in Program.cs​

Program.cs (the Lesson 80 lines)
builder.Services.AddControllers()
.AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()))
.ConfigureApiBehaviorOptions(ApiResponseSetup.UseApiResponseForValidation); // Lesson 80: 400s as ApiResponse

builder.Services.AddExceptionHandler<SchoolRuleExceptionHandler>();
builder.Services.AddExceptionHandler<UnhandledExceptionHandler>();
// UseExceptionHandler() refuses to start without it. Our handlers always answer first,
// so no ProblemDetails is written any more.
builder.Services.AddProblemDetails();

app.UseExceptionHandler();
app.UseStatusCodePages(ApiResponseSetup.WriteStatusCodePage); // Lesson 80: empty error responses get an ApiResponse body

Handlers run in the order they are added. The rule handler returns false for anything that is not a SchoolRuleException, so the safety net only sees the rest.

We tried removing AddProblemDetails(), since nothing writes ProblemDetails any more. The app refused to start:

Output
System.InvalidOperationException: An error occurred when configuring the exception handler middleware.
Either the 'ExceptionHandlingPath' or the 'ExceptionHandler' property must be set in 'UseExceptionHandler()'.
... or configure to generate a 'ProblemDetails' response in 'service.AddProblemDetails()'.

Step 5: every answer, from the outside​

The API ran on port 5080, and curl called each path. Successes first:

Output
GET /api/students/ADM-2017-0003 200
{"status":"success","message":"Student found.","data":{"id":3,"admissionNumber":"ADM-2017-0003","fullName":"Kabir Singh","dateOfBirth":"2011-01-30","gender":"Male","status":"Active"}}

POST /api/students (src/requests/admit-student.json) 201
Location: http://localhost:5080/api/students/ADM-2026-10001
{"status":"success","message":"Student admitted.","data":{"id":10141,"admissionNumber":"ADM-2026-10001","fullName":"Ishaan Kumar","dateOfBirth":"2016-04-12","gender":"Male","status":"Active"}}

DELETE /api/students/ADM-2026-10001 200
{"status":"success","message":"Student ADM-2026-10001 was removed."}

Then every kind of error. The trace ids are shortened here:

Output
GET /api/students/ADM-1999-0001 404
{"status":"error","message":"No student has admission number ADM-1999-0001.","errorCode":50030,"traceId":"00-dc38…-00"}

POST /api/students (the same student again) 409
{"status":"error","message":"Admission number ADM-2026-10001 is already in use.","errorCode":50020,"traceId":"00-e878…-00"}

GET /api/studnets 404
{"status":"error","message":"Nothing was found at GET /api/studnets.","traceId":"00-4bf4…-00"}

PATCH /api/students/ADM-2017-0003 405
{"status":"error","message":"Method Not Allowed.","traceId":"00-2ad6…-00"}

POST /api/students (empty first name, no admission number) 400
{"status":"error","message":"One or more fields are invalid.","errors":{"FirstName":["The FirstName field is required."],"AdmissionNumber":["The AdmissionNumber field is required."]},"traceId":"00-4a31…-00"}

PUT /api/sections/19/attendance?date=2026-07-05 422
{"status":"error","message":"2026-07-05 is a Sunday, not a school day.","errorCode":50041,"traceId":"00-87f0…-00"}

GET /api/diagnostics/error 500
{"status":"error","message":"Something went wrong on our side. Please try again later.","traceId":"00-6ead8d76c583a2679fbc05f2d04167a2-2e8acf84fd67aef9-00"}

The server's log had the real exception under the same trace id:

Output
Unhandled exception for GET /api/diagnostics/error, traceId 00-6ead8d76c583a2679fbc05f2d04167a2-2e8acf84fd67aef9-00
System.InvalidOperationException: A test failure from the diagnostics endpoint.
at SchoolApp.Api.Controllers.DiagnosticsController.Error() in ...\DiagnosticsController.cs:line 57

The demo student was removed from SchoolDb afterwards.

Step 6: tests from the client's side​

ApiResponseTests runs the whole API in memory with WebApplicationFactory (package Microsoft.AspNetCore.Mvc.Testing). It sends real HTTP requests and checks six answers: success, 404, unknown route, 400, 422, and 500.

One helper checks every error the same way. It wants the right status code and "error" as the status. It also wants a message and a trace id, and no data at all.

In .NET 10 the test can use Program directly, because the web SDK makes it public. The suite now has 59 tests, all passing.

The OpenAPI document agrees with the code. GET /api/students/{admissionNumber} lists 200 as ApiResponseOfStudentDto and 404 as ApiResponse.

Common mistakes​

  1. 200 with "status": "error". Monitoring and retries think everything worked. Fix: set the real status code; the wrapper only changes the body.
  2. Putting the exception message in the response. It can leak table names and file paths. Fix: a fixed message and a traceId; the details go to the log.
  3. Wrapping only the controllers. Validation, unknown routes, and crashes still answer in another shape. Fix: cover all four error paths from Step 3.
  4. Forgetting to log in the handler. In .NET 10, a handled exception is not logged for you. Fix: log it with the same traceId.
  5. Sending "data": null on errors. Clients start checking data instead of status. Fix: errors use the class without Data.

In real projects​

From 16 years of .NET projects

A school group's API returned raw objects on success and three different error shapes. One came from validation, one from a custom filter, and crashes sent an HTML page. The mobile app's error screen showed "Unexpected token <" whenever the server failed.

We added one envelope with an Ok and a Fail method, and made all four error paths use it. The app replaced dozens of special cases with one parser, and support started asking parents for the trace id.

The first week, the trace ids found two real bugs that had been invisible for months. Both were hidden behind "something went wrong" screens.

Best practices​

  • Build every response through one set of common methods, never by hand.
  • Keep HTTP status codes correct; the wrapper is the body, not the status.
  • Never put data in an error, or exception details in any response.
  • Give every error a traceId, and log 500s under the same id.
  • Document the wrapper in OpenAPI, so generated clients know both shapes.

Recap​

  • Every SMS answer is now status, message, and either data or error details.
  • ApiResponse.Ok and ApiResponse.Fail are the only way responses are built.
  • Rule errors, crashes, validation, and empty errors all use the wrapper.
  • Status codes did not change: 200, 201, 400, 404, 405, 409, 422, and 500 as before.
  • .NET 10 does not log handled exceptions, so the safety net logs them with the traceId.

Homework​

Library module task: Take the library desk API from the Lesson 74 homework and make every answer an ApiResponse. Issuing a book answers 201 with the issue as data, and returning answers 200 with a message.

Rule errors, invalid requests, and unknown routes answer with "status": "error" and no data.

A correct result looks like:

Output
POST /api/book-issues (library-assignment/074-issue-request.json) 201
{"status":"success","message":"Book issued.","data":{"id":1,"isbn":"9780143039655","studentId":6}}
POST /api/book-issues (the same request again) 409
{"status":"error","message":"Every copy of this book is issued.","traceId":"00-bdd1…-00"}
POST /api/book-issues {"studentId":6} 400
{"status":"error","message":"One or more fields are invalid.","errors":{"Isbn":["The Isbn field is required."]},"traceId":"00-f6fb…-00"}
DELETE /api/book-issues/1 200
{"status":"success","message":"Issue 1 returned."}
DELETE /api/book-issues/1 404
{"status":"error","message":"No open issue has id 1.","traceId":"00-ee46…-00"}
GET /api/books 404
{"status":"error","message":"Nothing was found at GET /api/books.","traceId":"00-6ff7…-00"}
Show the solution
library-assignment/080-library-api-response.cs
// Library homework, Lesson 80: the Lesson 74 library desk, now answering with one ApiResponse shape.
// Issue: 201 + data. Return: 200 + message. Rule errors, bad requests, and unknown routes: status "error", no data.
// Run with: dotnet run library-assignment/080-library-api-response.cs
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using System.Diagnostics;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context => new BadRequestObjectResult(ApiResponse.Fail(
"One or more fields are invalid.",
context.ModelState.Where(e => e.Value is { Errors.Count: > 0 })
.ToDictionary(e => e.Key, e => e.Value!.Errors.Select(x => x.ErrorMessage).ToArray())));
options.SuppressMapClientErrors = true;
});
builder.Services.AddExceptionHandler<LibraryExceptionHandler>();
builder.Services.AddProblemDetails(); // UseExceptionHandler() needs it to start
builder.Services.AddSingleton<LibraryDesk>();

WebApplication app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages(async context =>
{
HttpRequest request = context.HttpContext.Request;
await context.HttpContext.Response.WriteAsJsonAsync(
ApiResponse.Fail($"Nothing was found at {request.Method} {request.Path}."));
});
app.MapControllers();
app.Run("http://localhost:5080");

// The same shape as SchoolApp.Api's ApiResponse.
public class ApiResponse
{
public string Status { get; init; } = "success";
public string Message { get; init; } = "";

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IDictionary<string, string[]>? Errors { get; init; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TraceId { get; init; }

public static ApiResponse<T> Ok<T>(T data, string message) => new ApiResponse<T> { Message = message, Data = data };

public static ApiResponse Ok(string message) => new ApiResponse { Message = message };

public static ApiResponse Fail(string message, IDictionary<string, string[]>? errors = null) =>
new ApiResponse { Status = "error", Message = message, Errors = errors, TraceId = Activity.Current?.Id };
}

public class ApiResponse<T> : ApiResponse
{
[JsonPropertyOrder(1)]
public T? Data { get; init; }
}

public class LibraryRuleException : Exception
{
public LibraryRuleException(int statusCode, string message) : base(message)
{
StatusCode = statusCode;
}

public int StatusCode { get; }
}

// Rule errors keep their own status code; anything else is a 500 that hides the exception text.
public class LibraryExceptionHandler : IExceptionHandler
{
private readonly ILogger<LibraryExceptionHandler> _logger;

public LibraryExceptionHandler(ILogger<LibraryExceptionHandler> logger)
{
_logger = logger;
}

public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
ApiResponse response;
if (exception is LibraryRuleException rule)
{
httpContext.Response.StatusCode = rule.StatusCode;
response = ApiResponse.Fail(rule.Message);
}
else
{
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
response = ApiResponse.Fail("Something went wrong on our side.");
_logger.LogError(exception, "Unhandled exception, traceId {TraceId}", response.TraceId);
}
await httpContext.Response.WriteAsJsonAsync(response, cancellationToken);
return true;
}
}

public record IssueRequest(string Isbn, int StudentId);
public record BookIssue(int Id, string Isbn, int StudentId);

// Unchanged from Lesson 74: the desk throws, and never knows about HTTP or ApiResponse.
public class LibraryDesk
{
private readonly Dictionary<string, int> _availableCopies = new() { ["9780143039655"] = 1 };
private readonly Dictionary<int, BookIssue> _openIssues = new();
private int _nextId = 1;

public BookIssue Issue(IssueRequest request)
{
if (!_availableCopies.TryGetValue(request.Isbn, out int available))
{
throw new LibraryRuleException(StatusCodes.Status404NotFound, $"No book has ISBN {request.Isbn}.");
}
if (available == 0)
{
throw new LibraryRuleException(StatusCodes.Status409Conflict, "Every copy of this book is issued.");
}
_availableCopies[request.Isbn] = available - 1;
BookIssue issue = new BookIssue(_nextId++, request.Isbn, request.StudentId);
_openIssues.Add(issue.Id, issue);
return issue;
}

public void Return(int issueId)
{
if (!_openIssues.Remove(issueId, out BookIssue? issue))
{
throw new LibraryRuleException(StatusCodes.Status404NotFound, $"No open issue has id {issueId}.");
}
_availableCopies[issue.Isbn]++;
}
}

[ApiController]
[Route("api/book-issues")]
public class BookIssuesController : ControllerBase
{
private readonly LibraryDesk _desk;

public BookIssuesController(LibraryDesk desk)
{
_desk = desk;
}

[HttpPost]
public ActionResult<ApiResponse<BookIssue>> Issue(IssueRequest request)
{
BookIssue issue = _desk.Issue(request);
return Created($"/api/book-issues/{issue.Id}", ApiResponse.Ok(issue, "Book issued.")); // 201
}

[HttpDelete("{id:int}")]
public ApiResponse Return(int id)
{
_desk.Return(id);
return ApiResponse.Ok($"Issue {id} returned."); // 200
}
}

LibraryDesk did not change at all. It still throws, and never knows about HTTP or the wrapper.

Quiz​

  1. A student is not found. What should the API send?

    1. 200 with status error
    2. 404 with status error and errorCode 50030
    3. 204 with no body
    4. 500 with the exception
    Show answer

    Answer: B. 404 with status error and errorCode 50030 The wrapper changes the body; the status code still says 404.

  2. Why does an error response never contain data?

    1. To save bytes
    2. Fail returns the plain ApiResponse class, which has no Data property
    3. JSON cannot hold it
    4. The status code removes it
    Show answer

    Answer: B. Fail returns the plain ApiResponse class, which has no Data property Only ApiResponse of T has Data, and only Ok builds it.

  3. Why does DELETE now answer 200 instead of 204?

    1. 204 is deprecated
    2. A 204 response can never have a body, so it cannot carry the message
    3. 200 is faster
    4. Browsers need it
    Show answer

    Answer: B. A 204 response can never have a body, so it cannot carry the message With a wrapper, every answer has a body.

  4. What did the crash response give the client?

    1. The stack trace
    2. A fixed message and a traceId that matches the log entry
    3. Nothing
    4. The exception message
    Show answer

    Answer: B. A fixed message and a traceId that matches the log entry The details stay in the log, found by the traceId.

  5. Why does UnhandledExceptionHandler call LogError itself?

    1. For speed
    2. In .NET 10 the middleware does not log exceptions a handler has handled
    3. To send email
    4. Logging is required by JSON
    Show answer

    Answer: B. In .NET 10 the middleware does not log exceptions a handler has handled Without it, the crash left nothing in the log.

Interview questions​

Questions are grouped by experience level. Click a question to see a model answer. All questions: Web API interview questions.

Fresher (0-2 yrs)​

F What is an API response wrapper (envelope), and why do client teams ask for one? concept

A fixed outer shape for every response body, such as status, message, and data. A client can then read every endpoint with one piece of code: check status, show message, use data. Without it, each endpoint and each kind of error can have its own shape, and every app needs special parsing for each.

See the example · Permalink

F If every response has a status field, why keep using HTTP status codes? concept

Browsers, proxies, load balancers, monitoring, and retry logic read the HTTP status code, never the JSON body. Answering 200 with "status": "error" hides every failure from them. In the SMS, a missing student is still 404 and a broken rule still 422; the wrapper only changes the body.

See the example · Permalink

F Why does the SMS DELETE endpoint answer 200 with a message instead of 204 No Content? concept

A 204 response can never have a body, so it cannot carry the wrapper. With an envelope, every answer has a body, so a successful delete answers 200 with ApiResponse.Ok("Student ADM-2026-10001 was removed.") and no data.

See the example · Permalink

Mid (3-5 yrs)​

M How can a C# design guarantee that an error response never contains data? coding

Use two classes: a base ApiResponse with status, message, errorCode, errors, and traceId, and ApiResponse<T> that adds Data. The Fail method returns the base class, so an error object has no Data property at all, and the JSON cannot contain one even by mistake.

See the example · Permalink

M Which error paths in ASP.NET Core must be changed so that every error uses the wrapper? coding

Four: exceptions you expect (an IExceptionHandler for SchoolRuleException), every other exception (a last IExceptionHandler that answers 500), automatic validation errors (ApiBehaviorOptions. InvalidModelStateResponseFactory), and empty error responses such as unknown routes or 405 (UseStatusCodePages with a handler). Also set SuppressMapClientErrors, or a bare NotFound() still becomes a ProblemDetails.

See the example · Permalink

M What is a traceId in an error response, and how is it used? concept

The id of the request's trace (Activity.Current.Id in ASP.NET Core). The client gets it in the error response; the server logs the real exception under the same id. Support asks the user for the traceId and finds the exact log entry, without the response ever exposing exception details.

See the example · Permalink

Senior (6-9 yrs)​

S After adding an IExceptionHandler for 500s, production crashes no longer appear in the logs. What happened, and how do you fix it? scenario

Since .NET 10, the exception handler middleware does not write its own log entry when an IExceptionHandler returns true. The handler answered the client but nobody logged the exception. Fix it by logging inside the handler with ILogger, including the same traceId the response carries, and add a test or check that a crash produces a log entry.

How to answer: Start from the symptom, name the framework behaviour change, give the fix (log in the handler with the traceId), and say how you would stop it happening again.

See the example · Permalink

S Your team is split: some want a response envelope, others want plain bodies with ProblemDetails (RFC 9457) for errors. How do you decide? design

ProblemDetails is a standard: tools, gateways, and generated clients understand it, and success bodies stay plain. An envelope gives one shape for success and error, which many front-end and mobile teams prefer, but it is a house format that every client must learn. Decide by who consumes the API: public or partner APIs lean to standards; one team's own apps often lean to an envelope. Either way, keep real status codes, never leak exception details, and document it in OpenAPI. The SMS chose an envelope for its own apps and keeps the same status codes.

How to answer: Compare both on standards, tooling, and client simplicity, tie the choice to the API's consumers, and name what must stay true in both designs.

See the example · Permalink

Architect / Lead (10+ yrs)​

A Many services each have their own response format, and clients struggle. How would you standardise the response contract across them? design

Agree one contract (envelope or ProblemDetails) and publish it as a small shared library with the common Ok and Fail methods, the error handlers, and the validation setup, so services adopt it with a few lines. Define error codes per domain, require a traceId linked to distributed tracing, describe the shapes in each OpenAPI document, and add contract tests that call every endpoint's error paths. Roll it out with API versioning, so existing clients keep the old shape until they move.

How to answer: Cover the contract, how teams adopt it cheaply, how it is enforced (tests, OpenAPI), observability, and a migration plan that does not break existing clients.

See the example · Permalink

Resources​

  • Code for this lesson: git tag lesson-80-api-response-wrapper (code repository coming soon)
  • Files: src/SchoolApp.Api/Models/ApiResponse.cs, src/SchoolApp.Api/ErrorHandling/UnhandledExceptionHandler.cs, src/SchoolApp.Api/ErrorHandling/ApiResponseSetup.cs, src/SchoolApp.Api/Controllers/StudentsController.cs, tests/SchoolApp.Tests/Api/ApiResponseTests.cs, library-assignment/080-library-api-response.cs
  • Video: coming soon

What's next​

Every answer has one shape. Next, the SMS switches its data access from EF Core to Dapper, and the controllers do not even notice. Lesson 81: Switching the API to Dapper

Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.