# REST API design

> Design resource-oriented JSON APIs with correct methods and status codes, consistent errors and pagination that scales.

Backend with Go roadmap · Building APIs · 16 min read · https://levelcode.org/roadmaps/go-backend/rest-api-design

You already know how to write handlers, route requests and decode JSON. This lesson is about the *contract* your API offers. A well-designed API is predictable: a developer who has used one endpoint can guess how the next one works. We'll cover resource modelling, method semantics, status codes, a standard error format, pagination, and how to change an API without breaking the people using it.

## Model resources, not actions

REST APIs are built around **resources**: the nouns of your domain. URLs name resources, and HTTP methods say what to do with them. Most resources come in two shapes: a *collection* and an *item* in that collection.

```text
GET    /v1/articles             list articles
POST   /v1/articles             create an article
GET    /v1/articles/42          fetch article 42
PATCH  /v1/articles/42          change some fields of article 42
DELETE /v1/articles/42          delete article 42
GET    /v1/articles/42/comments list the comments that belong to article 42
```

RPC-style URLs like `/getArticle?id=42` put the verb in the path, so every endpoint is a one-off. With resources, the verbs come from a small set everyone already knows. A few rules keep URLs consistent:

- Use plural nouns for collections (`/articles`, not `/article`), lowercase, with hyphens between words (`/order-items`).
- Nest only for real ownership, and only one level deep. `/articles/42/comments` is fine; `/users/7/articles/42/comments/3` is not. Once you have a comment's ID, `/comments/3` is enough.
- Model actions such as publishing as a state change (`PATCH /v1/articles/42` with `{"status": "published"}`) or a sub-resource (`POST /v1/articles/42/publication`), not `/publishArticle`.

Go's `ServeMux` maps directly onto this style:

```go
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/articles", listArticles)
mux.HandleFunc("POST /v1/articles", createArticle)
mux.HandleFunc("GET /v1/articles/{id}", getArticle)
mux.HandleFunc("PATCH /v1/articles/{id}", updateArticle)
mux.HandleFunc("DELETE /v1/articles/{id}", deleteArticle)
```

A `PUT /v1/articles/42` gets an automatic `405 Method Not Allowed` with an `Allow` header listing the methods that exist.

## Methods have semantics

Each HTTP method makes promises that clients, proxies and retry logic rely on. Your handlers must keep them.

| Method | Purpose | Safe | Idempotent | Typical success |
|---|---|---|---|---|
| GET | Read a resource | Yes | Yes | 200 |
| POST | Create in a collection, or trigger processing | No | No | 201 + `Location` |
| PUT | Replace the whole resource | No | Yes | 200 or 204 |
| PATCH | Change part of a resource | No | Not guaranteed | 200 |
| DELETE | Remove a resource | No | Yes | 204 |

**Safe** means the request does not change server state. **Idempotent** means sending it twice has the same effect as sending it once: deleting article 42 twice still leaves it deleted (the second call may return 404, but the *state* is the same), while POSTing twice creates two articles. This matters when the network fails: a client whose PUT timed out can simply retry; a client whose POST timed out can't know whether it was applied.

> [!WARNING]
> Never change state in a GET handler. A `GET /v1/articles/42/delete` link will eventually be followed by a link-preview bot, a browser prefetch or a monitoring tool, and the article will vanish.

### PUT versus PATCH

PUT replaces the entire resource; fields you leave out are reset. PATCH changes only the fields present, which is what clients usually want. Decode PATCH bodies into a struct of pointers so you can tell "not sent" (`nil`) from "set to empty":

```go
type articlePatch struct {
	Title  *string `json:"title"`
	Body   *string `json:"body"`
	Status *string `json:"status"`
}
```

### Making POST safe to retry

Where duplicates are costly (payments, orders), accept an `Idempotency-Key` header. The client generates a unique key per logical operation; the server stores the key with the response it produced and replays that response if the key arrives again.

## Status codes that carry meaning

Clients use the status code to decide whether to retry, ask the user to fix input, or give up. Use a small, consistent set:

| Code | Use it when |
|---|---|
| 200 OK | A read or update succeeded and you return a body |
| 201 Created | POST created a resource; set `Location: /v1/articles/43` |
| 204 No Content | Success with no body (typical for DELETE) |
| 400 Bad Request | The request is malformed: invalid JSON, wrong types, unknown query parameter |
| 401 Unauthorized | No credentials, or invalid ones. The client should authenticate |
| 403 Forbidden | Authenticated, but not allowed to do this |
| 404 Not Found | The resource doesn't exist (or the caller may not know it exists) |
| 409 Conflict | The request clashes with current state, such as a duplicate email |
| 422 Unprocessable Content | Well-formed JSON that fails validation rules |
| 429 Too Many Requests | Rate limited; include `Retry-After` |
| 500 Internal Server Error | Your bug. Never put internal details in the body |

Two pairs get confused constantly. **401 vs 403**: 401 means "I don't know who you are", 403 means "I know who you are and the answer is no". **400 vs 422**: pick a rule and apply it everywhere. A common one is 400 when the body can't be parsed into your struct at all, and 422 when it parses but the values are wrong (an empty title, a negative quantity).

