Beginner-Friendly Guide

REST API Best Practices for Dummies

What REST really means, then a Products API built the professional way: typed results, correct PUT/PATCH/DELETE, ProblemDetails errors, pagination, search & sorting, data shaping and HATEOAS — all in plain English with copy-paste C# for .NET 8+.

No prior API experience needed .NET 8 · C# · Minimal API Typed Results · HATEOAS · Data Shaping
01

What is REST?

Imagine a restaurant. You are the client (a mobile app, a website, another program). The kitchen is the server — your ASP.NET Core app and its database. The waiter is the API: the messenger carrying requests in and responses out. And the menu is the documentation — what you're allowed to ask for.

You never walk into the kitchen. You tell the waiter "I want dish #5", and you get back either your food or an explanation ("we're out of that"). An API is exactly this: a defined way for one program to ask another program for things.

REST (REpresentational State Transfer) is a set of conventions for building that waiter on top of HTTP — the web's own protocol. Learn the four ideas below and every REST API on the internet suddenly makes sense.

Brain Power

REST says a GET request must never change anything on the server. Why would browsers, proxies and search engines all depend on that promise?

(Hint: what happens if a crawler follows every link on your site — and one of them is GET /api/products/delete/5?)

Because GET is declared safe, the whole web infrastructure feels free to fire GETs without asking: browsers prefetch links, proxies cache responses, crawlers follow every URL they see. If your API deletes data on a GET, a search engine bot can literally wipe your database just by crawling. That's why actions live in the method, never in the URL.
Big Idea #1

Everything is a resource with a URL

A resource is a "thing" your API manages — a product, an order, a customer. Each one gets an address. Nouns in the URL, plural, no verbs — the verb comes from the HTTP method.

https://myshop.com/api/products      ← ALL products
https://myshop.com/api/products/5    ← product #5
https://myshop.com/api/products/5/reviews
                                     ← its reviews

// ✔ GOOD — nouns, plural, ids in the path
GET    /api/products/5
DELETE /api/products/5

// ✘ BAD — verbs in the URL, actions as paths
GET /api/getProduct?id=5
GET /api/products/delete/5   ← crawler bait!
Big Idea #2

The HTTP method is the verb

HTTP already has verbs built in — REST just uses them with their real meaning. Five cover 99% of every API you'll ever build.

GET    /api/products/5   "Give me…"    (read only!)
POST   /api/products     "Create a new…"
PUT    /api/products/5   "REPLACE entirely"
PATCH  /api/products/5   "Change PART of"
DELETE /api/products/5   "Remove"

// Idempotent = same request twice → same end state
// GET, PUT, DELETE  → idempotent (safe to retry)
// POST              → NOT idempotent
//   (retry a POST → you might create 2 orders!)
Big Idea #3

Status codes — how it went

Every response starts with a 3-digit verdict. Memory trick: 2xx = success · 4xx = YOU messed up · 5xx = the SERVER messed up. Using the right one is half of "best practices".

200 OK          Here's what you asked for
201 Created     Made it — here's where it lives
204 No Content  Done, nothing to show you

400 Bad Request   Your request makes no sense
404 Not Found     That resource doesn't exist
409 Conflict      A business rule blocks this
422 Unprocessable Validation failed

500 Internal Server Error  We crashed. Not you.

// ✘ Rookie mistake: 200 for everything,
//   with { "success": false } in the body
Big Idea #4

Anatomy of one request/response

This is literally the text that travels over the network. Method + path, headers (metadata), then the body as JSON — curly braces for objects, "key": value pairs, square brackets for lists.

// ── REQUEST ──────────────────────────────
GET /api/products/5 HTTP/1.1
Host: myshop.com
Accept: application/json   ← "answer in JSON"

// ── RESPONSE ─────────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 5,
  "name": "Mechanical Keyboard",
  "price": 89.99,
  "inStock": true
}

