HATEOAS in Minimal APIs: A Complete Guide with a Sample Project

Published on: April 16, 2025

Today, we're embarking on a journey into HATEOAS, Hypermedia as the Engine of Application State. A RESTful API design principle that makes your APIs dynamic, discoverable, and future-proof.

I promise that if you follow along you'll understand this with clarity, enthusiasm, and, as a result, you'll get a practical mini-project using Minimal APIs in C# with .NET.

We'll build a Library API, incorporate HATEOAS links via an extension method, and address how to access IUrlHelper in a Minimal API context.

Let's dive in!

What is HATEOAS? The Big Picture

HATEOAS is like giving your API a built-in GPS. Instead of clients hardcoding URLs, your API responses include data (e.g., a book's details) and hypermedia links (e.g., URLs to borrow or update the book).

Clients follow these links to navigate the API, just like clicking "Next Page" on a website.

For example, a book resource might return:

{
  "id": 1,
  "title": "The Great Gatsby",
  "isAvailable": true,
  "links": {
    "self": { "href": "/api/books/1", "rel": "self", "method": "GET" },
    "borrow": {
      "href": "/api/books/1/borrow",
      "rel": "borrow",
      "method": "POST"
    }
  }
}

Why HATEOAS Matters

  • Discoverability: Clients find new endpoints via links, reducing reliance on documentation.
  • Flexibility: API changes (e.g., new URLs) don't break clients if links are updated.
  • Loose Coupling: Clients depend on links, not fixed paths, making maintenance easier.

Common Misunderstandings

  • "It's just adding URLs!" Not quite! HATEOAS guides state transitions (e.g., from "available" to "borrowed").
  • "Too complex for small APIs." Even simple APIs benefit from future-proofing.
  • "Clients ignore links." Well-designed links encourage automation and adoption.

HATEOAS in Action: The Concept

Imagine you're at a library:

  • You request a book (GET /api/books/1).
  • The librarian hands you the book's details and a note: "Borrow it here" or "Check availability online" (hypermedia links).
  • You don't need to know the library's internal system; you follow the note's instructions.

HATEOAS brings this intuitive navigation to APIs, making them self-documenting and adaptable.

Our Mini-Project: A Library API with Minimal APIs

We'll build a Library API using Minimal APIs in C#.

The API will manage books, and responses will include HATEOAS links generated via a Book.ToDto() extension method.

We'll also tackle accessing IUrlHelper from HttpContext, a key challenge in Minimal APIs.

Step 1: Setting Up the Project

Let's create our Minimal API project.

  1. Create a new web api project using your favorite IDE. Although it's a Library API, I'll call it HateoasDemo.

  2. Remove any pre-cooked code (if applies) like Weather endpoints, etc.

  3. Add dependencies: We need to add the package Microsoft.AspNetCore.Mvc.Core to have access to IUrlHelperFactory. So go ahead and install it using your IDE's NuGet Package Manager or your terminal.

Step 2: Modeling the Domain

Our API manages books. Let's define the Book model.

Create Models/Book.cs:

namespace HateoasDemo.Models;

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Author { get; set; } = string.Empty;
    public bool IsAvailable { get; set; }
}

Note: We initialize strings to avoid null warnings. Links will be handled by an extension method, not embedded in Book.

Step 3: Creating the HATEOAS Extension Method

We'll extend Book with a ToDto method that generates a response with HATEOAS links.

In Minimal APIs, we need IUrlHelper from HttpContext using IUrlHelperFactory and IActionContextAccessor, as there's no controller-provided Url.

So, let's create Models/BookHateoasExtensions.cs:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;

namespace HateoasDemo.Models;

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

public static class BookHateoasExtensions
{
    public static object ToDto(this Book book, HttpContext httpContext)
    {
        var urlHelperFactory = httpContext.RequestServices.GetRequiredService<IUrlHelperFactory>();
        var actionContextAccessor = httpContext.RequestServices.GetRequiredService<IActionContextAccessor>();

        var urlHelper = urlHelperFactory.GetUrlHelper(actionContextAccessor.ActionContext
            ?? new ActionContext(httpContext, new RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor()));