## A consistent error shape

Clients write one error-handling path, so every error should look the same. Rather than inventing a format, use **Problem Details** (RFC 9457), served as `application/problem+json`:

```json
{
  "type": "https://api.example.com/problems/validation",
  "title": "Request body failed validation",
  "status": 422,
  "detail": "2 fields are invalid",
  "instance": "/v1/articles",
  "errors": [
    {"field": "title", "message": "must not be empty"},
    {"field": "status", "message": "must be one of draft, published"}
  ]
}
```

`type` is a stable identifier clients can switch on, `title` a short summary, `status` repeats the HTTP code, and `detail` explains this occurrence. Extension members such as the per-field `errors` list are allowed. An omitted `type` defaults to `about:blank`, meaning "see the status code".

A small helper keeps every handler consistent:

```go
type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

type Problem struct {
	Type     string       `json:"type,omitempty"`
	Title    string       `json:"title"`
	Status   int          `json:"status"`
	Detail   string       `json:"detail,omitempty"`
	Instance string       `json:"instance,omitempty"`
	Errors   []FieldError `json:"errors,omitempty"`
}

func writeProblem(w http.ResponseWriter, r *http.Request, p Problem) {
	if p.Instance == "" {
		p.Instance = r.URL.Path
	}
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(p.Status)
	_ = json.NewEncoder(w).Encode(p)
}
```

A 404 then becomes one line: `writeProblem(w, r, Problem{Title: "Resource not found", Status: http.StatusNotFound})`.

For 500s, log the real error with a request ID and return only a generic title plus that ID, never stack traces or SQL.

## Pagination

A list endpoint that returns every row works in development and falls over in production. Always paginate, with a default page size and a maximum (say, 20 and 100).

### Offset pagination

`?limit=20&offset=40` maps to `LIMIT 20 OFFSET 40`. It's simple and supports "jump to page 7", but the database still reads and discards every skipped row, so deep pages get slow, and a row inserted mid-browse shifts everything by one, so users see duplicates or miss items.

### Cursor (keyset) pagination

Instead of "skip N rows", the client says "give me rows after this one". The server returns an opaque `next_cursor` that encodes the sort key of the last item:

```json
{
  "data": [{"id": 981, "title": "..."}, {"id": 975, "title": "..."}],
  "next_cursor": "eyJ0IjoiMjAyNi0xMC0wMVQwOToxMjowMFoiLCJpZCI6OTc1fQ"
}
```

The query uses a row comparison on a unique, indexed sort key. Adding `id` as a tie-breaker makes the order total even when two articles share a timestamp:

```sql
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;
```

With an index on `(created_at, id)`, page 1,000 costs the same as page 1, and concurrent inserts can't shift results. The trade-off: clients can't jump to an arbitrary page, which rarely matters for infinite scroll or data syncing. Encode the cursor so clients treat it as opaque and can't come to depend on its format:

```go
type cursor struct {
	CreatedAt time.Time `json:"t"`
	ID        int64     `json:"id"`
}

func encodeCursor(c cursor) string {
	b, _ := json.Marshal(c) // cannot fail for this struct
	return base64.RawURLEncoding.EncodeToString(b)
}

func decodeCursor(s string) (cursor, error) {
	var c cursor
	b, err := base64.RawURLEncoding.DecodeString(s)
	if err != nil {
		return c, err
	}
	err = json.Unmarshal(b, &c)
	return c, err
}
```

To know whether there's a next page without a separate `COUNT(*)`, fetch `limit + 1` rows. If you get the extra row, there is more data: drop it and emit a cursor for the last row you keep.

## Filtering, sorting and evolving the API

Filters and sorting belong in the query string: `GET /v1/articles?status=published&sort=-created_at` (a leading `-` for descending is a common convention). Never pass `sort` into SQL directly; map it through an allowlist:

```go
var sortColumns = map[string]string{
	"created_at":  "created_at ASC, id ASC",
	"-created_at": "created_at DESC, id DESC",
	"title":       "title ASC, id ASC",
}
```

An unknown value gets a 400 with a Problem explaining the allowed options.

APIs outlive the code behind them, so plan for change. **Version in the path** (`/v1/...`): it's visible and easy to route. **Additive changes are free**: a new optional field or endpoint doesn't break clients that ignore unknown fields. **Breaking changes need a new version**: removing or renaming a field, changing a type, or making an optional field required. Run both versions side by side during a deprecation window.

> [!TIP]
> Pick `snake_case` or `camelCase` once, use RFC 3339 timestamps in UTC, and return IDs as the same type everywhere. Consistency is most of what makes an API feel well designed.

## Practice

Implement `GET /v1/articles` with cursor pagination over an in-memory slice (so you can focus on the API, not the database).