// Everything in this tutorial is about
// producing GOOD versions of that response.
Section 1 wrap — what you now know
  • API = the waiter between client and kitchen; REST = the conventions for that waiter over HTTP.
  • Resources are nouns with URLs (/api/products/5) — plural, no verbs in the path.
  • The HTTP method is the verb: GET reads, POST creates, PUT replaces, PATCH tweaks, DELETE removes.
  • Status codes: 2xx success, 4xx client error, 5xx server error — and GET must never change data.
  • Data travels as JSON in the request/response body.
02

Setup, DTOs & Data

Time to build. We'll create a Products API you can run with zero database setup — the data lives in an in-memory list, so the tutorial is copy-paste-run all the way. (Swap in EF Core later; the endpoint code barely changes.)

This section also introduces the single most important habit in API design: the difference between your entity (internal data shape) and your DTO (what the world sees). Get this wrong and you leak private data. Get it right and half of "best practices" comes free.

Brain Power

Your Product class has an InternalSupplierCode column you use for purchasing. If your endpoint does return Results.Ok(product); — who can see that code?

(Hint: who can call a public API? Everyone.)

Everyone on the internet. The JSON serializer doesn't know "internal" — it serializes every public property. That's why the API must return a DTO: a separate class that contains only what clients should see. If the property doesn't exist on the DTO, leaking it is impossible. Rule for life: the API speaks DTO, never entity.
Step 1

Create the project & add Swagger

dotnet new web gives you an empty Minimal API. Swagger UI adds a free interactive test page — every endpoint gets a "Try it out" button. Run with dotnet watch so the app restarts on every save.

dotnet new web -n ProductsApi
cd ProductsApi
dotnet add package Swashbuckle.AspNetCore
dotnet watch

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();   // → http://localhost:PORT/swagger

app.Run();
Core Habit

Entity vs DTO — different jobs, different shapes

The entity is your internal/database shape. DTOs are the public contract — and reading, creating and patching genuinely need different shapes. record declares each in one line.

// Models/Product.cs — INTERNAL
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string? Description { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public string InternalSupplierCode { get; set; } = "";
}                       // ⚠ must NEVER leak ─┘

// Dtos/ProductDtos.cs — PUBLIC contract
public record ProductDto(int Id, string Name,
    string? Description, decimal Price, int Stock);

public record CreateProductDto(string Name,
    string? Description, decimal Price, int Stock);
    // no Id — the SERVER assigns ids!

public record UpdateProductDto(string Name,
    string? Description, decimal Price, int Stock);

public record PatchProductDto(string? Name,
    string? Description, decimal? Price, int? Stock);
    // ALL optional: null = "don't change"
Glue

The mapper — entity → DTO in one call

An extension method lets you write product.ToDto() anywhere. Notice what it doesn't copy: the internal supplier code never crosses the line.

// Dtos/ProductMappings.cs
public static class ProductMappings
{
    public static ProductDto ToDto(this Product p) =>
        new(p.Id, p.Name, p.Description,
            p.Price, p.Stock);
}

// usage anywhere:
var dto = product.ToDto();

// Larger project? Same idea, automated:
// AutoMapper / Mapster generate these for you.
// Start manual — you SEE what crosses the line.
Step 2

The repository + dependency injection

One class owns the data; endpoints ask it, never touch storage directly. Register it once with DI — AddSingleton = "ONE instance, shared by everyone" (right for an in-memory list; real databases use AddScoped).

// Data/ProductRepository.cs
public class ProductRepository
{
    private readonly List<Product> _products =
    [
        new() { Id = 1, Name = "Mechanical Keyboard",
                Price = 89.99m, Stock = 12,
                InternalSupplierCode = "SUP-889-X" },
        new() { Id = 2, Name = "USB-C Hub",
                Price = 34.50m, Stock = 0,
                InternalSupplierCode = "SUP-102-A" },
    ];
    private int _nextId = 3;

