ワンクリックで
error-handling
Result pattern, ProblemDetails (RFC 7807), global exception boundaries, and typed error records for .NET APIs.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
Result pattern, ProblemDetails (RFC 7807), global exception boundaries, and typed error records for .NET APIs.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
JWT bearer auth, ASP.NET Identity, OIDC, and policy-based authorization patterns for ASP.NET Core APIs.
HybridCache (.NET 9+), output caching, cache-aside pattern, and IMemoryCache — registration, usage, invalidation, and key strategy.
Protocol for detecting and replicating existing project conventions before generating new code — naming, folder structure, DI registration, test framework, DTOs, and error handling.
Domain-Driven Design patterns for .NET — aggregates, value objects, strongly-typed IDs, domain events, repositories, and layer rules.
Serilog structured logging for ASP.NET Core — setup, message templates, LogContext enrichment, request logging middleware, and log level guidelines.
C# 12–14 language features with practical patterns — primary constructors, collection expressions, records, pattern matching, nullable reference types, and more.
| name | error-handling |
| description | Result pattern, ProblemDetails (RFC 7807), global exception boundaries, and typed error records for .NET APIs. |
Reference for structured error handling in .NET APIs. Used by dnp-planner, dnp-api-scaffolder, and dnp-tdd-developer-hard.
| Scenario | Approach |
|---|---|
| Domain rule violation (not found, invalid state, business constraint) | Result<TValue, TError> — expected failure path |
| Infrastructure failure (DB timeout, network error, config missing) | Exception — unexpected, unrecoverable at call site |
| Validation failure (bad input from HTTP layer) | ValidationProblemDetails via model binding / FluentValidation |
| Unhandled exception escaping to HTTP | GlobalExceptionHandler → 500 ProblemDetails |
Never throw exceptions for expected domain outcomes. Never swallow exceptions at call sites.
Define in a shared location (e.g., Common/Result.cs):
namespace MyApp.Common;
public readonly record struct Result<TValue, TError>
{
private readonly TValue? _value;
private readonly TError? _error;
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
private Result(TValue value) { _value = value; IsSuccess = true; }
private Result(TError error) { _error = error; IsSuccess = false; }
public static Result<TValue, TError> Success(TValue value) => new(value);
public static Result<TValue, TError> Failure(TError error) => new(error);
public TResult Match<TResult>(
Func<TValue, TResult> onSuccess,
Func<TError, TResult> onFailure)
=> IsSuccess ? onSuccess(_value!) : onFailure(_error!);
public void Match(Action<TValue> onSuccess, Action<TError> onFailure)
{
if (IsSuccess) onSuccess(_value!);
else onFailure(_error!);
}
}
Define errors as discriminated records in the domain layer:
namespace MyApp.Domain.Errors;
public abstract record DomainError(string Message);
public record NotFoundError(string Resource, object Id)
: DomainError($"{Resource} with id '{Id}' was not found.");
public record ConflictError(string Resource, string Reason)
: DomainError($"{Resource} conflict: {Reason}");
public record ValidationError(string Field, string Reason)
: DomainError($"Validation failed for '{Field}': {Reason}");
public record ForbiddenError(string Action, string Resource)
: DomainError($"Not permitted to {Action} {Resource}.");
public class OrderService(IOrderRepository repo, ILogger<OrderService> logger)
{
public async Task<Result<Order, DomainError>> GetByIdAsync(int id, CancellationToken ct)
{
var order = await repo.GetByIdAsync(id, ct);
if (order is null)
return Result<Order, DomainError>.Failure(new NotFoundError("Order", id));
return Result<Order, DomainError>.Success(order);
}
public async Task<Result<Order, DomainError>> CancelAsync(int id, CancellationToken ct)
{
var order = await repo.GetByIdAsync(id, ct);
if (order is null)
return Result<Order, DomainError>.Failure(new NotFoundError("Order", id));
if (order.Status == "Shipped")
return Result<Order, DomainError>.Failure(
new ConflictError("Order", "cannot cancel a shipped order"));
order.Cancel();
await repo.SaveChangesAsync(ct);
return Result<Order, DomainError>.Success(order);
}
}
app.MapGet("/orders/{id}", async (int id, OrderService svc, CancellationToken ct) =>
{
var result = await svc.GetByIdAsync(id, ct);
return result.Match(
order => TypedResults.Ok(order),
error => MapError(error));
});
app.MapDelete("/orders/{id}/cancel", async (int id, OrderService svc, CancellationToken ct) =>
{
var result = await svc.CancelAsync(id, ct);
return result.Match(
order => TypedResults.Ok(order),
error => MapError(error));
});
static IResult MapError(DomainError error) => error switch
{
NotFoundError e => TypedResults.NotFound(new { e.Message }),
ConflictError e => TypedResults.Conflict(new { e.Message }),
ForbiddenError e => TypedResults.Forbid(),
ValidationError e => TypedResults.ValidationProblem(
new Dictionary<string, string[]> { [e.Field] = [e.Reason] }),
_ => TypedResults.Problem(error.Message, statusCode: 500)
};
[ApiController]
[Route("api/[controller]")]
public class OrdersController(OrderService svc) : ControllerBase
{
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id, CancellationToken ct)
{
var result = await svc.GetByIdAsync(id, ct);
return result.Match<IActionResult>(
order => Ok(order),
error => error switch
{
NotFoundError => NotFound(new { error.Message }),
ForbiddenError => Forbid(),
_ => Problem(error.Message)
});
}
}
ASP.NET Core returns ProblemDetails for 4xx/5xx by default when AddProblemDetails() is configured.
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Instance =
$"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}";
ctx.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
};
});
Standard fields:
| Field | Description | Example |
|---|---|---|
status | HTTP status code | 404 |
title | Short human-readable summary | "Not Found" |
type | URI identifying the problem type | "https://tools.ietf.org/html/rfc7231#section-6.5.4" |
detail | Specific explanation for this occurrence | "Order 42 was not found." |
instance | URI of the specific request | "GET /orders/42" |
Catches unhandled exceptions at the HTTP boundary. Infrastructure failures (DB, external services) bubble up here.
public class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext ctx,
Exception exception,
CancellationToken ct)
{
logger.LogError(exception,
"Unhandled exception on {Method} {Path}",
ctx.Request.Method,
ctx.Request.Path);
var problem = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred.",
Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
Instance = $"{ctx.Request.Method} {ctx.Request.Path}"
};
ctx.Response.StatusCode = problem.Status.Value;
await ctx.Response.WriteAsJsonAsync(problem, ct);
return true;
}
}
Registration:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
// In pipeline (before routing):
app.UseExceptionHandler();
Only catch exceptions at:
GlobalExceptionHandler (unhandled infrastructure failures)IHostedService.ExecuteAsync, Hangfire job methods// Background job boundary
public class OrderSyncJob(OrderSyncService svc, ILogger<OrderSyncJob> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await svc.SyncPendingOrdersAsync(ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Order sync failed — retrying in 60s");
await Task.Delay(TimeSpan.FromSeconds(60), ct);
}
}
}
}
| Do | Don't |
|---|---|
Return Result<TValue, TError> for domain failures | Throw DomainException for expected failure paths |
| Define typed error records per domain concept | Use stringly-typed error messages |
| Catch exceptions only at HTTP/job boundaries | Catch Exception at the service or repository layer |
| Log at the boundary with full exception | Log the same exception multiple times as it propagates |
Set ClockSkew = TimeSpan.Zero on JWT validation | Let expired tokens succeed within the default 5-minute window |
Use TypedResults (compile-time checked) over Results | Mix IActionResult and IResult in the same endpoint |
Include traceId in ProblemDetails extensions | Expose stack traces or internal exception messages to clients |
skills/modern-csharp/SKILL.md — record types and pattern matching used in error typesskills/aspnet-api-patterns/SKILL.md — endpoint conventions that consume these patterns