How Routing Works in ASP.NET Core Web API

How Routing Works in ASP.NET Core Web API

Routing is the heart of any ASP.NET Core Web API application. It is the mechanism that decides which controller action should handle a given HTTP request. When you type https://example.com/api/products/5, the framework must intelligently determine which method inside your code should run.

In ASP.NET Core, routing is no longer based on the old RouteTable.Routes structure of ASP.NET MVC. Instead, it uses a modern, high-performance Endpoint Routing System. This system dynamically builds and stores route information in memory and connects it to controller actions through middleware components.

To truly understand it, let’s travel step-by-step through what happens from application startup to request execution, and see where the route data lives and how it’s used.

Step 1: Application Startup Begins

When your application starts, Program.cs is the first file that runs. It configures everything the framework needs to function, including routing.

namespace RoutingInASPNETCoreWebAPI
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.
            builder.Services.AddControllers();
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.UseAuthorization();

            app.MapControllers();

            app.Run();
        }
    }
}
The Program class does not have an app.UseRouting(), but the application still works. How?

Starting from .NET 6 and the Minimal Hosting Model, ASP.NET Core automatically wires up routing internally. Even though app.UseRouting() and app.UseEndpoints() aren’t visible; they are implicitly included inside the generated pipeline when you call app.MapControllers() or app.MapGet() / app.MapPost().

So, the routing middleware is present, but the new hosting model hides this code for simplicity. In earlier versions (3.1 / 5.0), you had to manually write:

  • app.UseRouting();
  • app.UseEndpoints(endpoints => endpoints.MapControllers());

Now this happens automatically under the hood. So, when you call the app.MapControllers(), the routing and endpoint middleware are registered automatically in the correct order.

Step 2: AddControllers() Registers MVC Services

When we call the builder.Services.AddControllers(), it tells ASP.NET Core to load all the services required for Web API functionality, such as:

  • Controller Discovery and Activation
  • Model Binding and Validation
  • Routing Metadata Providers

At this point, No Actual Routes Exist Yet. The framework loads the services and prepares the logic needed to discover controller classes later and build endpoints from their attributes.

Think of it like gathering ingredients before cooking; the recipe is ready, but no dish has been made yet. That means nothing is built yet, but everything is ready.

Step 3: Build() Finalizes the Service Container

When the builder.Build() executes, the Dependency Injection (DI) container is finalized. This means all services, including routing-related services, are registered and ready to be used during pipeline construction. Still, no routes exist at this point. The app now only knows how to build them, but it hasn’t done so yet.

Step 4: UseRouting() Adds the Routing Middleware

The app.UseRouting() (implicit in modern templates) adds the Routing Middleware to the request processing pipeline. This middleware’s job at runtime will be to:

  1. Read the incoming URL.
  2. Match it to a known endpoint.
  3. Store the matched endpoint in the HttpContext.

However, when this line runs during startup, it only registers the middleware. It does not yet know about any routes. Those are created next.

Step 5: MapControllers() Creates Endpoint Definitions

When you call the app.MapControllers(), the real magic starts. ASP.NET Core performs Controller Discovery, scanning all assemblies for classes ending in “Controller” and reading their route attributes. For example:

using Microsoft.AspNetCore.Mvc;
namespace RoutingInASPNETCoreWebAPI.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class ProductsController : ControllerBase
    {
        private static readonly List<Product> _products = new()
        {
            new Product { Id = 1, Name = "Laptop", Price = 55000 },
            new Product { Id = 2, Name = "Keyboard", Price = 1200 },
            new Product { Id = 3, Name = "Mouse", Price = 800 }
        };

        [HttpGet]
        public IActionResult GetAll()
        {
            return Ok(_products);
        }

        [HttpGet("{id}")]
        public IActionResult GetById(int id)
        {
            var p = _products.FirstOrDefault(x => x.Id == id);
            if (p == null) return NotFound($"Product {id} not found.");
            return Ok(p);
        }

        [HttpPost]
        public IActionResult Create(Product newProduct)
        {
            newProduct.Id = _products.Max(x => x.Id) + 1;
            _products.Add(newProduct);
            return CreatedAtAction(nameof(GetById), new { id = newProduct.Id }, newProduct);
        }

        [HttpPut("{id}")]
        public IActionResult Update(int id, Product updated)
        {
            var existing = _products.FirstOrDefault(x => x.Id == id);
            if (existing == null) return NotFound();
            existing.Name = updated.Name;
            existing.Price = updated.Price;
            return Ok(existing);
        }

        [HttpDelete("{id}")]
        public IActionResult Delete(int id)
        {
            var p = _products.FirstOrDefault(x => x.Id == id);
            if (p == null) return NotFound();
            _products.Remove(p);
            return NoContent();
        }
    }

    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; } = null!;
        public decimal Price { get; set; }
    }
}

