Skip to main content

5. Controllers

About this chapter

In this chapter we'll add a Platforms Controller to the API, this includes:

  • Creating a feature branch to host our changes
  • Creating a controller class
  • Adding Create, Read, Update and Delete endpoints
  • Creating a GitHub Pull Request and merging

Learning outcomes:

  • Understand the role of a controller in an API app
  • Understand routing
  • Understand how to create controller actions
  • Understand the role of the [ApiController] attribute
  • Understand how to pass back success and error codes
  • Understand the role of feature branches and pull requests

Architecture Checkpoint

In reference to our solution architecture we'll be making code changes to the highlighted components in this chapter:

  • Controllers (partially complete)

Figure 5.1 Chapter 5 Solution Architecture


Companion Code
  • The code for this section can be found here on GitHub
  • The complete finished code can be found here on GitHub

Feature Branch

Up until now we have just been making changes on the main branch, which is ok to start with, but the time has come to start working with feature branches.

Feature branches allow us to take a "snapshot" of main in its current state, and branch off from there - allowing us make whatever changes we like completely isolated from the working main branch.

This approach has many advantages, including but not limited to:

  • Giving us confidence to make complex or potentially breaking changes in isolation (we won't break main)
  • Managing the complexity of multiple collaborators.

When we are happy that the feature code on the feature branch is complete we can then merge those changes back in to main. There are different ways to do this but we are going to use GitHub and Pull Requests to achieve this.

tip

You can deep-dive Git by reading the theory section.

At a command prompt type:

git branch

This should return main, if not checkout main byt typing:

git checkout main

Confirming you're on the main branch, type:

git pull

This just ensures we have the latest copy of main from GitHub.

At this point there should be no one else making changes there, but in the future if you're collaborating, then it's entirely possible your colleagues will have been making changes. It's therefore always useful to start a feature branch from a recent version of main (you never know a colleague may have implemented the feature or fixed the bug you're about to work on already!)

We'll now create and checkout the feature branch by typing:

git branch chapter_5_add_a_controller
git checkout chapter_5_add_a_controller

From this point on, we're now making code changes just to the feature branch.

tip

You can type: git branch at any time to check what branch you're on, but there are other ways to get this info.

The current branch is shown in the status bar along the bottom of the VS Code window:

Figure 5.2 Current branch in VSCode

Shell plugins and enhancements can also report this info, for example I'm using the zsh shell with the Oh My Zsh plugin:

Figure 5.3 Current branch in zsh

Add a controller

Add a file called PlatformsController.cs to the existing Controllers folder in the project, and add the following code:

using CommandAPI.Data;
using CommandAPI.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

[Route("api/[controller]")]
[ApiController]
public class PlatformsController : ControllerBase
{
private readonly AppDbContext _context;

public PlatformsController(AppDbContext context)
{
_context = context;
}

[HttpGet]
public async Task<ActionResult<IEnumerable<Platform>>> GetPlatforms()
{
var platforms = await _context.Platforms.ToListAsync();

return Ok(platforms);
}
}

This code:

  • Creates a new class called PlatformsController, that inherits from ControllerBase
  • The class is decorated with the [ApiController] attribute that confers a host of default API controller behaviors on our class
  • Has a class constructor that has an AppDbContext injected into it for our use
  • Has 1 ActionResult, decorated with [HttpGet], that returns a collection of platforms
    • The endpoint (ActionResult) can be reached at the following route: <base_url>/api/platforms/
why async?

The action result is an async method returning a Task of type ActionResult<IEnumerable<Platform>>.

Using an async method has the following benefits:

  1. Non-blocking I/O operations: database operations like .ToListAsync() do not block while waiting
  2. Improved scalability: each request is handled by a thread from the thread pool. With synchronous code, that thread would be blocked waiting for the database. With async, the thread can handle other incoming requests while waiting, allowing the API to serve more concurrent requests with the same resources.
  3. Better resource utilization: Threads are expensive resources. By not blocking them during I/O operations, you reduce thread pool starvation and improve overall application performance under load.

You can read more about the async/await pattern in the theory section.

Testing

We are in a position to test the endpoint we have, we'll do this by creating a .http file to test all requests to the PlatformsController.

In the Requests folder, create a new file called: platforms.http, and add the following code:

@baseUrl = https://localhost:7276
@baseUrlHttp = http://localhost:5181

### Get all platforms
GET {{baseUrl}}/api/platforms
tip

Double check the port numbers you are using are consistent with what you'd find in Properties/launchSettings.json

Then at a command prompt type:

docker ps

