Error API yang konsisten di ASP.NET Core dengan ProblemDetails
Satu bentuk error untuk semua kegagalan: validasi, data tidak ditemukan, dan exception yang tidak tertangani, memakai format RFC 9457 bawaan ASP.NET Core.
3 menit baca
Di halaman ini
Masalah error yang dibuat seadanya
Banyak API berawal dengan bentuk error yang berbeda-beda tergantung siapa yang menulis endpoint-nya.
Ada yang mengembalikan { "message": "Not found" }, ada yang berupa string biasa, dan exception
yang tidak tertangani malah mengembalikan halaman error HTML. Akibatnya setiap client (admin panel
Angular, aplikasi mobile, atau service lain) butuh penanganan khusus hanya untuk menampilkan pesan
yang masuk akal.
ASP.NET Core sebenarnya sudah punya jawaban standar: ProblemDetails, format
application/problem+json dari RFC 9457. Tujuan artikel ini sederhana: semua respons non-2xx dari
API punya bentuk yang sama.
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "Policy 'P-001' was not found.",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}Mengaktifkannya
Cukup dua baris di Program.cs untuk mendapatkan ProblemDetails pada respons status code dan
exception:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(options =>
{
// Add the trace id to every problem, so support can find the request in the logs.
options.CustomizeProblemDetails = context =>
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
});
builder.Services.AddControllers();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapControllers();
app.Run();UseExceptionHandler()menangkap exception yang tidak tertangani dan menulis ProblemDetails 500, alih-alih membocorkan stack trace.UseStatusCodePages()membuat respons kosong seperti404polos juga punya body ProblemDetails.- Controller dengan atribut
[ApiController]sudah otomatis mengembalikanValidationProblemDetails(400) ketika model binding atau data annotation gagal.
Memetakan exception milik sendiri
Aturan bisnis biasanya ada di service, bukan di controller. Daripada menulis try/catch di setiap
action, lempar exception yang bermakna lalu terjemahkan di satu tempat dengan IExceptionHandler
(.NET 8+):
public sealed class NotFoundException(string message) : Exception(message);
public sealed class ConflictException(string message) : Exception(message);
internal sealed class DomainExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
var status = exception switch
{
NotFoundException => StatusCodes.Status404NotFound,
ConflictException => StatusCodes.Status409Conflict,
_ => 0,
};
// Not ours: let the default handler produce a generic 500.
if (status == 0) return false;
httpContext.Response.StatusCode = status;
return await problemDetails.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails = { Status = status, Detail = exception.Message },
});
}
}Daftarkan sebelum aplikasi di-build:
builder.Services.AddExceptionHandler<DomainExceptionHandler>();Melewati IProblemDetailsService itu penting: callback CustomizeProblemDetails yang sama ikut
dijalankan, sehingga traceId juga muncul di sini.
Memakainya dari controller
Controller tetap fokus pada alur normal:
[ApiController]
[Route("api/policies")]
public sealed class PoliciesController(IPolicyService policies) : ControllerBase
{
[HttpGet("{number}")]
public async Task<ActionResult<PolicyDto>> Get(string number, CancellationToken ct)
{
var policy = await policies.FindAsync(number, ct)
?? throw new NotFoundException($"Policy '{number}' was not found.");
return Ok(policy);
}
}Untuk hasil yang memang sudah diperkirakan, Anda juga bisa langsung mengembalikan Problem(...) atau
NotFound(). Exception lebih praktis ketika aturannya berada jauh di dalam service dan controller
tidak perlu tahu soal itu.
Yang tidak boleh masuk ke detail
detail ditampilkan ke client, jadi perlakukan sebagai teks publik:
- Jangan ada stack trace, SQL, connection string, atau nama host internal.
- Jangan ada data pribadi yang tidak dikirim sendiri oleh pemanggil.
- Untuk error 500, biarkan pesan generik bawaan.
traceIdsudah cukup untuk menemukan error sebenarnya di log.
Menanganinya di client
Karena bentuknya selalu sama, client cukup punya satu pemeta error. Di Angular, misalnya, sebuah HTTP
interceptor bisa membaca title, detail, dan dictionary errors dari problem validasi, lalu
mengubahnya menjadi toast atau error di form. Tidak ada lagi endpoint yang butuh penanganan khusus.
Ringkasan
AddProblemDetails+UseExceptionHandler+UseStatusCodePagessudah mencakup sebagian besar kasus.IExceptionHandlermemetakan exception domain ke status code di satu tempat.- Tambahkan
traceIdsupaya setiap error yang dilaporkan pengguna bisa dilacak. - Pastikan
detailaman dibaca siapa saja.