Good API design is the difference between an API that developers want to use and one they tolerate. The rules are not arbitrary — they exist because consistent naming, predictable responses, and clear error messages reduce the cognitive load of integration. This guide covers the conventions and patterns that make Go APIs professional.
For implementation foundations see Go building REST APIs and Go authentication and authorization.
URL and Naming Conventions
REST API URLs represent resources (nouns), not actions (verbs). The HTTP method expresses the action:
# ✅ Resource-oriented — nouns, plural, lowercase, hyphenated
GET /api/v1/users # list users
POST /api/v1/users # create user
GET /api/v1/users/{id} # get one user
PUT /api/v1/users/{id} # replace user
PATCH /api/v1/users/{id} # partial update
DELETE /api/v1/users/{id} # delete user
# Nested resources for relationships
GET /api/v1/users/{id}/orders # user's orders
POST /api/v1/users/{id}/orders # create order for user
# ❌ Verb-based — common but wrong
GET /api/getUsers
POST /api/createUser
DELETE /api/removeUser/{id}
Consistent naming reduces the surface area developers need to memorize. If they know /users exists, they can guess /orders, /products, /subscriptions.
API Versioning
Versioning in the URL path is the most explicit and widely understood approach:
mux := http.NewServeMux()
// Version groups — apply middleware per version
v1 := http.NewServeMux()
v1.HandleFunc("GET /users", listUsersV1)
v1.HandleFunc("GET /users/{id}", getUserV1)
v1.HandleFunc("POST /users", createUserV1)
v2 := http.NewServeMux()
v2.HandleFunc("GET /users", listUsersV2) // V2 has different response shape
v2.HandleFunc("GET /users/{id}", getUserV2)
v2.HandleFunc("POST /users", createUserV2)
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", v1))
mux.Handle("/api/v2/", http.StripPrefix("/api/v2", v2))
Alternatives: header versioning (Accept: application/vnd.myapp.v2+json) is cleaner but harder to test with curl and harder for consumers to discover. Query parameter versioning (?version=2) works but pollutes every URL.
The key rule: once a version is public, don’t break it. Add fields to responses (backwards compatible). Never remove or rename fields in a versioned API without bumping the version.
Consistent Response Format
Define a standard response envelope and use it everywhere:
// Success response
type APIResponse struct {
Data any `json:"data"`
Meta *PageMeta `json:"meta,omitempty"`
}
// Error response
type APIError struct {
Error string `json:"error"`
Code string `json:"code"` // machine-readable error code
Details map[string]string `json:"details,omitempty"` // field-level validation errors
}
type PageMeta struct {
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Pages int `json:"pages"`
}
// Helper functions used in every handler
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, APIError{Error: message, Code: code})
}
func writeValidationError(w http.ResponseWriter, details map[string]string) {
writeJSON(w, http.StatusUnprocessableEntity, APIError{
Error: "validation failed",
Code: "VALIDATION_ERROR",
Details: details,
})
}
Machine-readable code fields (like USER_NOT_FOUND, DUPLICATE_EMAIL) let clients branch on specific error types without parsing message strings — which change, get translated, or get reformatted.
Pagination
Offset pagination is simple but slow on large tables. Cursor pagination scales better:
// Offset pagination — simple, works for most cases up to ~1M rows
type OffsetPagination struct {
Page int `form:"page"`
PerPage int `form:"per_page"`
}
func (p *OffsetPagination) Offset() int {
if p.Page < 1 { p.Page = 1 }
if p.PerPage < 1 || p.PerPage > 100 { p.PerPage = 20 }
return (p.Page - 1) * p.PerPage
}
// Cursor pagination — scales to billions of rows
type CursorPagination struct {
Cursor string `form:"cursor"` // opaque cursor from previous response
Limit int `form:"limit"`
}
type CursorResult struct {
Items []User `json:"items"`
NextCursor string `json:"next_cursor,omitempty"` // empty when no more pages
HasMore bool `json:"has_more"`
}
Always include pagination metadata in the response — clients should never need to calculate it:
func listUsers(w http.ResponseWriter, r *http.Request) {
var p OffsetPagination
// parse p from query params...
users, total, err := db.ListUsers(r.Context(), p.Offset(), p.PerPage)
if err != nil {
writeError(w, 500, "INTERNAL_ERROR", "failed to list users")
return
}
pages := (total + p.PerPage - 1) / p.PerPage
writeJSON(w, 200, APIResponse{
Data: users,
Meta: &PageMeta{Total: total, Page: p.Page, PerPage: p.PerPage, Pages: pages},
})
}
Rate Limiting
Per-client rate limiting prevents one consumer from starving others. Use golang.org/x/time/rate with a per-client limiter map:
import "golang.org/x/time/rate"
type RateLimiter struct {
mu sync.Mutex
limiters map[string]*rate.Limiter
rate rate.Limit
burst int
}
func NewRateLimiter(r rate.Limit, burst int) *RateLimiter {
return &RateLimiter{limiters: make(map[string]*rate.Limiter), rate: r, burst: burst}
}
func (rl *RateLimiter) Allow(key string) bool {
rl.mu.Lock()
l, ok := rl.limiters[key]
if !ok {
l = rate.NewLimiter(rl.rate, rl.burst)
rl.limiters[key] = l
}
rl.mu.Unlock()
return l.Allow()
}
func rateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key == "" { key = r.RemoteAddr }
if !limiter.Allow(key) {
w.Header().Set("Retry-After", "1")
w.Header().Set("X-RateLimit-Limit", "100")
w.Header().Set("X-RateLimit-Remaining", "0")
writeError(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "too many requests")
return
}
next.ServeHTTP(w, r)
})
}
}
Always set Retry-After and X-RateLimit-* headers — they tell clients when to retry, preventing thundering herd when the limit resets.
OpenAPI Documentation with swaggo
Annotate handlers with comments that swaggo/swag converts to an OpenAPI spec:
go install github.com/swaggo/swag/cmd/swag@latest
go get github.com/swaggo/gin-swagger
go get github.com/swaggo/files
// @title MyApp API
// @version 1.0
// @description User and order management API
// @host api.example.com
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @Summary List users
// @Description Returns paginated list of users
// @Tags users
// @Produce json
// @Param page query int false "Page number" minimum(1)
// @Param per_page query int false "Items per page" minimum(1) maximum(100)
// @Success 200 {object} APIResponse{data=[]User,meta=PageMeta}
// @Failure 401 {object} APIError
// @Failure 429 {object} APIError "Rate limit exceeded"
// @Security BearerAuth
// @Router /users [get]
func listUsers(w http.ResponseWriter, r *http.Request) { ... }
Generate and serve the spec:
swag init --generalInfo cmd/server/main.go --output docs/
import (
_ "myapp/docs" // generated swagger docs
ginSwagger "github.com/swaggo/gin-swagger"
swaggerFiles "github.com/swaggo/files"
)
// Serve the Swagger UI at /swagger/index.html
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
Hypermedia Links (HATEOAS)
Including links in responses lets clients navigate the API without hardcoding URLs. This is optional but valuable for APIs with complex workflows:
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Links []Link `json:"_links,omitempty"`
}
type Link struct {
Rel string `json:"rel"`
Href string `json:"href"`
Method string `json:"method,omitempty"`
}
func userLinks(baseURL, userID string) []Link {
return []Link{
{Rel: "self", Href: baseURL + "/users/" + userID, Method: "GET"},
{Rel: "update", Href: baseURL + "/users/" + userID, Method: "PUT"},
{Rel: "delete", Href: baseURL + "/users/" + userID, Method: "DELETE"},
{Rel: "orders", Href: baseURL + "/users/" + userID + "/orders", Method: "GET"},
}
}
Minimal HATEOAS — just self links — is worth adding to every response even if you don’t go full REST-level-3. It makes the API self-navigable in tools like Postman and Insomnia.
Summary
- Resource URLs use nouns, plural, lowercase:
/users,/orders, not/getUser,/createOrder - Version in the path (
/api/v1/) is explicit and testable; never break a published version - Consistent response envelope with
dataandmetafields; errors always have machine-readablecode - Return
Retry-AfterandX-RateLimit-*headers with 429 responses — clients need them to back off correctly - swaggo generates OpenAPI specs from annotations — keeps docs in sync with code automatically
- Even minimal hypermedia links (just
self) make APIs more navigable and reduce hardcoded URL dependencies
Resources
- golang.org/x/time/rate
- swaggo/swag OpenAPI generator
- REST API design guide (Google)
- HTTP status codes (RFC 7231)
Comments