Skip to main content

Command Palette

Search for a command to run...

ASP.NET 9: Contextual Controller Actions

Published
5 min read

Most developers treat Controllers as simple "Model Binders" (Input → Process → Output). However, to build high-performance or complex middleware-like logic, you must understand the Context—the execution environment surrounding the controller.

1. The Context Hierarchy (The "Russian Doll" Model)

In ASP.NET Core, "Context" is not a single object. It is a layered wrapper. Understanding this hierarchy helps you know exactly where to look for data.

A. HttpContext (The Raw Web)

  • What it is: The lowest level abstraction above the server (Kestrel).

  • Contains: Raw Request/Response streams, Connection info, User Identity, WebSockets manager.

  • Lifetime: Exists strictly for the duration of the request.

  • Corner Case: Never capture HttpContext in a singleton service or a background thread. It is not thread-safe and is disposed when the request ends. accessing it later causes ObjectDisposedException or unpredictable nulls.

B. ActionContext (The Routing Layer)

  • What it is: HttpContext + Routing Intelligence.

  • Contains:

    • RouteData: Which route matched? (e.g., id=5, controller=Home).

    • ModelState: Validation errors occurred during binding.

    • ActionDescriptor: Metadata about the method being called (attributes, name).

C. ControllerContext (The Application Layer)

  • What it is: ActionContext + Controller Instance.

  • Usage: This is what this.HttpContext or this.Request inside a controller actually points to.


2. Method Injection ([FromServices])

The Problem: Constructor Injection bloat.

Imagine a ReportsController with 5 actions. Only one action (GeneratePdf) needs the heavy IPdfGenerator service.

If you inject IPdfGenerator into the Constructor, the runtime must create/resolve it for every request to that controller, even for the 4 actions that don't use it.

The Solution: Inject the dependency specifically into the Action method.

public class ReportsController : ControllerBase
{
    // 1. Constructor: Only keep "Shared" dependencies here
    public ReportsController(ILogger<ReportsController> logger) { ... }

    [HttpGet("simple-data")]
    public IActionResult GetJson() 
    {
        // IPdfGenerator is NOT instantiated here. Faster startup.
        return Ok(...);
    }

    // 2. Method Injection: Dependency is resolved ONLY when this action runs.
    [HttpPost("generate-pdf")]
    public async Task<IActionResult> GeneratePdf(
        [FromBody] ReportData data,
        [FromServices] IPdfGenerator pdfGen) // <--- OPTIMIZATION
    {
        var bytes = await pdfGen.CreateAsync(data);
        return File(bytes, "application/pdf");
    }
}

Corner Case:

Do not use [FromServices] for standard services used by multiple methods. It clutters the method signature. Use it strictly for heavy, rarely used, or exclusive dependencies.


3. The FeatureCollection (The "Metal")

HttpContext is actually just a wrapper around a collection of "Features". If you need raw access to the underlying server (Kestrel/IIS), you use HttpContext.Features.1

This is critical for advanced networking, proxies, or identifying the client's physical connection.

Example: Getting the Real Connection Info

Standard Request.RemoteIpAddress can be spoofed or hidden by proxies. The Feature collection gives you the raw socket truth.

[HttpGet("debug-connection")]
public IActionResult GetConnectionInfo()
{
    // Access the low-level connection feature
    var connectionFeature = HttpContext.Features.Get<IHttpConnectionFeature>();

    if (connectionFeature != null)
    {
        string localIp = connectionFeature.LocalIpAddress?.ToString();
        int localPort = connectionFeature.LocalPort;
        string remoteIp = connectionFeature.RemoteIpAddress?.ToString();
        string connectionId = connectionFeature.ConnectionId; // Unique Kestrel ID

        return Ok(new { localIp, localPort, remoteIp, connectionId });
    }
    return BadRequest("Not running on Kestrel?");
}