    public List<Product> GetAll() => _products;

    public Product? Find(int id) =>
        _products.FirstOrDefault(p => p.Id == id);

    public Product Add(CreateProductDto d) { /* …Id = _nextId++… */ }
    public bool Delete(int id) { /* remove, return found? */ }
}

// Program.cs — register it ONCE:
builder.Services.AddSingleton<ProductRepository>();
Watch Out

Why does CreateProductDto have no Id? Because the server assigns ids. If clients could send ids you'd invite collisions and overwrites ("I'd like to create product… #7, which already exists"). Same reasoning later for PUT: the id lives in the URL, never in the body. The URL identifies; the body describes.

Section 2 wrap — what you now know
  • dotnet new web + Swashbuckle = running API with a free interactive test UI at /swagger.
  • Entity = internal shape. DTO = public contract. The API speaks DTO, never entity.
  • Different operations need different DTOs: read, create (no Id), update (full), patch (all-optional).
  • A repository owns the data; endpoints receive it via dependency injection.
  • AddSingleton = one shared instance for the app's lifetime.
03

GET with Typed Results

Here's where this tutorial departs from most: we'll write the first endpoint the naive way, watch it leak data and lie to the documentation — then fix it with typed results, the single best habit in modern ASP.NET Core.

Typed results mean the endpoint's signature says exactly which outcomes exist: Results<Ok<ProductDto>, NotFound> reads as "this returns a 200 with a ProductDto, or a 404 — nothing else is possible." The compiler enforces it. Swagger reads it. Your future self thanks you.

Brain Power

Results.Ok(anything) compiles no matter what you pass it. What class of bug does that allow through to production that TypedResults + a declared union type would catch at compile time?

Returning the wrong thing — the entity instead of the DTO, the repository instead of the product, a raw string where JSON was promised. With Results<Ok<ProductDto>, NotFound>, passing anything that isn't exactly a ProductDto is a red squiggle before you even run. Compile-time errors are the cheapest bugs you'll ever fix.
Bad Code

The naive GET — two hidden bugs

It works! And it leaks internalSupplierCode to the internet, and its docs can't tell anyone what it returns. Both problems are invisible until someone gets hurt.

// ✘ DON'T keep this version
app.MapGet("/api/products/{id}",
    (int id, ProductRepository repo) =>
{
    var product = repo.Find(id);
    if (product is null)
        return Results.NotFound();
    return Results.Ok(product);   // ← ENTITY!
});

// GET /api/products/1 returns:
{
  "id": 1,
  "name": "Mechanical Keyboard",
  "price": 89.99,
  "stock": 12,
  "internalSupplierCode": "SUP-889-X"  // 🚨 LEAKED
}
Good Code

The typed GET — compiler-checked

Three upgrades: {id:int} rejects /products/abc before your code runs; the union type declares every outcome; ToDto() keeps the entity inside. Swagger now documents the 200 and 404 automatically.

using Microsoft.AspNetCore.Http.HttpResults;

app.MapGet("/api/products/{id:int}",
    Results<Ok<ProductDto>, NotFound>
    (int id, ProductRepository repo) =>
{
    var product = repo.Find(id);

    return product is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(product.ToDto());
});

// The union type is a CONTRACT:
TypedResults.Ok(product);       // ❌ won't compile
TypedResults.BadRequest();      // ❌ not in the list
TypedResults.Ok(product.ToDto());  // ✅
How It Works

Where do the parameters come from?

The lambda's parameters are filled automatically, each from a different source, matched by name and type. This one rule explains every endpoint in this tutorial.

app.MapGet("/api/products/{id:int}",
    (int id,                  // ← route {id}
     string? fields,          // ← ?fields=... query
     ProductRepository repo,  // ← DI container
     HttpContext http)        // ← special type
    => ...);

app.MapPost("/api/products",
    (CreateProductDto input,  // ← JSON body!
     ProductRepository repo)  //   (complex type)
    => ...);

