REST API Engineering · Lesson 11 of 19

HATEOAS — Hypermedia Links in REST APIs (When It's Worth It)

HATEOAS (Hypermedia as the Engine of Application State) is the level of REST maturity that most APIs never reach — and for most APIs, that's fine. This lesson explains what it is, what it actually gives you, how to implement it in ASP.NET Core, and where the honest trade-off sits.


What HATEOAS Means

In a plain REST API, the client needs to know the URL structure in advance. If you want to cancel order 42, you need to know that the endpoint is DELETE /api/orders/42 — that knowledge is hard-coded in the client.

In a HATEOAS API, every response includes links describing what the client can do next, and the URLs for those actions. The client discovers the API at runtime rather than having the URL structure baked in.

JSON
{
  "id": 42,
  "status": "processing",
  "total": 299.99,
  "_links": {
    "self":   { "href": "/api/orders/42",        "method": "GET"    },
    "cancel": { "href": "/api/orders/42/cancel", "method": "POST"   },
    "items":  { "href": "/api/orders/42/items",  "method": "GET"    },
    "customer": { "href": "/api/customers/7",    "method": "GET"    }
  }
}

The cancel link only appears when the order can actually be cancelled. If it's already shipped, the link is absent — the client doesn't need to know the business rules; it just checks if the link exists.


The Richardson Maturity Model

REST has levels of maturity. Most "REST APIs" are Level 2; HATEOAS is Level 3.

Level 0 — One endpoint, action in the body (XML-RPC, SOAP)
Level 1 — Resources (separate URL per resource)
Level 2 — HTTP verbs + correct status codes
Level 3 — Hypermedia controls (HATEOAS)

Most teams should reach Level 2 and stay there unless they have specific reasons for Level 3.


What HATEOAS Actually Buys You

What it's good for:

  • Public APIs consumed by many different unknown clients
  • APIs where workflow state matters (orders, approvals, workflows) and the client shouldn't know the state machine
  • APIs where you want to change URLs without breaking clients

What it doesn't fix:

  • Clients still need to understand the semantics of each link (cancel vs refund)
  • It doesn't eliminate API documentation — clients still need to know what links mean
  • Most mobile/SPA clients hardcode URL patterns anyway

Honest summary: HATEOAS is genuinely useful for public APIs and workflow-heavy domains. It's over-engineering for internal microservice APIs or simple CRUD services.


Implementing HATEOAS in ASP.NET Core

Step 1: Link Model

C#
public record Link(string Href, string Rel, string Method);

public class LinkedResource<T>
{
    public T Data { get; init; }
    public IReadOnlyList<Link> Links { get; init; }

    public LinkedResource(T data, IReadOnlyList<Link> links)
    {
        Data  = data;
        Links = links;
    }
}

Step 2: Controller with Links

C#
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    [HttpGet("{id}")]
    public async Task<ActionResult<LinkedResource<OrderDto>>> GetById(
        int id, CancellationToken ct)
    {
        var order = await _service.GetAsync(id, ct);
        if (order is null) return NotFound();

        var dto   = OrderDto.From(order);
        var links = BuildLinks(order);

        return Ok(new LinkedResource<OrderDto>(dto, links));
    }

    private List<Link> BuildLinks(Order order)
    {
        var links = new List<Link>
        {
            new(Url.Action(nameof(GetById), new { id = order.Id })!, "self", "GET"),
            new(Url.Action(nameof(GetItems), new { orderId = order.Id })!, "items", "GET"),
            new($"/api/customers/{order.CustomerId}", "customer", "GET"),
        };

        // Conditional links based on state
        if (order.Status == OrderStatus.Processing)
        {
            links.Add(new(
                Url.Action(nameof(Cancel), new { id = order.Id })!,
                "cancel", "POST"));
        }

        if (order.Status == OrderStatus.Shipped)
        {
            links.Add(new(
                Url.Action(nameof(TrackShipment), new { id = order.Id })!,
                "track", "GET"));
        }

        if (order.Status == OrderStatus.Delivered)
        {
            links.Add(new(
                Url.Action(nameof(Refund), new { id = order.Id })!,
                "refund", "POST"));
        }

        return links;
    }
}

Response for an order in Processing status:

