Consistent routing and responses across your API — without attributes, base classes, or boilerplate.
The Problem: Inconsistent APIs
You start with a clean ASP.NET Core project.
A few sprints later:
GET /api/UserProfile/{userId}
POST /api/V1/Products/Create
GET /api/userOrders/GetRecent
- Controllers use different casing styles
- New endpoints introduce their own conventions
- Error responses vary depending on who implemented them
Now your API is inconsistent — and your frontend team has to guess how each endpoint behaves.
Sounds familiar?
This isn’t a framework problem.
It’s a convention problem.
The One-Line Fix
Instead of enforcing conventions manually, your API follows them automatically using AspNetConventions:
dotnet add package AspNetConventions
builder.Services.AddControllers()
.AddAspNetConventions(); // ← That's it
No attributes. No base classes. No custom middleware.
From this single registration, your API gets four features automatically.
1. Route Standardization (Zero Code Changes)
Your controller stays exactly the same:
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpGet("[action]/{userId}")]
public IActionResult GetUserDetails(int userId)
{
return Ok(new { userId, name = "John Doe" });
}
}
Before:
GET /api/Users/GetUserDetails/{userId}
After:
GET /api/users/get-user-details/{user-id}
What gets standardized automatically:
- Controller names → consistent casing (
kebab-caseby default) - Action names → transformed and stripped of HTTP prefixes
- Route parameters → consistent format
No refactoring. No attributes. No rewrites. Model binding still works — {user-id} maps back to your userId parameter transparently.
Want a different style? Just change one option:
options.Route.CaseStyle = CaseStyle.CamelCase;
2. Response Standardization
Different endpoints often return different shapes.
That inconsistency leaks into your frontend, mobile apps, and integrations.
Before:
{ "userId": 123, "name": "John Doe" }
{ "UserId": 456, "FullName": "Jane Smith" }
"System.ArgumentNullException: Value cannot be null."
After — every response follows the same predictable structure:
Success:
{
"status": "success",
"statusCode": 200,
"message": "Request completed successfully.",
"data": {
"userId": 123,
"name": "John Doe"
},
"metadata": {
"requestType": "GET",
"timestamp": "2026-04-19T14:32:00.000Z",
"traceId": "00-ed89d1cc507c35126d6f0e933984f774-99b8b9a3feb75652-00",
"path": "/api/user-profile/get-by-id/123"
}
}
Error:
{
"status": "failure",
"statusCode": 400,
"type": "VALIDATION_ERROR",
"message": "One or more validation errors occurred.",
"errors": [
{ "Email": ["'Email' is not a valid email address."] }
],
"metadata": {
"requestType": "POST",
"timestamp": "2026-04-19T14:33:00.000Z",
"traceId": "00-8e5513ae9369648487c2323d9a3508aa-2a8f92c7d45d3f74-00",
"path": "/api/user-profile/create-account"
}
}
3. Exception Handling
Without centralized handling, you end up:
- Writing
try/catchin controllers - Returning different error formats
- Missing logging in some paths
AspNetConventions handles exceptions globally.
So your controller actions stay clean:
[HttpGet("GetById/{userId}")]
public ActionResult GetById(int userId)
{
var user = _users.Find(userId)
?? throw new KeyNotFoundException($"User {userId} was not found.");
return Ok(user);
}
The KeyNotFoundException is automatically caught, mapped to 404 Not Found, and returned in the standard error envelope. No try/catch. No custom middleware. No boilerplate.
The same applies to ValidationException → 400, UnauthorizedAccessException → 401, InvalidOperationException → 409, and more — all out of the box.
Need to handle your own domain exceptions?
AspNetConventions supports exception mappers.
4. JSON Serialization
Define how your API serializes JSON once — apply it everywhere.
builder.Services.AddControllers()
.AddAspNetConventions(options =>
{
options.Json.ConfigureTypes = cfg =>
{
// Ignore a property globally
cfg.IgnorePropertyName("InternalKey");
// Configure a specific type
cfg.Type<User>(type =>
{
type.Property(x => x.Id).Order(0);
type.Property(x => x.Password).Ignore();
type.Property(x => x.Alias).Name("userName");
});
};
});
You can also implement class-based configuration for your own types.
Not Just MVC
AspNetConventions works the same way with Minimal APIs and Razor Pages — same conventions, same response envelope, same exception handling:
Minimal APIs:
var api = app.UseAspNetConventions();
api.MapGet("Products/GetFeatured", () => Results.Ok(featured));
// → GET /products/get-featured
Razor Pages:
// Pages/UserProfile/EditAddress.cshtml
// → /user-profile/edit-address/{user-id}/{address-id}
Define your conventions once — apply them everywhere.
Customization (When You Need It)
The defaults are sensible, but you’re not locked in.
builder.Services.AddControllers()
.AddAspNetConventions(options =>
{
// Routes
options.Route.CaseStyle = CasingStyle.SnakeCase;
options.Route.Controllers.RemoveActionPrefixes.Add("Get");
options.Route.Controllers.ExcludeControllers.Add("LegacyController");
// JSON
options.Json.CaseStyle = CasingStyle.SnakeCase;
options.Json.ScanAssemblies(typeof(UserJsonConfiguration).Assembly);
// Responses
options.Response.IncludeMetadata = false;
options.Response.Pagination.DefaultPageSize = 50;
options.Response.ErrorResponse.DefaultStatusCode = HttpStatusCode.BadRequest;
// Exceptions
options.ExceptionHandling.Mappers.Add(new OrderNotFoundMapper());
options.ExceptionHandling.ExcludeException.Add(typeof(OperationCanceledException));
});
What You Get
| Before | After | |
|---|---|---|
| Route style | Mixed, manual | Consistent, automatic |
| Response shape | Varies by developer | Same envelope everywhere |
| Exception handling | try/catch per controller | One global pipeline |
| JSON settings | Scattered | One config block |
Try It
dotnet add package AspNetConventions
- Docs: keizrivas.github.io/AspNetConventions
- NuGet: nuget.org/packages/AspNetConventions
- GitHub: github.com/keizrivas/AspNetConventions
MIT licensed. Contributions welcome.
Top comments (0)