// Route values  → by name from the URL path
// Simple types  → from the query string
// Complex types → deserialized from JSON body
// Registered services → from DI
// HttpContext / LinkGenerator → framework
Structure

Groups — stop repeating the prefix

MapGroup factors out the shared /api/products prefix. Add the list endpoint and the API starts taking shape. (The list gets pagination in Section 6 — never ship an unbounded list!)

var products = app.MapGroup("/api/products");

products.MapGet("/", (ProductRepository repo) =>
    TypedResults.Ok(
        repo.GetAll().Select(p => p.ToDto())));

products.MapGet("/{id:int}",
    Results<Ok<ProductDto>, NotFound>
    (int id, ProductRepository repo) =>
{
    var product = repo.Find(id);
    return product is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(product.ToDto());
});

// ✅ Checkpoint:
// GET /api/products     → JSON array, no leaks
// GET /api/products/1   → single product
// GET /api/products/999 → 404
// GET /api/products/abc → 404 (route constraint)
Section 3 wrap — what you now know
  • Declare Results<A, B> unions and use TypedResults.Xxx() — the compiler checks every outcome.
  • Swagger documents typed endpoints automatically — no attributes needed.
  • {id:int} route constraints reject garbage before your code runs.
  • Parameters bind by convention: route → name, query → simple types, body → complex types, DI → services.
  • MapGroup removes prefix repetition.
04

POST — Creating

Creating a resource properly involves three professional touches most tutorials skip: return 201 Created (not plain 200), include a Location header pointing at the new resource's URL, and return the created object so the client learns its new Id.

And before creating anything: validate — collecting all the problems, not just the first one. Nothing is more annoying than fixing one field, resubmitting, and being told about the next field.

Brain Power

Why does the client even need the response body after a POST? It sent the product data itself — what's the one thing it doesn't know?

The Id (and anything else the server computed — timestamps, defaults). The client sent name and price; the server decided the new product is #4. Without the body (or the Location header), the client would have to search for its own creation. That's why 201 + Location + body is the standard trio.
The Endpoint

POST with validation + 201 Created

The union type says it all: this creates, or it explains why not. ValidationProblem produces the standard error format with every failed field listed.

products.MapPost("/",
    Results<Created<ProductDto>, ValidationProblem>
    (CreateProductDto input, ProductRepository repo) =>
{
    // collect ALL errors, not just the first
    var errors = new Dictionary<string, string[]>();

    if (string.IsNullOrWhiteSpace(input.Name))
        errors["name"]  = ["Name is required."];
    if (input.Price < 0)
        errors["price"] = ["Price cannot be negative."];
    if (input.Stock < 0)
        errors["stock"] = ["Stock cannot be negative."];

    if (errors.Count > 0)
        return TypedResults.ValidationProblem(errors);

    var product = repo.Add(input);

    // 201 + Location header + body
    return TypedResults.Created(
        $"/api/products/{product.Id}",
        product.ToDto());
});
Test It

Swagger & curl — see the 201 yourself

In Swagger: POST → Try it out → paste the JSON → Execute. Or from any terminal with curl (-i shows the headers so you can see Location).

curl -i -X POST http://localhost:5088/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Wireless Mouse","price":25.99,"stock":30}'

HTTP/1.1 201 Created
Location: /api/products/3
Content-Type: application/json

{"id":3,"name":"Wireless Mouse",
 "description":null,"price":25.99,"stock":30}

// Now break it on purpose — empty name, price -5:
HTTP/1.1 400 Bad Request
{
  "title": "One or more validation errors occurred.",
  "errors": {
    "name":  ["Name is required."],
    "price": ["Price cannot be negative."]
  }
}   // ← BOTH problems, one round-trip
Section 4 wrap — what you now know
  • POST success = 201 Created + Location header + the created object (so the client learns the Id).
  • Complex parameters (CreateProductDto) deserialize automatically from the JSON body.
  • Validate before creating; collect all errors into one ValidationProblem response.
  • POST is not idempotent — retrying may create duplicates.