1. Seed 50 articles with descending `CreatedAt` times and unique IDs.
2. Accept `limit` (default 20, max 100) and an optional `cursor`.
3. Return `{"data": [...], "next_cursor": "..."}`, omitting `next_cursor` on the last page.
4. Return a Problem Details 400 for a non-numeric or out-of-range `limit`, or an undecodable cursor.

Test it with `curl` by following `next_cursor` until it disappears.

**Solution**

This reuses `cursor`, `encodeCursor`, `decodeCursor`, `Problem` and `writeProblem` from the lesson. The articles are sorted newest first, and the "after cursor" test mirrors the SQL row comparison.

```go
type Article struct {
	ID        int64     `json:"id"`
	Title     string    `json:"title"`
	CreatedAt time.Time `json:"created_at"`
}

type page struct {
	Data       []Article `json:"data"`
	NextCursor string    `json:"next_cursor,omitempty"`
}

// articles is sorted by (CreatedAt DESC, ID DESC).
var articles []Article

func listArticles(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	limit := 20
	if s := q.Get("limit"); s != "" {
		n, err := strconv.Atoi(s)
		if err != nil || n < 1 || n > 100 {
			writeProblem(w, r, Problem{Title: "Invalid limit", Status: http.StatusBadRequest,
				Detail: "limit must be an integer between 1 and 100"})
			return
		}
		limit = n
	}

	start := 0
	if s := q.Get("cursor"); s != "" {
		c, err := decodeCursor(s)
		if err != nil {
			writeProblem(w, r, Problem{Title: "Invalid cursor", Status: http.StatusBadRequest})
			return
		}
		// Skip everything at or before the cursor position.
		for start < len(articles) {
			a := articles[start]
			if a.CreatedAt.Before(c.CreatedAt) || (a.CreatedAt.Equal(c.CreatedAt) && a.ID < c.ID) {
				break
			}
			start++
		}
	}

	end := min(start+limit+1, len(articles)) // fetch one extra
	rows := articles[start:end]

	resp := page{Data: rows}
	if len(rows) > limit {
		resp.Data = rows[:limit]
		last := resp.Data[limit-1]
		resp.NextCursor = encodeCursor(cursor{CreatedAt: last.CreatedAt, ID: last.ID})
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(resp)
}

func main() {
	base := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
	for i := 50; i >= 1; i-- {
		articles = append(articles, Article{
			ID:        int64(i),
			Title:     fmt.Sprintf("Article %d", i),
			CreatedAt: base.Add(time.Duration(i) * time.Minute),
		})
	}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /v1/articles", listArticles)
	log.Fatal(http.ListenAndServe(":8080", mux))
}
```

Request `?limit=2`, then pass the returned `next_cursor` back as `&cursor=...`; `?limit=500` returns a 400 `application/problem+json` response.

## Key takeaways

- URLs name resources (plural nouns, shallow nesting); HTTP methods supply the verbs.
- GET is safe; GET, PUT and DELETE are idempotent; POST is neither, so use an `Idempotency-Key` where duplicates hurt.
- Use a small, consistent set of status codes: 201 with `Location` for creation, 401 for "who are you", 403 for "not allowed".
- Return every error as Problem Details (`application/problem+json`) so clients need one error path.
- Prefer cursor (keyset) pagination on a unique, indexed sort key; fetch `limit + 1` to detect the next page.
- Version in the path, make additive changes freely, and ship breaking changes only as a new version.

## Quiz

1. Which HTTP method is idempotent but not safe?

   A. GET
   B. POST
   C. PUT
   D. PATCH

   **Answer: C.** PUT replaces a resource, so it changes state (not safe), but sending the same PUT twice leaves the same final state (idempotent). GET is both safe and idempotent; POST is neither; PATCH is not guaranteed to be idempotent.

2. A logged-in user with the 'viewer' role calls DELETE /v1/articles/42, which only editors may do. What status should the API return?

   A. 403 Forbidden
   B. 401 Unauthorized
   C. 404 Not Found
   D. 409 Conflict

   **Answer: A.** The server knows who the caller is; they simply lack permission, which is 403. 401 means the caller is not (validly) authenticated and should send credentials.

3. Why does cursor (keyset) pagination stay fast on page 1,000 while OFFSET pagination slows down?

   A. Cursors are cached by PostgreSQL between requests
   B. OFFSET makes the database read and discard all skipped rows, while a keyset WHERE clause seeks directly into an index
   C. Cursor pagination returns fewer columns per row
   D. OFFSET queries cannot use indexes at all

   **Answer: B.** With an index on the sort key, WHERE (created_at, id) < ($1, $2) jumps straight to the right position. OFFSET N still has to produce and throw away N rows. OFFSET queries can use an index for ordering, but they still walk past every skipped row.

4. Which change to a public v1 API is safe to make without a new version?

   A. Renaming the field "author" to "author_id"
   B. Making the optional "tags" field required on create
   C. Changing "price" from a number to a string
   D. Adding a new optional "subtitle" field to article responses

   **Answer: D.** Adding an optional field is additive: clients that don't know about it ignore it. Renaming, changing types or tightening requirements breaks existing clients and needs a new version.