When this controller is discovered during the app.MapControllers():

  • [Route(“api/[controller]”)] becomes api/products
  • ASP.NET Core builds the following RouteEndpoint objects in memory:
    1. GET /api/products → maps to GetAll()
    2. GET /api/products/{id} → maps to GetById(int id)
    3. POST /api/products → maps to Create(Product newProduct)
    4. PUT /api/products/{id} → maps to Update(int id, Product updatedProduct)
    5. DELETE /api/products/{id} → maps to Delete(int id)

Each endpoint has:

  • A Route Pattern (like /api/products/{id})
  • A RequestDelegate that encapsulates how to execute the controller pipeline
  • Metadata (HTTP Verb, Filters, Attributes)
What is RequestDelegate?

RequestDelegate is a compiled function that represents everything required to process a request for that endpoint. For every endpoint, the framework generates one such delegate that encapsulates:

  • Model Binding: It reads data from the HTTP request (query string, body, headers, route values) and maps it to action method parameters or model objects.
  • Validation: It checks whether the bound model or input data meets the validation rules defined using data annotations or custom validators.
  • Filter Execution: It runs various filters (like authorization, action, or exception filters) that can inspect, modify, or short-circuit the request before or after the action executes.
  • Controller Activation: It creates an instance of the controller class using the dependency injection container.
  • Action Invocation: It calls the matched action method on the controller and waits for it to complete (synchronously or asynchronously).
  • Response Writing: It serializes the action’s result (like JSON, XML, or plain text) and writes it to the HTTP response stream with the appropriate status code and headers.

Each route has its own RequestDelegate. You can consider it as a pre-built mini-pipeline that knows how to run that endpoint from start to finish.

Step 6: Where the Routing Information Is Stored

Once all endpoints are discovered, ASP.NET Core stores them in a memory-based collection called EndpointDataSource. It’s an in-memory list managed by dependency injection. There are multiple data sources under the hood:

  • Controller Endpoints → managed by ControllerActionEndpointDataSource
  • Razor Pages → managed by PageActionEndpointDataSource
  • Minimal APIs → managed by RouteEndpointDataSource

All of these are combined into one CompositeEndpointDataSource, which the routing middleware uses to perform matches.

Step 7: Request Enters the Pipeline

Now, when the app starts running, the middleware pipeline looks like this:

How Routing Works in ASP.NET Core Web API

When a client sends a request like: GET /api/products/5, Kestrel receives it and passes it into the middleware pipeline.

Step 8: The Routing Middleware Matches the Request

When the request hits the Routing Middleware, the following happens:

  1. The middleware reads the incoming URL (/api/products/5).
  2. It compares it against the route patterns stored in the compiled EndpointDataSource.
  3. It finds a match, /api/products/{id}.
  4. It extracts route values (id = 5) and attaches the matched RouteEndpoint to the current HttpContext.

Now your HttpContext literally knows which controller action will handle this request. This is stored temporarily using:

  • context.SetEndpoint(matchedEndpoint);
  • context.Request.RouteValues[“id”] = 5;

At this stage, no controller is called yet; only the correct route is selected.

Step 9: Authorization and Other Middleware Run

After routing, other middlewares such as Authentication, Authorization, or CORS execute. These components can read metadata attached to the matched endpoint, for example, [Authorize] or [AllowAnonymous] attributes, and take appropriate actions before the controller runs.

Step 10: The Endpoint Middleware Executes the Action

Finally, when the request reaches the Endpoint Middleware (automatically added by MapControllers()), the framework executes the endpoint’s RequestDelegate. That delegate internally performs:

  • Model Binding
  • Validation
  • Controller Activation
  • Action Method Invocation
  • Response Writing

This is where your controller action actually runs, processes the request, and returns the response.

What is Endpoint Middleware?
  • Routing Middleware = decides which endpoint should handle the request.
  • Endpoint Middleware = actually executes that endpoint’s compiled delegate.

Think of Routing Middleware as a Navigator choosing the route, and Endpoint Middleware as the Driver following that route to completion.

Step 11: Response Sent Back to the Client

After the action method completes, the response is written back into the HttpContext.Response stream. The response then travels back through the middleware pipeline to Kestrel, which sends it to the client.

How to See the Registered Routing Endpoints in ASP.NET Core Web API

