Skip to main content

RA.Utilities.Core.Exceptions

NuGet version NuGet Downloads Codecov Publish NuGet GitHub license

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.Api catches 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.Http or an HTTP status code.
  • Consistent defaults — every exception starts from the same BaseResponseCode, BaseResponseMessages, and ResponseType values used elsewhere in the ecosystem.
  • Less boilerplate — endpoints no longer need repeated try-catch blocks 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 passing null) resolves to the package default at runtime; an explicit "" is preserved
  • Entity-aware constructors — NotFoundException and ConflictException build messages such as "Product with value '99' was not found." and expose EntityName / EntityValue
  • Structured validation errors — ValidationError carries property name, message, attempted value, expected value, and a custom error code
  • Stable contract — RaBaseException exposes ErrorCode (int) and ResponseType (ResponseType enum)
  • Immutable after construction — all properties are init-only or read-only
  • Result-pattern ready — any exception implicitly converts to Result.Failure through RA.Utilities.Core.Results
  • No configuration — no DI registration, no options, no appsettings.json; just throw the right type
Version 10.2.0

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.

Version 10.1.0

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​

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:

FamilyAvailable onExample
Default — package code, type, and messageAll 10 exceptionsnew NotFoundException()
Message only — package code and type, custom textUnauthorized, PaymentRequired, Forbidden, Unprocessable, GatewayTimeout, TooManyRequests, ServiceUnavailablenew ForbiddenException("Not the owner.")
Error code + messageAll exceptions except BadRequestException, which needs a ValidationError firstnew ForbiddenException(4030, "Not the owner.")
Entity aware — builds the message and populates EntityName / EntityValueNotFoundException, ConflictExceptionnew NotFoundException("Product", 99)
Validation aware — accepts one or many ValidationError itemsBadRequestExceptionnew 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:

ExceptionErrorCodeResponseTypeDefault Message
RaBaseException()500ErrorSomething happened on our end.
BadRequestException(errors)400BadRequestThe request is invalid.
NotFoundException()404NotFoundThe requested resource was not found.
ConflictException()409ConflictA conflict occurred with the current state of the resource.
UnauthorizedException()401UnauthorizedAuthentication failed or is missing.
PaymentRequiredException()402PaymentRequiredPayment is required to access this resource.
ForbiddenException()403ForbiddenYou do not have permission to access this resource.
UnprocessableException()422UnprocessableUnprocessable entity.
TooManyRequestsException()429TooManyRequestsToo many requests. Please try again later.
ServiceUnavailableException()503ServiceUnavailableThe service is temporarily unavailable. Please try again later.
GatewayTimeoutException()504GatewayTimeoutThe server, while acting as a gateway or proxy, did not receive a timely response from the upstream server.
null and "" are not the same

Only 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​

TypePropertyTypePopulated by
RaBaseExceptionErrorCodeintEvery constructor
RaBaseExceptionResponseTypeResponseTypeEvery constructor
NotFoundExceptionEntityNamestringEntity/value constructors only
NotFoundExceptionEntityValueobjectEntity/value constructors only
ConflictExceptionEntityNamestringEntity/value constructors only
ConflictExceptionEntityValueobjectEntity/value constructors only
BadRequestExceptionErrorsValidationError[]Every constructor
ValidationErrorPropertyNamestring?Object initializer
ValidationErrorErrorMessagestringConstructor
ValidationErrorAttemptedValueobject?Object initializer
ValidationErrorExpectedValueobject?Object initializer
ValidationErrorErrorCodestring?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-readable int carried 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 from RA.Utilities.Core.Constants that labels the outcome. It serializes as a number unless JsonStringEnumConverter is registered — see ResponseType.
  • Entity-aware exception: NotFoundException or ConflictException constructed with an entity name and value; it exposes both and generates the message.
  • Default message: the BaseResponseMessages.* value applied when message is null or omitted.
  • Implicit conversion to Result: RA.Utilities.Core.Results defines the conversion that turns an RaBaseException into Result.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 status

ErrorResultResponse 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() from RA.Utilities.Api register the GlobalExceptionHandler that maps these exceptions.
  • Enum serialization — register JsonStringEnumConverter if clients expect "responseType": "NotFound" instead of "responseType": 8. See ResponseType.

✨ Available Types​


🙌 Contributing & Support​

Found a bug or have a suggestion?​

Contributing​

Contributions are welcome! Please follow the standard workflow:

  1. Fork the RA.Utilities repository
  2. Create a branch from main with a descriptive name (e.g., feature/add-payment-exception)
  3. Make your changes — new exceptions must inherit from RaBaseException, include XML doc comments, and ship with tests
  4. Submit a pull request to the main branch with a clear description
ResourceURL
NuGetRA.Utilities.Core.Exceptions
SourceGitHub — RedonAlla/RA.Utilities
LicenseMIT
Build StatusGitHub Actions