Skip to main content

    Lesson 31 • Advanced Track

    Building REST APIs with ASP.NET Core

    By the end of this lesson you'll be able to design a REST API the way professionals do — modelling resources, mapping each HTTP verb to the right action, returning correct status codes, shaping data with DTOs, and choosing between minimal APIs and controllers. You'll practise the underlying logic in the runner, then read production-ready ASP.NET Core worked examples.

    What You'll Learn

    • Apply REST principles: resources, statelessness, and a uniform interface
    • Map HTTP verbs (GET, POST, PUT, DELETE) to read/create/update/delete
    • Return the right status codes (200, 201, 204, 400, 404) for each outcome
    • Build endpoints two ways: minimal APIs vs controllers, and when to use each
    • Use model binding and DTOs to control exactly what your API accepts and returns
    • Version an API and avoid over-posting, missing validation, and leaking entities

    💡 Real-World Analogy

    A REST API is like a restaurant menu of resources. The menu lists the things you can ask for — dishes, drinks, the bill — and these are your resources (/api/orders, /api/products). The HTTP verbs are the actions you take with each one: GET reads the menu (show me the dishes), POST places an order (create something new), PUT changes your order, and DELETE cancels it. The kitchen always replies with a status code — "here you go" (200), "your order is in" (201), "we don't have that" (404). You never walk into the kitchen and grab a pan yourself; you go through the waiter (the API), and the menu is the same for every customer (a uniform interface).

    📊 HTTP Verbs & Status Codes

    VerbMeansSuccess codeExample route
    GETRead200 OKGET /api/products
    POSTCreate201 CreatedPOST /api/products
    PUTUpdate / replace200 OKPUT /api/products/1
    DELETERemove204 No ContentDELETE /api/products/1
    Status codeMeaningWhen to return it
    200 OKSuccess, here's dataA successful GET or PUT
    201 CreatedNew resource madeA successful POST (add a Location header)
    204 No ContentSuccess, nothing to sendA successful DELETE
    400 Bad RequestClient sent bad inputValidation failed
    404 Not FoundNo such resourceThe id doesn't exist

    1. What "REST" Actually Means

    REST (Representational State Transfer) is a set of conventions for building web APIs around resources — the nouns your system is about, like products, orders, or users. Each resource gets a URL (/api/products/1), and you act on it with the standard HTTP verbs instead of inventing your own. Two ideas matter most. First, statelessness: every request carries everything the server needs, so the server doesn't remember you between calls — that's what lets an API scale to thousands of servers. Second, a uniform interface: GET always reads and never changes data, POST always creates, and so on, so any developer can guess how your API behaves. Get the nouns and verbs right and the rest of the design follows.

    2. Resources, Verbs & Status Codes (Worked Example)

    Here's a complete minimal API exposing a /api/todos resource. Read each endpoint and the // ✅ Expected output comment beside it — that comment is the HTTP response the call returns. Notice how each verb maps to one action and one success status code, and how a missing resource always returns 404 Not Found. This is the whole CRUD shape in one file.

    Worked example • ASP.NET Core (read-only)

    A minimal API for a Todo resource. The comments show the exact HTTP response for each call.

    // Program.cs — a minimal API (ASP.NET Core 8+)
    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    
    // An in-memory "resource" collection. Each Todo is a RESOURCE the API exposes.
    var todos = new List<Todo>
    {
        new(1, "Learn C#", true),
        new(2, "Build an API", false)
    };
    var nextId = 3;
    
    // GET = READ a collection.  Always returns 200 OK with the data.
    app.MapGet("/api/todos", () => Results.Ok(todos));
    //   GET /api/todos           -> 200 OK   [{ "id":1, ... }, { "id":2, ... }]
    
    // GET one resource by id.  Found -> 200 OK, missing -> 404 Not Found.
    app.MapGet("/api/todos/{id}", (int id) =>
    {
        var todo = todos.FirstOrDefault(t => t.Id == id);
        return todo is not null ? Results.Ok(todo) : Results.NotFound();
    });
    //   GET /api/todos/1         -> 200 OK   { "id":1, "title":"Learn C#", "isDone":true }
    //   GET /api/todos/99        -> 404 Not Found
    
    // POST = CREATE.  Returns 201 Created + a Location header to the new resource.
    app.MapPost("/api/todos", (CreateTodoDto dto) =>
    {
        var todo = new Todo(nextId++, dto.Title, false);
        todos.Add(todo);
        return Results.Created($"/api/todos/{todo.Id}", todo);
    });
    //   POST /api/todos  { "title":"Ship it" }  -> 201 Created  Location: /api/todos/3
    
    // PUT = REPLACE/UPDATE an existing resource.  Returns 200 OK or 404.
    app.MapPut("/api/todos/{id}", (int id, UpdateTodoDto dto) =>
    {
        var i = todos.FindIndex(t => t.Id == id);
        if (i == -1) return Results.NotFound();
        todos[i] = todos[i] with { Title = dto.Title, IsDone = dto.IsDone };
        return Results.Ok(todos[i]);
    });
    //   PUT /api/todos/1  { "title":"Learn C# well", "isDone":true } -> 200 OK
    
    // DELETE = REMOVE.  Returns 204 No Content (success, nothing to send back).
    app.MapDelete("/api/todos/{id}", (int id) =>
        todos.RemoveAll(t => t.Id == id) > 0 ? Results.NoContent() : Results.NotFound());
    //   DELETE /api/todos/2      -> 204 No Content
    //   DELETE /api/todos/99     -> 404 Not Found
    
    app.Run();
    
    // Records make tidy DTOs — the SHAPE the API sends and receives.
    record Todo(int Id, string Title, bool IsDone);
    record CreateTodoDto(string Title);
    record UpdateTodoDto(string Title, bool IsDone);

    This needs a web server, so it won't run in the browser runner — but you'll model its logic in the runnable exercise just below.

    Now you try the logic behind it in plain C# — exactly what each endpoint decides before it sends a response. Fill in the ___ blanks, then run it.

    🎯 Your turn: status codes & routes

    Return the right status string and build the route, then check the two lines.

    Try it Yourself »
    C#
    using System;
    
    class Program
    {
        // 🎯 YOUR TURN — model the LOGIC of a REST endpoint with plain C#.
        // (Real ASP.NET returns these as HTTP responses; here we just print them.)
    
        // 1) Return "200 OK" when the resource was found, else "404 Not Found".
        static string StatusFor(bool found)
        {
            return found ? ___ : ___;   // 👉 "200 OK"   and   "404 Not Found"
        }
    
        // 2) Build the route string for a verb + resource id, e.g. "GET /api/todos/7".
        static string Route(str
    ...

    3. Minimal APIs vs Controllers

    ASP.NET Core gives you two styles. Minimal APIs (the example above) define endpoints with a line each — app.MapGet(...), app.MapPost(...) — with almost no ceremony, which is perfect for microservices and small services. Controllers group related endpoints into a class decorated with attributes like [HttpGet] and [Route]; they scale better for large APIs because they centralise routing, model binding, filters, and validation. They're equally "REST" — the difference is organisation, not behaviour. Reach for minimal APIs when an app is small or focused, and controllers when it grows many endpoints that share cross-cutting concerns.

    Worked example • ASP.NET Core (read-only)

    The same kind of API as a controller — with model binding, DTOs, and versioning in the route.

    using Microsoft.AspNetCore.Mvc;
    
    // Controllers/ProductsController.cs
    [ApiController]                       // turns on auto model binding + auto 400 on invalid input
    [Route("api/v1/[controller]")]        // [controller] -> "products". Route: /api/v1/products
    public class ProductsController : ControllerBase
    {
        private static readonly List<Product> _products = new()
        {
            new() { Id = 1, Name = "Laptop", Price = 999.99m },
            new() { Id = 2, Name = "Mouse",  Price = 24.99m }
        };
    
        // GET /api/v1/products?minPrice=100   ([FromQuery] = read from the query string)
        [HttpGet]
        public ActionResult<IEnumerable<ProductDto>> GetAll([FromQuery] decimal? minPrice)
        {
            var items = _products.Where(p => minPrice is null || p.Price >= minPrice);
            // Map ENTITIES -> DTOs so the API shape never leaks internal columns.
            return Ok(items.Select(p => new ProductDto(p.Id, p.Name, p.Price)));
        }
        //   GET /api/v1/products?minPrice=100 -> 200 OK  [{ "id":1,"name":"Laptop","price":999.99 }]
    
        // GET /api/v1/products/1   ({id} is bound from the route by model binding)
        [HttpGet("{id:int}")]
        public ActionResult<ProductDto> GetById(int id)
        {
            var p = _products.FirstOrDefault(x => x.Id == id);
            if (p is null) return NotFound();                 // 404
            return Ok(new ProductDto(p.Id, p.Name, p.Price)); // 200
        }
        //   GET /api/v1/products/1   -> 200 OK   { "id":1, "name":"Laptop", "price":999.99 }
        //   GET /api/v1/products/99  -> 404 Not Found
    
        // POST /api/v1/products    ([FromBody] = bind the JSON request body to the DTO)
        [HttpPost]
        public ActionResult<ProductDto> Create([FromBody] CreateProductDto dto)
        {
            // [ApiController] already rejected an invalid body with 400 before we got here.
            var product = new Product { Id = _products.Max(p => p.Id) + 1, Name = dto.Name, Price = dto.Price };
            _products.Add(product);
            var result = new ProductDto(product.Id, product.Name, product.Price);
            return CreatedAtAction(nameof(GetById), new { id = product.Id }, result); // 201
        }
        //   POST /api/v1/products  { "name":"Keyboard", "price":59.99 }
        //     -> 201 Created   Location: /api/v1/products/3
    }
    
    // ENTITY — the internal storage shape (could map to a database row).
    public class Product { public int Id; public string Name = ""; public decimal Price; }
    
    // DTOs — the PUBLIC shape the API exposes. Never return the entity directly.
    public record ProductDto(int Id, string Name, decimal Price);
    public record CreateProductDto(string Name, decimal Price);

    Read the [FromQuery] / [FromBody] attributes — that's model binding, covered next.

    4. Model Binding & DTOs

    Model binding is ASP.NET Core reading values out of the incoming request and handing them to your method as typed parameters. {id} in the route binds to an int id parameter; [FromQuery] reads the query string (?minPrice=100); [FromBody] deserialises the JSON request body into an object. A DTO (Data Transfer Object) is the dedicated shape for that data — one DTO for what the client may send (CreateProductDto) and one for what you send back (ProductDto). DTOs matter because your internal entity might have fields you must never expose (a password hash, an internal cost) or never let the client set (the Id, an IsAdmin flag). Records make DTOs a one-liner. Practise building one now.

    🎯 Your turn: build & return a DTO

    Construct a ProductDto record and print the fields the API would expose.

    Try it Yourself »
    C#
    using System;
    
    // A DTO (Data Transfer Object) is the SHAPE your API sends back.
    // A record gives you a clean value type with a built-in ToString().
    record ProductDto(int Id, string Name, decimal Price);
    
    class Program
    {
        static void Main()
        {
            // 🎯 YOUR TURN — build the DTO the GET endpoint would return.
    
            // 1) Create a ProductDto with Id 1, Name "Laptop", Price 999.99m.
            ProductDto dto = ___;       // 👉 new ProductDto(1, "Laptop", 999.99m)
    
            // 2) Print ju
    ...

    🔎 Deep Dive: Versioning Your API

    Once real clients depend on your API, you can't freely change its shape — renaming a field or removing one breaks them. Versioning lets you ship breaking changes under a new version while old clients keep using the old one. The simplest, most common approach is to put the version in the URL path:

    [Route("api/v1/[controller]")]   // /api/v1/products  — the original shape
    [Route("api/v2/[controller]")]   // /api/v2/products  — the new, changed shape

    Add /v1/ from day one — it's far easier than retrofitting it later. Other strategies include a header (api-version: 2) or a query string (?api-version=2); the official Asp.Versioning package supports all three. Whichever you pick, the rule is the same: never silently break an existing version.

    Pro Tips

    • 💡 Use TypedResults in minimal APIs: TypedResults.Ok(data) is compile-time checked and produces better OpenAPI docs than Results.Ok(data).
    • 💡 Always return a DTO, never the entity: it stops internal fields leaking and decouples your API from your database schema.
    • 💡 POST returns 201 with a Location header: CreatedAtAction(...) tells the client the URL of the thing it just made.
    • 💡 Keep verbs honest: a GET must never change data. If it does, it should be a POST — caches and crawlers assume GET is safe.
    • 💡 Add /v1/ to your routes on day one so future breaking changes have somewhere to live.

    Common Errors (and the fix)

    • Returning 200 for everything: sending 200 OK on a create or a not-found hides what happened. Use 201 Created for POST, 204 No Content for DELETE, and 404 Not Found when the id doesn't exist.
    • Over-posting / mass assignment: binding the request straight onto your entity lets a client set fields they shouldn't (like Id or IsAdmin). Bind to a narrow input DTO that only contains the fields they're allowed to send.
    • Not validating input: trusting the body leads to bad data and crashes. With [ApiController], Data Annotations like [Required] and [Range] auto-return 400 Bad Request before your code runs — use them.
    • Returning entities instead of DTOs: serialising your database entity can leak sensitive columns and ties your public contract to your storage. Map to a ProductDto and return that.
    • "404" when the route is wrong: a typo like /api/product/1 (singular) won't match /api/products/{id}. Check the controller's [Route] and the verb attribute match the URL you're calling.

    📋 Quick Reference

    TaskMinimal APIController
    Read a listapp.MapGet("/items", ...)[HttpGet]
    Createapp.MapPost("/items", ...)[HttpPost]
    Read from body(Dto dto) => ...[FromBody] Dto dto
    Return 200Results.Ok(x)return Ok(x);
    Return 201Results.Created(url, x)CreatedAtAction(...)
    Return 404Results.NotFound()return NotFound();
    Return 204Results.NoContent()return NoContent();

    Frequently Asked Questions

    Q: What's the difference between PUT and POST?

    POST creates a new resource and the server assigns its id (201 Created). PUT replaces an existing resource at a known URL (200 OK, or 404 if it doesn't exist). Rule of thumb: POST to a collection (/api/todos), PUT to a specific item (/api/todos/1).

    Q: Should I use minimal APIs or controllers?

    Both are first-class and equally RESTful. Use minimal APIs for small or focused services where less ceremony wins; use controllers when an API grows many endpoints that share routing, filters, and validation. You can even mix them in one project.

    Q: Why bother with a DTO instead of returning my entity?

    A DTO is your public contract. Returning the entity ties your API to your database and risks leaking fields you never meant to expose. A DTO lets you change storage freely and control exactly what goes over the wire.

    Q: When do I return 400 vs 404?

    400 Bad Request means the client sent something invalid (a missing field, a bad value). 404 Not Found means the request was fine but the resource doesn't exist (no product with that id). Validation failures are 400; missing ids are 404.

    Q: What does "stateless" really mean?

    The server keeps no memory of you between requests — each call carries everything it needs (such as an auth token). That's what lets you put many identical servers behind a load balancer, because any of them can handle any request.

    Mini-Challenge: Model a Notes Resource

    No blanks this time — just a brief and an outline. Model an in-memory /api/notes resource over a List<Note>, with Get, Post, and Delete methods that return the same status strings a real REST API would. This is the runner-friendly version of everything you read in the worked examples. Run it and check your output against the comments.

    🎯 Mini-Challenge: a notes resource collection

    Implement Get/Post/Delete over a List<Note> and match the expected output.

    Try it Yourself »
    C#
    using System;
    using System.Collections.Generic;
    using System.Linq;
    
    // 🎯 MINI-CHALLENGE: Model a REST resource collection with plain C#.
    // You are modelling /api/notes backed by an in-memory List<Note>.
    //
    // 1. A 'Note' record has an int Id and a string Text.
    // 2. Get(int id):    return the matching note's Text, or "404 Not Found".
    // 3. Post(string t): add a new note with the next Id, return "201 Created".
    // 4. Delete(int id): remove it; return "204 No Content" or "404 Not Found".
    //
    // In
    ...

    🎉 Lesson Complete

    • REST models your system as resources with a uniform, stateless interface
    • Verbs map to actions: GET read, POST create, PUT update, DELETE remove
    • Status codes tell the truth: 200, 201, 204 for success; 400 and 404 for problems
    • Minimal APIs for small services; controllers for larger, attribute-driven ones
    • Model binding fills your parameters; DTOs control what you accept and return
    • Version from day one and never return entities directly or skip validation
    • Next lesson: Middleware & Filters — cross-cutting concerns like logging and auth

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service