You can inspect all the registered routing endpoints in your ASP.NET Core web api application. To see the same, please create an empty API Controller named DiagnosticsController within the Controllers folder, and then copy-paste the following code. The following code is self-explained, so please read the comment lines for a better understanding.

using Microsoft.AspNetCore.Mvc;

namespace RoutingInASPNETCoreWebAPI.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class DiagnosticsController : ControllerBase
    {
        // ASP.NET Core injects all registered EndpointDataSource instances.
        // Each source provides endpoints for controllers, Razor pages, minimal APIs, etc.
        private readonly IEnumerable<EndpointDataSource> _endpointDataSources;

        public DiagnosticsController(IEnumerable<EndpointDataSource> endpointDataSources)
        {
            _endpointDataSources = endpointDataSources;
        }

        // Returns detailed runtime information about all discovered endpoints,
        // including route templates, parameters, constraints, defaults, HTTP methods, and metadata.
        [HttpGet("endpoints")]
        public ActionResult<IEnumerable<EndpointDetailsDTO>> GetAllEndpointsDetailed()
        {
            var results = new List<EndpointDetailsDTO>();

            // Iterate through each endpoint source registered in DI.
            foreach (var dataSource in _endpointDataSources)
            {
                // Each data source may represent controllers, minimal APIs, Swagger, etc.
                foreach (var endpoint in dataSource.Endpoints)
                {
                    // Extract Common Metadata

                    // RouteNameMetadata and EndpointNameMetadata store developer-assigned names.
                    var routeName = endpoint.Metadata.GetMetadata<IRouteNameMetadata>()?.RouteName;
                    var endpointName = endpoint.Metadata.GetMetadata<EndpointNameMetadata>()?.EndpointName;

                    // HttpMethodMetadata contains information about allowed HTTP verbs (GET, POST, etc.)
                    var httpMethodMetadata = endpoint.Metadata.GetMetadata<HttpMethodMetadata>();
                    var httpMethods = httpMethodMetadata?.HttpMethods?.ToArray() ?? Array.Empty<string>();

                    // Attribute metadata: collects attribute types applied to controller actions (e.g., [HttpGet], [Authorize]).
                    var attributeTypeNames = endpoint.Metadata
                        .OfType<Attribute>()
                        .Select(a => a.GetType().FullName ?? a.GetType().Name)
                        .ToArray();

                    // Initialize DTO with base details
                    var details = new EndpointDetailsDTO
                    {
                        DisplayName = endpoint.DisplayName, // human-readable identifier
                        EndpointType = endpoint.GetType().FullName ?? endpoint.GetType().Name, // RouteEndpoint, Endpoint, etc.
                        IsRouteEndpoint = endpoint is RouteEndpoint, // true if it supports a route pattern
                        RouteName = routeName,
                        EndpointName = endpointName,
                        HttpMethods = httpMethods,
                        AttributeMetadataTypeNames = attributeTypeNames
                    };

                    // Extract Route-Specific Data (if this is a RouteEndpoint)
                    if (endpoint is RouteEndpoint routeEndpoint)
                    {
                        var pattern = routeEndpoint.RoutePattern;

                        // Raw route pattern text (e.g., "api/products/{id:int:min(1)=1}")
                        details.Pattern = pattern.RawText;

                        // Route Defaults
                        // Example: {id=1} produces "id" -> "1"
                        if (pattern.Defaults.Count > 0)
                        {
                            details.PatternDefaults = pattern.Defaults
                                .ToDictionary(kv => kv.Key, kv => kv.Value?.ToString());
                        }

                        // Route Parameters
                        // Each parameter may have:
                        //   - a name (e.g., "id")
                        //   - an optional flag (e.g., "{id?}")
                        //   - a default value (e.g., "{id=1}")
                        //   - constraints/policies (e.g., ":int", ":min(1)")
                        if (pattern.Parameters.Count > 0)
                        {
                            details.Parameters = pattern.Parameters
                                .Select(p => new RouteParameterDetailsDTO
                                {
                                    Name = p.Name,
                                    IsOptional = p.IsOptional,
                                    DefaultValue = p.Default?.ToString(),

                                    // Each ParameterPolicyReference contains a "Content" object that describes the constraint.
                                    // Converting it to string gives readable names like "int", "min(1)", etc.
                                    Policies = p.ParameterPolicies
                                        .Select(pol => pol.Content?.ToString() ?? string.Empty)
                                        .ToArray()
                                })
                                .ToArray();
                        }
                    }

                    // Add to results
                    results.Add(details);
                }
            }

            // Return as JSON
            return Ok(results);
        }
    }

    //DTO DEFINITIONS
    // Represents detailed information about a single endpoint.
    public sealed class EndpointDetailsDTO
    {
        // ---------- General ----------
        public string? DisplayName { get; set; }             // e.g., "ProductsController.GetById (RoutingInASPNETCoreWebAPI)"
        public string? EndpointType { get; set; }            // e.g., "Microsoft.AspNetCore.Routing.RouteEndpoint"
        public bool IsRouteEndpoint { get; set; }            // true if it's a RouteEndpoint (as opposed to a non-routed endpoint like Swagger)

        // ---------- Route Endpoint Specific ----------
        public string? Pattern { get; set; }                 // Full route pattern string (e.g., "api/products/{id:int:min(1)=1}")
        public Dictionary<string, string?>? PatternDefaults { get; set; } // Route-level or parameter-level default values
        public RouteParameterDetailsDTO[]? Parameters { get; set; }       // All parameters in the pattern

        // ---------- Metadata ----------
        public string? RouteName { get; set; }               // From [HttpGet(Name = "...")] or similar
        public string? EndpointName { get; set; }            // From EndpointNameMetadata (used by minimal APIs)
        public string[] HttpMethods { get; set; } = Array.Empty<string>(); // Allowed HTTP verbs (GET, POST, PUT, etc.)
        public string[] AttributeMetadataTypeNames { get; set; } = Array.Empty<string>(); // Attribute metadata attached to this endpoint
    }

    // Represents details about a single route parameter, including constraints and defaults.
    public sealed class RouteParameterDetailsDTO
    {
        public string Name { get; set; } = string.Empty;     // Parameter name (e.g., "id")
        public bool IsOptional { get; set; }                 // True if the parameter is optional ({id?})
        public string? DefaultValue { get; set; }            // Default value if defined ({id=1})
        public string[] Policies { get; set; } = Array.Empty<string>(); // Parameter constraints/policies (e.g., "int", "min(1)")
    }
}

