| name | dck-error-handling |
| description | Use when mapping AHKFlowApp errors, Ardalis.Result outcomes, validation failures, exceptions, or ProblemDetails. |
Error Handling
Core Principles
- Ardalis.Result for expected failures — Handlers return
Result<T>. Never throw exceptions for not-found, validation, or conflict. These are expected outcomes.
- FluentValidation in ValidatingUseCase —
ValidatingUseCase<TRequest, TResult> runs before every handler. Handlers never receive invalid requests.
- Controllers map via
result.ToActionResult(this) — From Ardalis.Result.AspNetCore. No manual if/else status code mapping.
- Every API error returns ProblemDetails — RFC 9457.
GlobalExceptionMiddleware handles unhandled exceptions.
- Reserve exceptions for unexpected failures — Infrastructure errors, null reference bugs, network timeouts. These propagate to the global handler.
Patterns
Ardalis.Result in Handlers
Use typed factory methods: Result.Success(value), Result.NotFound(), Result.Invalid(errors), Result.Conflict(), Result.Error().
internal sealed class GetHotstringHandler(AppDbContext db)
: IUseCaseHandler<GetHotstringQuery, Result<HotstringDto>>
{
public async Task<Result<HotstringDto>> ExecuteAsync(
GetHotstringQuery request, CancellationToken ct)
{
var entity = await db.Hotstrings.FindAsync([request.Id], ct);
return entity is not null
? Result.Success(new HotstringDto(entity.Id, entity.Trigger, entity.Replacement))
: Result.NotFound();
}
}
internal sealed class CreateHotstringHandler(AppDbContext db)
: IUseCaseHandler<CreateHotstringCommand, Result<HotstringDto>>
{
public async Task<Result<HotstringDto>> ExecuteAsync(
CreateHotstringCommand request, CancellationToken ct)
{
var exists = await db.Hotstrings.AnyAsync(h => h.Trigger == request.Trigger, ct);
if (exists) return Result.Conflict();
var hotstring = new Hotstring { Trigger = request.Trigger, Replacement = request.Replacement };
db.Hotstrings.Add(hotstring);
await db.SaveChangesAsync(ct);
return Result.Success(new HotstringDto(hotstring.Id, hotstring.Trigger, hotstring.Replacement));
}
}
Controller Mapping with ToActionResult
[ApiController]
[Route("api/v1/[controller]")]
public sealed class HotstringsController(
IUseCase<GetHotstringQuery, Result<HotstringDto>> getHotstring)
: ControllerBase
{
[HttpGet("{id:int}")]
[ProducesResponseType<HotstringDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken ct)
{
var result = await getHotstring.ExecuteAsync(new GetHotstringQuery(id), ct);
return result.ToActionResult(this);
}
}
ToActionResult(this) maps:
Result.Success(value) → 200 OK (or 201 Created for POST)
Result.NotFound() → 404
Result.Invalid(errors) → 400 with validation errors
Result.Conflict() → 409
Result.Error() → 500
ValidatingUseCase Pipeline
internal sealed class ValidatingUseCase<TRequest, TResult>(
IEnumerable<IValidator<TRequest>> validators,
IUseCaseHandler<TRequest, TResult> inner)
: IUseCase<TRequest, TResult>
where TRequest : notnull
{
public async Task<TResult> ExecuteAsync(
TRequest request,
CancellationToken ct)
{
if (!validators.Any()) return await inner.ExecuteAsync(request, ct);
var context = new ValidationContext<TRequest>(request);
var failures = (await Task.WhenAll(
validators.Select(v => v.ValidateAsync(context, ct))))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await inner.ExecuteAsync(request, ct);
}
}
Register in DI:
services.AddScoped(typeof(IUseCase<,>), typeof(ValidatingUseCase<,>));
services.AddUseCase<CreateHotstringCommand, Result<HotstringDto>, CreateHotstringCommandHandler>();
services.AddValidatorsFromAssembly(typeof(CreateHotstringValidator).Assembly);
FluentValidation Validator
public sealed class CreateHotstringValidator : AbstractValidator<CreateHotstringCommand>
{
public CreateHotstringValidator()
{
RuleFor(x => x.Trigger)
.NotEmpty().WithMessage("Trigger is required")
.MaximumLength(50).WithMessage("Trigger must be 50 characters or less");
RuleFor(x => x.Replacement)
.NotEmpty().WithMessage("Replacement is required")
.MaximumLength(500).WithMessage("Replacement must be 500 characters or less");
}
}
GlobalExceptionMiddleware
Catches unexpected exceptions and returns RFC 9457 ProblemDetails.
public sealed class GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception ex)
{
logger.LogError(ex, "Unhandled exception for {Method} {Path}",
context.Request.Method, context.Request.Path);
var problem = new ProblemDetails
{
Title = "An unexpected error occurred",
Status = StatusCodes.Status500InternalServerError,
Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1"
};
context.Response.StatusCode = problem.Status!.Value;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(problem);
}
}
}
app.UseMiddleware<GlobalExceptionMiddleware>();
Anti-patterns
Don't Define a Custom Result Class
public class Result<T>
{
public bool IsSuccess { get; }
public T Value { get; }
public List<string> Errors { get; }
}
Don't Use ValidationFilter Endpoint Filter
group.MapPost("/", CreateHotstring)
.AddEndpointFilter<ValidationFilter<CreateHotstringCommand>>();
Don't Manually Map Result to HTTP
var result = await createHotstring.ExecuteAsync(command, ct);
if (result.IsSuccess) return Ok(result.Value);
if (result.Status == ResultStatus.NotFound) return NotFound();
return BadRequest();
return result.ToActionResult(this);
Don't Throw Exceptions for Flow Control
var hotstring = await db.Hotstrings.FindAsync(id)
?? throw new NotFoundException($"Hotstring {id} not found");
var entity = await db.Hotstrings.FindAsync([id], ct);
return entity is not null ? Result.Success(...) : Result.NotFound();
Don't Return Raw Error Strings from APIs
return BadRequest("Something went wrong");
return BadRequest(new { error = "Invalid input" });
Don't Catch and Swallow Exceptions
try { await ProcessAsync(ct); }
catch (Exception) { }
try { await ProcessAsync(ct); }
catch (ExternalApiException ex)
{
logger.LogWarning(ex, "External API failed");
return Result.Error("External service unavailable");
}
Decision Guide
| Scenario | Approach |
|---|
| Entity not found | Result.NotFound() |
| Input invalid | Result.Invalid(errors) from handlers, or ValidationException through ValidatingUseCase |
| Duplicate / conflict | Result.Conflict() |
| External service failure | Catch specific exception, return Result.Error() |
| Unhandled crash | GlobalExceptionMiddleware → ProblemDetails 500 |
| Controller HTTP mapping | result.ToActionResult(this) — always |
| Validation runs where | ValidatingUseCase<TRequest, TResult> — before handler, never in handler |