Parameter Binding
How AspNetConventions handles parameter transformation and model binding across different binding sources.
Basic Parameter Binding
Parameter names are transformed in the URL, but model binding always uses the original name. AspNetConventions registers internal binding aliases, making the resolution transparent.
// Route: GET /api/orders/get-by-user/{user-id}
[HttpGet("GetByUser/{userId}")]
public ActionResult GetByUser(int userId) // binds from {user-id}
{
return Ok(userId);
}
Supported Binding Sources
| Attribute | Binding Source | Example |
|---|---|---|
[FromRoute] |
Route values | /orders/{order-id} |
[FromQuery] |
Query string | /orders?order-id=123 |
[FromHeader] |
HTTP headers | X-Order-Id: 123 |
[FromForm] |
Form values | form-data; name="order-id" |
[FromBody] |
Request body | JSON property names |
Example with multiple binding sources:
// Route: POST /api/account/create/{tenant-id}?referral-code=
[HttpPost("{tenantId}")]
public ActionResult Create(
[FromRoute] Guid tenantId, // binds from {tenant-id}
[FromQuery] string? referralCode, // binds from ?referral-code=
[FromBody] UserDto request) // binds from JSON body
{
return Ok(request);
}
Route Constraints
Constraints survive transformation — only the parameter name is rewritten:
{UserId:int} → {user-id:int}
{Slug:regex(...)} → {slug:regex(...)}
{Id:guid} → {id:guid}
Complex Types
When you bind a complex type (a class with multiple properties), AspNetConventions transforms all property names recursively to match your configured case style.
How It Works
- Property Discovery — Scans all public properties of the complex type
- Recursive Transformation — Nested objects have their properties transformed as well
- Binding Alias Registration — Internal aliases map transformed names back to original property names
- Transparent Resolution — Your C# code continues using original property names
Example Model
public class ProductSearchRequest
{
public string CategoryName { get; set; } // → category-name
public string ProductCode { get; set; } // → product-code
public PriceRange PriceFilter { get; set; } // → price-filter (nested)
}
public class PriceRange
{
public decimal MinPrice { get; set; } // → min-price
public decimal MaxPrice { get; set; } // → max-price
}
Binding Source Examples
Query string parameters use dot notation for nested properties:
[HttpGet("[action]")]
public ActionResult Search([FromQuery] ProductSearchRequest request)
{
return Ok(request);
}
Request URL:
GET /search?category-name=Electronics&product-code=SKU123&price-filter.min-price=10&price-filter.max-price=100
Parsed Object:
// request.CategoryName = "Electronics"
// request.ProductCode = "SKU123"
// request.PriceFilter.MinPrice = 10
// request.PriceFilter.MaxPrice = 100
JSON body properties are serialized using the configured case style:
[HttpPost("[action]")]
public ActionResult Create([FromBody] ProductSearchRequest request)
{
return Ok(request);
}
Request Body:
{
"category-name": "Electronics",
"product-code": "SKU123",
"price-filter": {
"min-price": 10,
"max-price": 100
}
}
Form data uses dot notation, same as query strings:
[HttpPost("[action]")]
public ActionResult Submit([FromForm] ProductSearchRequest request)
{
return Ok(request);
}
Request (multipart/form-data):
POST /submit HTTP/1.1
Content-Type: multipart/form-data; boundary=----FormBoundary
------FormBoundary
Content-Disposition: form-data; name="category-name"
Electronics
------FormBoundary
Content-Disposition: form-data; name="price-filter.min-price"
10
------FormBoundary--
Complex types can bind from route values when the template includes matching placeholders:
[HttpGet("[action]/{CategoryName}/{ProductCode}")]
public ActionResult GetProduct([FromRoute] ProductIdentifier product)
{
return Ok(product);
}
public class ProductIdentifier
{
public string CategoryName { get; set; }
public string ProductCode { get; set; }
}
Route template becomes:
/get-product/{category-name}/{product-code}
For individual header binding, use separate parameters instead:
[HttpGet("[action]")]
public ActionResult GetWithHeaders(
[FromHeader(Name = "X-Client-Version")] string clientVersion,
[FromHeader(Name = "X-Request-Id")] string requestId)
{
return Ok(new { clientVersion, requestId });
}
Request Headers:
GET /get-with-headers HTTP/1.1
x-client-version: 1.0.0
x-request-id: abc-123
Custom Binding Names
Some binding attributes allow you to explicitly define the parameter name via IModelNameProvider. When an explicit name is provided, AspNetConventions transforms it according to your configured casing style.
// Route: /api/profile/theme-generator/{accent-color}
[HttpGet("[action]/{AccentColor}")]
public ActionResult ThemeGenerator([FromRoute(Name = "AccentColor")] string color)
{
return Ok(color);
}
Preserving Explicit Names
To skip transformation for explicitly named parameters, enable PreserveExplicitBindingNames:
// MVC Controllers
options.Route.Controllers.PreserveExplicitBindingNames = true;
// Razor Pages
options.Route.RazorPages.PreserveExplicitBindingNames = true;
// Route: /get-user/{user_id} (parameter not transformed)
[HttpGet("GetUser/{user_id}")]
public ActionResult GetUser([FromRoute(Name = "user_id")] int userId)
{
return Ok(userId);
}
Complex Objects with Custom Names
There are two levels of customization:
- Parameter-level prefix — Using
[FromQuery(Name = "...")]on the action parameter - Property-level names — Using
[ModelBinder(Name = "...")]on class properties
Both levels are transformed:
public class ProductFilter
{
public string CategoryName { get; set; }
public decimal MinPrice { get; set; }
}
[HttpGet("[action]")]
public ActionResult Search([FromQuery(Name = "FilterBy")] ProductFilter filter)
{
return Ok(filter);
}
Before:
/search?FilterBy.CategoryName=Electronics&FilterBy.MinPrice=10
After:
/search?filter-by.category-name=Electronics&filter-by.min-price=10
Property-Level Custom Names
Override individual property names using [ModelBinder(Name = "...")]:
public class SearchFilters
{
[ModelBinder(Name = "Category")]
public string CategoryName { get; set; } // "Category" → "category"
[ModelBinder(Name = "Q")]
public string SearchQuery { get; set; } // "Q" → "q"
public PriceRange Prices { get; set; } // "Prices" → "prices"
}
public class PriceRange
{
[ModelBinder(Name = "From")]
public decimal Min { get; set; } // "From" → "from"
[ModelBinder(Name = "To")]
public decimal Max { get; set; } // "To" → "to"
}
[HttpGet("[action]")]
public ActionResult Search([FromQuery(Name = "F")] SearchFilters filters)
{
return Ok(filters);
}
Result (kebab-case):
/search?f.category=Electronics&f.q=laptop&f.prices.from=100&f.prices.to=500
When PreserveExplicitBindingNames is enabled, only properties without explicit [ModelBinder(Name = "...")] are transformed.
Razor Pages Property Binding
In Razor Pages, TransformPropertyNames (enabled by default) transforms [BindProperty] names automatically, creating a seamless experience between standardized routes and your page models.
Basic Property Binding
// /Pages/Edit.cshtml.cs
public class EditModel : PageModel
{
[BindProperty(SupportsGet = true)]
public int UserId { get; set; } // bound from {user-id}
[BindProperty(SupportsGet = true, Name = "SourceDevice")]
public string? Source { get; set; } // bound from {source-device}
[BindProperty]
public string UserName { get; set; } // bound from form field (POST) "user-name"
}
<!-- /Pages/Edit.cshtml -->
@page "{UserId}"
<!-- Route becomes: /edit/{user-id}?source-device= -->
@model EditModel
<h1>Edit User</h1>
<form method="post">
<input asp-for="UserName" /> <!-- Generates name="user-name" -->
<button type="submit">Save</button>
</form>
...
How Property Binding Works
The transformation applies to three binding scenarios:
| Binding Source | Original Name | Transformed Name | Example |
|---|---|---|---|
| Route Values | UserId |
user-id |
/edit/123 → UserId = 123 |
| Query Strings | SourceDevice |
source-device |
?source-device=mobile → Source = "mobile" |
| Form Fields | UserName |
user-name |
<input name="user-name"> → UserName = "value" |
Property Binding With Razor View
AspNetConventions extends ASP.NET Core’s built-in Tag Helpers to automatically transform asp-for attribute outputs into your configured casing style. The form fields always match your standardized route conventions without any extra code in your views.
Example:
// Your page model
[BindProperty]
public string UserName { get; set; }
<!-- Your Razor view (no changes needed) -->
<input asp-for="UserName" />
Generated HTML with AspNetConventions:
<input name="user-name" id="UserName" />
This seamless integration means you never have to manually maintain HTML attribute names, see how to Enable Tag Helpers in your setup.