Responses and Status Codes: One Error Format for the Whole API – Web API | Lesson 74
Introduction
An API answers every request, including the ones it cannot fulfil. A parent's app needs to know not only that a payment failed, but why: is the invoice missing, already paid, or was the amount too high?
Lesson 73 ended with a duplicate admission answering 500, as if the server had crashed. This lesson
gives every outcome its proper status code, and every error the same JSON shape, from one place in the
code.
By the end of this lesson, you will be able to:
- Choose the right status code for success and for each kind of failure.
- Explain the ProblemDetails format, and add your own fields to it.
- Turn domain exceptions into HTTP responses with
IExceptionHandler. - Give every error response, even routing 404s, the same format.
- Explain why 500 responses hide details, and where the details go instead.
Why this matters
Clients make decisions from status codes, so wrong codes lead to wrong decisions. For the fee clerk's app:
409means: reload the invoice.422means: show a message and let the clerk correct the amount.500means: try again later and alert support.
One error format also saves every client team from writing a different parser for each endpoint. It is a small design decision with a large effect on everyone who uses the API.
The concept
Example: status codes for SchoolApp
| Code | Meaning | SchoolApp example |
|---|---|---|
200 OK | Success, with a body | GET /api/students/ADM-2017-0003 |
201 Created | Something new exists; Location says where | Admitting a student; recording a payment |
204 No Content | Success, nothing to return | Returning a library book (homework) |
400 Bad Request | The request is malformed or invalid | An empty first name (Lesson 73) |
404 Not Found | No such resource | Invoice INV-2026-999999 |
409 Conflict | Valid, but clashes with the current state | A duplicate admission number; an invoice changed by someone else |
422 Unprocessable Entity | Well-formed, but breaks a business rule | Paying more than the balance |
500 Internal Server Error | The server failed | A bug or an unexpected exception |
Example: 400, 422, or 409
These three are easy to confuse. Think of the school office's reply slips: "your form is filled in
wrongly" is 400, and "your form is fine, but our rules do not allow it" is 422.
"Your form is fine, but someone else changed this record meanwhile, or it already exists" is 409.
| Question | If yes |
|---|---|
| Can the request be read, and does each field follow its format rules? | If no: 400 |
| Does the request clash with what already exists, or with a change made meanwhile? | 409 |
| Is it well-formed but against a business rule, such as an amount above the balance? | 422 |
Teams draw these lines slightly differently. What matters most is to choose once, write it down, and
apply it everywhere, as sms-reference.md now does for SchoolApp.
Example: ProblemDetails
ProblemDetails is a standard JSON format for HTTP errors, defined in RFC 9457. Its fields are:
| Field | Holds |
|---|---|
type | A link identifying the kind of error |
title | A short, general summary, such as "Conflict" |
status | The HTTP status code again, for convenience |
detail | What went wrong this time, such as "Amount is more than the balance due." |
instance | Which request or resource it concerns |
| Your own fields | Extensions, such as errorNumber |
ASP.NET Core also adds traceId, which identifies the request. Since Lesson 68, NotFound() in a
controller already produced this format; this lesson makes every error use it.
Example: exceptions as answers
Core reports broken rules by throwing SchoolRuleException with a number (Lessons 37 and 64). Catching
it in every action would repeat the same try/catch dozens of times.
Instead, the exception travels up to one exception handler in the pipeline. ASP.NET Core's
UseExceptionHandler middleware calls each registered IExceptionHandler; the first one that says "mine"
writes the response. Anything nobody handles becomes a 500.
Apply it to the SMS
Step 1: a number for duplicates
StudentService threw InvalidOperationException for a duplicate admission number, an exception that
could mean anything. It now throws SchoolRuleException with a new number, 50020, recorded in
sms-reference.md:
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Entities;
using SchoolApp.Core.Exceptions;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Mapping;
namespace SchoolApp.Core.Services;
// Lesson 46: one job (admitting and finding students), and it depends on an interface,
// not on any particular database code. The repository is passed in through the constructor.
// Lesson 62: asynchronous, like the repository it uses.
// Lesson 74: a duplicate admission number is a school rule error, 50020, which the API turns into 409.
public class StudentService
{
private readonly IStudentRepository _students;
public StudentService(IStudentRepository students)
{
_students = students;
}
public async Task<StudentDto> AdmitAsync(CreateStudentRequest request, CancellationToken cancellationToken = default)
{
if (await _students.GetByAdmissionNumberAsync(request.AdmissionNumber, cancellationToken) is not null)
{
throw new SchoolRuleException(50020, $"Admission number {request.AdmissionNumber} is already in use.");
}
Student student = request.ToEntity();
await _students.AddAsync(student, cancellationToken);
return student.ToDto();
}
public async Task<StudentDto?> FindAsync(string admissionNumber, CancellationToken cancellationToken = default)
{
Student? student = await _students.GetByAdmissionNumberAsync(admissionNumber, cancellationToken);
return student?.ToDto();
}
}
The unit test from Lesson 63 was updated to expect the new exception and number. All 32 tests pass.
Step 2: the exception handler
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using SchoolApp.Core.Exceptions;
namespace SchoolApp.Api.ErrorHandling;
// Lesson 74: turns a SchoolRuleException from Core into the right HTTP answer, in the same
// ProblemDetails format as every other error, with the SMS error number added.
// Controllers stay free of try/catch: they let the exception travel, and this class answers.
public class SchoolRuleExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetails;
public SchoolRuleExceptionHandler(IProblemDetailsService problemDetails)
{
_problemDetails = problemDetails;
}
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (exception is not SchoolRuleException rule)
{
return false; // not ours: the default handler answers 500
}
(int status, string title) = StatusFor(rule.ErrorNumber);
httpContext.Response.StatusCode = status;
return await _problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = new ProblemDetails
{
Status = status,
Title = title,
Detail = rule.Message,
Extensions = { ["errorNumber"] = rule.ErrorNumber }
}
});
}
// The error numbers from sms-reference.md, grouped by what the client did wrong.
private static (int Status, string Title) StatusFor(int errorNumber) => errorNumber switch
{
50010 => (StatusCodes.Status404NotFound, "Not found"), // invoice not found
50014 or 50020 => (StatusCodes.Status409Conflict, "Conflict"), // changed by someone else; duplicate
_ => (StatusCodes.Status422UnprocessableEntity, "School rule broken") // 50011 to 50013 and other rules
};
}
TryHandleAsync returns false for exceptions it does not know, so they reach the default handler. For a
SchoolRuleException, it chooses the status from the error number, and writes a ProblemDetails with the
rule's message as detail and the number as errorNumber.
IProblemDetailsService writes the response, adding type and traceId the same way as everywhere else.
Step 3: the pipeline
// Lesson 66: the start of SchoolApp.Api. Program.cs has two halves:
// 1. Register services (the "builder" part), 2. build the app and describe how requests are handled.
using System.Text.Json.Serialization;
using SchoolApp.Api.Diagnostics;
using SchoolApp.Api.ErrorHandling;
using SchoolApp.Api.Middleware;
using SchoolApp.Api.Options;
using Scalar.AspNetCore;
using SchoolApp.Core.Services;
using SchoolApp.Data;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Services: things the app can use. AddControllers finds every controller class in this project.
// Lesson 73: enums travel as their names ("Male"), not as numbers, in requests and responses.
builder.Services.AddControllers()
.AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// Lesson 71: the OpenAPI document, with a title and description of our own.
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, context, cancellationToken) =>
{
document.Info.Title = "SchoolApp API";
document.Info.Description = "The School Management System API from the NexCoding SMS course.";
return Task.CompletedTask;
});
});
// Lesson 74: one error format for the whole API. AddProblemDetails makes every error a ProblemDetails;
// the handler turns SchoolRuleException into 404, 409, or 422 with the SMS error number.
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<SchoolRuleExceptionHandler>();
// Lesson 70: settings come from configuration: appsettings.json, then appsettings.{Environment}.json,
// then user secrets (Development only), then environment variables, then the command line.
// A later source overrides an earlier one.
string? connectionString = builder.Configuration.GetConnectionString("SchoolDb");
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException("The connection string 'SchoolDb' is missing or empty.");
}
// The "School" section as a SchoolOptions object, checked when the app starts.
builder.Services.AddOptions<SchoolOptions>()
.BindConfiguration(SchoolOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
// Lesson 69: the dependency injection container. Each line says "when something asks for X, give it Y,
// and keep it for this long". SchoolApp.Data registers its own classes.
builder.Services.AddSchoolData(connectionString);
builder.Services.AddScoped<StudentService>();
builder.Services.AddScoped<FeePaymentService>();
// Lesson 69: the three lifetimes, side by side (see DiagnosticsController).
builder.Services.AddSingleton<SingletonExample>(); // one object for the whole app
builder.Services.AddScoped<ScopedExample>(); // one object per request
builder.Services.AddTransient<TransientExample>(); // a new object every time one is asked for
builder.Services.AddScoped<LifetimeReporter>();
WebApplication app = builder.Build();
// The request pipeline: what happens to every request, in this order (Lesson 67).
// Each Use... adds one middleware; a request goes down the list, and the response comes back up.
app.UseExceptionHandler(); // Lesson 74: first, so it catches exceptions from everything below it
app.UseStatusCodePages(); // Lesson 74: empty error responses, such as route 404s, get a ProblemDetails body
app.UseRequestTiming(); // times everything below it
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // /openapi/v1.json, only while developing
app.MapScalarApiReference(); // Lesson 71: /scalar, a page to read and try every endpoint
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers(); // send each request to the matching controller action
app.Run(); // start the web server and wait for requests
AddProblemDetails() registers the ProblemDetails service. UseExceptionHandler() goes first in the
pipeline, as Lesson 67's order table said, so it catches exceptions from every step after it.
UseStatusCodePages() fills in empty error responses, such as the routing 404s from Lesson 72, with a
ProblemDetails body. Clients now get one format whether the error came from routing, validation, a rule,
or a bug.
Step 4: a fee payment endpoint
using Microsoft.AspNetCore.Mvc;
using SchoolApp.Core.Dtos;
using SchoolApp.Core.Entities;
using SchoolApp.Core.Interfaces;
using SchoolApp.Core.Mapping;
using SchoolApp.Core.Services;
namespace SchoolApp.Api.Controllers;
// Lesson 74: fee invoices and payments. Payments use FeePaymentService from Lesson 64; when a rule is
// broken, it throws SchoolRuleException, and SchoolRuleExceptionHandler chooses the status code.
[ApiController]
[Route("api/fee-invoices")]
[Produces("application/json")]
public class FeeInvoicesController : ControllerBase
{
private readonly IFeeInvoiceRepository _invoices;
private readonly FeePaymentService _payments;
public FeeInvoicesController(IFeeInvoiceRepository invoices, FeePaymentService payments)
{
_invoices = invoices;
_payments = payments;
}
/// <summary>Gets one invoice with what has been paid and what is still due.</summary>
/// <param name="invoiceNumber">The invoice number, such as INV-2026-000003.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="200">The invoice summary.</response>
/// <response code="404">No invoice has this number.</response>
[HttpGet("{invoiceNumber}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<FeeInvoiceSummaryDto>> Get(string invoiceNumber, CancellationToken cancellationToken)
{
FeeInvoice? invoice = await _invoices.GetWithPaymentsAsync(invoiceNumber, cancellationToken);
return invoice is null ? NotFound() : invoice.ToSummaryDto();
}
/// <summary>Records a payment against an invoice and returns the receipt.</summary>
/// <param name="invoiceNumber">The invoice to pay, such as INV-2026-000003.</param>
/// <param name="request">The amount and how it was paid.</param>
/// <param name="cancellationToken">Cancelled when the client gives up.</param>
/// <response code="201">The payment was recorded; Location gives the invoice.</response>
/// <response code="400">The request is malformed.</response>
/// <response code="404">No invoice has this number (error 50010).</response>
/// <response code="409">Someone else changed the invoice meanwhile (error 50014); load it again.</response>
/// <response code="422">A fee rule was broken, such as paying more than the balance (50011 to 50013).</response>
[HttpPost("{invoiceNumber}/payments")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<FeePaymentDto>> Pay(string invoiceNumber, RecordPaymentRequest request,
CancellationToken cancellationToken)
{
FeePayment payment = await _payments.PayAsync(invoiceNumber, request.Amount, request.PaymentMode, cancellationToken);
return CreatedAtAction(nameof(Get), new { invoiceNumber }, payment.ToDto(payment.FeeInvoice!));
}
}
The Pay action has no error handling at all. FeePaymentService from Lesson 64 applies the fee rules and
saves with the concurrency check. If it throws, the handler answers.
The XML comments list every status the endpoint can return, so the OpenAPI document shows them too.
The repository now also includes each invoice's student, so the summary shows a name instead of "(student not loaded)" from Lesson 45:
using Microsoft.EntityFrameworkCore;
using SchoolApp.Core.Entities;
using SchoolApp.Core.Exceptions;
using SchoolApp.Core.Interfaces;
namespace SchoolApp.Data.EfCore;
// Lesson 64: fee invoices with EF Core. The DbContext is the unit of work: SaveChangesAsync writes
// the new payment and the invoice's status in one transaction, checking the invoice's RowVersion.
public class EfFeeInvoiceRepository : IFeeInvoiceRepository
{
private readonly SchoolDbContext _db;
public EfFeeInvoiceRepository(SchoolDbContext db)
{
_db = db;
}
public Task<FeeInvoice?> GetWithPaymentsAsync(string invoiceNumber, CancellationToken cancellationToken = default)
{
// Lesson 74: the student too, so an invoice summary can show the student's name.
return _db.FeeInvoices
.Include(i => i.Payments)
.Include(i => i.Enrollment!).ThenInclude(e => e.Student)
.SingleOrDefaultAsync(i => i.InvoiceNumber == invoiceNumber, cancellationToken);
}
// The same format as usp_RecordFeePayment: RCP-<year>-NNNNNN. ToListAsync, not SingleAsync:
// NEXT VALUE FOR is not allowed inside the subquery that SingleAsync would wrap around it.
public async Task<string> NextReceiptNumberAsync(CancellationToken cancellationToken = default)
{
List<long> next = await _db.Database
.SqlQuery<long>($"SELECT NEXT VALUE FOR dbo.ReceiptNumbers AS Value")
.ToListAsync(cancellationToken);
return $"RCP-{DateTime.UtcNow.Year}-{next[0]:000000}";
}
// A concurrency conflict becomes a school rule error that callers understand.
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
try
{
await _db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException ex)
{
throw new SchoolRuleException(50014, "The invoice was changed by someone else. Load it again and retry.", ex);
}
}
}
Step 5: every outcome
Kabir's invoice, before and after a payment of 5,000:
GET /api/fee-invoices/INV-2026-000003
HTTP/1.1 200 OK
{"invoiceNumber":"INV-2026-000003","studentName":"Kabir Singh","total":45500.00,"paid":0,"balance":45500.00,"status":"Unpaid"}
POST /api/fee-invoices/INV-2026-000003/payments {"amount": 5000, "paymentMode": "Online"}
HTTP/1.1 201 Created
Location: http://localhost:5080/api/fee-invoices/INV-2026-000003
{"receiptNumber":"RCP-2026-000022","invoiceNumber":"INV-2026-000003","amount":5000,"paymentMode":"Online","paidOn":"2026-10-07T15:20:34.0077784Z","invoiceBalance":40500.00,"invoiceStatus":"PartiallyPaid"}
This payment is really saved; your receipt number will differ. Then the failures:
POST .../INV-2026-000003/payments {"amount": 50000, "paymentMode": "Cash"}
HTTP/1.1 422 Unprocessable Entity
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"School rule broken","status":422,"detail":"Amount is more than the balance due.","errorNumber":50013,"traceId":"00-343fb6e17ed136ae4b1e3db819be65e2-164e192b0308602b-00"}
POST .../INV-2026-000003/payments {"amount": 0, "paymentMode": "Cash"}
HTTP/1.1 422 Unprocessable Entity
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"School rule broken","status":422,"detail":"Amount must be more than zero.","errorNumber":50012,"traceId":"00-07828b6967565417b50fe3acb40bb177-8659d701eecb3607-00"}
POST /api/fee-invoices/INV-2026-999999/payments
HTTP/1.1 404 Not Found
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not found","status":404,"detail":"Invoice not found.","errorNumber":50010,"traceId":"00-c82764f95d61f605b3d37de80e133b8d-bb044afdcd07ce90-00"}
The error numbers are the same ones usp_RecordFeePayment throws in SQL (Lesson 22). A client can show a
message from detail, and decide what to do from status and errorNumber.
Example: the duplicate, fixed
Admitting Ishaan a second time, the request that gave 500 in Lesson 73:
HTTP/1.1 409 Conflict
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"detail":"Admission number ADM-2026-10001 is already in use.","errorNumber":50020,"traceId":"00-93ecc401cdd70ba428096930676f17ac-b9c9025513ff60ac-00"}
The same service, the same check: only the answer changed, because the exception now says what it means.
A concurrency conflict on a payment, error 50014 from Lesson 64, gets 409 in the same way.
Example: routing 404s and real failures
GET /api/classes/11/sections/A/students, the empty 404 from Lesson 72, now has a body:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-8be7fd65766a1ac980d8cfd7eb0acb04-2a6197d71a9d571d-00"}
And an unexpected exception, thrown on purpose by the Development-only GET /api/diagnostics/error:
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json; charset=utf-8
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-7165fd6fe1db1cf48f277a8bdc6fe502-4e65a043d0c261c3-00"}
The response says nothing about the cause. The full exception, "A test failure from the diagnostics endpoint", with its stack trace, went to the server's log instead. Stack traces in responses show attackers file paths, library versions, and SQL; logs are for the team only.
Example: which helper returns what
| In a controller | Status | Body |
|---|---|---|
return dto; or Ok(dto) | 200 | The DTO |
CreatedAtAction(...) | 201 | The new resource, plus Location |
NoContent() | 204 | None |
NotFound(), Conflict(), UnprocessableEntity() | 404, 409, 422 | ProblemDetails, through [ApiController] |
Problem(detail: ..., statusCode: ...) | Any | A ProblemDetails you describe |
throw new SchoolRuleException(...) | 404, 409, or 422 | ProblemDetails with errorNumber, through the handler |
Use the helpers for outcomes the controller decides itself, such as "not found". Let rule exceptions travel when the decision is made in Core.
Common mistakes
500for client mistakes. Clients retry requests that can never succeed. Fix: map rule exceptions to4xxcodes.try/catchin every action. Mappings drift apart. Fix: oneIExceptionHandler.- Exception details in responses. Attackers learn your internals. Fix: a general
500, details in logs. - A different error shape per endpoint. Every client writes many parsers. Fix: ProblemDetails
everywhere, with
AddProblemDetailsandUseStatusCodePages. UseExceptionHandlerlate in the pipeline. Exceptions from earlier steps escape it. Fix: put it first.
In real projects
A school's fee counter app retried every failed payment three times. That was sensible for network problems, and dangerous for everything else.
The API returned 500 for "amount above the balance" too. So the app retried a request that could never
succeed, three times, and the clerk waited a minute for a message that said "server error".
Mapping rule errors to 422 with the rule's message fixed both problems: no retries, and a clear reason
on screen. Status codes are instructions to the client, not just labels.
Best practices
- Decide, write down, and apply one mapping from errors to status codes.
- Use ProblemDetails for every error, with extensions such as
errorNumberfor machine-readable reasons. - Handle domain exceptions in one
IExceptionHandler; keep controllers free oftry/catch. - Put
UseExceptionHandler()first; addUseStatusCodePages()for empty error responses. - Never send exception details to clients; log them with the
traceId.
Recap
- Status codes are instructions:
2xxsucceeded,4xxthe client must change something,5xxthe server failed. 400malformed,409conflicts with current state,422breaks a business rule.- ProblemDetails gives every error one shape, extended with
errorNumber. SchoolRuleExceptionHandlermaps 50010 to 404, 50014 and 50020 to 409, and other rules to 422.500responses hide details; the log keeps them.- Module 10 is complete: SchoolApp.Api reads, creates, and pays through a documented, validated API.
Homework
Library module task: In a single-file library API, add a LibraryRuleException that carries a status
code, and one IExceptionHandler that turns it into ProblemDetails. Issue Malgudi Days (201), issue it
again (409), return it (204), and return it again (404).
A correct result looks like:
HTTP/1.1 201 Created
{"id":1,"isbn":"9780143039655","studentId":6}
HTTP/1.1 409 Conflict
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Library rule","status":409,"detail":"Every copy of this book is issued.","traceId":"00-5950d760b4d15d7a64b9c6458b20e795-51314ab2c84c6b97-00"}
HTTP/1.1 204 No Content
HTTP/1.1 404 Not Found
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Library rule","status":404,"detail":"No open issue has id 1.","traceId":"00-0a554867bbf603a42622e0493412fcb4-a02e2fb8b3ab675c-00"}
Show the solution
// Library homework, Lesson 74: every library error as a ProblemDetails with the right status code,
// from one exception handler, plus 201 for an issue and 204 for a return.
// Run with: dotnet run library-assignment/074-library-responses.cs
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<LibraryExceptionHandler>();
builder.Services.AddSingleton<LibraryDesk>();
WebApplication app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapControllers();
app.Run("http://localhost:5074");
// A library rule error knows which HTTP status describes it.
public class LibraryRuleException : Exception
{
public LibraryRuleException(int statusCode, string message) : base(message)
{
StatusCode = statusCode;
}
public int StatusCode { get; }
}
public class LibraryExceptionHandler : IExceptionHandler
{
private readonly IProblemDetailsService _problemDetails;
public LibraryExceptionHandler(IProblemDetailsService problemDetails)
{
_problemDetails = problemDetails;
}
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (exception is not LibraryRuleException rule)
{
return false;
}
httpContext.Response.StatusCode = rule.StatusCode;
return await _problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
ProblemDetails = new ProblemDetails { Status = rule.StatusCode, Title = "Library rule", Detail = rule.Message }
});
}
}
public record IssueRequest(string Isbn, int StudentId);
public record BookIssue(int Id, string Isbn, int StudentId);
// In memory, with one copy of Malgudi Days. The desk throws; it never knows about HTTP.
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<BookIssue> Issue(IssueRequest request)
{
BookIssue issue = _desk.Issue(request);
return Created($"/api/book-issues/{issue.Id}", issue); // 201
}
[HttpDelete("{id:int}")]
public IActionResult Return(int id)
{
_desk.Return(id);
return NoContent(); // 204
}
}
Send the issue twice with curl.exe -i --json "@library-assignment/074-issue-request.json" http://localhost:5074/api/book-issues, then curl.exe -i -X DELETE http://localhost:5074/api/book-issues/1
twice. LibraryDesk never mentions HTTP: it throws, and the handler decides how to answer.
Quiz
A payment of 50,000 on a 45,500 balance. Which status does SchoolApp.Api return?
Show answer
Answer: C. 422 Unprocessable Entity The request is well-formed but breaks fee rule 50013.
A second admission with the same admission number gets which status?
Show answer
Answer: B. 409 Conflict It clashes with a student who already exists: error 50020.
What does TryHandleAsync returning false mean?
Show answer
Answer: B. This handler does not handle that exception; the next one or the default may Unknown exceptions reach the default handler and become 500.
Why does the 500 response not include the exception message?
Show answer
Answer: B. Details help attackers; they go to the server log instead The log kept the full exception and stack trace.
What did UseStatusCodePages change?
Show answer
Answer: B. Empty error responses, such as routing 404s, now get a ProblemDetails body /api/classes/11/... now answers with a ProblemDetails.
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 ProblemDetails? concept
A standard JSON format for HTTP error responses (RFC 9457), with type, title, status, detail,
and instance, plus your own extension fields such as errorNumber. ASP.NET Core can produce it for
every error, so clients parse one shape everywhere.
F Which status code should a successful POST that creates a resource return? concept
201 Created, with a Location header giving the new resource's address and, usually, the resource in
the body. In a controller, CreatedAtAction builds both from another action's route.
F Why should an API not return exception details in a 500 response? concept
Stack traces and messages reveal file paths, library versions, SQL, and internal names that help
attackers, and they mean nothing to users. Return a general ProblemDetails with a traceId, and write
the full exception to the server log.
Mid (3-5 yrs)
M When would you return 400, 409, and 422? concept
400 when the request is malformed or fails field validation. 409 when a valid request conflicts with the current state, such as a duplicate admission number or a record changed by someone else. 422 when a well-formed request breaks a business rule, such as paying more than the balance.
M How do you handle exceptions globally in ASP.NET Core 8 and later? coding
Register AddProblemDetails() and one or more IExceptionHandler classes with AddExceptionHandler<T>(),
and put app.UseExceptionHandler() first in the pipeline. Each handler's TryHandleAsync returns true
for exceptions it answers; anything unhandled becomes a 500 ProblemDetails.
M What does UseStatusCodePages add when ProblemDetails is registered? concept
It fills empty error responses, such as a 404 from a failed route constraint, with a ProblemDetails body. Without it, those responses have no body, so clients see two different error shapes.
Senior (6-9 yrs)
S A client retries every failed payment three times. Some failures can never succeed. What do you change in the API? scenario
Return 4xx codes for client-side and rule failures, such as 422 for amount above balance and 409 for
conflicts, so the client knows retrying is pointless, and 5xx only for real server failures, which may
be retried. Include a machine-readable errorNumber, and make payments idempotent so a retried request
cannot charge twice.
How to answer: Explain status codes as instructions for client behaviour, give the mapping, and mention idempotency keys for safe retries of money operations.
S Should controllers catch domain exceptions with try/catch? What is the alternative? design
Usually not: repeated try/catch blocks drift apart and hide the action's real work. Let domain exceptions
carry a meaning, such as an error number, and translate them in one place: an IExceptionHandler that
maps them to status codes and ProblemDetails. Use controller helpers only for outcomes the controller
decides itself, such as not found.
How to answer: Contrast the two approaches with an example, and mention testing the mapping once and keeping the domain free of HTTP concepts.
Architect / Lead (10+ yrs)
A How would you design an API's error contract for many client teams over several years? design
One format (ProblemDetails) for every error; a documented catalogue of stable error codes, such as the SMS's 500xx numbers, each with its HTTP status; codes and status listed in the OpenAPI document; human messages that may change and machine codes that never do; a traceId for support; and versioning rules for adding codes without breaking clients.
How to answer: Separate what clients may depend on (status, code) from what may change (text), show where the catalogue lives, and how it is tested and documented.
Resources
- Code for this lesson: git tag
lesson-74-responses-status-codes(code repository coming soon) - Files:
src/SchoolApp.Api/ErrorHandling/SchoolRuleExceptionHandler.cs,src/SchoolApp.Api/Controllers/FeeInvoicesController.cs,src/SchoolApp.Api/Program.cs,src/SchoolApp.Core/Services/StudentService.cs,library-assignment/074-library-responses.cs - Video: coming soon
What's next
Module 10 is complete: SchoolApp.Api has a pipeline, dependency injection, configuration, documentation, routing, validation, and correct responses.
Next, Module 11 builds every SMS feature as an endpoint, starting with full student management. Lesson 75: Student management
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.