Introduction
URL routing is a fundamental part of ASP.NET Core applications, determining how incoming requests are mapped to controller actions. While the framework provides numerous built-in route constraints like int
, bool
, and guid
, there are situations where you need more sophisticated validation rules for your URL parameters. This is where custom route constraints become invaluable.
In this guide, we’ll explore how to create and implement custom route constraints in ASP.NET Core using the IRouteConstraint
interface. By the end, you’ll understand how to validate URL parameters with complex business rules specific to your application.
Understanding Route Constraints
Route constraints in ASP.NET Core allow you to restrict how URL parameters are matched when routing incoming requests. They’re specified in route templates using the syntax {parameter:constraint}
, where constraint
defines the validation rule.
When the ASP.NET Core routing system processes an incoming request, it evaluates these constraints to determine if the URL matches a defined route. If any constraint fails, the route is not matched, and the framework continues searching for a matching route or returns a 404 error if none is found.
The IRouteConstraint Interface
At the heart of custom route constraints is the IRouteConstraint
interface. It’s pretty straightforward, you only need to implement a single method called Match
:
public interface IRouteConstraint
{
bool Match(
HttpContext httpContext,
IRouter route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection);
}
Don’t let all those parameters intimidate you. They’re actually quite useful:
httpContext
gives you access to the current request, which can be handy if you need to check headers or other request dataroute
references the route this constraint is attached torouteKey
is just the name of your parameter (like “id” in “{id:alpha}”)values
contains all the route values from the URL, this is where you’ll find the actual value to validaterouteDirection
tells you if the framework is matching an incoming URL or generating a URL (most of the time you’ll focus on incoming requests)
Creating a Custom Route Constraint
Let’s implement a simple but useful custom route constraint that checks if a parameter contains only alphabetical characters:
public class AlphaConstraint : IRouteConstraint
{
public bool Match(
HttpContext httpContext,
IRouter route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection)
{
// Check if the parameter exists and has a value
if (!values.TryGetValue(routeKey, out var value) || value == null)
{
return false;
}
// Verify the value is a string and contains only letters
var paramValue = value.ToString();
return !string.IsNullOrEmpty(paramValue) && paramValue.All(char.IsLetter);
}
}
This constraint ensures that the parameter:
- Exists in the route values
- Is not null
- Contains only alphabetical characters (A-Z, a-z)
Registering Your Custom Constraint
Before you can use your custom constraint in route templates, you need to register it with the routing system. In modern .NET 8/9 applications, this is done in the Program.cs
file:
// In Program.cs (.NET 8/9)
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddControllers();
// Register the custom constraint
builder.Services.Configure<RouteOptions>(options =>
{
options.ConstraintMap.Add("alpha", typeof(AlphaConstraint));
});
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
The ConstraintMap
is a dictionary that maps constraint names to their implementing types. After registration, you can use your custom constraint in route templates using the name you provided (in this case, “alpha”).
Using Custom Constraints in Routes
Once registered, you can use your custom constraint in various routing scenarios:
In Controller Route Attributes
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("{id:alpha}")]
public IActionResult GetProduct(string id)
{
// Only executes if id contains only letters
return Ok($"Product with alphabetic ID: {id}");
}
[HttpGet("code/{code:alpha:length(6)}")]
public IActionResult GetProductByCode(string code)
{
// Only executes if code contains exactly 6 letters
return Ok($"Product with code: {code}");
}
}
In Conventional Routing
app.MapControllerRoute(
name: "products",
pattern: "products/{category:alpha}/{id:int}",
defaults: new { controller = "Products", action = "GetByCategoryAndId" }
);
In Minimal API Routes
// In Program.cs with .NET 8/9 minimal APIs
app.MapGet("api/products/{sku:alpha:length(10)}", (string sku, HttpContext context) =>
{
// Access the current user with new .NET 8/9 Identity features
var user = context.User;
// Use typed results for better API documentation
return TypedResults.Ok(new { Sku = sku, LookupTime = DateTime.UtcNow });
});
Creating a More Complex Custom Constraint
Let’s implement a more practical constraint that validates a product code with a specific format (three letters followed by four digits):
public class ProductCodeConstraint : IRouteConstraint
{
// Regex for 3 letters followed by 4 digits
private static readonly Regex _regex =
new Regex(@"^[A-Za-z]{3}[0-9]{4}$", RegexOptions.Compiled);
public bool Match(
HttpContext httpContext,
IRouter route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection)
{
if (!values.TryGetValue(routeKey, out var value) || value == null)
{
return false;
}
var valueString = value.ToString();
return !string.IsNullOrEmpty(valueString) &&
_regex.IsMatch(valueString);
}
}
After registering this constraint as “productCode”:
options.ConstraintMap.Add("productCode", typeof(ProductCodeConstraint));
You can use it in your routes:
[HttpGet("products/{code:productCode}")]
public IActionResult GetProductByCode(string code)
{
// Only executes for product codes that match the format (e.g., ABC1234)
return Ok($"Found product with code: {code}");
}
Combining Multiple Constraints
One powerful feature of ASP.NET Core routing is the ability to combine multiple constraints for a single parameter. This allows for more precise route matching:
// Matches only if id is alphabetic and between 5-10 characters
[HttpGet("products/{id:alpha:length(5,10)}")]
public IActionResult GetProduct(string id)
{
return Ok($"Product ID: {id}");
}
You can also combine custom and built-in constraints:
// Using our custom constraint with built-in min/max constraint
[HttpGet("orders/{region:alpha:minlength(2):maxlength(3)}")]
public IActionResult GetOrdersByRegion(string region)
{
return Ok($"Orders for region: {region}");
}
Using Constraints with Dependency Injection
For more advanced scenarios, your custom constraint might need access to services registered in the dependency injection (DI) container. Here’s how to implement a constraint that uses DI in .NET 8/9:
public class ValidCompanyConstraint : IRouteConstraint
{
private readonly ICompanyService _companyService;
public ValidCompanyConstraint(ICompanyService companyService)
{
_companyService = companyService;
}
public bool Match(
HttpContext httpContext,
IRouter route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection)
{
if (!values.TryGetValue(routeKey, out var value) || value == null)
{
return false;
}
string companyId = value.ToString();
// Use the injected service to validate the company ID
// Leveraging new async-over-sync pattern with Task.WaitAsync for timeout support
try
{
return _companyService.CompanyExistsAsync(companyId)
.WaitAsync(TimeSpan.FromSeconds(1))
.GetAwaiter()
.GetResult();
}
catch (TimeoutException)
{
// Log timeout and fail gracefully
return false;
}
}
}
Register this constraint in your .NET 8/9 application:
// Register your service with the appropriate lifetime
builder.Services.AddScoped<ICompanyService, CompanyService>();
// Register the constraint with dependency resolution
builder.Services.Configure<RouteOptions>(options =>
{
options.ConstraintMap.Add("validCompany", serviceProvider =>
new ValidCompanyConstraint(
serviceProvider.GetRequiredService<ICompanyService>()));
});
Best Practices for Custom Route Constraints
When implementing custom route constraints, follow these best practices:
Keep constraints focused: Each constraint should validate a single aspect of the parameter.
Consider performance: Since constraints are evaluated for every request, optimize for performance. For complex regex patterns, use the
RegexOptions.Compiled
flag.Handle null values properly: Always check if the parameter exists and has a value before validation.
Use meaningful names: Choose constraint names that clearly describe their purpose (e.g., “zipCode”, “ssn”).
Document constraints: Add XML comments to explain what each constraint does and what format it expects.
Consider case sensitivity: Be explicit about whether your constraint is case-sensitive.
Unit test your constraints: Ensure your constraints work correctly in isolation and with different inputs.
Conclusion
Custom route constraints in ASP.NET Core provide a powerful way to validate URL parameters according to specific business rules. By implementing the IRouteConstraint
interface, you can create constraints that perfectly match your application’s requirements.
Custom constraints improve your application in several ways:
- They enforce a consistent URL structure across your API
- They prevent unnecessary processing of invalid requests
- They make your routing self-documenting by clearly defining acceptable parameter formats
- They reduce boilerplate validation code in your controllers
With the knowledge gained from this guide, you can now create sophisticated routing rules that enhance both the security and usability of your ASP.NET Core applications. Remember that well-designed URLs are an essential part of a professional web API, and custom constraints help ensure your routes follow your application’s business rules.