JSON
{
  "data": {
    "id": 42,
    "status": "processing",
    "total": 299.99,
    "customerName": "Acme Corp"
  },
  "links": [
    { "href": "/api/orders/42",        "rel": "self",     "method": "GET"  },
    { "href": "/api/orders/42/items",  "rel": "items",    "method": "GET"  },
    { "href": "/api/customers/7",      "rel": "customer", "method": "GET"  },
    { "href": "/api/orders/42/cancel", "rel": "cancel",   "method": "POST" }
  ]
}

For a Delivered order, cancel is absent and refund appears instead. The client checks links.find(l => l.rel === 'cancel') — if it's null, the cancel button is hidden. No state machine knowledge in the client.


HAL — A Standard Hypermedia Format

Rather than inventing your own link structure, use HAL (Hypertext Application Language), a widely used hypermedia standard:

JSON
{
  "id": 42,
  "status": "processing",
  "total": 299.99,
  "_links": {
    "self":   { "href": "/api/orders/42" },
    "cancel": { "href": "/api/orders/42/cancel" },
    "items":  { "href": "/api/orders/42/items" }
  },
  "_embedded": {
    "items": [
      { "productId": 5, "quantity": 2, "price": 149.99 }
    ]
  }
}

HAL uses _links for links and _embedded for nested resources. It has a registered media type: application/hal+json.

Bash
dotnet add package Halcyon.Net
C#
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id, CancellationToken ct)
{
    var order = await _service.GetAsync(id, ct);
    if (order is null) return NotFound();

    var response = HALResponse.Create(OrderDto.From(order))
        .AddSelfLink(Url.Action(nameof(GetById), new { id })!)
        .AddLink("items", Url.Action(nameof(GetItems), new { orderId = id })!);

    if (order.Status == OrderStatus.Processing)
        response.AddLink("cancel", Url.Action(nameof(Cancel), new { id })!);

    return Ok(response);
}

Collection Links and Pagination

HATEOAS shines for paginated collections:

JSON
{
  "data": [ { "id": 1, ... }, { "id": 2, ... } ],
  "totalCount": 87,
  "page": 2,
  "pageSize": 10,
  "_links": {
    "self":  { "href": "/api/orders?page=2&pageSize=10", "method": "GET" },
    "first": { "href": "/api/orders?page=1&pageSize=10", "method": "GET" },
    "prev":  { "href": "/api/orders?page=1&pageSize=10", "method": "GET" },
    "next":  { "href": "/api/orders?page=3&pageSize=10", "method": "GET" },
    "last":  { "href": "/api/orders?page=9&pageSize=10", "method": "GET" }
  }
}
C#
private List<Link> BuildPaginationLinks(PagedQuery query, int total)
{
    var totalPages = (int)Math.Ceiling((double)total / query.PageSize);
    var links = new List<Link>
    {
        new(BuildUrl(query.Page, query.PageSize), "self", "GET"),
        new(BuildUrl(1, query.PageSize),          "first", "GET"),
        new(BuildUrl(totalPages, query.PageSize), "last",  "GET"),
    };

    if (query.Page > 1)
        links.Add(new(BuildUrl(query.Page - 1, query.PageSize), "prev", "GET"));

    if (query.Page < totalPages)
        links.Add(new(BuildUrl(query.Page + 1, query.PageSize), "next", "GET"));

    return links;
}

These next/prev/first/last links are how GitHub's API pagination works — clients never hardcode page numbers.


When to Add HATEOAS (Honest Advice)

Add it when:

  • You're building a public API consumed by clients you don't control
  • Your domain has complex state machines (orders, approvals, subscriptions)
  • URL stability matters — you want to change URLs without breaking clients
  • You're targeting Richardson Maturity Level 3 as an explicit design goal

Skip it when:

  • Internal API consumed only by your own frontend/mobile app
  • Simple CRUD with no meaningful workflow states
  • Your team doesn't have buy-in — half-implemented HATEOAS is worse than none

Start simple: Add pagination links first — they're immediately useful and low effort. Add state-dependent links (cancel, refund, approve) when the domain demands it. Don't add self links everywhere just to say you have HATEOAS.


Quick Reference

HATEOAS:   Include links in responses describing what the client can do next
HAL:       Standard format — _links object, application/hal+json media type
Conditional links: only include links for valid actions given current state
Pagination links:  first, prev, self, next, last in collection responses
Richardson Level 3: the "full REST" goal — most APIs stop at Level 2