Skip to content
gohacks.dev
APIs

REST API Design Rules for Go Services That Hold Up

REST API design rules for Go services, from URLs and methods to errors, paging and versioning, with a small linter that checks the URL rules.

13 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Most arguments about REST are arguments about words. The useful part of the discipline is much smaller and more practical: a handful of rules that make an API predictable to the people who call it and cheap for you to change. A client developer who has seen GET /v1/tasks and GET /v1/tasks/7 can guess GET /v1/projects/3 without opening the documentation, and a retry library can decide what is safe to repeat from the method alone. Those properties are what "holds up" means, and they come from consistency far more than from cleverness.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article sets out the rules I design Go services by, with the reasoning for each, and then does something a style guide cannot: it encodes the URL and method rules as a small program, runs it on a deliberately flawed route table, and wires it into a test so the rules stay true. It is the design companion to the JSON API cornerstone, which builds a service that follows them, and a CRUD API with the standard library. It belongs to the APIs series, which rests on the web work in a production HTTP server. Everything ran on Go 1.25.6 with the standard library only.

URLs name things

A URL identifies a resource, a thing a client can talk about: a task, a project, a list of tasks. It does not name an operation. The operation is the method.

DoDo not
GET /v1/tasksGET /v1/getTasks
POST /v1/tasksPOST /v1/tasks/create
DELETE /v1/tasks/{id}POST /v1/deleteTask?id=7
GET /v1/projects/{projectId}/tasksGET /v1/getTasksForProject

Six smaller rules follow from that, and every one is checkable.

Use plural nouns for collections. /v1/tasks is the collection and /v1/tasks/7 is a member. Mixing singular and plural (/v1/task/7 next to /v1/tasks) forces clients to remember which endpoint spells it which way.

Lowercase, with hyphens. URLs are case sensitive in the path, so Tasks and tasks are different resources to a server and the same word to a person. Underscores, camel case and mixed case all produce 404s that look like bugs. Use /v1/api-keys.

One URL per resource, no trailing slash. /v1/tasks and /v1/tasks/ as separate routes double the surface, confuse caches and split analytics. Pick the form without the slash and make the other a redirect or a 404.

Version in the path (/v1/) so a breaking change has somewhere to go, as in API versioning.

Keep nesting shallow. /v1/projects/{projectId}/tasks is fine, because tasks belong to a project and the path says so. Four levels deep (/orgs/{id}/projects/{id}/tasks/{id}/comments) mean every client builds long strings, every handler checks four ownership links, and the URL leaks your data model. Past two levels, promote the inner resource to the top level and filter: GET /v1/comments?task_id=7.

Query parameters filter, sort and page; they do not identify. GET /v1/tasks?status=done&limit=20 is a view of the collection. The route itself never contains a query string.

For the Go side, the standard ServeMux expresses exactly this shape: GET /v1/tasks/{id} registers the method, the path and a named parameter in one string, as covered in the Go 1.22 patterns.

Methods carry the action

HTTP defines what each method promises, and generic software relies on those promises: browsers, proxies, caches, retry libraries and API gateways all treat methods differently. Break the promises and the infrastructure misbehaves in ways that are hard to debug. The table is from RFC 9110:

MethodSafe (no state change)Idempotent (repeating is the same as once)Typical use
GETyesyesRead a resource or a collection
HEADyesyesHeaders only
PUTnoyesReplace a resource at a known URL
DELETEnoyesRemove a resource
PATCHnono, unless you make it soChange some fields
POSTnonoCreate under a collection, or run an action

Two consequences matter every day. First, never change state on a GET. Crawlers, prefetchers and link scanners issue GET freely, and one that follows GET /v1/tasks/7/delete deletes your data. Second, only idempotent requests are safe for automatic retry. A client library that retries PUT and DELETE after a timeout is correct, and one that retries POST creates duplicates. If your clients need to retry creates, add an Idempotency-Key, as in idempotency keys; the client-side counterpart is retries and backoff.

Not everything is create, read, update and delete. An operation like cancelling an order or resending an invoice is a verb. The pragmatic rule is to give it a noun anyway, as a sub-resource under the thing it acts on: POST /v1/orders/{id}/cancel. The linter below allows exactly this shape, an action as the last segment of a POST, immediately after an id, and rejects the same word under a collection (POST /v1/tasks/create). The question to ask is whether the operation is really a state change you could model as an update (PATCH /v1/orders/{id} with {"status":"cancelled"}) or a distinct command with its own rules and side effects. If it is the latter, the action endpoint is honest.