Other Powerful Features:

  • IHttpUpgradeFeature: Used to manually upgrade to WebSockets (as seen in the previous topic).

  • ITlsConnectionFeature: Access the client's SSL/TLS Client Certificate (Mutual TLS auth).

  • IHttpResponseBodyFeature: Allows taking over the response stream completely (bypassing MVC output formatters).


4. Binding Source Control (Granular Input)

ASP.NET Core attempts to be smart: if you have a parameter string id, it looks in Route → Query → Form. Sometimes "smart" is dangerous. You must force the source.

The Attributes

  • [FromRoute]: /users/{id} (URL Path)

  • [FromQuery]: /users?id=5 (URL Query String)

  • [FromBody]: JSON/XML payload.

  • [FromHeader]: HTTP Headers (e.g., X-API-Key).2

  • [FromForm]: multipart/form-data (File uploads).

The Conflict Scenario

Imagine a PUT request to update a user:

PUT /users/5?id=99 with body { "id": 77 }

Which ID is used?

  • If you just use Update(int id, User model), the result is ambiguous.

  • Best Practice: Be explicit.

[HttpPut("{routeId}")]
public IActionResult Update(
    [FromRoute] int routeId,      // Takes "5" (Trusted Source)
    [FromQuery] int? metadataId,  // Takes "99" (Optional)
    [FromBody] UserUpdateDto body // Takes body.Id "77"
)
{
    if (routeId != body.Id)
    {
        return BadRequest("Path ID and Body ID mismatch."); // Security Check
    }
    // ...
}

Corner Case: The Empty Body Trap

By default, ASP.NET Core throws a 400 Error if [FromBody] is missing.

If the body is optional, you must allow empty input:

public IActionResult Post([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] MyModel model)


5. Async Action Filters (Contextual Interception)

This is the most powerful pattern for "Contextual" logic.

An Action Filter wraps the execution of a specific controller action.3 It is like "Middleware", but it has access to the specific C# arguments passed to the method.

Scenario: We want to prevent modifying a "Locked" Document.

Instead of writing if (doc.IsLocked) in every method, we write an attribute.

The Attribute Implementation

public class EnsureNotLockedAttribute : Attribute, IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context, 
        ActionExecutionDelegate next)
    {
        // 1. INSPECT THE CONTEXT
        // We can access the actual arguments passed to the controller method!
        if (context.ActionArguments.TryGetValue("documentId", out var idObj) 
            && idObj is int docId)
        {
            // Resolve services from the HttpContext (Service Locator pattern is valid here)
            var db = context.HttpContext.RequestServices.GetRequiredService<AppDbContext>();

            var isLocked = await db.Documents
                .AnyAsync(d => d.Id == docId && d.IsLocked);

            if (isLocked)
            {
                // 2. SHORT-CIRCUIT
                // We set the Result directly. The Controller Action will NEVER run.
                context.Result = new BadRequestObjectResult("This document is locked.");
                return;
            }
        }

        // 3. CONTINUE
        // Execute the actual controller action
        var executedContext = await next();

        // 4. POST-PROCESSING (Optional)
        // You can inspect the result *after* the controller finishes
        if (executedContext.Result is OkObjectResult okResult)
        {
            // e.g., Add a header to the response
            context.HttpContext.Response.Headers.Add("X-Doc-Status", "Checked");
        }
    }
}

Usage

[HttpPut("{documentId}")]
[EnsureNotLocked] // <--- The Logic is applied here
public async Task<IActionResult> UpdateDocument(int documentId, [FromBody] DocDto dto)
{
    // This code ONLY runs if the document is NOT locked.
    // Clean, readable, focused on business logic.
    return Ok();
}

Summary

  1. Context Access: Use HttpContext for raw request data, but never store it.

  2. Performance: Use [FromServices] to keep constructors lightweight for large controllers.

  3. Features: Use HttpContext.Features when you need socket/TLS/network level data.4

  4. Binding: Always use [FromRoute], [FromBody], etc., to explicitly define where data comes from. Avoid ambiguity.

  5. Interception: Use IAsyncActionFilter to inspect arguments or block execution based on context before the controller runs.

More from this blog

.

.Net Core

32 posts