Skip to main content

REST API Design

REST API is the most widely used architectural style for communication between web services. Proper REST API design allows clients to interact with servers in a predictable way, improving maintainability and scalability. Go is well-suited for implementing REST API servers thanks to its concise syntax and powerful standard library.

REST Principles and URL Design

The core of REST (Representational State Transfer) is resource-centric design.

URL Design Principles

# Good URL design (use nouns, express hierarchical structure)
GET /v1/users # List users
POST /v1/users # Create user
GET /v1/users/{id} # Get specific user
PUT /v1/users/{id} # Full update of user
PATCH /v1/users/{id} # Partial update of user
DELETE /v1/users/{id} # Delete user

GET /v1/users/{id}/posts # List posts by user
GET /v1/users/{id}/posts/{pid} # Specific post by user

# Bad URL design (avoid using verbs)
GET /getUsers
POST /createUser
GET /getUserById?id=1

HTTP Method Semantics

MethodMeaningIdempotentSafe
GETReadOO
POSTCreateXX
PUTFull updateOX
PATCHPartial updateXX
DELETEDeleteOX

Idempotent: Making the same request multiple times produces the same result Safe: Does not change server state

Correct Usage of HTTP Status Codes

// Usage scenarios by status code
const (
// 2xx Success
StatusOK = 200 // GET, PUT, PATCH success
StatusCreated = 201 // Resource created successfully via POST
StatusNoContent = 204 // DELETE success (no response body)

// 4xx Client errors
StatusBadRequest = 400 // Malformed request
StatusUnauthorized = 401 // Authentication required
StatusForbidden = 403 // No permission (authenticated but not authorized)
StatusNotFound = 404 // Resource not found
StatusMethodNotAllowed = 405 // HTTP method not allowed
StatusConflict = 409 // Resource conflict (e.g., duplicate creation)
StatusUnprocessableEntity = 422 // Validation failed

// 5xx Server errors
StatusInternalServerError = 500 // Internal server error
StatusServiceUnavailable = 503 // Service temporarily unavailable
)

Standard Error Response Structure

Consistent error responses allow clients to handle errors in a predictable manner.

package main

import (
"encoding/json"
"net/http"
)

// APIError standard error response structure
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details []ErrorDetail `json:"details,omitempty"`
}

// ErrorDetail per-field validation error detail
type ErrorDetail struct {
Field string `json:"field"`
Message string `json:"message"`
}

