Skip to main content

API Design Best Practices for Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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))

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 data and meta fields; errors always have machine-readable code
  • Return Retry-After and X-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

Comments

👍 Was this article helpful?