RA.Utilities.Core.Exceptions
RA.Utilities.Core.Exceptions provides ten standardized, semantic exceptions such as NotFoundException and ConflictException. Each carries an HTTP error code, a semantic ResponseType, and a consistent message, so predictable business failures no longer need generic Exception or ArgumentException types.
The package is the error vocabulary of the RA.Utilities ecosystem: the application layer throws these types without knowing about HTTP, and the API layer maps them to standardized responses.
🎯 Purpose
Instead of throwing a generic Exception with a message string, this package lets the type of the exception communicate the outcome. That has several benefits:
- Clear intent —
throw new NotFoundException(nameof(Product), id)says exactly what happened; a generic exception forces every reader to parse the message. - Standardized error handling —
RA.Utilities.Apicatches these types and maps each one to the correct HTTP status code and a structured JSON body. No string matching required. - Decoupled layers — domain and application code can signal a missing entity or a conflict without referencing
Microsoft.AspNetCore.Httpor an HTTP status code. - Consistent defaults — every exception starts from the same
BaseResponseCode,BaseResponseMessages, andResponseTypevalues used elsewhere in the ecosystem. - Less boilerplate — endpoints no longer need repeated
try-catchblocks for common failures; one global handler covers them.
✨ Key Features
- 10 semantic exception types —
NotFoundException,ConflictException,BadRequestException,UnauthorizedException,PaymentRequiredException,ForbiddenException,UnprocessableException,GatewayTimeoutException,TooManyRequestsException,ServiceUnavailableException - Defaults that always apply — omitting
message(or passingnull) resolves to the package default at runtime; an explicit""is preserved - Entity-aware constructors —
NotFoundExceptionandConflictExceptionbuild messages such as"Product with value '99' was not found."and exposeEntityName/EntityValue - Structured validation errors —
ValidationErrorcarries property name, message, attempted value, expected value, and a custom error code - Stable contract —
RaBaseExceptionexposesErrorCode(int) andResponseType(ResponseTypeenum) - Immutable after construction — all properties are
init-only or read-only - Result-pattern ready — any exception implicitly converts to
Result.FailurethroughRA.Utilities.Core.Results - No configuration — no DI registration, no options, no
appsettings.json; just throw the right type
Adds PaymentRequiredException for HTTP 402. The release is additive: the existing exception types are unchanged, and it depends on RA.Utilities.Core.Constants 10.1.1 for the new PaymentRequired code, message, and ResponseType member.
This release is additive. It adds parameterless constructors to NotFoundException and ConflictException, entity/value convenience overloads to both, a single-error BadRequestException constructor, and entity/value/message overloads. message parameters are now string? with a null default, so omitting a message (or passing null) resolves to BaseResponseMessages.* at runtime. See the Migration Guides.
🛠️ Getting Started
Prerequisites
- .NET 10.0 or later
RA.Utilities.Core.Constants— installed automatically as a dependency
Installation
.NET CLI
dotnet add package RA.Utilities.Core.Exceptions
Package Manager Console
Install-Package RA.Utilities.Core.Exceptions
PackageReference (csproj)
<PackageReference Include="RA.Utilities.Core.Exceptions" Version="10.1.0" />
Hello World
Throw a semantic exception from your business logic — no setup required:
using RA.Utilities.Core.Exceptions;
public Product GetProductById(int id)
{
Product? product = _repository.Find(id);
if (product is null)
{
throw new NotFoundException(nameof(Product), id);
}
return product;
}
The exception already carries everything the API layer needs: ErrorCode is 404, ResponseType is ResponseType.NotFound, and Message is "Product with value '99' was not found.".
No DI registration is required. If you use the Result pattern, the same exception can be returned instead of thrown:
// Implicitly converts to Result.Failure
return new NotFoundException(nameof(Product), id);
🧠 Core Concepts
The hierarchy
RaBaseException is a concrete class (not abstract) and derives from System.Exception. Every semantic exception derives from it:
System.Exception
└─ RaBaseException ← ErrorCode (int) + ResponseType (enum)
├─ NotFoundException ← EntityName + EntityValue
├─ ConflictException ← EntityName + EntityValue
├─ BadRequestException ← ValidationError[]
├─ UnauthorizedException ← HTTP 401
├─ PaymentRequiredException ← HTTP 402
├─ ForbiddenException ← HTTP 403
├─ UnprocessableException ← HTTP 422
├─ GatewayTimeoutException ← HTTP 504
├─ TooManyRequestsException ← HTTP 429
└─ ServiceUnavailableException ← HTTP 503
ValidationError is not an exception. It is the value type stored in BadRequestException.Errors.
Constructor families
Every exception follows the same constructor conventions, so switching types rarely changes the call shape:
| Family | Available on | Example |
|---|---|---|
| Default — package code, type, and message | All 10 exceptions | new NotFoundException() |
| Message only — package code and type, custom text | Unauthorized, PaymentRequired, Forbidden, Unprocessable, GatewayTimeout, TooManyRequests, ServiceUnavailable | new ForbiddenException("Not the owner.") |
| Error code + message | All exceptions except BadRequestException, which needs a ValidationError first | new ForbiddenException(4030, "Not the owner.") |
Entity aware — builds the message and populates EntityName / EntityValue | NotFoundException, ConflictException | new NotFoundException("Product", 99) |
Validation aware — accepts one or many ValidationError items | BadRequestException | new BadRequestException(error) |
BadRequestException has no message-only overload: pass a ValidationError first, then optionally an errorCode and message.
Message defaults
Every overload that accepts a message declares it as string? message = null. When the argument is omitted or null, the constructor falls back to the matching BaseResponseMessages value:
| Exception | ErrorCode | ResponseType | Default Message |
|---|---|---|---|
RaBaseException() | 500 | Error | Something happened on our end. |
BadRequestException(errors) | 400 | BadRequest | The request is invalid. |
NotFoundException() | 404 | NotFound | The requested resource was not found. |
ConflictException() | 409 | Conflict | A conflict occurred with the current state of the resource. |
UnauthorizedException() | 401 | Unauthorized | Authentication failed or is missing. |
PaymentRequiredException() | 402 | PaymentRequired | Payment is required to access this resource. |
ForbiddenException() | 403 | Forbidden | You do not have permission to access this resource. |
UnprocessableException() | 422 | Unprocessable | Unprocessable entity. |
TooManyRequestsException() | 429 | TooManyRequests | Too many requests. Please try again later. |
ServiceUnavailableException() | 503 | ServiceUnavailable | The service is temporarily unavailable. Please try again later. |
GatewayTimeoutException() | 504 | GatewayTimeout | The server, while acting as a gateway or proxy, did not receive a timely response from the upstream server. |
null and "" are not the sameOnly null (or an omitted argument) triggers the default. An explicit empty string is treated as a real message and preserved:
new NotFoundException(404, null); // "The requested resource was not found."
new NotFoundException(404, ""); // "" (empty)
NotFoundException and ConflictException replace the generic default with an entity-specific message whenever the entity/value overload is used:
new NotFoundException("Product", 99).Message; // "Product with value '99' was not found."
new ConflictException("User", "a@b.com").Message; // "User with value 'a@b.com' already exists."
How exceptions flow through the system
Business / application layer API layer (RA.Utilities.Api)
────────────────────────── ─────────────────────────────
throw new NotFoundException("Product", 99)
│
├─ GlobalExceptionHandler catches it
│ logs 4xx as Warning, 5xx as Error
│
└─ ErrorResultResponse.Result(exception)
pattern-matches on the exception type
→ 404 + NotFoundResponse JSON
Returning an exception through Result.Failure takes the same path when an endpoint passes ErrorResultResponse.Result to Result.Match:
return result.Match<IResult>(
success: product => SuccessResult.Ok(product),
failure: ErrorResultResponse.Result
);
The exact JSON envelope belongs to the API package. See ErrorResultResponse for the payload shapes and the known NotFoundResult nested-payload issue.
Property reference
| Type | Property | Type | Populated by |
|---|---|---|---|
RaBaseException | ErrorCode | int | Every constructor |
RaBaseException | ResponseType | ResponseType | Every constructor |
NotFoundException | EntityName | string | Entity/value constructors only |
NotFoundException | EntityValue | object | Entity/value constructors only |
ConflictException | EntityName | string | Entity/value constructors only |
ConflictException | EntityValue | object | Entity/value constructors only |
BadRequestException | Errors | ValidationError[] | Every constructor |
ValidationError | PropertyName | string? | Object initializer |
ValidationError | ErrorMessage | string | Constructor |
ValidationError | AttemptedValue | object? | Object initializer |
ValidationError | ExpectedValue | object? | Object initializer |
ValidationError | ErrorCode | string? | Object initializer |
The parameterless and error-code-only constructors of NotFoundException and ConflictException do not set EntityName or EntityValue (they remain null at runtime). Choose an entity/value overload when the API response should identify the entity.
Terminology
- Semantic exception: an exception whose type communicates the outcome, rather than relying on message text.
ErrorCode: the machine-readableintcarried by every exception. It defaults to an HTTP status code but can hold a domain-specific code when you pass one explicitly.ResponseType: the enum fromRA.Utilities.Core.Constantsthat labels the outcome. It serializes as a number unlessJsonStringEnumConverteris registered — seeResponseType.- Entity-aware exception:
NotFoundExceptionorConflictExceptionconstructed with an entity name and value; it exposes both and generates the message. - Default message: the
BaseResponseMessages.*value applied whenmessageisnullor omitted. - Implicit conversion to
Result:RA.Utilities.Core.Resultsdefines the conversion that turns anRaBaseExceptionintoResult.Failure.
📖 How-To Guides
Return a 404 when a resource is missing
using RA.Utilities.Core.Results;
using RA.Utilities.Core.Exceptions;
public async Task<Result<ProductDto>> GetProductAsync(int id)
{
Product? product = await _repository.FindByIdAsync(id);
if (product is null)
{
return new NotFoundException(nameof(Product), id);
}
return _mapper.Map<ProductDto>(product);
}
Return a 404 without entity details
Use the parameterless constructor, or supply a custom code and message, when the endpoint should not disclose the entity name:
// Defaults: 404, ResponseType.NotFound, BaseResponseMessages.NotFound
return new NotFoundException();
// Or a domain-specific code with the default message (null → BaseResponseMessages.NotFound)
return new NotFoundException(errorCode: 4040, message: null);
Return a 409 when a duplicate is detected
public async Task<Result> CreateUserAsync(string email)
{
if (await _userRepository.ExistsAsync(email))
{
return new ConflictException(nameof(User), email);
}
// ... creation logic
return Result.Success();
}
Override the entity message while keeping the entity data
Both entity-aware exceptions accept a custom message; EntityName and EntityValue are still populated:
throw new ConflictException(
entity: nameof(User),
value: email,
message: "An account with this email is already active."
);
Return validation errors with a 400
Convert FluentValidation failures into ValidationError items and pass the array to BadRequestException:
try
{
await _validator.ValidateAndThrowAsync(command);
}
catch (ValidationException ex)
{
ValidationError[] errors = [.. ex.Errors.Select(e => new ValidationError(e.ErrorMessage)
{
PropertyName = e.PropertyName,
AttemptedValue = e.AttemptedValue,
ErrorCode = e.ErrorCode
})];
return new BadRequestException(errors);
}
To attach a custom error code and message to the whole request, use the (errors, errorCode, message) overload:
return new BadRequestException(errors, errorCode: 4001, message: "The registration form is invalid.");
Signal an invalid state transition with a 422
Unlike BadRequestException, UnprocessableException means the request was well formed but the current resource state makes it impossible:
public async Task<Result> CancelOrderAsync(Guid orderId)
{
Order order = await _orderRepository.GetByIdAsync(orderId)
?? throw new NotFoundException(nameof(Order), orderId);
if (order.Status == OrderStatus.Shipped)
{
return new UnprocessableException("Cannot cancel an order that has already been shipped.");
}
order.Status = OrderStatus.Cancelled;
await _orderRepository.UpdateAsync(order);
return Result.Success();
}
Distinguish 401 from 403
- 401
UnauthorizedException— authentication is missing or failed; the client should authenticate. - 403
ForbiddenException— authentication succeeded, but the identity lacks permission.
if (token is null)
{
return new UnauthorizedException("No authentication token was provided.");
}
if (resource.OwnerId != currentUser.Id)
{
return new ForbiddenException("You do not have permission to modify this resource.");
}
Require payment with a 402
Reserve 402 for resources the caller can unlock by paying. It is not a substitute for 403 (insufficient permission) or 429 (rate limit):
public async Task<Result<Report>> GenerateReportAsync(Guid userId)
{
Subscription subscription = await _subscriptions.GetAsync(userId);
if (subscription.Plan == Plan.Free)
{
return new PaymentRequiredException("This report is available on paid plans.");
}
return await _reports.GenerateAsync(userId);
}
Enforce rate limits with a 429
if (requestCount >= _maxRequestsPerMinute)
{
return new TooManyRequestsException(
$"Rate limit exceeded. Maximum {_maxRequestsPerMinute} requests per minute allowed."
);
}
Signal a temporary outage with a 503
if (_maintenanceModeService.IsActive())
{
return new ServiceUnavailableException(
"The service is currently undergoing scheduled maintenance. Please try again in a few minutes."
);
}
Handle upstream timeouts with a 504
try
{
return await _paymentGateway.ProcessAsync(orderId, cts.Token);
}
catch (TaskCanceledException)
{
return new GatewayTimeoutException("The payment gateway did not respond in time.");
}
Add a domain-specific error code
ErrorCode is an int, so it can carry anything the caller needs. Combine a custom code with the default message by passing null:
throw new ConflictException(
entity: nameof(Subscription),
value: subscriptionId,
errorCode: 4091,
message: "The subscription is already active."
);
ErrorCode is not the HTTP statusErrorResultResponse selects the HTTP status from the exception type (BaseResponseCode.Conflict → 409). The exception's ErrorCode is copied into the JSON responseCode field instead. If clients compare responseCode with the HTTP status, keep ErrorCode equal to the matching BaseResponseCode value and put domain detail in ValidationError.ErrorCode or the message.
Create a derived exception
Subclass RaBaseException when you need a new outcome type, and call the base constructor with the code, type, and message:
using RA.Utilities.Core.Constants;
using RA.Utilities.Core.Exceptions;
public sealed class PaymentDeclinedException : RaBaseException
{
public PaymentDeclinedException(string reason)
: base(BaseResponseCode.BadRequest, ResponseType.BadRequest, $"Payment declined: {reason}")
{
}
}
⚙️ Configuration
This package has no configuration options. There is no DI registration, no appsettings.json entries, and no options classes. Exceptions are thrown or returned directly.
The optional setup lives in the consuming API project:
- Exception handling —
AddRaExceptionHandling()/UseRaExceptionHandling()fromRA.Utilities.Apiregister theGlobalExceptionHandlerthat maps these exceptions. - Enum serialization — register
JsonStringEnumConverterif clients expect"responseType": "NotFound"instead of"responseType": 8. SeeResponseType.
✨ Available Types
RaBaseException
RaBaseException is the base class for all custom, semantic exceptions in the RA.Utilities ecosystem. It is a concrete class — you can throw it directly — and derives from System.Exception.
NotFoundException
NotFoundException signals that a requested resource does not exist. The API layer maps it to HTTP 404 Not Found.
ConflictException
ConflictException signals that a request cannot be processed because it conflicts with the current state of the target resource. The API layer maps it to HTTP 409 Conflict.
BadRequestException
BadRequestException represents a failure caused by invalid client-side input. The API layer maps it to HTTP 400 Bad Request.
ValidationError
ValidationError describes a single validation failure. It is the element type of BadRequestException.Errors, not an exception itself.
UnauthorizedException
UnauthorizedException signals that authentication is missing or failed. The API layer maps it to HTTP 401 Unauthorized.
PaymentRequiredException
PaymentRequiredException signals that the requested resource requires payment before it can be accessed. The API layer maps it to HTTP 402 Payment Required.
ForbiddenException
ForbiddenException signals that an authenticated caller is not allowed to perform the requested action. The API layer maps it to HTTP 403 Forbidden.
UnprocessableException
UnprocessableException signals that a request is syntactically valid but cannot be processed because of the resource's current state. The API layer maps it to HTTP 422 Unprocessable Entity.
TooManyRequestsException
TooManyRequestsException signals that the caller exceeded a rate limit or throttling threshold. The API layer maps it to HTTP 429 Too Many Requests.
ServiceUnavailableException
ServiceUnavailableException signals that the service is temporarily unable to handle requests. The API layer maps it to HTTP 503 Service Unavailable.
GatewayTimeoutException
GatewayTimeoutException signals that an upstream server or dependency did not respond in time. The API layer maps it to HTTP 504 Gateway Timeout.
Migration Guides
Note: ResponseType has changed shape twice. It was a plain enum through Core.Constants v10.0.1, a record in v10.0.2–v10.0.3, and is a plain enum again from Core.Constants v10.0.4 onward. With the current enum, read the semantic label with ex.ResponseType.ToString(), not ex.ResponseType.Value.
Troubleshooting & FAQ
Common Issues
🙌 Contributing & Support
Found a bug or have a suggestion?
- GitHub Issues: Report a bug or request a feature
- Discussions: Ask questions or start a discussion
Contributing
Contributions are welcome! Please follow the standard workflow:
- Fork the RA.Utilities repository
- Create a branch from
mainwith a descriptive name (e.g.,feature/add-payment-exception) - Make your changes — new exceptions must inherit from
RaBaseException, include XML doc comments, and ship with tests - Submit a pull request to the
mainbranch with a clear description
Links
| Resource | URL |
|---|---|
| NuGet | RA.Utilities.Core.Exceptions |
| Source | GitHub — RedonAlla/RA.Utilities |
| License | MIT |
| Build Status | GitHub Actions |