ApiController (Attribute)

What is [ApiController]?

[ApiController] is an attribute you apply to controller classes to enable API-specific behaviors and conventions in ASP.NET Core. Think of it as “API mode” for your controllers - it automatically handles many common API tasks that you’d otherwise have to code manually.

Here’s a typical use:

[Route("api/[controller]")]
[ApiController]  // This attribute right here
public class ProductsController : ControllerBase
{
    // Your API actions
}

That single attribute gives you a collection of automatic behaviors that make building APIs easier, cleaner, and more consistent.

What Does It Do?

When you decorate a controller with [ApiController], you get these automatic behaviors:

1. Automatic HTTP 400 Responses

Without [ApiController]:

You have to manually check if the model state is valid and return appropriate errors:

public class ProductsController : ControllerBase
{
    [HttpPost]
    public ActionResult<Product> CreateProduct(Product product)
    {
        // You must manually check this
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);  // Manual error handling
        }

        // Your actual logic
        return Ok(product);
    }
}

With [ApiController]:

Model validation happens automatically - no manual checks needed:

[ApiController]
public class ProductsController : ControllerBase
{
    [HttpPost]
    public ActionResult<Product> CreateProduct(Product product)
    {
        // ModelState is automatically checked
        // If invalid, 400 BadRequest is returned automatically
        // You never get here with invalid data

        // Your actual logic - assume product is valid
        return Ok(product);
    }
}

If a client sends invalid data, they automatically get a 400 response with details about what went wrong:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": [
      "The Name field is required."
    ],
    "Price": [
      "The field Price must be between 0 and 10000."
    ]
  }
}

2. Binding Source Parameter Inference

Without [ApiController]:

You must explicitly tell ASP.NET Core where each parameter comes from:

public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public ActionResult<Product> GetProduct([FromRoute] Guid id)  // Must specify
    {
        // ...
    }

    [HttpPost]
    public ActionResult<Product> CreateProduct([FromBody] Product product)  // Must specify
    {
        // ...
    }

    [HttpGet]
    public ActionResult<IEnumerable<Product>> Search([FromQuery] string name)  // Must specify
    {
        // ...
    }
}

With [ApiController]:

The attribute infers where parameters come from based on their type and position:

[ApiController]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public ActionResult<Product> GetProduct(Guid id)  // Automatically [FromRoute]
    {
        // ...
    }

    [HttpPost]
    public ActionResult<Product> CreateProduct(Product product)  // Automatically [FromBody]
    {
        // ...
    }

    [HttpGet]
    public ActionResult<IEnumerable<Product>> Search(string name)  // Automatically [FromQuery]
    {
        // ...
    }
}

The inference rules:

  • Complex types (classes, objects) → [FromBody]
  • Simple types (string, int, Guid, etc.) in route template → [FromRoute]
  • Simple types not in route template → [FromQuery]
  • IFormFile and IFormFileCollection[FromForm]

3. Multipart/Form-Data Request Inference

When you use IFormFile or IFormFileCollection, the attribute automatically infers [FromForm]:

[ApiController]
public class FilesController : ControllerBase
{
    [HttpPost("upload")]
    public ActionResult UploadFile(IFormFile file)  // Automatically [FromForm]
    {
        // Handle file upload
        return Ok();
    }
}

4. Problem Details for Error Status Codes

When you return error status codes (4xx and 5xx), the response is automatically wrapped in a standardized problem details format (RFC 7807):

[ApiController]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public ActionResult<Product> GetProduct(Guid id)
    {
        var product = _repository.GetById(id);
        
        if (product == null)
        {
            return NotFound();  // Returns standardized problem details
        }

        return Ok(product);
    }
}

Response for NotFound():

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title": "Not Found",
  "status": 404,
  "traceId": "00-abc123..."
}

Why Use [ApiController]?

1. Less Boilerplate Code

Compare these two approaches for a simple POST action:

Without [ApiController] (manual everything):

[Route("api/products")]
public class ProductsController : ControllerBase
{
    [HttpPost]
    public ActionResult<Product> CreateProduct([FromBody] Product product)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        if (product == null)
        {
            return BadRequest("Product cannot be null");
        }

        // Actual logic
        _repository.Add(product);
        return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
    }
}

With [ApiController] (automatic behaviors):

[Route("api/products")]
[ApiController]
public class ProductsController : ControllerBase
{
    [HttpPost]
    public ActionResult<Product> CreateProduct(Product product)
    {
        // Validation already happened, product is guaranteed valid
        // Just write your business logic
        _repository.Add(product);
        return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
    }
}

2. Consistent Error Responses

All validation errors follow the same format across your entire API - no need to manually format error responses.

3. Convention Over Configuration

The attribute embraces conventions - simple types in routes come from the route, complex types come from the body. You write less code and the behavior is predictable.

Requirements for [ApiController]

To use [ApiController], your controller must:

1. Inherit from ControllerBase

// ✅ Good - inherits from ControllerBase
[ApiController]
public class ProductsController : ControllerBase
{
    // ...
}

// ❌ Bad - doesn't inherit from ControllerBase
[ApiController]
public class ProductsController
{
    // Won't work
}

2. Have a Route Attribute

You must specify a route on the controller or action:

// ✅ Good - has route attribute
[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
    // ...
}

// ❌ Bad - no route attribute
[ApiController]
public class ProductsController : ControllerBase
{
    // Won't work properly
}

Applying to Multiple Controllers

You can apply [ApiController] to individual controllers, or to a base class that all your API controllers inherit from:

Individual Controllers

[Route("api/products")]
[ApiController]
public class ProductsController : ControllerBase { }

[Route("api/orders")]
[ApiController]
public class OrdersController : ControllerBase { }

