一键导入
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