Built-in route constraints like :int, :guid, and :alpha are powerful — but what happens when your application needs to validate URLs against custom business rules? That's where IRouteConstraint transforms your routing from basic pattern matching into an intelligent request gateway.
Route constraints are gatekeepers in ASP.NET Core's routing pipeline. They evaluate incoming URL parameters before the request reaches your controller. If a constraint fails, the route is rejected and the framework tries the next matching route — or returns a 404.
ASP.NET Core ships with 15+ built-in constraints:
| Constraint | Example | Purpose |
|---|---|---|
:int | {id:int} | Matches only integer values |
:guid | {token:guid} | Matches GUID format |
:alpha | {name:alpha} | Matches alphabetic characters only |
:minlength(n) | {slug:minlength(3)} | Minimum string length |
:range(m,n) | {age:range(18,120)} | Integer within range |
:regex(pattern) | {code:regex(^[A-Z]{{2,5}}$)} | Matches regex pattern |
:required | {name:required} | Parameter must have a value |
Built-in constraints are syntactic — they check data types and formats. But real applications require semantic validation:
^[a-z0-9-]+${lang} to supported locales like en-us, fr-fr{status} is one of active, inactive, archivedv1, v2, v3 in the URLEvery custom route constraint implements the IRouteConstraint interface with a single method: Match().
// Constraints/SlugConstraint.cs
// Validates URLs like /blog/my-first-post (lowercase, numbers, hyphens only)
using System.Text.RegularExpressions;
public class SlugConstraint : IRouteConstraint
{
// Compiled regex for maximum performance in the hot routing path
private static readonly Regex SlugPattern = new(
@"^[a-z0-9]+(?:-[a-z0-9]+)*$",
RegexOptions.Compiled | RegexOptions.CultureInvariant
);
public bool Match(
HttpContext? httpContext,
IRouter? route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection)
{
if (values.TryGetValue(routeKey, out var value) && value is string slug)
{
// Must be 3-100 chars and match slug pattern
return slug.Length >= 3 && slug.Length <= 100 && SlugPattern.IsMatch(slug);
}
return false;
}
}
// Program.cs — Register custom constraints BEFORE routing
builder.Services.AddRouting(options =>
{
options.ConstraintMap.Add("slug", typeof(SlugConstraint));
options.ConstraintMap.Add("locale", typeof(LocaleConstraint));
options.ConstraintMap.Add("apiVersion", typeof(ApiVersionConstraint));
});
// Attribute Routing — Clean and readable
[Route("blog/{slug:slug}")]
public IActionResult GetPost(string slug)
{
// slug is guaranteed to match ^[a-z0-9]+(?:-[a-z0-9]+)*$
var post = _blogService.GetBySlug(slug);
if (post == null) return NotFound();
return View(post);
}
// Conventional Routing
app.MapControllerRoute(
name: "blog",
pattern: "blog/{slug:slug}",
defaults: new { controller = "Blog", action = "GetPost" }
);
// Constraints/LocaleConstraint.cs
public class LocaleConstraint : IRouteConstraint
{
// Supported locales — load from config in production
private static readonly HashSet<string> SupportedLocales = new(StringComparer.OrdinalIgnoreCase)
{
"en-us", "en-gb", "fr-fr", "de-de", "ja-jp", "hi-in", "es-es"
};
public bool Match(HttpContext? httpContext, IRouter? route, string routeKey,
RouteValueDictionary values, RouteDirection routeDirection)
{
if (values.TryGetValue(routeKey, out var value) && value is string locale)
{
return SupportedLocales.Contains(locale);
}
return false;
}
}
// Usage: /en-us/products, /fr-fr/products — invalid locales get 404
[Route("{lang:locale}/products")]
public IActionResult Products(string lang) => View();
// Constraints/ApiVersionConstraint.cs
public class ApiVersionConstraint : IRouteConstraint
{
// Matches v1, v2, v3 — up to v99
private static readonly Regex VersionPattern = new(@"^vd{1,2}$", RegexOptions.Compiled);
public bool Match(HttpContext? httpContext, IRouter? route, string routeKey,
RouteValueDictionary values, RouteDirection routeDirection)
{
if (values.TryGetValue(routeKey, out var value) && value is string version)
{
return VersionPattern.IsMatch(version);
}
return false;
}
}
// Usage: /api/v1/users — /api/v99/users work, /api/latest does not
[Route("api/{version:apiVersion}/[controller]")]
public class UsersController : ControllerBase { }
// Stack multiple constraints on a single parameter
[HttpGet("products/{id:int:min(1):max(999999)}")]
public IActionResult GetProduct(int id)
{
// id is guaranteed: integer, >= 1, <= 999999
return View();
}
// Or with custom + built-in
[HttpGet("shop/{category:alpha:minlength(2)}/{slug:slug}")]
public IActionResult Product(string category, string slug)
{
// category: alphabetic, min 2 chars
// slug: matches custom slug pattern
return View();
}
RegexOptions.Compiled for constraints in the hot routing pathProgram.cs with descriptive namesHashSet for lookup-based constraints (O(1) performance)Match() — return false insteadIn a high-traffic application, constraints execute in the routing middleware pipeline — before controllers, before action filters. If you put a database query inside Match(), you create a bottleneck that runs on every incoming request, even for routes that don't match. Always validate existence (e.g., "does this blog slug exist in the database?") inside your controller action, not your constraint.
Q: "When would you create a custom route constraint instead of using model validation?"
Architect Answer: "Constraints and model validation serve different architectural layers. Constraints operate at the routing level — they determine whether a URL even matches a route. If the constraint fails, the route is skipped entirely and the next route is tried (or a 404 is returned). Model validation operates at the action level — the route has already matched, and we're validating the data payload. I use constraints for structural URL rules (slug format, API version, locale) and model validation for business data rules (email format, required fields, range checks)."