Now, run the application and access the GET /api/diagnostics/endpoints endpoint, and you should see the list of all registered routing endpoints.

Understanding Core Routing Components in ASP.NET Core Web API:

This is one of the most crucial parts of understanding ASP.NET Core’s Endpoint Routing Architecture, how these core classes (Endpoint, RouteEndpoint, EndpointDataSource, and CompositeEndpointDataSource) work together internally.

Understanding Core Routing Components in ASP.NET Core Web API

Let’s go step-by-step in a structured, conceptual, and visual way so that you will understand how these classes fit together in memory, what role each plays, and how they interact when a request comes in.

The Endpoint Class:

At the very center of the Endpoint Routing system is the Endpoint class. Think of it as the “contract” or common representation of anything that can handle a request in ASP.NET Core, whether it’s a controller action, a Razor Page, or a Minimal API handler.

When the Routing Middleware matches a URL, it doesn’t know (or care) what kind of target it’s routing to; it only cares that the target is an Endpoint.

What It Contains

An Endpoint has three main things:

  1. A RequestDelegate: A compiled function (a delegate) that knows how to handle the request.
  2. A Metadata Collection: A list of objects describing additional behaviours (for example, [Authorize], HTTP method filters, display names, etc.).
  3. A DisplayName: A friendly identifier, useful for debugging or logging.

This abstraction allows ASP.NET Core to treat all request targets, such as Controllers, Razor Pages, Minimal APIs, SignalR hubs, etc., uniformly.

Example

When the Routing Middleware finds a match, it stores the selected Endpoint inside the HttpContext like this:

  • context.SetEndpoint(matchedEndpoint);

Later, the Endpoint Middleware will execute:

  • await context.GetEndpoint().RequestDelegate(context);

That’s how a matched route eventually becomes a running controller action. 

The RouteEndpoint Class:

While Endpoint is a generic concept that represents any executable destination, such as Web APIs, MVC controllers, and Razor Pages, it relies on route templates (like /api/products/{id}). For these cases, ASP.NET Core uses a subclass called RouteEndpoint. RouteEndpoint extends Endpoint and adds Routing-Specific Data, such as the Pattern, Parameters, Constraints, and Defaults.

What It Adds

RouteEndpoint includes:

  • A RoutePattern property: represents the parsed structure of the route template (like “api/products/{id?}”). This route pattern also stores the Parameters, Constraints, and Defaults.
  • An Order property: used for route priority when multiple routes could match.
  • Everything is inherited from Endpoint (RequestDelegate, Metadata, DisplayName).

