Writing Your First Controller: Routing, Attributes and Action Methods
We write our first real controller. How routing works, what attributes do, how action methods are built, and how an incoming request reaches the right method, step by step.
In the previous article we talked about the difference between controllers and Minimal APIs, and I said we'd proceed mainly with the controller-based approach in this series. We're keeping our word. Today we write our first real controller from scratch and understand, line by line, how an incoming request reaches this class and what happens inside. This article has a bit more of a "get your hands dirty" feel; so try every example you read in your own project too.
What Is a Controller, Really?
The name sounds fancy, but at its core a controller is just an ordinary C# class. The only thing that makes it special is that ASP.NET Core recognizes this class as "the place that handles incoming HTTP requests." So a controller is like a reception clerk standing between requests coming from the outside world and your business logic. A request arrives, the controller receives it, does the right operation, and returns a response.
A controller usually deals with a single resource. Everything about users is gathered in UsersController, everything about products in ProductsController. This is the code reflection of the resource-oriented thinking we discussed in the first article. When you look at a class, you should be able to tell which resource it deals with just from its name.
Creating the First Controller
We return to the KullaniciApi project we set up in the second article. There may be a folder named Controllers inside the project; if not, create it. Add a file named UsersController.cs inside it and write this:
using Microsoft.AspNetCore.Mvc;
namespace KullaniciApi.Controllers;
[ApiController]
[Route("users")]
public class UsersController : ControllerBase
{
[HttpGet]
public IActionResult GetAll()
{
var users = new[] { "Anna", "Tom", "Sophie" };
return Ok(users);
}
}
When you run the project and go to /users in the browser, you'll meet a JSON array of three names. You just wrote your first endpoint. Now let's unpack every piece of this tiny bit of code one by one, because every line here has a job.
Attributes: Labels Attached to the Class
Expressions in square brackets like [ApiController], [Route("users")] and [HttpGet] are called attributes. Think of them as little labels you attach to a class or a method. They sit there even when the code isn't running and give .NET instructions like "let this class behave this way."
[ApiController] says that this class is a Web API controller and brings a few conveniences along with it. The handiest one is that when there's a validation error in the incoming data, it automatically catches it and returns a proper error response without you having to check by hand. We'll come back to this topic in the validation article.
[Route("users")] determines the address under which all endpoints in this class will be gathered. So everything in this class will respond to an address starting with /users. Think of it as a sign hung on the class's door: "From here on, everything is about users."
Routing: How Does a Request Reach the Right Method?
Routing is the job of looking at an incoming request's address and directing it to the right method of the right controller. Think of a telephone switchboard: the caller dials a number, and the switchboard connects them to the right department. Routing does exactly this, only it uses a URL instead of a number.
In the example above, two pieces come together. The [Route("users")] above the class specifies the first part of the address, while the [HttpGet] above the method specifies both the HTTP method and that this method will respond to that address. Combined, they produce the rule "when a GET request comes to /users, run the GetAll method."
Now let's make things a bit more interesting. We want to fetch a single user by their id. For this, we add a new method to the class:
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
return Ok($"Requested user: {id}");
}
The [HttpGet("{id}")] here adds one more piece to the address. Now this method responds to /users/{id}; the id inside the curly braces is a placeholder. So when a request comes to /users/42, .NET takes the value 42 from the address and places it into the method's id parameter. When you go to /users/42 in the browser, you'll see the response "Requested user: 42." This value that passes automatically from the address into the method is called a route parameter.
Action Methods: Where the Actual Work Is Done
The public methods inside a controller that respond to a request are called action methods. GetAll and GetById are both action methods. You name them yourself; .NET decides which method to call not by the method's name but by looking at the attributes above it. So even if you named the method Everything instead of GetAll, it would work as long as [HttpGet] sits there. Still, giving meaningful names is a big favor to the next person reading the code (and that person is often you, six months later).
Pay attention to the return type of these methods: IActionResult. This is the way to say "this method will return an HTTP response, but exactly what kind of response it'll be may vary depending on the situation." Sometimes you might return a successful result, sometimes an error, sometimes a "not found" response. IActionResult provides that flexibility.
Returning the Right Response
In the examples we used the Ok(...) helper method. This returns the response along with a 200 OK status code; in other words, it's the short way of saying "everything's fine, here's the data you asked for." Because we inherit from the ControllerBase class, we have a whole bunch of ready-made helpers like this at hand. A few examples:
return Ok(user); // 200 - success, with data
return NotFound(); // 404 - resource not found
return BadRequest("Error"); // 400 - something's wrong with the request
return NoContent(); // 204 - success but no data to return
Think of these as little helpers: each one both sets the right status code and adds the data if needed. Returning the right status code is critical so that the side using your API understands what happened. Whether it returned 200 or 404 tells a lot on its own. We'll dive into status codes in much more detail in the next article; for now, knowing Ok and NotFound is enough.
A Small Experiment
Before moving on to the next article, do this: add a little logic to UsersController. Inside the GetById method, if the incoming id value is less than or equal to zero, return BadRequest; otherwise, continue with Ok. Then try both a valid id and a negative value from the browser and see how the returned responses change. This small exercise will show you how natural it is to make decisions inside an action method.
In the next article we'll cover HTTP status codes and the subtleties of returning the right response in depth: the difference between IActionResult and Results, which code to return in which situation, and the little details that make life easier for those using your API. Don't delete this first controller you wrote today; we'll keep building on top of it.