If you run an API long enough, you’ll notice the same support tickets repeating:
- “It failed, can you check logs?”
- “We’re getting 400 but don’t know what field is wrong.”
- “It worked yesterday, now it’s broken.”
- “We retried and created duplicates.”
- “We can’t reproduce it.”
These aren’t just “support problems” — they’re API usability problems. The fix is to design your API so it is self-explanatory, diagnosable, and safe to integrate.
Below are the highest-leverage usability patterns and a practical ASP.NET Core (Controllers) code sample you can drop into an enterprise API.
What “usability” means for an enterprise API
In enterprise systems, usability isn’t just UX — it’s integration UX. Your API is usable when:
- errors are consistent and actionable
- every request is traceable across services
- retry behavior is safe
- consumers can self-diagnose without emailing your team
The result: fewer support tickets and faster incident resolution.
The patterns (what you should implement)
Goal: make every request uniquely trackable across APIM → ingress → API → DB.
- Client can send: x-correlation-id: <string>
- If missing, the API generates one
- API echoes it back on every response
- API logs include it automatically
Benefit: when an exception happens, the client can paste the correlation ID into a ticket and you can find it in seconds.
2) Standard error format using Problem Details (RFC 7807)
Goal: every error looks the same to clients.
Your API should return:
- application/problem+json
- status, title, detail
- stable errorCode
- traceId and correlationId
Benefit: fewer “what does this mean?” tickets; easier client-side handling.
3) Global exception handling (don’t litter controllers with try/catch)
Goal: one place to:
- log exceptions once
- map exceptions to status codes
- return ProblemDetails consistently
Benefit: controllers stay clean; production errors become easy to search and triage.
4) Friendly validation errors (field-level)
Goal: return what field is wrong and why (no guesswork).
Benefit: eliminates back-and-forth with integrators.
A practical .NET implementation (Controllers)
This sample implements the two most ticket-killing patterns:
- Correlation ID everywhere
- Consistent ProblemDetails with traceId + correlationId + errorCode
Step 1 — Add a small middleware for x-correlation-id
using System.Diagnostics;
public sealed class CorrelationIdMiddleware : IMiddleware
{
public const string HeaderName = "x-correlation-id";
private readonly ILogger<CorrelationIdMiddleware> _logger;
public CorrelationIdMiddleware(ILogger<CorrelationIdMiddleware> logger)
=> _logger = logger;
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var incoming = context.Request.Headers.TryGetValue(HeaderName, out var v) ? v.ToString() : null;
var correlationId = !string.IsNullOrWhiteSpace(incoming)
? incoming!
: Guid.NewGuid().ToString("N");
context.Items[HeaderName] = correlationId;
// Ensure header is present even if downstream throws
context.Response.OnStarting(() =>
{
context.Response.Headers[HeaderName] = correlationId;
return Task.CompletedTask;
});
// Attach to distributed tracing (OpenTelemetry / App Insights)
Activity.Current?.SetTag("correlationId", correlationId);
// Attach to logs automatically
using (_logger.BeginScope(new Dictionary<string, object>
{
["correlationId"] = correlationId
}))
{
await next(context);
}
}
}Step 2 — Standardize errors using ProblemDetails
Create a tiny helper to build consistent error responses:
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
public static class ApiProblems
{
public static ProblemDetails Create(
HttpContext http,
int status,
string title,
string detail,
string errorCode)
{
var traceId = Activity.Current?.TraceId.ToString() ?? http.TraceIdentifier;
var correlationId = http.Items[CorrelationIdMiddleware.HeaderName]?.ToString();
var pd = new ProblemDetails
{
Status = status,
Title = title,
Detail = detail,
Type = $"https://httpstatuses.com/{status}"
};
pd.Extensions["errorCode"] = errorCode;
pd.Extensions["traceId"] = traceId;
if (!string.IsNullOrWhiteSpace(correlationId))
pd.Extensions["correlationId"] = correlationId;
return pd;
}
}Step 3 — Wire it up in Program.cs
using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Middleware
builder.Services.AddSingleton<CorrelationIdMiddleware>();
// ProblemDetails (automatically adds traceId/correlationId on results that use ProblemDetails)
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] =
Activity.Current?.TraceId.ToString() ?? ctx.HttpContext.TraceIdentifier;
var correlationId = ctx.HttpContext.Items[CorrelationIdMiddleware.HeaderName]?.ToString();
if (!string.IsNullOrWhiteSpace(correlationId))
ctx.ProblemDetails.Extensions["correlationId"] = correlationId;
};
});
var app = builder.Build();
// Correlation ID must be early so it’s present during exception handling
app.UseMiddleware<CorrelationIdMiddleware>();
// Global exception handling: log + ProblemDetails response
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var ex = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("GlobalExceptionHandler");
var traceId = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier;
var correlationId = context.Items[CorrelationIdMiddleware.HeaderName]?.ToString();
logger.LogError(ex,
"Unhandled exception. traceId={TraceId} correlationId={CorrelationId} method={Method} path={Path}",
traceId, correlationId, context.Request.Method, context.Request.Path);
var problem = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "Unexpected error",
Detail = "Contact support with the traceId/correlationId.",
Type = "https://httpstatuses.com/500"
};
problem.Extensions["errorCode"] = "UNHANDLED_ERROR";
problem.Extensions["traceId"] = traceId;
if (!string.IsNullOrWhiteSpace(correlationId))
problem.Extensions["correlationId"] = correlationId;
context.Response.StatusCode = problem.Status.Value;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problem);
});
});
app.MapControllers();
app.Run();Step 4 — Use it in a controller (example)
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("v1/orders")]
public class OrdersController : ControllerBase
{
private readonly IOrdersRepository _repo;
public OrdersController(IOrdersRepository repo) => _repo = repo;
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
{
if (id == Guid.Empty)
{
var pd = ApiProblems.Create(
HttpContext,
status: StatusCodes.Status400BadRequest,
title: "Invalid order id",
detail: "Order id must be a non-empty GUID.",
errorCode: "INVALID_ORDER_ID");
return BadRequest(pd);
}
var order = await _repo.Get(id, ct); // if this throws, global handler catches it
if (order is null)
{
var pd = ApiProblems.Create(
HttpContext,
status: StatusCodes.Status404NotFound,
title: "Order not found",
detail: "No order exists for the provided id.",
errorCode: "ORDER_NOT_FOUND");
return NotFound(pd);
}
return Ok(order);
}
}
public interface IOrdersRepository
{
Task<object?> Get(Guid id, CancellationToken ct);
}What the client sees (why this reduces tickets)
A client error response becomes immediately actionable:
{
"type": "https://httpstatuses.com/404",
"title": "Order not found",
"status": 404,
"detail": "No order exists for the provided id.",
"errorCode": "ORDER_NOT_FOUND",
"traceId": "2c5f9d8b7f0a0d9e3f2c7f0b0a1b2c3d",
"correlationId": "9f5c2e2d7a5a4e0f8f4e4b7c8e2d1a9b"
}Support win: the customer can paste correlationId/traceId into a ticket, and you can find the exact request in Application Insights immediately.
Developer win: the errorCode is stable and can be handled in code (retry, fix payload, show UI message).
Operational benefits (what you’ll notice in production)
- Faster incident resolution: “find the failing request” becomes trivial.
- Lower ticket volume: better validation and predictable errors reduce confusion.
- Better client behavior: consistent status codes and error codes encourage correct retries and handling.
- Higher trust: external consumers perceive the API as stable and professional.
Hope you enjoyed the article. Happy Programming.
No comments:
Post a Comment