So, if Endpoint is the “engine,” RouteEndpoint is the “engine plus navigation map.”

Why It Exists

ASP.NET Core supports endpoints that don’t always have a route. For example:

  • A SignalR Hub endpoint doesn’t follow route templates like api/{controller}.
  • Static file endpoints or Swagger UI endpoints may not have variable route patterns.

Therefore, not all endpoints can or should have a RoutePattern. This is why RouteEndpoint was designed separately. It allows only the endpoints that need route patterns to have them, while others remain lightweight Endpoint instances. Hence, every controller action that has a route attribute becomes a RouteEndpoint, stored in memory.

The EndpointDataSource

EndpointDataSource is an abstraction that provides a collection of endpoints to the routing system. Think of it as a “Repository” or “Factory” that produces all the endpoints belonging to a specific subsystem (like MVC, Razor Pages, or Minimal APIs). Each subsystem in ASP.NET Core registers its own EndpointDataSource when you configure the app. For example:

  • When you call the app.MapControllers(), the MVC framework registers a ControllerActionEndpointDataSource.
  • When you call the app.MapRazorPages(), Razor registers a PageActionEndpointDataSource.
  • When you add Minimal APIs (like app.MapGet(“/weather”, …)), the runtime adds a RouteEndpointDataSource.

All of these are specialized versions of EndpointDataSource.

What It Does Internally

Each data source:

  1. Scans the application for its relevant handlers (like controllers or pages).
  2. Builds RouteEndpoint objects (or Endpoint objects for non-routed handlers).
  3. Makes them available through a property: IReadOnlyList<Endpoint> Endpoints { get; }

When the application starts, the routing system queries all registered EndpointDataSource instances and combines their endpoints for matching.

Real-time Analogy:

You can think of each EndpointDataSource as a department in a large company, each responsible for producing a specific type of task.

  • The “Controllers Department” (MVC) produces endpoints for controller actions.
  • The “Razor Department” produces endpoints for pages.
  • The “Minimal API Department” produces endpoints for lightweight handlers.

Later, a manager (the routing middleware) collects all these tasks into one big list to decide which one will execute.

The CompositeEndpointDataSource

Since multiple data sources can exist (controllers, Razor, minimal APIs, hubs, etc.) in a single ASP.NET Core Application, the routing system needs a way to treat them all as one logical collection. That’s exactly what the CompositeEndpointDataSource does. This class acts as a wrapper that merges all individual EndpointDataSource instances into one unified data source.

How It Works

When the application starts:

  1. Each subsystem registers its own EndpointDataSource.
  2. The framework automatically creates a single CompositeEndpointDataSource that aggregates them all.
  3. The Routing Middleware uses this composite object to perform request matching.

So, when a request like /api/products/5 arrives, the routing system doesn’t query just the controller data source; it queries the composite, which internally checks across all registered endpoint sources. This ensures that no matter how an endpoint was defined, it can be discovered and matched in a single unified way.

How These Classes Work Together at Runtime

To connect the dots, here’s the actual flow from startup to request execution:

During Startup
  • Each framework component (MVC, Razor, Minimal API) registers its own EndpointDataSource.
  • ASP.NET Core creates a CompositeEndpointDataSource that wraps all these sources.
  • Each source builds its respective Endpoint or RouteEndpoint objects in memory.
When a Request Arrives
  • The Routing Middleware retrieves all endpoints from the CompositeEndpointDataSource.
  • It uses a matcher to compare the request’s path and HTTP method against the available endpoints.
  • If a match is found, the corresponding Endpoint (often a RouteEndpoint) is attached to the HttpContext.
  • Finally, the Endpoint Middleware executes the matched endpoint’s RequestDelegate.
During Execution
  • The framework executes your controller action (if it’s a RouteEndpoint).
  • The HttpContext carries endpoint metadata so filters, authorization, and model binding can access it.
Conclusion

Routing in ASP.NET Core Web API is a multi-phase process built on a modern Endpoint Routing System. Routes are discovered dynamically during startup and stored in memory inside EndpointDataSource objects.

When a request arrives, the Routing Middleware finds the best match and attaches it to the HttpContext. Then, the Endpoint Middleware executes the corresponding controller action using the RequestDelegate.

In the next article, I will discuss the different Controller Action Method Return Types in ASP.NET Core Web API Applications with Examples. In this article, I try to explain Route Constraints in ASP.NET Core Web API applications with examples. And I hope you enjoy this article.

Leave a Reply

Your email address will not be published. Required fields are marked *