[Route("api/customers")]
[ApiController]
public class CustomersController : ControllerBase { }

Base Controller

[ApiController]
public class ApiControllerBase : ControllerBase
{
    // Common functionality for all API controllers
}

[Route("api/products")]
public class ProductsController : ApiControllerBase { }

[Route("api/orders")]
public class OrdersController : ApiControllerBase { }

[Route("api/customers")]
public class CustomersController : ApiControllerBase { }

Assembly-Level

Apply to all controllers in the assembly:

// In Program.cs or a separate file
[assembly: ApiController]

// Now all controllers automatically get the behavior
[Route("api/products")]
public class ProductsController : ControllerBase { }

Customizing Behavior

You can customize how [ApiController] behaves:

Custom Validation Response

In Program.cs, configure how validation errors are formatted:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.InvalidModelStateResponseFactory = context =>
        {
            var errors = context.ModelState
                .Where(e => e.Value.Errors.Count > 0)
                .Select(e => new
                {
                    Name = e.Key,
                    Message = e.Value.Errors.First().ErrorMessage
                })
                .ToArray();

            return new BadRequestObjectResult(new
            {
                Message = "Validation failed",
                Errors = errors
            });
        };
    });

Now your validation errors look like:

{
  "message": "Validation failed",
  "errors": [
    {
      "name": "Name",
      "message": "The Name field is required."
    },
    {
      "name": "Price",
      "message": "The field Price must be between 0 and 10000."
    }
  ]
}

Disable Automatic 400 Responses

If you want to manually handle validation:

builder.Services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.SuppressModelStateInvalidFilter = true;
    });

Now you’re back to manually checking ModelState.IsValid.

Disable Binding Source Inference

If you want to explicitly specify [FromBody], [FromRoute], etc.:

builder.Services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.SuppressInferBindingSourcesForParameters = true;
    });

Real-World Example

Here’s a complete controller showing [ApiController] in action:

using Microsoft.AspNetCore.Mvc;

namespace ProductAPI.Controllers;

[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
    private readonly IProductRepository _repository;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(
        IProductRepository repository,
        ILogger<ProductsController> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    // GET: api/products
    [HttpGet]
    public async Task<ActionResult<IEnumerable<Product>>> GetAll()
    {
        var products = await _repository.GetAllAsync();
        return Ok(products);
    }

    // GET: api/products/{id}
    [HttpGet("{id}")]
    public async Task<ActionResult<Product>> GetById(Guid id)  // id automatically from route
    {
        var product = await _repository.GetByIdAsync(id);

        if (product == null)
        {
            return NotFound();  // Automatic problem details response
        }

        return Ok(product);
    }

    // POST: api/products
    [HttpPost]
    public async Task<ActionResult<Product>> Create(Product product)  // product automatically from body
    {
        // No need to check ModelState - already validated automatically
        // If validation failed, this code never runs

        await _repository.AddAsync(product);
        
        return CreatedAtAction(
            nameof(GetById),
            new { id = product.Id },
            product);
    }

    // PUT: api/products/{id}
    [HttpPut("{id}")]
    public async Task<ActionResult> Update(Guid id, Product product)
    {
        // id from route, product from body - both automatic

        if (id != product.Id)
        {
            return BadRequest("ID mismatch");
        }

        var exists = await _repository.ExistsAsync(id);
        if (!exists)
        {
            return NotFound();
        }

        await _repository.UpdateAsync(product);
        return NoContent();
    }

    // DELETE: api/products/{id}
    [HttpDelete("{id}")]
    public async Task<ActionResult> Delete(Guid id)
    {
        var product = await _repository.GetByIdAsync(id);
        
        if (product == null)
        {
            return NotFound();
        }

        await _repository.DeleteAsync(id);
        return NoContent();
    }

    // GET: api/products/search?name=laptop&minPrice=100
    [HttpGet("search")]
    public async Task<ActionResult<IEnumerable<Product>>> Search(
        string name,      // Automatically from query string
        decimal? minPrice // Automatically from query string
    )
    {
        var products = await _repository.SearchAsync(name, minPrice);
        return Ok(products);
    }
}

Notice how clean this is:

  • No manual ModelState.IsValid checks
  • No [FromRoute], [FromBody], [FromQuery] attributes needed
  • Automatic problem details for errors
  • All validation happens before your code runs

When NOT to Use [ApiController]

There are rare cases where you might not want it:

1. MVC Controllers (Views)

If you’re returning views (HTML), don’t use [ApiController]:

// This is an MVC controller, not an API controller
public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View();  // Returns HTML
    }
}

2. Need Manual Control

If you need complete control over validation and error responses:

public class CustomController : ControllerBase
{
    [HttpPost]
    public ActionResult Create(Product product)
    {
        // Custom validation logic
        if (!IsValid(product))
        {
            return BadRequest(new CustomErrorFormat
            {
                // Your specific error format
            });
        }

        // ...
    }
}

But honestly, these cases are rare. 99% of the time, [ApiController] is exactly what you want.

Wrap Up

The [ApiController] attribute is one of those features that just makes sense - it automates the boring, repetitive parts of building APIs while giving you a consistent, predictable behavior.

Key benefits:

  • Automatic model validation - no manual ModelState.IsValid checks
  • Automatic parameter binding - infers [FromBody], [FromRoute], [FromQuery]
  • Standardized error responses - consistent problem details format
  • Less boilerplate - more time writing business logic, less time on plumbing

When building ASP.NET Core APIs, just use [ApiController] on all your controllers. It’s a modern best practice and makes your code cleaner and more maintainable. The behaviors it provides are exactly what you’d want anyway - it just does them automatically.