DEV Community

Cover image for Building Your First Web API with ASP.NET Core Part 2: Controllers, Models & Your First Endpoint
Ahsan Khan
Ahsan Khan

Posted on

Building Your First Web API with ASP.NET Core Part 2: Controllers, Models & Your First Endpoint

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 Pizza model to represent our data
  • Build an in-memory data service
  • Wire up a PizzaController with 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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 Controller used in MVC tutorials. Don't use that here. Controller extends ControllerBase with 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]")]
Enter fullscreen mode Exit fullscreen mode

[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 Request without 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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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, and Delete exactly 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
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

This method:

  • Handles GET /pizza/{id} a separate route from the one above
  • Returns a 404 Not Found if no matching pizza exists, via the built-in NotFound() 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
Enter fullscreen mode Exit fullscreen mode

Then start the server:

dotnet run
Enter fullscreen mode Exit fullscreen mode

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

###
Enter fullscreen mode Exit fullscreen mode

Hit Send Request on each one. You should see:

  • /pizza/200 OK with both pizzas as a JSON array
  • /pizza/1200 OK with just "Classic Italian"
  • /pizza/5404 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 /pizza add 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)