Status codes: a short list, used precisely

Clients write code against status codes, so they must mean one thing. A working set for most JSON APIs:

CodeUse
200A read or an update that returns the resource
201A create, with a Location header
202Accepted for later processing, with a way to check progress
204Success with nothing to return, such as DELETE
400The request cannot be understood: bad JSON, bad id, bad query parameter
401No or invalid credentials
403Authenticated, not allowed
404No such resource (also for resources the caller may not know exist)
405Known path, wrong method, with an Allow header
409Conflicts with current state, such as a duplicate or a stale version
413 / 415Body too large / wrong content type
422Well-formed, but a field fails validation
429Rate limited, with Retry-After
500 / 503A bug on your side / temporarily unavailable

The fuller list with rationale is in status codes for APIs. Never return 200 with an error inside the body, a habit that breaks monitoring, retries and every client that checks the status first.

Representations: decide once

A resource's JSON is the part clients depend on most, and the part where small inconsistencies cost the most. Settle these in writing before the second endpoint exists.

Field names. Pick snake_case or camelCase and use it everywhere. Go's struct tags make either easy (json:"due_date"); mixed casing in one API is the mistake. Identifiers are opaque strings or integers, consistently; if ids are sequential integers, authorization has to carry the whole burden of keeping users out of each other's data (object-level authorization). Times are RFC 3339 strings in UTC (2026-03-31T09:00:00Z), and dates without a time of day are YYYY-MM-DD; never Unix seconds in one place and ISO strings in another. Money is an integer in the smallest unit plus a currency code, never a float. Enums are lowercase strings. Absent versus null versus empty: omit what you do not know, use null only where it has a defined meaning, and always return [] for an empty list, not null (telling null from missing). Large numbers: JavaScript clients lose precision above 2^53, so send 64-bit ids as strings if browsers consume them.

Lists need a shape that can grow: {"data": [...], "next_cursor": "..."} leaves room for paging, counts and links, while a bare array does not (response structure). Errors use one document everywhere, ideally problem details.

Collections: page, filter, sort, cap

Every endpoint that returns a list needs a maximum page size from the first release. An unbounded GET /v1/tasks works with ten rows and takes a server down at ten million. Use cursors, which stay stable when rows are inserted, in preference to offsets (pagination); allow filtering on a small set of documented fields and sorting on an allowlist of columns, never passing a client-supplied column name into SQL (filtering and sorting); and validate every parameter, so ?limit=-5 is a 400 rather than a surprise.

Writes: validate, retry, update safely

Validate the whole request and report every problem at once with a 422 and a field map. Make creates safe to retry. Decide what an update means: PUT replaces the resource and requires the full representation; PATCH changes the fields present and nothing else (partial updates). Protect against lost updates when two clients edit the same resource: return an ETag, require If-Match on updates, and answer 412 Precondition Failed when it is stale (conditional requests). Decode request bodies into dedicated input structs, not your database models, so a client cannot set id, created_at or is_admin by adding fields (mass assignment).

Evolving without breaking

Additive changes are safe: a new field in a response, a new optional parameter, a new endpoint. Everything else is a breaking change and needs a new version: removing or renaming a field, changing a type, changing what a value means, tightening validation, changing a default. Tell clients to ignore unknown response fields, so you can add them. Keep a changelog and a deprecation process (backward compatibility, deprecation).

The rules as code

URL and method rules are mechanical, and mechanical rules belong in a test. The linter below takes a route written like GET /v1/tasks/{id}, applies the rules above and reports each violation by name.

design.goGo
package main

import (
	"fmt"
	"regexp"
	"strings"
)

type Finding struct {
	Rule, Message string
}

var (
	version   = regexp.MustCompile(`^v[0-9]+$`)
	kebab     = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
	param     = regexp.MustCompile(`^\{[a-z][a-zA-Z0-9]*\}$`)
	verbStart = regexp.MustCompile(`^(get|create|update|delete|remove|add|set|fetch|list|new|edit|make|do)([A-Z_-]|$)`)
)