05

PUT · PATCH · DELETE

Updates are where semantics matter as much as code. PUT = "here is the complete new version — replace everything." PATCH = "change only what I'm sending." DELETE = remove. Each has its own contract, and clients rely on you honoring it.

The success answer for all three is usually 204 No Content — "done, nothing to show." The client already knows what it sent; an empty response saves bandwidth.

Brain Power

Your phone sends a PUT, the Wi-Fi drops, no response arrives. The app retries the same PUT. Is that safe? Now same story with a POST — safe?

The PUT retry is safe — replacing a product with the same data twice leaves the same state. That's idempotency, and it's why PUT must never do things like "append to a list". The POST retry is not safe — you might create two products. This is why the verbs' contracts matter: network code everywhere assumes them.
PUT

Full replacement — every field, every time

The client sends the complete resource; you overwrite every updatable field. A missing field means "reset to default" — that's the contract. If that feels wrong for your use case, you want PATCH.

products.MapPut("/{id:int}",
    Results<NoContent, NotFound, ValidationProblem>
    (int id, UpdateProductDto input,
     ProductRepository repo) =>
{
    var errors = new Dictionary<string, string[]>();
    if (string.IsNullOrWhiteSpace(input.Name))
        errors["name"] = ["Name is required."];
    if (input.Price < 0)
        errors["price"] = ["Price cannot be negative."];
    if (errors.Count > 0)
        return TypedResults.ValidationProblem(errors);

    var product = repo.Find(id);
    if (product is null)
        return TypedResults.NotFound();

    // replace EVERY updatable field — PUT's contract
    product.Name        = input.Name;
    product.Description = input.Description;
    product.Price       = input.Price;
    product.Stock       = input.Stock;

    return TypedResults.NoContent();   // 204
});
PATCH

Partial update — null means "keep it"

Every property of PatchProductDto is nullable. The client sends only what changes; you apply only what arrived. Note input.Price is < 0 — "not null AND negative" in one pattern.

products.MapPatch("/{id:int}",
    Results<NoContent, NotFound, ValidationProblem>
    (int id, PatchProductDto input,
     ProductRepository repo) =>
{
    var product = repo.Find(id);
    if (product is null)
        return TypedResults.NotFound();

    var errors = new Dictionary<string, string[]>();
    if (input.Name is not null &&
        string.IsNullOrWhiteSpace(input.Name))
        errors["name"] = ["Name cannot be empty."];
    if (input.Price is < 0)
        errors["price"] = ["Price cannot be negative."];
    if (errors.Count > 0)
        return TypedResults.ValidationProblem(errors);

    // apply ONLY what the client sent
    if (input.Name is not null)
        product.Name = input.Name;
    if (input.Description is not null)
        product.Description = input.Description;
    if (input.Price is not null)
        product.Price = input.Price.Value;
    if (input.Stock is not null)
        product.Stock = input.Stock.Value;

    return TypedResults.NoContent();
});

// PATCH /api/products/1  { "price": 79.99 }
// → 204. Price changed. Nothing else touched.
DELETE

Remove — and 409 when rules forbid it

