Moes.Common.ApiResponse
1.3.1
dotnet add package Moes.Common.ApiResponse --version 1.3.1
NuGet\Install-Package Moes.Common.ApiResponse -Version 1.3.1
<PackageReference Include="Moes.Common.ApiResponse" Version="1.3.1" />
<PackageVersion Include="Moes.Common.ApiResponse" Version="1.3.1" />
<PackageReference Include="Moes.Common.ApiResponse" />
paket add Moes.Common.ApiResponse --version 1.3.1
#r "nuget: Moes.Common.ApiResponse, 1.3.1"
#:package Moes.Common.ApiResponse@1.3.1
#addin nuget:?package=Moes.Common.ApiResponse&version=1.3.1
#tool nuget:?package=Moes.Common.ApiResponse&version=1.3.1
Moes.Common.ApiResponse
Unified API response envelope for microservices.
Moes.Common.ApiResponse provides a consistent JSON response envelope for ASP.NET Core microservices, so consumers of your APIs never have to guess whether a given service wraps errors differently than another. Unhandled exceptions are converted into that envelope automatically; for success responses, you opt in by returning ApiResponse<T> from your actions.
Features
- A single response envelope (
ApiResponse<T>) for success, failure, and paginated results. - Unhandled exceptions — domain exceptions, FluentValidation failures, ModelState failures, SQL Server exceptions, and cancelled requests — are converted into the envelope automatically, with stable, machine-readable error codes.
- Automatic validation error responses in the unified envelope, replacing ASP.NET Core's built-in automatic 400 response.
- Pagination metadata helpers.
- Setup via two extension methods: one for DI, one for the middleware pipeline.
Installation
dotnet add package Moes.Common.ApiResponse
Requirements
net8.0ornet10.0- An ASP.NET Core host
This package brings in FluentValidation and Microsoft.Data.SqlClient as dependencies, used for automatic validation and SQL exception mapping respectively.
Getting Started
Register the services and add the middleware in Program.cs:
using Moes.Common.ApiResponse.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddUnifiedApiResponse(o =>
{
o.ServiceName = "orders-service";
o.IncludeExceptionDetailsInResponse = builder.Environment.IsDevelopment();
o.UseCamelCase = true;
});
builder.Services.AddControllers();
// register your FluentValidation validators — see Validation below
var app = builder.Build();
app.UseUnifiedApiResponse(); // register as early as possible in the pipeline
app.MapControllers();
app.Run();
Then, in a controller, return the envelope instead of raw payloads and throw domain exceptions instead of building error responses by hand:
using Moes.Common.ApiResponse.Exceptions;
using Moes.Common.ApiResponse.Models;
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(Guid id)
{
var order = await _orders.FindAsync(id)
?? throw new NotFoundException(nameof(Order), id);
return Ok(ApiResponse<OrderDto>.OK(order));
}
If order is null, throwing NotFoundException produces a 404 response in the unified shape automatically — no try/catch needed in the action. Note that this automatic handling only applies to errors: a plain Ok(someObject) is never wrapped for you, so use ApiResponse<T> explicitly whenever you want the envelope on a success response.
Configuration
AddUnifiedApiResponse accepts an optional Action<UnifiedApiResponseOptions> delegate:
| Option | Type | Default | Description |
|---|---|---|---|
ServiceName |
string |
"unknown" |
Name of the current service, included in logs. Recommended to use snake_case. |
IncludeExceptionDetailsInResponse |
bool |
false |
Whether stack traces / inner exception details / SQL diagnostics are included in the response body. Only enable in development. |
UseCamelCase |
bool |
true |
JSON property naming policy used when error responses are serialized. |
AddUnifiedApiResponse also disables ASP.NET Core's built-in automatic 400 response application-wide (ApiBehaviorOptions.SuppressModelStateInvalidFilter = true) so validation failures come back in the unified envelope instead — see Validation below.
UseUnifiedApiResponse wires up the automatic exception-to-response conversion described throughout this document. Call it as early as possible in the pipeline so it can catch exceptions from everything downstream.
The Response Envelope
Every response carries exactly one of a success payload or a list of errors — never both — plus optional metadata. Null members are omitted from the JSON output.
Success
{
"data": { "id": "1a2b3c", "status": "Pending" }
}
Build it with ApiResponse<T>.OK(data), or ApiResponse<T>.OK(data, meta) to attach metadata. Use the non-generic ApiResponse.OK() for endpoints that return no payload.
Paginated success
{
"data": [ { "id": "1a2b3c" }, { "id": "4d5e6f" } ],
"meta": {
"pagination": { "pageNumber": 1, "pageSize": 20, "totalItems": 42, "totalPages": 3, "hasPrevious": false, "hasNext": true }
}
}
Build it with ApiResponse<T>.Paged(pagedResult) — see Pagination. Note this returns ApiResponse<IReadOnlyList<T>> (a page of T, not a single T).
Failure
{
"errors": [
{ "message": "Order with id '1a2b3c' not found.", "code": "NOT_FOUND", "details": { "resourceType": "Order", "id": "1a2b3c" } }
]
}
Build it with ApiResponse<T>.Fail(errors), or simply throw one of the exceptions below and let it happen automatically. Each error is an ApiError:
| Field | Description |
|---|---|
message |
Human-readable, developer-facing message. |
code |
Stable, machine-readable code — see Error codes reference. |
details |
Optional context (e.g. the offending field name for validation errors). |
An error response also carries an X-Trace-Id header for correlating it with your logs. Successful responses do not include this header.
Validation
Register FluentValidation validators as usual. For each action argument, if a matching IValidator<T> is registered, it runs and any failures are converted automatically into the unified error envelope. ASP.NET Core's own ModelState errors (e.g. from data-annotation attributes) are checked too and converted the same way, so both validation styles end up in the same response shape. Either way, you never need to check ModelState.IsValid yourself — the built-in automatic 400 response is suppressed in favor of this unified shape, and you get one error per field/message pair.
using FluentValidation;
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Quantity).GreaterThan(0);
}
}
builder.Services.AddScoped<IValidator<CreateOrderRequest>, CreateOrderRequestValidator>();
Example resulting 400 response:
{
"errors": [
{ "message": "'Customer Id' must not be empty.", "code": "VALIDATION_ERROR", "details": { "field": "customerId" } },
{ "message": "'Quantity' must be greater than '0'.", "code": "VALIDATION_ERROR", "details": { "field": "quantity" } }
]
}
Field names in details.field are camelCased, preserving dot notation for nested properties (e.g. address.postalCode).
Exceptions
Throw these from anywhere in the request pipeline — they are caught automatically and turned into the matching response, no try/catch required. All of them derive from the public abstract AppException, so you can also define your own domain exceptions by extending it directly with a custom status code and error code — they'll be handled the same way.
| Exception | HTTP status | Error code | Notes |
|---|---|---|---|
ValidationException(field, message) |
400 | VALIDATION_ERROR |
Also accepts a dictionary of field → messages; produces one error per (field, message) pair. |
UnauthorizedException(message?) |
401 | UNAUTHORIZED |
Request lacks valid authentication credentials. |
ForbiddenException(message?) |
403 | FORBIDDEN |
Caller is authenticated but not permitted. |
NotFoundException(resourceType, id?) |
404 | NOT_FOUND |
Message is "{resourceType} not found.", or "{resourceType} with id '{id}' not found." when id is supplied. |
BusinessRuleException(message, errorCode?, details?) |
409 | BUSINESS_RULE_VIOLATION (or your own errorCode) |
A domain rule prevented the operation, e.g. "INSUFFICIENT_STOCK". |
using Moes.Common.ApiResponse.Exceptions;
throw new NotFoundException(nameof(Order), id);
throw new ForbiddenException();
throw new BusinessRuleException("Cannot cancel a shipped order.", "ORDER_ALREADY_SHIPPED");
throw new Moes.Common.ApiResponse.Exceptions.ValidationException("email", "must be a valid email address.");
Note:
ValidationExceptionshares its short name withFluentValidation.ValidationExceptionandSystem.ComponentModel.DataAnnotations.ValidationException. If your file also uses either of those — likely, since this library uses FluentValidation — qualify the type as shown above, or alias it (using ValidationException = Moes.Common.ApiResponse.Exceptions.ValidationException;) to avoid ambiguity.
Note: per AIP-193, if an unauthorized caller could otherwise infer that a resource exists from a
404vs.403response, prefer throwingForbiddenExceptionregardless of whether the resource exists, rather than leaking existence viaNotFoundException.
Also handled automatically
Beyond the exceptions above, the following are converted into the envelope without you throwing anything from this library:
| Situation | HTTP status | Error code |
|---|---|---|
| A FluentValidation or ModelState failure (see Validation) | 400 | VALIDATION_ERROR |
FluentValidation.ValidationException thrown directly (e.g. via ValidateAndThrow()) |
400 | VALIDATION_ERROR |
A Microsoft.Data.SqlClient.SqlException (see SQL Server Exception Mapping) |
varies | varies |
A cancelled request (OperationCanceledException) |
499 | REQUEST_CANCELLED |
| Any other unhandled exception | 500 | INTERNAL_SERVER_ERROR |
Error Codes Reference
ErrorCodes is a fixed set of stable, machine-readable codes shared across all microservices. Once a code is published it is a contract — never rename, remove, or repurpose an existing one.
| Code | Thrown by |
|---|---|
VALIDATION_ERROR |
ValidationException, FluentValidation failures, ModelState failures |
UNAUTHORIZED |
UnauthorizedException |
FORBIDDEN |
ForbiddenException |
NOT_FOUND |
NotFoundException |
CONFLICT |
SQL unique/PK/FK/check constraint violations |
BUSINESS_RULE_VIOLATION |
BusinessRuleException, SQL RAISERROR/THROW (error number ≥ 50000) |
RATE_LIMITED |
Reserved — not thrown by this library; use it in your own rate-limiting code |
EXTERNAL_SERVICE_ERROR |
Reserved — not thrown by this library; use it when a downstream service call fails |
INTERNAL_SERVER_ERROR |
Any unhandled exception without a more specific mapping |
DATABASE_DEADLOCK |
SQL deadlock victim |
DATABASE_TIMEOUT |
SQL command timeout |
DATABASE_UNAVAILABLE |
SQL login failure / cannot open database |
The automatic 499 cancelled-request response (see Exceptions) uses the code REQUEST_CANCELLED, which is not part of ErrorCodes.
SQL Server Exception Mapping
SQL Server exceptions are mapped automatically to the following statuses and codes, so a raw database failure never leaks past your API as an unhandled 500 with schema details:
| SQL error number | HTTP status | Error code |
|---|---|---|
≥ 50000 (custom RAISERROR/THROW) |
400 | BUSINESS_RULE_VIOLATION |
| 2627, 2601 (unique / primary key violation) | 409 | CONFLICT |
| 547 (foreign key / check constraint) | 409 | CONFLICT |
| 1205 (deadlock victim) | 409 | DATABASE_DEADLOCK |
| -2 (command timeout) | 504 | DATABASE_TIMEOUT |
| 18456, 4060 (login failed / cannot open database) | 503 | DATABASE_UNAVAILABLE |
| anything else | 500 | INTERNAL_SERVER_ERROR |
Detailed SQL diagnostics (error number, state, procedure, line number, message) are only included in the response details when IncludeExceptionDetailsInResponse is true.
Pagination
Build a page in your service/repository layer, then convert it to an envelope:
using Moes.Common.ApiResponse.Models;
var items = await _orders.GetPageAsync(page, pageSize);
var totalItems = await _orders.CountAsync();
var result = new PagedResult<OrderDto>(items, page, pageSize, totalItems);
return Ok(ApiResponse<OrderDto>.Paged(result));
The resulting pagination metadata:
| Field | Description |
|---|---|
pageNumber |
Current page number (1-based). |
pageSize |
Number of items requested per page. |
totalItems |
Total number of items across all pages. |
totalPages |
Computed total number of pages. |
hasPrevious |
true when pageNumber > 1. |
hasNext |
true when there are more pages after the current one. |
Limitations
UseCamelCasescope: this option only controls the JSON serialization of the automatic error envelope described above. It does not configure your application's general MVC /System.Text.Jsonoutput settings — configure those separately if you need consistent casing for successful responses too.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- FluentValidation (>= 12.1.1)
- Microsoft.Data.SqlClient (>= 7.0.1)
-
net8.0
- FluentValidation (>= 12.1.1)
- Microsoft.Data.SqlClient (>= 7.0.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.