// APIResponse standard response wrapper
type APIResponse[T any] struct {
Data T `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
Meta *PageMeta `json:"meta,omitempty"`
}

// PageMeta pagination metadata
type PageMeta struct {
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
HasNext bool `json:"has_next"`
}

// WriteJSON JSON response helper
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

// WriteError error response helper
func WriteError(w http.ResponseWriter, status int, code, message string, details ...ErrorDetail) {
resp := APIResponse[any]{
Error: &APIError{
Code: code,
Message: message,
Details: details,
},
}
WriteJSON(w, status, resp)
}

URL Versioning

package main

import (
"fmt"
"net/http"
)

func main() {
mux := http.NewServeMux()

// v1 routes
v1 := http.NewServeMux()
v1.HandleFunc("GET /users", v1GetUsers)
v1.HandleFunc("POST /users", v1CreateUser)

// v2 routes (add features while maintaining backward compatibility)
v2 := http.NewServeMux()
v2.HandleFunc("GET /users", v2GetUsers) // changed response format
v2.HandleFunc("POST /users", v2CreateUser)

// Separate by version prefix
mux.Handle("/v1/", http.StripPrefix("/v1", v1))
mux.Handle("/v2/", http.StripPrefix("/v2", v2))

fmt.Println("Server started: http://localhost:8080")
http.ListenAndServe(":8080", mux)
}

func v1GetUsers(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"users": []map[string]string{
{"id": "1", "name": "John Doe"},
},
})
}

func v2GetUsers(w http.ResponseWriter, r *http.Request) {
// v2 includes pagination meta
WriteJSON(w, http.StatusOK, APIResponse[any]{
Data: []map[string]string{
{"id": "1", "name": "John Doe"},
},
Meta: &PageMeta{Total: 1, Page: 1, Limit: 20, HasNext: false},
})
}

func v1CreateUser(w http.ResponseWriter, r *http.Request) {}
func v2CreateUser(w http.ResponseWriter, r *http.Request) {}

Pagination

Offset-Based Pagination

package main

import (
"net/http"
"strconv"
)

type PaginationParams struct {
Page int // starts from 1
Limit int
Offset int
}

// ParsePagination parse pagination from query parameters
func ParsePagination(r *http.Request) PaginationParams {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))

if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 20 // default, max 100
}

return PaginationParams{
Page: page,
Limit: limit,
Offset: (page - 1) * limit,
}
}

// Usage example
func handleGetPosts(w http.ResponseWriter, r *http.Request) {
p := ParsePagination(r)

// DB query: SELECT * FROM posts LIMIT ? OFFSET ?
// posts, total := db.GetPosts(p.Limit, p.Offset)
posts := []map[string]any{} // fetched from DB in real code
total := int64(0)

WriteJSON(w, http.StatusOK, APIResponse[any]{
Data: posts,
Meta: &PageMeta{
Total: total,
Page: p.Page,
Limit: p.Limit,
HasNext: int64(p.Page*p.Limit) < total,
},
})
}

Cursor-Based Pagination

The offset approach has performance issues with large datasets. Cursor-based pagination is more efficient.

package main

import (
"encoding/base64"
"fmt"
"net/http"
)

type CursorParams struct {
Cursor string
Limit int
}

// EncodeCursor encode cursor to base64 (ID-based)
func EncodeCursor(id int64) string {
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", id)))
}

// DecodeCursor decode cursor
func DecodeCursor(cursor string) (int64, error) {
b, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return 0, err
}
var id int64
_, err = fmt.Sscanf(string(b), "%d", &id)
return id, err
}

// cursor-based response structure
type CursorResponse[T any] struct {
Data T `json:"data"`
NextCursor string `json:"next_cursor,omitempty"`
HasNext bool `json:"has_next"`
}

func handleGetPostsCursor(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")

var afterID int64
if cursor != "" {
id, err := DecodeCursor(cursor)
if err != nil {
WriteError(w, http.StatusBadRequest, "INVALID_CURSOR", "Invalid cursor")
return
}
afterID = id
}

// DB query: SELECT * FROM posts WHERE id > ? ORDER BY id LIMIT ?
_ = afterID
posts := []map[string]any{} // fetch from DB
hasNext := false

var nextCursor string
if hasNext {
// create next cursor from the last item's ID
// nextCursor = EncodeCursor(posts[len(posts)-1].ID)
}

WriteJSON(w, http.StatusOK, CursorResponse[any]{
Data: posts,
NextCursor: nextCursor,
HasNext: hasNext,
})
}
package main

import (
"net/http"
"strings"
)

type PostFilter struct {
AuthorID int64
Status string
Search string
SortBy string
SortDir string
}

// ParsePostFilter parse filter from query parameters
func ParsePostFilter(r *http.Request) PostFilter {
q := r.URL.Query()

sortDir := strings.ToUpper(q.Get("sort_dir"))
if sortDir != "ASC" && sortDir != "DESC" {
sortDir = "DESC"
}

sortBy := q.Get("sort_by")
// only accept allowed columns (prevent SQL injection)
allowedSortFields := map[string]bool{"created_at": true, "title": true, "views": true}
if !allowedSortFields[sortBy] {
sortBy = "created_at"
}

return PostFilter{
Status: q.Get("status"),
Search: q.Get("q"),
SortBy: sortBy,
SortDir: sortDir,
}
}

// Request example:
// GET /v1/posts?status=published&q=golang&sort_by=views&sort_dir=DESC&page=1&limit=10
func handleFilteredPosts(w http.ResponseWriter, r *http.Request) {
filter := ParsePostFilter(r)
p := ParsePagination(r)

_ = filter
_ = p

// Build and execute DB query
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

Practical Example: Gin-Based Blog REST API

package main

import (
"net/http"
"strconv"
"sync"
"time"

"github.com/gin-gonic/gin"
)

// Post blog post model
type Post struct {
ID int64 `json:"id"`
Title string `json:"title" binding:"required,min=1,max=200"`
Content string `json:"content" binding:"required"`
AuthorID int64 `json:"author_id"`
Status string `json:"status"` // draft, published
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// CreatePostRequest create post request
type CreatePostRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Content string `json:"content" binding:"required"`
Status string `json:"status" binding:"omitempty,oneof=draft published"`
}

// UpdatePostRequest update post request
type UpdatePostRequest struct {
Title *string `json:"title" binding:"omitempty,min=1,max=200"`
Content *string `json:"content"`
Status *string `json:"status" binding:"omitempty,oneof=draft published"`
}

// PostStore in-memory store
type PostStore struct {
mu sync.RWMutex
posts map[int64]*Post
nextID int64
}

func NewPostStore() *PostStore {
return &PostStore{posts: make(map[int64]*Post), nextID: 1}
}

func (s *PostStore) Create(req CreatePostRequest) *Post {
s.mu.Lock()
defer s.mu.Unlock()

status := req.Status
if status == "" {
status = "draft"
}

post := &Post{
ID: s.nextID,
Title: req.Title,
Content: req.Content,
AuthorID: 1,
Status: status,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
s.posts[s.nextID] = post
s.nextID++
return post
}

func (s *PostStore) GetAll(page, limit int) ([]*Post, int64) {
s.mu.RLock()
defer s.mu.RUnlock()

all := make([]*Post, 0, len(s.posts))
for _, p := range s.posts {
all = append(all, p)
}

total := int64(len(all))
start := (page - 1) * limit
if start >= len(all) {
return []*Post{}, total
}
end := start + limit
if end > len(all) {
end = len(all)
}
return all[start:end], total
}

func (s *PostStore) GetByID(id int64) (*Post, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.posts[id]
return p, ok
}

func (s *PostStore) Update(id int64, req UpdatePostRequest) (*Post, bool) {
s.mu.Lock()
defer s.mu.Unlock()

post, ok := s.posts[id]
if !ok {
return nil, false
}
if req.Title != nil {
post.Title = *req.Title
}
if req.Content != nil {
post.Content = *req.Content
}
if req.Status != nil {
post.Status = *req.Status
}
post.UpdatedAt = time.Now()
return post, true
}

func (s *PostStore) Delete(id int64) bool {
s.mu.Lock()
defer s.mu.Unlock()

if _, ok := s.posts[id]; !ok {
return false
}
delete(s.posts, id)
return true
}

// PostHandler collection of Gin handlers
type PostHandler struct {
store *PostStore
}

func (h *PostHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if page < 1 { page = 1 }
if limit < 1 || limit > 100 { limit = 20 }

posts, total := h.store.GetAll(page, limit)
c.JSON(http.StatusOK, gin.H{
"data": posts,
"meta": gin.H{
"total": total,
"page": page,
"limit": limit,
"has_next": int64(page*limit) < total,
},
})
}

func (h *PostHandler) Create(c *gin.Context) {
var req CreatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()},
})
return
}
post := h.store.Create(req)
c.JSON(http.StatusCreated, gin.H{"data": post})
}

func (h *PostHandler) Get(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{"code": "INVALID_ID", "message": "Invalid ID"},
})
return
}

post, ok := h.store.GetByID(id)
if !ok {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "Post not found"},
})
return
}
c.JSON(http.StatusOK, gin.H{"data": post})
}

func (h *PostHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{"code": "INVALID_ID", "message": "Invalid ID"},
})
return
}

var req UpdatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()},
})
return
}

post, ok := h.store.Update(id, req)
if !ok {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "Post not found"},
})
return
}
c.JSON(http.StatusOK, gin.H{"data": post})
}

func (h *PostHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{"code": "INVALID_ID", "message": "Invalid ID"},
})
return
}

if !h.store.Delete(id) {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "Post not found"},
})
return
}
c.Status(http.StatusNoContent)
}

func main() {
r := gin.Default()
handler := &PostHandler{store: NewPostStore()}

v1 := r.Group("/v1")
{
posts := v1.Group("/posts")
{
posts.GET("", handler.List)
posts.POST("", handler.Create)
posts.GET("/:id", handler.Get)
posts.PATCH("/:id", handler.Update)
posts.DELETE("/:id", handler.Delete)
}
}

// Health check
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})

r.Run(":8080")
}

Content-Type Negotiation

package main

import (
"encoding/json"
"encoding/xml"
"net/http"
)

type User struct {
ID int `json:"id" xml:"id"`
Name string `json:"name" xml:"name"`
}

// Select response format based on client's Accept header
func handleUser(w http.ResponseWriter, r *http.Request) {
user := User{ID: 1, Name: "John Doe"}

accept := r.Header.Get("Accept")
switch accept {
case "application/xml":
w.Header().Set("Content-Type", "application/xml")
xml.NewEncoder(w).Encode(user)
default: // application/json or */*
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
}

Pro Tips

1. Maintain consistent response structure: Use the same wrapper for both success and error responses

2. Use string error codes: "NOT_FOUND" is easier for clients to handle than numeric codes

3. Idempotency-Key: Use a unique key header to prevent duplicate POST requests

// Prevent duplicate requests with idempotency key
func handleCreateOrder(w http.ResponseWriter, r *http.Request) {
idempotencyKey := r.Header.Get("Idempotency-Key")
if idempotencyKey == "" {
WriteError(w, http.StatusBadRequest, "MISSING_IDEMPOTENCY_KEY",
"Idempotency-Key header is required")
return
}
// Check if the request has already been processed in cache
// if cached := cache.Get(idempotencyKey); cached != nil { ... }
}

4. Request ID propagation: Assign a unique ID to every request and include it in the response header

import "github.com/google/uuid"

func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r)
})
}