Course outline

ASP.NET Core / Basic: HTTP and Application Composition / Lesson 5

Controllers and MVC API Contracts

Use controller conventions, model state, action results, and filters while keeping direct action tests distinct from framework execution.

About 5 min readContent reviewed
On this page

Lesson goals

  • Explain ControllerBase, attribute routing, and ApiController.
  • Separate model binding, model validation, and domain checks.
  • Test action intent without claiming the MVC pipeline ran.

A controller organizes HTTP actions, not every responsibility

ControllerBase supports API-oriented action results and HTTP conveniences; Controller additionally supports views. Use controllers to translate an HTTP boundary into application operations, not to accumulate SQL, authorization assumptions, filesystem work, and business invariants in one large class. Constructor injection makes dependencies visible and testable.

ApiController supplies documented conventions including attribute-routing requirements and automatic model-state rejection behavior. Those conventions depend on MVC registration/execution, not on C# attributes magically intercepting every method call. A direct new Controller().Action() invocation does not run model binding, filters, or authorization.

Service registration and endpoint mapping are both required

The non-runnable excerpt shows controller registration and mapping in an application. It omits host construction and real request execution deliberately. Adding controllers does not mean every public method becomes a safe API; action discovery, routing, attributes, filters, and application conventions determine the exposed surface.

Keep method/path contracts and result metadata consistent with actual status behavior. Avoid using entity types as unrestricted input models when clients should not set server-owned fields. Distinct request/response DTOs can make overposting and version compatibility easier to review.

csharp Illustrative controller registration and mapping
builder.Services.AddControllers();
// After the application is built:
app.MapControllers();

This host fragment is not executed and does not create a listener or run MVC binding.

Model state is not the complete domain model

ModelState can contain conversion and validation errors from MVC. ApiController can produce automatic 400 responses under its configured behavior, often with ProblemDetails-style validation information. Domain rules involving current ownership, uniqueness, or workflow state still belong in an application/domain operation. A correctly shaped model can remain unauthorized or inconsistent with persisted state.

Keep predictable error categories and avoid returning raw exception/SQL text. If automatic behavior is customized, test both the customization and the endpoint contract. Do not write unit tests expecting automatic 400 from a direct method call unless the action explicitly implements that check.

A direct action test inspects returned intent

The complete program constructs a controller and calls one action without a host. It inspects an OkObjectResult and its synthetic DTO. This is a meaningful unit check of action branching, not a request-pipeline test. Attributes remain declarations until the relevant framework components interpret them.

A controller may return ActionResult<T> or IActionResult according to its contract. Keep success, absence, invalid input, conflict, and forbidden outcomes explicit. CreatedAtAction and other link-producing results also need integration checks for routing/link generation rather than a unit assertion that only sees the result object.

csharp Inspect a direct controller action result
using System;
using Microsoft.AspNetCore.Mvc;

var controller = new ReportsController();
IActionResult response = controller.Get(7);
var ok = response as OkObjectResult ?? throw new InvalidOperationException("expected Ok result");
var report = ok.Value as ReportDto ?? throw new InvalidOperationException("expected report");
Console.WriteLine($"status={ok.StatusCode} id={report.Id}");

[ApiController]
[Route("reports")]
public sealed class ReportsController : ControllerBase
{
    [HttpGet("{id:int}")]
    public IActionResult Get(int id) => id == 7 ? Ok(new ReportDto(7)) : NotFound();
}
internal sealed record ReportDto(int Id);
Expected output
status=200 id=7

The direct call returns an Ok result containing 7; MVC routing and model-state filters are not invoked.

Filters and endpoint policies need their own boundary tests

MVC filters, endpoint middleware, and application services act at different points. An action filter is not a universal exception handler for every middleware failure, and an authorization attribute does not secure a direct method call outside the pipeline. Review scope and order rather than placing every cross-cutting concern on the controller.

Test direct branches with fakes, then use a supported isolated host test for binding, automatic validation, filters, auth, serialization, links, and HTTP status. Keep test data synthetic and provider claims honest. The lesson uses no configured app, database, external test host, or network listener.

Common errors and fixes

A direct action unit test expects ApiController’s automatic 400.

Why it happens
The MVC pipeline was not invoked.
How to fix it
Test the action’s explicit logic separately from model-state integration.
Prevent it next time
Name validation level in each test.

Client DTO exposes server-owned fields.

Why it happens
Persistence shape was reused as an unrestricted input contract.
How to fix it
Map a bounded request model to domain operations.
Prevent it next time
Test overposting and authorization boundaries.

A controller filter is expected to catch every middleware failure.

Why it happens
Different execution scopes were conflated.
How to fix it
Place error handling at the owning pipeline boundary.
Prevent it next time
Test failures before and inside MVC.

Check your understanding

1. A controller action is annotated Authorize but a unit test calls it directly as a normal method. What security claim is valid?

Show worked solution

The direct test can verify its returned logic, but it does not prove authorization was applied. The annotation is metadata interpreted by the configured pipeline. Test the policy separately and include an appropriate isolated request test for authenticated, unauthenticated, forbidden, and resource-owner cases. Do not infer access control from a successful direct call or attribute presence alone.

2. Why can a CreatedAtAction unit result be correct as an object while its public Location link is broken?

Show worked solution

Link generation depends on the actual route table, action/controller names, parameters, and execution context. A direct result inspection may never execute that work. Keep the unit check for intended status/value and add integration coverage for the generated Location and a retrievable target. Route changes are compatibility changes even if the action still compiles.

Key takeaways

  • Controllers organize HTTP translation, not every domain/infrastructure concern.
  • ApiController conventions require the MVC execution path.
  • Direct action tests and public API integration tests establish different facts.

Official references