        return new
        {
            book.Id,
            book.Title,
            book.Author,
            book.IsAvailable,
            Links = new Dictionary<string, LinkDto?>
            {
                ["self"] = new LinkDto(
                    urlHelper.Link("GetBook", new { id = book.Id }) ?? string.Empty,
                    "self",
                    "GET"
                ),
                ["update"] = new LinkDto(
                    urlHelper.Link("UpdateBook", new { id = book.Id }) ?? string.Empty,
                    "update",
                    "PUT"
                ),
                ["delete"] = new LinkDto(
                    urlHelper.Link("DeleteBook", new { id = book.Id }) ?? string.Empty,
                    "delete",
                    "DELETE"
                ),
                ["borrow"] = book.IsAvailable
                    ? new LinkDto(
                        urlHelper.Link("BorrowBook", new { id = book.Id }) ?? string.Empty,
                        "borrow",
                        "POST"
                    )
                    : null,
                ["return"] = book.IsAvailable
                    ? null
                    : new LinkDto(
                        urlHelper.Link("ReturnBook", new { id = book.Id }) ?? string.Empty,
                        "return",
                        "POST"
                    )
            }.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
        };
    }
}

Explanation

  • LinkDto: A record for links, with Href (URL), Rel (relationship), and Method (HTTP verb).
  • ToDto:
    • Takes HttpContext to access DI services.
    • Uses IUrlHelperFactory and IActionContextAccessor to create IUrlHelper.
    • Returns an anonymous object with book properties and a Links dictionary.
    • Includes self, update, delete, and conditional borrow links (only if IsAvailable).
  • urlHelper.Link: Uses named routes (e.g., "GetBook") for URL generation, critical for Minimal APIs.

Gotcha: Forgetting to register IActionContextAccessor or name routes causes null URLs. We'll address this in the next step.

Step 4: Building the Minimal API

We'll define endpoints using MapGet, MapPost, etc., and name them for urlHelper.Link.

Let's update our Program.cs:

using Microsoft.AspNetCore.Mvc.Infrastructure;
using HateoasDemo.Models;

var builder = WebApplication.CreateBuilder(args);

// Register services for IUrlHelper
builder.Services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();
builder.Services.AddControllers(); // Needed for IUrlHelperFactory

var app = builder.Build();

app.UseHttpsRedirection();

// In-memory book store
var books = new List<Book>
{
    new Book { Id = 1, Title = "The Great Gatsby", Author = "F. Scott Fitzgerald", IsAvailable = true },
    new Book { Id = 2, Title = "1984", Author = "George Orwell", IsAvailable = false }
};

// Endpoints
app.MapGet("/api/books/{id}", (HttpContext context, int id) =>
{
    var book = books.FirstOrDefault(b => b.Id == id);
    return book != null ? Results.Ok(book.ToDto(context)) : Results.NotFound();

}).WithName("GetBook");

app.MapGet("/api/books", (HttpContext context) =>
{
    var bookDtos = books.Select(b => b.ToDto(context)).ToList();
    return Results.Ok(bookDtos);

}).WithName("GetBooks");

app.MapPost("/api/books/{id}/borrow", (HttpContext context, int id) =>
{
    var book = books.FirstOrDefault(b => b.Id == id);

    if (book == null)
    {
        return Results.NotFound();
    }

    if (!book.IsAvailable)
    {
        return Results.BadRequest("Book is not available.");
    }

    book.IsAvailable = false;

    return Results.Ok(book.ToDto(context));

}).WithName("BorrowBook");

app.MapPut("/api/books/{id}", (HttpContext context, int id, Book book) =>
{
    var bookToUpdate = books.FirstOrDefault(b => b.Id == id);

    if (bookToUpdate == null)
    {
        return Results.NotFound();
    }

    bookToUpdate.Title = book.Title;
    bookToUpdate.Author = book.Author;
    bookToUpdate.IsAvailable = book.IsAvailable;

    return Results.Ok(bookToUpdate.ToDto(context));

}).WithName("UpdateBook");

app.MapDelete("/api/books/{id}", (HttpContext context, int id) =>
{
    var bookToDelete = books.FirstOrDefault(b => b.Id == id);

    if (bookToDelete == null)
    {
        return Results.NotFound();
    }

    books.Remove(bookToDelete);

    return Results.Ok();

}).WithName("DeleteBook");


app.MapPost("/api/books/{id}/return", (HttpContext context, int id) =>
{
    var bookToReturn = books.FirstOrDefault(b => b.Id == id);

    if (bookToReturn == null)
    {
        return Results.NotFound();
    }

    bookToReturn.IsAvailable = true;

    return Results.Ok(bookToReturn.ToDto(context));

}).WithName("ReturnBook");

app.Run();

