Exception-based error handling for ASP.NET Core 8+ with RFC 9457-compliant ProblemDetails, structured logging, and SignalR support.
Note: If you prefer the Result pattern over exception-based error handling, consider **ResultCrafter ** by Haik Asatryan. ResultCrafter provides a more functional approach with explicit error handling through Result types. ResponseCrafter takes a different path—using typed exceptions with automatic ProblemDetails mapping—suitable for teams that prefer traditional exception flows.
dotnet add package Pandatech.ResponseCrafterusing ResponseCrafter.Enums;
using ResponseCrafter.Extensions;
builder.AddResponseCrafter(NamingConvention.ToSnakeCase);
// Optional: SignalR support
builder.Services.AddSignalR(o => o.AddFilter<SignalRExceptionFilter>());
var app = builder.Build();
app.UseResponseCrafter();using ResponseCrafter.HttpExceptions;
app.MapPost("/users", (CreateUserRequest req) =>
{
// Validation
BadRequestException.ThrowIfNullOrWhiteSpace(req.Email, "email_required");
// Not found
var user = db.Users.Find(req.Id);
NotFoundException.ThrowIfNull(user, "user_not_found");
// Conflict
if (db.Users.Any(u => u.Email == req.Email))
throw new ConflictException("email_already_exists");
// With field errors
throw new BadRequestException("invalid_payload", new Dictionary<string, string>
{
["email"] = "invalid_email_format",
["age"] = "must_be_18_or_older"
});
});All errors return RFC 9457 ProblemDetails:
{
"requestId": "0HN8K2MJ7F4QP:00000001",
"traceId": "00-abc123...",
"instance": "/api/users",
"statusCode": 400,
"type": "BadRequestException",
"message": "invalid_payload",
"errors": {
"email": "invalid_email_format",
"age": "must_be_18_or_older"
}
}| Exception | Status | Static Helpers |
|---|---|---|
BadRequestException |
400 | ✅ ThrowIf*, ThrowIfNull*, etc. |
UnauthorizedException |
401 | ✅ |
PaymentRequiredException |
402 | ❌ |
ForbiddenException |
403 | ✅ |
NotFoundException |
404 | ✅ |
ConflictException |
409 | ✅ |
TooManyRequestsException |
429 | ❌ |
ForceToChangePasswordException |
469 | ❌ |
InternalServerErrorException |
500 | ✅ |
ServiceUnavailableException |
503 | ✅ |
GatewayTimeoutException |
504 | ✅ |
All exceptions with static helpers provide:
// Null checks
NotFoundException.ThrowIfNull(user);
NotFoundException.ThrowIfNull(user, "custom_message");
// String checks
BadRequestException.ThrowIfNullOrWhiteSpace(input, "input_required");
// Collection checks
NotFoundException.ThrowIfNullOrEmpty(list, "no_results_found");
// Conditional
ForbiddenException.ThrowIf(condition, "access_denied");
// Numeric checks
BadRequestException.ThrowIfNullOrNegative(amount, "invalid_amount");ResponseCrafter automatically converts error messages and field names to your preferred case:
builder.AddResponseCrafter(NamingConvention.ToSnakeCase);Available conventions:
Default- No transformationToSnakeCase-user_not_foundToCamelCase-userNotFoundToPascalCase-UserNotFoundToKebabCase-user-not-foundToTitleCase-User Not FoundToHumanCase-User not foundToUpperSnakeCase-USER_NOT_FOUND
Handle exceptions in SignalR hubs with automatic error broadcasting:
using ResponseCrafter.ExceptionHandlers.SignalR;
// Hub method
public class ChatHub : Hub<IChatClient>
{
public Task SendMessage(Message request)
{
BadRequestException.ThrowIfNullOrWhiteSpace(request.Content, "message_empty");
// Process message...
}
}
// Request must implement IHubArgument
public class Message : IHubArgument
{
public required string Content { get; set; }
public required string InvocationId { get; set; }
}Client receives error via ReceiveError event:
{
"traceId": "00-abc123...",
"invocationId": "unique-call-id",
"instance": "SendMessage",
"statusCode": 400,
"message": "message_empty",
"errors": null
}ResponseCrafter automatically handles:
- DbUpdateConcurrencyException → 409 Conflict
- FluentImporter exceptions → 400 Bad Request (every
ImportException, including subclasses added by future importer releases;MessageDetailsis appended to the message, andMissingColumnsException/InvalidCellValueExceptionalso populateerrors). The one exception is InvalidPropertyNameException → 500, because it means an import rule references a property the model does not have, which is a bug in your rule and not something the caller's file can cause. - Gridify exceptions → 400 Bad Request
- BadHttpRequestException (malformed JSON) → 400 with parse details
- Unhandled exceptions → 500 Internal Server Error
Control error detail exposure via configuration:
{
"ResponseCrafterVisibility": "Public"
}- Public (default): 5xx errors show generic message, 4xx show actual errors
- Private: All errors show full details (dev/staging only)
app.MapPost("/users", (CreateUserRequest req) =>
{
// ... handler logic
})
.ProducesBadRequest()
.ProducesConflict()
.ProducesNotFound();Available extension methods:
ProducesBadRequest()- 400ProducesUnauthorized()- 401ProducesForbidden()- 403ProducesNotFound()- 404ProducesConflict()- 409ProducesTooManyRequests()- 429ProducesServiceUnavailable()- 503ProducesPaymentRequired()- 402ProducesErrorResponse(statusCode)- Custom statusProducesErrorResponse(params int[])- Multiple statuses
var errors = new Dictionary<string, string>
{
["field1"] = "error_message_1",
["field2"] = "error_message_2"
};
throw new BadRequestException("validation_failed", errors);
// or
throw new BadRequestException(errors); // Uses default messageBadRequestException.ThrowIf(age < 18, "must_be_adult");
ForbiddenException.ThrowIf(!user.IsAdmin, "admin_only");| Feature | ResponseCrafter | ResultCrafter |
|---|---|---|
| Approach | Exception-based | Result-based (functional) |
| Control flow | try/catch | Explicit Result |
| Performance | Exception overhead | No exceptions |
| API clarity | Implicit errors | Explicit in signatures |
| Best for | Traditional .NET teams | Teams preferring functional style |
Use ResponseCrafter if:
- Your team is comfortable with exceptions
- You have existing exception-based code
- You prefer implicit error handling
Use ResultCrafter if:
- You want explicit error handling in method signatures
- You prefer functional programming patterns
- Performance is critical (no exception overhead)
appsettings.json:
{
"ResponseCrafterVisibility": "Public"
}Program.cs:
builder.AddResponseCrafter(NamingConvention.ToSnakeCase);
app.UseResponseCrafter();MIT