This just ensures that docker is running with the PostgreSQL container (if you're working your way through the book you make have stopped Docker)

If Docker Desktop or your container is not started, do so before typing:

dotnet run

As before, in the .http file, right click the request and select: Send Request, you should get as response as follows:

HTTP/1.1 200 OK
Connection: close
Content-Type: application/json; charset=utf-8
Date: Thu, 08 Jan 2026 14:20:54 GMT
Server: Kestrel
Transfer-Encoding: chunked

[]

As you can see this returns an empty JSON array - which is what we'd expect.

Routing

Before moving onto implementing the remainder of the endpoints for this chapter, it's worth circling back to routing.

In addition to [ApiController], the controller class is also decorated with the following attribute that sets up the route pattern for the entire controller:

[Route("api/[controller]")]

The route token ([controller]) is a placeholder that .NET replaces at runtime with the controllers name, minus the Controller suffix, so in this case the route token would be platforms, rendering the route as: /api/platforms.

Of course that is not the entire route, as we need to account for:

  • The base url: the values can be found in Properties/launchSettings.json
  • The verb to be used as part of the request
http verbs

As already discussed in the Project Overview, http verbs are of key importance in REST APIs. The verb plus the route make the endpoint unique.

Remaining endpoints

We're going to add the new endpoints to the controller now. In order that the code changes take place be sure to restart the API every time you make changes, i.e:

  • CTRL + C to stop
  • dotnet run to start up again

Code changes made to an app started with dotnet run will not take effect until the app is restarted.

dotnet watch

Instead of using dotnet run to start .NET apps, you can use dotnet watch which implements hot reload functionality, meaning that the app will auto-restart every time code changes are made.

I've personally don't use this as I've found the results to be inconsistent, so I stick with dotnet run.

Get a platform by ID

Add the following code to the platforms controller, it should reside inside the class declaration, at the same level as the GetPlatforms:

[HttpGet("{id}", Name = "GetPlatformById")]
public async Task<ActionResult<Platform>> GetPlatformById(int id)
{
var platform = await _context.Platforms.FirstOrDefaultAsync(p => p.Id == id);
if (platform == null)
return NotFound();

return Ok(platform);
}

This code:

  • Defines that the endpoint responses to GET requests
  • Defines an additional route parameter {id} that expects an id to be passed (in the url) with the request, e.g.: /api/platforms/4
    • The [ApiController] attribute automatically binds the route parameter {id} to the method parameter int id without requiring explicit binding attributes like [FromRoute].
  • Defines a Name for the route, which allows it to be referenced elsewhere (see the Create endpoint)
  • Attempts to retrieve a platform from the database with the supplied id
    • If it cannot find one - it will return NotFound() which is the 404 HTTP status code
    • If it finds one, then it will return a HTTP 200 (OK) with the platform object

To test this endpoint, update the platforms.http file to include the following request:

### Get platform by ID
GET {{baseUrl}}/api/platforms/1

Run as before, and you should get a 404 not found as we don't have any Platforms in the DB:

HTTP/1.1 404 Not Found
Content-Length: 0
Connection: close
Date: Thu, 08 Jan 2026 14:49:32 GMT
Server: Kestrel
info

While I'll continue to specify the requests that should be added to the platforms.http file, I'm not going to continue to specify that you need to test - this is now assumed.

Indeed, in reference to the endpoint we just added (GetPlatformById), I'd expect you to re-test this yourself once we implement the Create endpoint.

Create a platform

Add the following code to the platforms controller, it should reside inside the class declaration, at the same level as:

  • GetPlatforms
  • GetPlatformById
[HttpPost]
public async Task<ActionResult<Platform>> CreatePlatform(Platform platform)
{
if (platform == null)
{
return BadRequest();
}
await _context.Platforms.AddAsync(platform);
await _context.SaveChangesAsync();

return CreatedAtRoute(nameof(GetPlatformById), new { Id = platform.Id }, platform);
}

This code:

  • Defines that the endpoint responses to POST requests
  • Checks to see if the platform model we pass in is null or not.
    • If it is null return BadRequest which is a HTTP 400 response
    • If not null then attempts to add to the database
  • When adding to the database the use of SaveChangesAsync() is mandatory, otherwise changes will not cascade to the DB
  • Finally, as per REST convention, we return:
    • The platform that was created
    • The route at which the platform can be reached (we reference the GetPlatformById by Name)

To test this endpoint, update the platforms.http file to include the following request:

### Create a new platform
POST {{baseUrl}}/api/platforms
Content-Type: application/json

{
"platformName": "Kubernetes"
}

When run, this should return the following:

HTTP/1.1 201 Created
Connection: close
Content-Type: application/json; charset=utf-8
Date: Thu, 08 Jan 2026 15:19:11 GMT
Server: Kestrel
Location: https://localhost:7276/api/Platforms/1
Transfer-Encoding: chunked

{
"id": 1,
"platformName": "Kubernetes"
}

Update a platform

Add the following code to the platforms controller, it should reside inside the class declaration, at the same level as:

  • GetPlatforms
  • GetPlatformById
  • CreatePlatform
[HttpPut("{id}")]
public async Task<ActionResult> UpdatePlatform(int id, Platform platform)
{
var platformFromContext = await _context.Platforms.FirstOrDefaultAsync(p => p.Id == id);
if (platformFromContext == null)
{
return NotFound();
}

//Manual Mapping
platformFromContext.PlatformName = platform.PlatformName;

await _context.SaveChangesAsync();

return NoContent();

}

This code:

  • Defines that the endpoint responses to PUT requests
  • Defines an additional route parameter {id} that expects an id to be passed (in the url) with the request, e.g.: /api/platforms/4
  • Attempts to retrieve a platform from the database with the supplied id
    • If it cannot find one - it will return NotFound() which is the 404 HTTP status code
    • If it finds one, then we updatePlatformName by using manual mapping (we discuss object mapping later)
    • We save changes
    • Return NoContent() which is a HTTP 204 response code

To test this endpoint, update the platforms.http file to include the following request:

### Update a platform
PUT {{baseUrl}}/api/platforms/1
Content-Type: application/json

{
"platformName": "Docker Updated"
}
tip

Run whatever test scenarios you feel will adequately exercise this endpoint.

Delete a platform

Add the following code to the platforms controller, it should reside inside the class declaration, at the same level as:

  • GetPlatforms
  • GetPlatformById
  • CreatePlatform
  • UpdatePlatform
[HttpDelete("{id}")]
public async Task<ActionResult> DeletePlatform(int id)
{
var platformFromContext = await _context.Platforms.FirstOrDefaultAsync(p => p.Id == id);
if (platformFromContext == null)
{
return NotFound();
}
_context.Platforms.Remove(platformFromContext);
await _context.SaveChangesAsync();

return NoContent();
}

Aside from the use of the [HttpDelete] attribute and the Remove method, there's nothing particularly novel about this code.

To test this endpoint, update the platforms.http file to include the following request:

### Delete a platform
DELETE {{baseUrl}}/api/platforms/1

Version Control

With all the endpoints added (for now), and all the requests behaving as expected, we can now merge our feature branch into main as follows:

  • Save all files

Then at a command prompt:

git add .
git commit -m "add the platforms controller to the API"
git push

The last command will not push our code to GitHub, instead it will return with an error, suggesting the command we need to run:

fatal: The current branch chapter_5_add_a_controller has no upstream branch.
To push the current branch and set the remote as upstream, use

git push --set-upstream origin chapter_5_add_a_controller

Copy that line and execute it:

git push --set-upstream origin chapter_5_add_a_controller

That should push the code to GitHub.

tip

I always use this approach (git push) knowing it will fail, and then giving me what I need. If you're comfortable directly entering in the correct line 1st time - please do so.

Move over to GitHub and select your repository, you should see something like this:

Figure 5.4 Compare pull request

  • Click Compare & pull request, this should take you to the Open a pull request screen. We really should add further narrative to the description (feel free to do so), in the interests of brevity I wont.

Figure 5.5 Open pull request

When happy click: Create pull request this takes you to the merge pull request screen:

Figure 5.6 Merge pull request

info

In production GitHub environments, this screen would usually:

  • Require you to invite someone to review your code
  • Have an AI review your code
  • Provide output from a CI/CD pipeline - detailing how regression testing (amongst other things) is doing

In short the raising of a Pull Request is just the start of what can be quite an elaborate workflow, designed to ensure that only quality code makes its way to production.

We'll cover more of these topics later in the book.

For now, we can just Merge pull request!

Click: Merge pull request, then: Confirm merge to merge the changes into main. You should see the following:

Figure 5.7 Merged pull request

This means that main on GitHub has been updated as the central source of truth. Locally all we have right now is:

  • Out feature branch
  • Local main - which is now out of date

At a command prompt back on your machine:

git checkout main
git pull

This will checkout the main local branch and pull any new changes down from GitHub.

At this point everything is synched.

If you type:

git branch

You will be listed both main (current branch) and the feature branch we just completed. What happens to this branch? You can delete it if you like:

git branch -D chapter_5_add_a_controller

Or leave it in place for now. If the PR was still open and being reviewed for example, you'd definitely keep it as chances are you'd need to do some updates.

We'll leave Git there for now.

tip

We'll be using feature branches throughout the rest of the book, so while I'll prompt you to create a new feature branch at the start of each chapter, I'll be keeping instructions very basic to avoid bloat, and more importantly to get you into the habit of learning the workflow if you're not familiar with it.

Conclusion

This was a more focused chapter on Controllers (although we did spend some time on Git as well). I don't feel there's anything too challenging about this chapter, if you want to dive deeper into any of the concepts than I'd suggest the following theory:

Technically we have a working API with some basic CRUD endpoints. However this is very basic and just the start, we have long way to go to upgrade it.

In the next part we enhance the data layer, starting with implementing Data Transfer Objects.