What's Happening?

  • DI Setup:
    • IActionContextAccessor enables IUrlHelper creation.
    • AddControllers provides IUrlHelperFactory (even without controllers).
  • Endpoints:
    • GET /api/books/{id}: Fetches a book with HATEOAS links.
    • GET /api/books: Lists all books.
    • POST /api/books/{id}/borrow: Updates availability, showing dynamic links.
    • PUT /api/books/{id}: Updates the given book.
    • DELETE /api/books/{id}: Deletes the given book from memory.
    • POST /api/books/{id}: Updates availability (returns the book), showing dynamic links.
  • Route Names: .WithName() assigns names (e.g., "GetBook") for urlHelper.Link.
  • HttpContext: Passed to ToDto for link generation.

Pro Tip: Use .WithName() consistently to ensure urlHelper.Link generates correct URLs.

Step 5: Testing the API

Let's run and test our HATEOAS-enabled Minimal API.

  1. Start the project

  2. Test with Postman, curl, or a browser:

    • GET https://localhost:5001/api/books/1

    • Expected response:

      {
        "id": 1,
        "title": "The Great Gatsby",
        "author": "F. Scott Fitzgerald",
        "isAvailable": true,
        "links": {
          "self": {
            "href": "/api/books/1",
            "rel": "self",
            "method": "GET"
          },
          "update": {
            "href": "/api/books/1",
            "rel": "update",
            "method": "PUT"
          },
          "delete": {
            "href": "/api/books/1",
            "rel": "delete",
            "method": "DELETE"
          },
          "borrow": {
            "href": "/api/books/1/borrow",
            "rel": "borrow",
            "method": "POST"
          }
        }
      }
      
    • GET https://localhost:5001/api/books/2 (unavailable book): No borrow link, but return link.

    • POST https://localhost:5001/api/books/1/borrow, then GET again: borrow link disappears and return link is displayed.

  3. Verify Links:

    • Check that href values match routes.
    • Ensure HTTP methods (GET, POST, etc.) are correct.

Gotcha: If links are null, verify .WithName() matches urlHelper.Link calls and services are registered.

To make tests easy, here is the HateoasDemo.http file:

@HateoasDemo_HostAddress = http://localhost:5000

### Get all books
GET {{HateoasDemo_HostAddress}}/api/books
Accept: application/json

### Get a specific book by ID
GET {{HateoasDemo_HostAddress}}/api/books/1
Accept: application/json

### Borrow a book
POST {{HateoasDemo_HostAddress}}/api/books/1/borrow
Accept: application/json

### Return a borrowed book
POST {{HateoasDemo_HostAddress}}/api/books/1/return
Accept: application/json

### Update a book
PUT {{HateoasDemo_HostAddress}}/api/books/1
Content-Type: application/json

{
  "id": 1,
  "title": "The Great Gatsby - Updated",
  "author": "F. Scott Fitzgerald",
  "isAvailable": true
}

### Delete a book
DELETE {{HateoasDemo_HostAddress}}/api/books/1
Accept: application/json

Step 6: Best Practices and Pro Tips

  1. Lean and Mean: Minimal APIs are perfect for lightweight APIs. Keep logic simple and focused.
  2. Named Routes: Always use .WithName() for endpoints that generate links.
  3. Service Layer: For complex APIs, move ToDto to a HateoasService injected via DI.
  4. Standardize Links: Adopt HAL (links) or JSON:API for consistency. Our approach is HAL-inspired.

Repo with the code

If you don't want to type all the code, I prepared this repo with the code in this mini-project.

The Final Mini-Project

You've built a HATEOAS-enabled Library API with:

  • A Book model and ToDto extension method for dynamic links.
  • Minimal API endpoints in Program.cs for GET, POST, PUT and DELETE.
  • IUrlHelper access via HttpContext, using IUrlHelperFactory and IActionContextAccessor.
  • State-driven links (e.g., borrow only for available books, return only for borrowed books).
  • A clean, extensible codebase.

Wrapping Up

HATEOAS transforms APIs into self-navigating systems, and Minimal APIs make implementation lightweight and fun.

By using Book.ToDto() and accessing IUrlHelper from HttpContext, you've created a dynamic, discoverable Library API that guides clients with links.

This project showcases the power of C# for tailored, elegant, and modern API design.

I hope this guide was as thrilling to read as it was to write!

Run the project, tweak the endpoints, or add new features.

If you hit a snag, check route names, DI setup, or test your links.

Bookmark this, share it with peers 🔄, and keep exploring RESTful APIs.


Happy Coding! ⚡


RESTfulAPIapi designHATEOAS