Success = 204. Already gone = 404 (and that's still idempotent — the state after both calls is identical). And when a business rule blocks deletion, that's 409 Conflict, not 400.

products.MapDelete("/{id:int}",
    Results<NoContent, NotFound, Conflict<string>>
    (int id, ProductRepository repo) =>
{
    var product = repo.Find(id);
    if (product is null)
        return TypedResults.NotFound();

    // business rule: stocked products can't be deleted
    if (product.Stock > 0)
        return TypedResults.Conflict(
            $"Product {id} still has stock.");

    repo.Delete(id);
    return TypedResults.NoContent();
});

// 400 = "I can't UNDERSTAND your request"
// 409 = "I understand it fine — but it
//        CONFLICTS with current state"
Watch Out — the PATCH null ambiguity

With the nullable-DTO pattern, a client cannot say "clear the description" — sending "description": null looks identical to not sending it at all. If clearing nullable fields matters in your API, use JSON Patch (RFC 6902) instead: a standard list of operations like { "op": "remove", "path": "/description" } via JsonPatchDocument<T>. And if you do: always apply the patch to the DTO (never the entity) and re-validate afterwards — a patch can produce an invalid state.

Section 5 wrap — what you now know
  • PUT replaces everything (idempotent, full DTO, id from the URL only). PATCH changes only what's sent.
  • Success for updates/deletes = 204 No Content.
  • Second DELETE → 404 is fine — idempotency is about state, not identical responses.
  • Business rule blocks the action → 409 Conflict, not 400.
  • Nullable-DTO PATCH can't distinguish "clear" from "keep" — JSON Patch can.
06

Errors & Pagination

Two habits separate professional APIs from hobby ones. First: one error format everywhere — never HTML error pages, never stack traces, never ad-hoc {"error": "..."} shapes. The standard is ProblemDetails (RFC 7807), and ASP.NET Core produces it with three lines.

Second: never return an unbounded list. With 3 products, GET /api/products is fine; with 50,000 it melts phones and databases. Pagination serves the list a page at a time — with enough metadata for the client to render "Page 2 of 7".

Brain Power

Your paginated endpoint accepts ?pageSize=. A client sends ?pageSize=99999999. What happens to your server — and whose fault is it?

Your server tries to serialize a hundred million rows and falls over — and it's your fault, not the client's. Any parameter that controls how much work the server does must be clamped: pageSize = Math.Clamp(pageSize, 1, 100). One line, and no client can ever order unbounded work. This applies to every "how much" parameter you'll ever accept.
Errors

ProblemDetails — one error shape for everything

Three lines of setup and every error — unhandled exceptions included — becomes clean, machine-readable JSON in the RFC 7807 format. Your ValidationProblem responses already speak it.

// Program.cs
builder.Services.AddProblemDetails();

var app = builder.Build();
app.UseExceptionHandler(); // exceptions → 500 JSON
app.UseStatusCodePages();  // bare 404s  → JSON too

// Now ANY error comes back as:
{
  "type":   "https://tools.ietf.org/html/...",
  "title":  "Not Found",
  "status": 404,
  "detail": "Product 42 does not exist."
}

// ✘ Never again:
//   HTML error pages · stack traces
//   { "success": false, "err": "oops" }
Envelope

PagedResult<T> — items + "where am I?"

A generic wrapper: the page of items plus the numbers a UI needs. <T> makes it reusable — today products, tomorrow orders.

public record PagedResult<T>(
    IReadOnlyList<T> Items,
    int Page,
    int PageSize,
    int TotalCount)
{
    public int TotalPages =>
        (int)Math.Ceiling(
            TotalCount / (double)PageSize);

    public bool HasPrevious => Page > 1;
    public bool HasNext     => Page < TotalPages;
}

// serializes to:
{
  "items": [ ... ],
  "page": 1, "pageSize": 10,
  "totalCount": 57, "totalPages": 6,
  "hasPrevious": false, "hasNext": true
}
The Endpoint

Skip / Take — the paging engine

Skip ignores the earlier pages, Take grabs one page's worth. Page 3, size 10 → skip 20, take 10. With EF Core the same two calls become SQL OFFSET/FETCH — the database does the work.

products.MapGet("/",
    Results<Ok<PagedResult<ProductDto>>,
            BadRequest<string>>
    (ProductRepository repo,
     int page = 1, int pageSize = 10) =>
{
    if (page < 1)
        return TypedResults.BadRequest(
            "page must be 1 or greater.");

    // NEVER trust client sizes
    pageSize = Math.Clamp(pageSize, 1, 100);

    var all = repo.GetAll();

    var items = all
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(p => p.ToDto())
        .ToList();

    return TypedResults.Ok(
        new PagedResult<ProductDto>(
            items, page, pageSize, all.Count));
});

// ✅ /api/products?page=1&pageSize=2
Section 6 wrap — what you now know
  • AddProblemDetails() + UseExceptionHandler() + UseStatusCodePages() = every error is RFC 7807 JSON.
  • Lists always paginate: Skip((page-1)*size), Take(size), wrapped in an envelope with totals.
  • Clamp every "how much" parameter — clients must not be able to order unbounded work.
  • The same LINQ becomes efficient SQL when you move to EF Core.
08

Data Shaping, HATEOAS & Cheatsheet

Two finishing moves. Data shaping: a mobile list view needs name and price — why ship id, description and stock thousands of times over cellular data? Let the client pick: ?fields=name,price.

HATEOAS (Hypermedia As The Engine Of Application State — don't memorize it): responses include links describing what the client can do next, like every web page does. The killer feature: the server only includes links for actions that are currently allowed — so business rules live in one place.

Brain Power

Your shaper copies "whatever fields the client names" off an object. If you point it at the entity instead of the DTO — what can a sneaky client ask for?

(Hint: remember ?fields=internalSupplierCode…)

Exactly — ?fields=internalSupplierCode would cheerfully un-do every DTO protection you built in Section 2. Shape the DTO, never the entity. Then only already-public fields are reachable, no matter what the client asks for. This is a security rule, not a style preference.
Data Shaping

The shaper — reflection + ExpandoObject

A normal class can't drop properties at runtime — but ExpandoObject is secretly a dictionary, so it holds any set of keys and serializes to exactly those. Reflection reads the DTO's properties; we copy over only the requested ones.

public static class DataShaper
{
    // null/empty fields → all props.
    // unknown field     → null (caller sends 400).
    public static ExpandoObject? Shape<T>(
        T source, string? fields)
    {
        var all = typeof(T).GetProperties(
            BindingFlags.Public |
            BindingFlags.Instance);

        PropertyInfo[] wanted;
        if (string.IsNullOrWhiteSpace(fields))
            wanted = all;
        else
        {
            var names = fields.Split(',',
                StringSplitOptions.TrimEntries |
                StringSplitOptions.RemoveEmptyEntries);
            var list = new List<PropertyInfo>();
            foreach (var n in names)
            {
                var hit = all.FirstOrDefault(p =>
                    p.Name.Equals(n, StringComparison
                        .OrdinalIgnoreCase));
                if (hit is null) return null;  // typo!
                list.Add(hit);
            }
            wanted = [.. list];
        }

        var shaped = new ExpandoObject()
            as IDictionary<string, object?>;
        foreach (var prop in wanted)
            shaped[JsonNamingPolicy.CamelCase
                .ConvertName(prop.Name)]
                = prop.GetValue(source);
        return (ExpandoObject)shaped;
    }
}
// GET /api/products/1?fields=name,price
// → { "name": "…", "price": 79.99 }
HATEOAS

Links — generated, never hand-built

Name each endpoint with .WithName(), then ask LinkGenerator for URLs by name — so links survive route changes. Each link: where, what it means (rel), which verb.

public record LinkDto(
    string Href, string Rel, string Method);

// name every endpoint:
products.MapGet("/{id:int}", …)
        .WithName("GetProduct");
products.MapPut("/{id:int}", …)
        .WithName("UpdateProduct");
products.MapDelete("/{id:int}", …)
        .WithName("DeleteProduct");

// inside GET-by-id — inject LinkGenerator:
string Url(string name) =>
    links.GetUriByName(http, name, new { id })!;

var linkList = new List<LinkDto>
{
    new(Url("GetProduct"),    "self",   "GET"),
    new(Url("UpdateProduct"), "update", "PUT"),
};

// ⭐ THE POINT: state-driven links.
// Only offer actions currently ALLOWED:
if (product.Stock == 0)
    linkList.Add(new(
        Url("DeleteProduct"), "delete", "DELETE"));

((IDictionary<string, object?>)shaped)
    ["links"] = linkList;
The Payoff

What the client sees — and why it's gold

The client checks: is there a delete link? Then show the delete button. The business rule lives on the server only. Change it tomorrow — zero clients need updating.

GET /api/products/2?fields=name,stock

{
  "name": "USB-C Hub",
  "stock": 0,
  "links": [
    { "href": ".../api/products/2",
      "rel": "self",   "method": "GET"    },
    { "href": ".../api/products/2",
      "rel": "update", "method": "PUT"    },
    { "href": ".../api/products/2",
      "rel": "delete", "method": "DELETE" }
  ]      // ← delete offered: stock is 0
}

GET /api/products/1?fields=name,stock
{
  "name": "Mechanical Keyboard",
  "stock": 12,
  "links": [ self, update ]   // no delete!
}

// ⚖ Honest advice: full HATEOAS shines for
// PUBLIC APIs. Internal API you own both
// sides of? next/prev pagination links get
// you 80% of the value at 20% of the work.
Cheatsheet

The whole tutorial in one scroll

Bookmark this card. When in doubt, find the line and copy it.

// ── Verbs & success codes ─────────────────
GET    read           → 200
POST   create         → 201 + Location + body
PUT    replace ALL    → 204   (idempotent)
PATCH  change part    → 204
DELETE remove         → 204   (repeat → 404 ok)

// ── Error codes ───────────────────────────
400 can't understand   404 doesn't exist
409 business conflict  422/400 validation
500 we crashed         → ALL as ProblemDetails

// ── Typed results ─────────────────────────
Results<Ok<TDto>, NotFound, ValidationProblem>
TypedResults.Ok(dto) / .NotFound()
  / .Created(url, dto) / .NoContent()
  / .Conflict(msg) / .ValidationProblem(errs)

// ── Golden rules ──────────────────────────
API speaks DTO, never entity
Server assigns ids; id in URL, not body
Clamp every "how much" param (pageSize ≤ 100)
filter → count → sort → page
Sort/shape via ALLOWLIST; typos → 400 + hints
Shape DTOs, never entities
Links: only offer actions currently allowed

// ── List endpoint params ──────────────────
?search=  ?minPrice=  ?inStock=true
?sortBy=price desc    ?fields=name,price
?page=2&pageSize=10
Section 8 wrap — what you now know
  • Data shaping: ?fields=name,price → reflection copies requested DTO properties onto an ExpandoObject.
  • Shape DTOs only — shaping entities re-opens the data-leak door.
  • HATEOAS: responses carry links; LinkGenerator + named endpoints build URLs safely.
  • State-driven links put business rules in one place — the server.
  • Unknown fields → 400 with hints, same as sorting.
Mastery Move — where to go next

You can now design and build a professional REST API: correct verbs and status codes, compiler-checked results, safe querying, shaped responses and hypermedia. The natural next jumps:

1. Minimal API for Dummies — the deeper platform tour this tutorial builds on: DI lifetimes, EF Core wiring, request validation and JWT authentication for the endpoints you just wrote.

2. OOP & SOLID — as your API grows past one file, these principles decide whether it stays maintainable. The repository you built is the Dependency Inversion Principle in miniature.

3. T-SQL Deep-Dive — pagination, filtering and sorting ultimately compile down to SQL. Knowing what OFFSET/FETCH and indexes do turns "it works" into "it's fast".

Things this tutorial deliberately skipped, in learn-next order: ETags & optimistic concurrency (two admins editing the same product — If-Match/412), JSON Patch (RFC 6902), API versioning, rate limiting, and integration testing with WebApplicationFactory. All worth learning after you've shipped something real.