// Check applies the rules to one route written like "GET /v1/tasks/{id}".
func Check(route string) []Finding {
	method, path, _ := strings.Cut(route, " ")
	var out []Finding
	add := func(rule, msg string, a ...any) { out = append(out, Finding{rule, fmt.Sprintf(msg, a...)}) }

	if strings.Contains(path, "?") {
		add("query-in-route", "the query string is not part of the route")
		path, _, _ = strings.Cut(path, "?")
	}
	if len(path) > 1 && strings.HasSuffix(path, "/") {
		add("trailing-slash", "a trailing slash makes two URLs for one resource")
	}
	segs := strings.Split(strings.Trim(path, "/"), "/")
	if len(segs) == 0 || !version.MatchString(segs[0]) {
		add("no-version", "the path should start with a version such as /v1")
	} else {
		segs = segs[1:]
	}

	collections := 0
	for i, s := range segs {
		isParam := param.MatchString(s)
		if !isParam {
			collections++
		}
		switch {
		case isParam:
		case verbStart.MatchString(s):
			// An action after an id is allowed on POST (POST /orders/{id}/cancel); anywhere else it is a verb in a noun's place.
			if !(method == "POST" && i == len(segs)-1 && i > 0 && param.MatchString(segs[i-1])) {
				add("verb-in-path", "%q is a verb; name the resource and let %s say the action", s, method)
			}
		case !kebab.MatchString(s):
			add("segment-case", "%q should be lowercase words joined by hyphens", s)
		}
		if i+1 < len(segs) && param.MatchString(segs[i+1]) && !isParam && !strings.HasSuffix(s, "s") {
			add("plural-collection", "%q is followed by an id, so it should be a plural noun", s)
		}
	}
	if collections > 2 {
		add("deep-nesting", "%d levels of nesting; consider a top-level resource with a filter", collections)
	}
	last := ""
	if len(segs) > 0 {
		last = segs[len(segs)-1]
	}
	isItem := param.MatchString(last)
	switch {
	case method == "DELETE" && !isItem && len(segs) > 0 && !param.MatchString(segs[len(segs)-1]):
		add("bulk-delete", "DELETE on a collection removes everything; use filters on a separate, deliberate endpoint")
	case method == "POST" && isItem:
		add("post-on-item", "POST to a single resource is unusual; use PUT or PATCH to change it")
	case method == "PUT" && !isItem:
		add("put-on-collection", "PUT replaces a resource and needs an id")
	}
	return out
}
main.goGo
package main

import "fmt"

func main() {
	routes := []string{
		"GET /v1/tasks",
		"POST /v1/tasks",
		"GET /v1/tasks/{id}",
		"PATCH /v1/tasks/{id}",
		"POST /v1/tasks/{id}/cancel",
		"GET /v1/projects/{projectId}/tasks",
		"GET /v1/getTasks",
		"POST /v1/tasks/create",
		"GET /tasks",
		"GET /v1/task/{id}",
		"GET /v1/tasks/",
		"GET /v1/Tasks_Archive",
		"DELETE /v1/tasks",
		"POST /v1/tasks/{id}",
		"GET /v1/orgs/{orgId}/projects/{projectId}/tasks/{id}/comments",
		"GET /v1/tasks?status=done",
	}
	clean := 0
	for _, r := range routes {
		findings := Check(r)
		if len(findings) == 0 {
			clean++
			fmt.Printf("ok    %s\n", r)
			continue
		}
		for _, f := range findings {
			fmt.Printf("FIX   %-52s %-17s %s\n", r, f.Rule, f.Message)
		}
	}
	fmt.Printf("\n%d of %d routes are clean\n", clean, len(routes))
}
Text
ok    GET /v1/tasks
ok    POST /v1/tasks
ok    GET /v1/tasks/{id}
ok    PATCH /v1/tasks/{id}
ok    POST /v1/tasks/{id}/cancel
ok    GET /v1/projects/{projectId}/tasks
FIX   GET /v1/getTasks                                     verb-in-path      "getTasks" is a verb; name the resource and let GET say the action
FIX   POST /v1/tasks/create                                verb-in-path      "create" is a verb; name the resource and let POST say the action
FIX   GET /tasks                                           no-version        the path should start with a version such as /v1
FIX   GET /v1/task/{id}                                    plural-collection "task" is followed by an id, so it should be a plural noun
FIX   GET /v1/tasks/                                       trailing-slash    a trailing slash makes two URLs for one resource
FIX   GET /v1/Tasks_Archive                                segment-case      "Tasks_Archive" should be lowercase words joined by hyphens
FIX   DELETE /v1/tasks                                     bulk-delete       DELETE on a collection removes everything; use filters on a separate, deliberate endpoint
FIX   POST /v1/tasks/{id}                                  post-on-item      POST to a single resource is unusual; use PUT or PATCH to change it
FIX   GET /v1/orgs/{orgId}/projects/{projectId}/tasks/{id}/comments deep-nesting      4 levels of nesting; consider a top-level resource with a filter
FIX   GET /v1/tasks?status=done                            query-in-route    the query string is not part of the route

