Skip to content

Error Handling

Complete guide to error handling and HTTP status mapping in Pragmatic.Endpoints.

Pragmatic.Endpoints uses the Result pattern from Pragmatic.Result for type-safe error handling:

[Endpoint(HttpVerb.Get, "/users/{id}")]
public partial class GetUserEndpoint : Endpoint<UserDto>
{
private IUserRepository _users = null!;
[FromRoute]
public Guid Id { get; set; }
public override async Task<Result<UserDto>> HandleAsync(CancellationToken ct)
{
var user = await _users.GetByIdAsync(Id, ct);
if (user is null)
return new NotFoundError("User", Id); // Error case
return new UserDto(user); // Success case
}
}

Pragmatic.Result.Http provides standard error types with automatic HTTP status mapping:

Error TypeHTTP StatusUse Case
NotFoundError404Resource not found
ValidationError422Invalid input
BadRequestError400Malformed request
UnauthorizedError401Authentication required
ForbiddenError403Insufficient permissions
ConflictError409Duplicate/concurrency conflict
InternalServerError500Unexpected failure
ServiceUnavailableError503External service down
[Endpoint(HttpVerb.Post, "/orders")]
public partial class CreateOrderEndpoint : Endpoint<OrderDto>
{
public override async Task<Result<OrderDto>> HandleAsync(CancellationToken ct)
{
// Validation error
if (Items.Count == 0)
return new ValidationError("Items", "At least one item required");
// Not found
var customer = await _customers.GetAsync(CustomerId, ct);
if (customer is null)
return NotFoundError.Create("Customer", CustomerId);
// Conflict (duplicate)
if (await _orders.ExistsAsync(IdempotencyKey, ct))
return new ConflictError("Order with this idempotency key exists");
// Success
var order = new Order(...);
return new OrderDto(order);
}
}

DomainAction endpoints can declare multiple error types:

[Endpoint(HttpVerb.Put, "/users/{id}")]
public partial class UpdateUser : DomainAction<UserDto, NotFoundError, ValidationError, ForbiddenError>
{
public override async Task<Result<UserDto, IError>> Execute(CancellationToken ct)
{
var user = await Users.GetByIdAsync(Id, ct);
if (user is null)
return new NotFoundError("User", Id); // → 404
if (!CurrentUser.CanEdit(user))
return new ForbiddenError("Cannot edit this user"); // → 403
var validation = user.Validate(Name, Email);
if (validation.HasErrors)
return new ValidationError(validation.Errors); // → 422
return new UserDto(user); // → 200
}
}

The generator produces OpenAPI documentation for all error types:

responses:
200:
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/UserDto'
404:
description: Not Found
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'
403:
description: Forbidden
422:
description: Validation Failed

Create domain-specific errors:

public sealed class InsufficientStockError : IError
{
public string Code => "INSUFFICIENT_STOCK";
public string Message { get; }
public int StatusCode => 422;
public Guid ProductId { get; }
public int Requested { get; }
public int Available { get; }
public InsufficientStockError(Guid productId, int requested, int available)
{
ProductId = productId;
Requested = requested;
Available = available;
Message = $"Insufficient stock for product {productId}: requested {requested}, available {available}";
}
}
[Endpoint(HttpVerb.Post, "/orders")]
public partial class CreateOrder : DomainAction<OrderId, InsufficientStockError, ValidationError>
{
public override async Task<Result<OrderId, IError>> Execute(CancellationToken ct)
{
foreach (var item in Items)
{
var stock = await Inventory.GetStockAsync(item.ProductId, ct);
if (stock < item.Quantity)
return new InsufficientStockError(item.ProductId, item.Quantity, stock);
}
// ...
}
}

Errors are automatically converted to RFC 7807 ProblemDetails:

{
"type": "https://httpstatuses.io/404",
"title": "Not Found",
"status": 404,
"detail": "User with ID '3fa85f64-5717-4562-b3fc-2c963f66afa6' was not found.",
"instance": "/users/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"extensions": {
"code": "ENTITY_NOT_FOUND",
"resourceType": "User",
"resourceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}
// Implement IHttpError for custom ProblemDetails
public sealed class RateLimitExceededError : IError, IHttpError
{
public string Code => "RATE_LIMIT_EXCEEDED";
public string Message => "Too many requests";
public int StatusCode => 429;
public int RetryAfterSeconds { get; }
public RateLimitExceededError(int retryAfterSeconds)
{
RetryAfterSeconds = retryAfterSeconds;
}
// Custom headers
public void ConfigureResponse(HttpResponse response)
{
response.Headers.RetryAfter = RetryAfterSeconds.ToString();
}
// Custom problem details
public ProblemDetails ToProblemDetails()
{
return new ProblemDetails
{
Type = "https://api.example.com/errors/rate-limit",
Title = "Rate Limit Exceeded",
Status = 429,
Detail = $"Please retry after {RetryAfterSeconds} seconds",
Extensions = { ["retryAfter"] = RetryAfterSeconds }
};
}
}

For operations without a response body:

[Endpoint(HttpVerb.Delete, "/users/{id}")]
[HttpStatus(204)]
public partial class DeleteUserEndpoint : VoidEndpoint
{
[FromRoute]
public Guid Id { get; set; }
public override async Task<VoidResult> HandleAsync(CancellationToken ct)
{
var user = await _users.GetByIdAsync(Id, ct);
if (user is null)
return new NotFoundError("User", Id); // → 404
await _users.DeleteAsync(Id, ct);
return Success; // → 204 No Content
}
}

The generator creates error mapping logic:

// Generated handler
var result = await endpoint.HandleAsync(ct);
return result.Match(
(success) => Results.Ok(success),
(error) => MapError(error)
);
static IResult MapError(IError error)
{
return error.ToResult(); // Uses ErrorExtensions
}
public static class ErrorExtensions
{
public static IResult ToResult(this IError error)
{
var statusCode = error.StatusCode;
var problemDetails = error.ToProblemDetails();
return Results.Problem(problemDetails, statusCode: statusCode);
}
}

For unexpected exceptions, use ASP.NET Core’s exception handling:

Program.cs
app.UseExceptionHandler(error =>
{
error.Run(async context =>
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/problem+json";
var problem = new ProblemDetails
{
Type = "https://httpstatuses.io/500",
Title = "Internal Server Error",
Status = 500,
Detail = "An unexpected error occurred"
};
await context.Response.WriteAsJsonAsync(problem);
});
});

Integration with Pragmatic.Validation:

[Endpoint(HttpVerb.Post, "/users")]
public partial class CreateUser : DomainAction<UserId, ValidationError>
{
[Required]
[Email]
public required string Email { get; set; }
[Required]
[MinLength(2)]
public required string Name { get; set; }
// Validation runs automatically before Execute
public override async Task<Result<UserId, IError>> Execute(CancellationToken ct)
{
// If we get here, validation passed
var user = new User(Email, Name);
await Users.CreateAsync(user, ct);
return user.Id;
}
}

Validation errors return 422 with details:

{
"type": "https://httpstatuses.io/422",
"title": "Validation Failed",
"status": 422,
"errors": {
"Email": ["Invalid email format"],
"Name": ["Name must be at least 2 characters"]
}
}
  1. Use typed errors - Not generic exceptions
  2. Include context - Resource type, ID, reason
  3. Map to correct status - Follow HTTP semantics
  4. Localize messages - Use Pragmatic.Internationalization
  5. Log errors - But don’t expose internals to clients
  6. Document all error types - In DomainAction generics