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)

- 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.
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.
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:

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

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 fromControllerBase - The class is decorated with the
[ApiController]attribute that confers a host of default API controller behaviors on our class- Read more about this is the theory section
- Has a class constructor that has an
AppDbContextinjected 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/
- The endpoint (
The action result is an async method returning a Task of type ActionResult<IEnumerable<Platform>>.
Using an async method has the following benefits:
- Non-blocking I/O operations: database operations like
.ToListAsync()do not block while waiting - 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. - 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
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
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 runto start up again
Code changes made to an app started with dotnet run will not take effect until the app is restarted.
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
GETrequests - 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 parameterint idwithout requiring explicit binding attributes like[FromRoute].
- The
- Defines a
Namefor 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
- If it cannot find one - it will return
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
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:
GetPlatformsGetPlatformById
[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
POSTrequests - Checks to see if the
platformmodel we pass in isnullor not.- If it is
nullreturnBadRequestwhich is a HTTP 400 response - If not
nullthen attempts to add to the database
- If it is
- 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
platformthat was created - The route at which the
platformcan be reached (we reference theGetPlatformByIdby Name)
- The
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:
GetPlatformsGetPlatformByIdCreatePlatform
[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
PUTrequests - 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 update
PlatformNameby using manual mapping (we discuss object mapping later) - We save changes
- Return
NoContent()which is a HTTP 204 response code
- If it cannot find one - it will return
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"
}
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:
GetPlatformsGetPlatformByIdCreatePlatformUpdatePlatform
[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.
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:

- 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.

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

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
- We cover testing later.
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:

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.
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.