6 of 16 routes are clean

The sample table has sixteen routes, and ten of them break a rule. The flagged ones are the ones people really write. GET /v1/getTasks and POST /v1/tasks/create put a verb where a noun belongs. GET /tasks has no version prefix. GET /v1/task/{id} uses the singular for a collection. A trailing slash, a Tasks_Archive segment, a DELETE on the whole collection, a POST to a single task and a four-level nest all trip their rules. The query string pasted into a route is caught as a route-definition mistake, and the clean routes at the top, including the cancel action after an id and the one-level nested collection, pass.

Wire it into the build so the rules stay enforced. The test checks both directions, that the rules accept good routes and that each rule fires on its own bad example, because a linter whose rules silently stop firing is worse than none.

design_test.goGo
package main

import "testing"

func rules(route string) map[string]bool {
	m := map[string]bool{}
	for _, f := range Check(route) {
		m[f.Rule] = true
	}
	return m
}

func TestGoodRoutesAreClean(t *testing.T) {
	for _, r := range []string{
		"GET /v1/tasks", "POST /v1/tasks", "GET /v1/tasks/{id}", "PATCH /v1/tasks/{id}", "DELETE /v1/tasks/{id}",
		"POST /v1/tasks/{id}/cancel", "GET /v1/projects/{projectId}/tasks", "GET /v1/api-keys",
	} {
		if got := Check(r); len(got) != 0 {
			t.Errorf("%s: %v", r, got)
		}
	}
}

func TestEachRuleFires(t *testing.T) {
	for route, want := range map[string]string{
		"GET /v1/getTasks":          "verb-in-path",
		"GET /tasks":                "no-version",
		"GET /v1/task/{id}":         "plural-collection",
		"GET /v1/tasks/":            "trailing-slash",
		"GET /v1/Tasks_Archive":     "segment-case",
		"DELETE /v1/tasks":          "bulk-delete",
		"POST /v1/tasks/{id}":       "post-on-item",
		"PUT /v1/tasks":             "put-on-collection",
		"GET /v1/tasks?status=done": "query-in-route",
		"GET /v1/a/{id}/b/{id}/c/d": "deep-nesting",
	} {
		if !rules(route)[want] {
			t.Errorf("%s did not trigger %s: %v", route, want, Check(route))
		}
	}
}
Text
=== RUN   TestGoodRoutesAreClean
--- PASS: TestGoodRoutesAreClean (0.00s)
=== RUN   TestEachRuleFires
--- PASS: TestEachRuleFires (0.00s)
PASS
ok  	example.test/lab	1.272s

In a real service, feed Check the actual route table. If you register routes through a small group helper like the one in route organization, which records every pattern, a test can walk that table and assert that no route reports a finding. A new endpoint then fails the build the moment it violates the house style, and the argument happens in the pull request, once, instead of in every code review.

What a linter cannot judge

The program checks form, not meaning. It cannot tell whether reports should be a resource at all, whether a list endpoint pages, whether errors share a format, whether an update is idempotent or whether a field is named well. Those need a design review, and a short checklist makes reviews quicker: does each endpoint have a documented success and failure status; is every collection bounded; are all writes validated and retry-safe; does every response use the standard error document; would a client written today still work after the next three additive changes? A design that passes those questions, and a route table that passes the linter, is one a stranger can use without asking you anything.

Two honest notes

The strictest reading of REST also asks for hypermedia, responses that carry the links to what a client can do next. Almost no JSON API does this, and clients are written against documentation and generated from OpenAPI instead (OpenAPI for Go services). Following the rules above while skipping hypermedia is the practical path. And REST is not the only choice: gRPC suits service-to-service calls with a strict schema, and GraphQL suits clients with varied data needs. The rules here are for the JSON-over-HTTP APIs that most services still expose. The method semantics are specified in RFC 9110, and the Go routing features that express these routes are documented in the net/http package.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.