This is Part 2 of a 4-part series. In Part 1, we set up the project and got our first response from the default weather endpoint. Now it's time to build something that actually belongs to us.
Where We Left Off
At the end of Part 1, you had a running ASP.NET Core project with a scaffolded WeatherForecastController. We hit it in the browser and got back JSON great. But that controller isn't ours, and it doesn't do anything useful for a pizza inventory system.
In this part, we'll:
- Understand how ASP.NET Core controllers work under the hood
- Create a
Pizzamodel to represent our data - Build an in-memory data service
- Wire up a
PizzaControllerwith working GET endpoints
How Controllers Actually Work
Before writing our own controller, let's decode the sample one that came with the project. Here's the full WeatherForecastController:
using Microsoft.AspNetCore.Mvc;
namespace ContosoPizza.Controllers;
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild",
"Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
There's a lot happening in a small amount of code. Let's break it down.
The Base Class: ControllerBase
A controller is just a public class whose public methods are exposed as HTTP endpoints. By convention, controllers live in the Controllers/ directory and their class names end with Controller.
This class inherits from ControllerBase, which gives it everything needed to handle HTTP requests parsing request data, returning status codes, sending responses. You don't write any of that plumbing yourself.
⚠️ Important: You might have seen
Controllerused in MVC tutorials. Don't use that here.ControllerextendsControllerBasewith view-rendering support, which is meant for pages not APIs.
The Two Key Attributes
Two attributes sit on top of the class definition:
[ApiController]
[Route("[controller]")]
[ApiController] turns on a set of smart, opinionated behaviors that make API development easier:
- It automatically infers where parameters come from (query string, body, route, etc.)
- It enforces attribute routing no routes work without explicit
[Route]or HTTP verb attributes - It handles model validation errors automatically, returning a
400 Bad Requestwithout extra code
[Route("[controller]")] maps the URL pattern for this controller. The [controller] token is a placeholder it gets replaced at runtime with the controller's class name minus the Controller suffix. So WeatherForecastController handles requests to /weatherforecast, and our upcoming PizzaController will handle /pizza.
The Action Method
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
The [HttpGet] attribute marks this method as the handler for HTTP GET requests to this controller's route. When someone hits GET /weatherforecast, this method runs and its return value gets automatically serialized to JSON.
Creating the Pizza Model
Now that we understand the structure, let's build our own. We'll start with the data model.
1. Create a Models folder:
mkdir Models
2. Inside it, create Pizza.cs and add the following:
namespace ContosoPizza.Models;
public class Pizza
{
public int Id { get; set; }
public string? Name { get; set; }
public bool IsGlutenFree { get; set; }
}
Simple and intentional an ID, a name, and a gluten-free flag. This is our core data shape. The Models/ directory name follows the model-view-controller convention, keeping things organized as the project grows.
Building the In-Memory Data Service
In a production app, you'd reach for a database. For now, we'll use a static in-memory list fast to set up, easy to understand, and perfectly fine for learning the API layer.
1. Create a Services folder:
mkdir Services
2. Inside it, create PizzaService.cs:
using ContosoPizza.Models;
namespace ContosoPizza.Services;
public static class PizzaService
{
static List<Pizza> Pizzas { get; }
static int nextId = 3;
static PizzaService()
{
Pizzas = new List<Pizza>
{
new Pizza { Id = 1, Name = "Classic Italian", IsGlutenFree = false },
new Pizza { Id = 2, Name = "Veggie", IsGlutenFree = true }
};
}
public static List<Pizza> GetAll() => Pizzas;
public static Pizza? Get(int id) => Pizzas.FirstOrDefault(p => p.Id == id);
public static void Add(Pizza pizza)
{
pizza.Id = nextId++;
Pizzas.Add(pizza);
}
public static void Delete(int id)
{
var pizza = Get(id);
if (pizza is null) return;
Pizzas.Remove(pizza);
}
public static void Update(Pizza pizza)
{
var index = Pizzas.FindIndex(p => p.Id == pizza.Id);
if (index == -1) return;
Pizzas[index] = pizza;
}
}
A few things worth noting here:
- The list is static, meaning it's shared across all requests for the lifetime of the app
- It starts with two seed pizzas so we have something to query immediately
- Every time you stop and restart the server, the list resets that's expected for in-memory storage
- The service exposes
GetAll,Get,Add,Update, andDeleteexactly the CRUD surface our API will need
Creating the PizzaController
With our model and service in place, it's time to hook everything together.
1. Inside Controllers/, create PizzaController.cs:
using ContosoPizza.Models;
using ContosoPizza.Services;
using Microsoft.AspNetCore.Mvc;
namespace ContosoPizza.Controllers;
[ApiController]
[Route("[controller]")]
public class PizzaController : ControllerBase
{
public PizzaController() { }
// GET all action
// GET by Id action
// POST action
// PUT action
// DELETE action
}
The comments are placeholders we'll fill them in as we go. For now, the controller is wired to handle requests to /pizza, thanks to [Route("[controller]")].
Adding the GET Endpoints
Let's implement the two read operations first.
GET all pizzas
Replace the // GET all action comment with:
[HttpGet]
public ActionResult<List<Pizza>> GetAll() =>
PizzaService.GetAll();
This method:
- Responds exclusively to
GET /pizza - Returns an
ActionResult<List<Pizza>>the wrapper type that lets ASP.NET Core handle serialization and status codes correctly - Pulls the full list from our service and automatically sends it as JSON
GET a single pizza by ID
Replace the // GET by Id action comment with:
[HttpGet("{id}")]
public ActionResult<Pizza> Get(int id)
{
var pizza = PizzaService.Get(id);
if (pizza == null)
return NotFound();
return pizza;
}
This method:
- Handles
GET /pizza/{id}a separate route from the one above - Returns a
404 Not Foundif no matching pizza exists, via the built-inNotFound()helper - Returns the pizza object directly (serialized to JSON) if found
Here's how the status codes map:
| Result | HTTP Status | When |
|---|---|---|
| Returns pizza | 200 OK |
ID matched a pizza in the list |
NotFound() |
404 Not Found |
No pizza with that ID exists |
Build & Test
Make sure everything compiles cleanly:
dotnet build
Then start the server:
dotnet run
Test with the .http file
Open ContosoPizza.http and add these requests below the existing ones:
GET {{ContosoPizza_HostAddress}}/pizza/
Accept: application/json
###
GET {{ContosoPizza_HostAddress}}/pizza/1
Accept: application/json
###
GET {{ContosoPizza_HostAddress}}/pizza/5
Accept: application/json
###
Hit Send Request on each one. You should see:
-
/pizza/→200 OKwith both pizzas as a JSON array -
/pizza/1→200 OKwith just "Classic Italian" -
/pizza/5→404 Not Found(that ID doesn't exist)
That last one is important your API handles bad input gracefully without any extra work from you.
What's Coming in Part 3?
The read side of our API is working. In Part 3, we'll implement the write operations:
-
POST /pizzaadd a new pizza -
PUT /pizza/{id}update an existing one -
DELETE /pizza/{id}remove it from the list
We'll also cover how IActionResult differs from ActionResult<T> and why it matters for write operations.
Questions so far? Drop them in the comments. See you in Part 3,!
Tags: dotnet csharp webapi aspnetcore beginners
Top comments (0)