Skip to main content

Building REST APIs with Go

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

Building a REST API in Go is straightforward — the standard library’s net/http handles routing, request parsing, and response writing. The challenge is doing it correctly: consistent error responses, proper input validation, authentication middleware that doesn’t tangle with business logic, and pagination that works at scale.

This guide builds a complete API pattern from first principles using the standard library. For framework-based routing (Gin, Chi) see Go Gin framework.

API Design Foundations

Before writing code, establish the conventions your API follows. Consistency matters more than which convention you pick:

Resource naming: nouns, lowercase, hyphenated (/api/v1/user-profiles, not /api/v1/getUserProfiles).

HTTP methods carry meaning:

  • GET — read, idempotent, no body
  • POST — create, returns 201 with the created resource
  • PUT — full replacement of a resource
  • PATCH — partial update
  • DELETE — remove, returns 204 (no content) or 200 with confirmation

Status codes tell the story: 200 OK, 201 Created, 204 No Content, 400 Bad Request (your fault), 401 Unauthorized (not authenticated), 403 Forbidden (authenticated but not allowed), 404 Not Found, 409 Conflict, 422 Unprocessable Entity (validation errors), 500 Internal Server Error (our fault).

Version in the path: /api/v1/... — this lets v1 and v2 coexist during transitions.

Project Structure

Separate the HTTP layer from business logic:

api/
  handlers/
    users.go      # HTTP handlers — decode input, call service, encode output
  middleware/
    auth.go       # authentication, logging, recovery
  server.go       # router setup, server configuration
service/
  users.go        # business logic — no HTTP types
repository/
  users.go        # database access
models/
  user.go         # shared domain types

Handlers know about HTTP. Services know about domain logic. Repositories know about the database. None of them bleed into each other’s layer.

A Complete User API

Domain Types and Service Interface

// models/user.go
type User struct {
    ID        string    `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email"`
    Role      string    `json:"role"`
    CreatedAt time.Time `json:"created_at"`
}

type CreateUserInput struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

type UpdateUserInput struct {
    Name string `json:"name,omitempty"`
}

// service/users.go — interface the handler depends on
type UserService interface {
    Get(ctx context.Context, id string) (*User, error)
    List(ctx context.Context, page, pageSize int) ([]User, int, error)
    Create(ctx context.Context, input CreateUserInput) (*User, error)
    Update(ctx context.Context, id string, input UpdateUserInput) (*User, error)
    Delete(ctx context.Context, id string) error
}

Response Helpers

Consistent response formatting in one place:

// api/handlers/response.go
type envelope map[string]any

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if err := json.NewEncoder(w).Encode(data); err != nil {
        // Response already started — log but can't send error to client
        slog.Error("encode response", slog.Any("error", err))
    }
}

func writeError(w http.ResponseWriter, status int, message string) {
    writeJSON(w, status, envelope{"error": message})
}

func writeValidationError(w http.ResponseWriter, errs map[string]string) {
    writeJSON(w, http.StatusUnprocessableEntity, envelope{
        "error":  "validation failed",
        "fields": errs,
    })
}

Handlers

// api/handlers/users.go
type UserHandler struct {
    service UserService
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    user, err := h.service.Get(r.Context(), id)
    if errors.Is(err, ErrNotFound) {
        writeError(w, http.StatusNotFound, "user not found")
        return
    }
    if err != nil {
        slog.ErrorContext(r.Context(), "get user", slog.Any("error", err))
        writeError(w, http.StatusInternalServerError, "internal error")
        return
    }

    writeJSON(w, http.StatusOK, envelope{"user": user})
}

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

    var input CreateUserInput
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    if err := dec.Decode(&input); err != nil {
        writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
        return
    }

    // Validate
    errs := make(map[string]string)
    if strings.TrimSpace(input.Name) == "" {
        errs["name"] = "required"
    }
    if !strings.Contains(input.Email, "@") {
        errs["email"] = "invalid format"
    }
    if len(errs) > 0 {
        writeValidationError(w, errs)
        return
    }

    user, err := h.service.Create(r.Context(), input)
    if errors.Is(err, ErrConflict) {
        writeError(w, http.StatusConflict, "email already registered")
        return
    }
    if err != nil {
        slog.ErrorContext(r.Context(), "create user", slog.Any("error", err))
        writeError(w, http.StatusInternalServerError, "internal error")
        return
    }

    w.Header().Set("Location", "/api/v1/users/"+user.ID)
    writeJSON(w, http.StatusCreated, envelope{"user": user})
}

Pagination

Cursor-based pagination scales to large datasets; offset-based pagination is simpler and fine for most use cases up to a few million rows:

func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
    // Parse and validate pagination params
    page, err := strconv.Atoi(r.URL.Query().Get("page"))
    if err != nil || page < 1 {
        page = 1
    }
    pageSize, err := strconv.Atoi(r.URL.Query().Get("per_page"))
    if err != nil || pageSize < 1 || pageSize > 100 {
        pageSize = 20
    }

    users, total, err := h.service.List(r.Context(), page, pageSize)
    if err != nil {
        writeError(w, http.StatusInternalServerError, "internal error")
        return
    }

    writeJSON(w, http.StatusOK, envelope{
        "users":    users,
        "total":    total,
        "page":     page,
        "per_page": pageSize,
        "pages":    (total + pageSize - 1) / pageSize,
    })
}

Authentication Middleware

Middleware extracts and validates the token, then passes the authenticated user ID via context:

type contextKey struct{ name string }
var userIDKey = contextKey{"userID"}

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if !strings.HasPrefix(authHeader, "Bearer ") {
            writeError(w, http.StatusUnauthorized, "missing or invalid Authorization header")
            return
        }

        token := strings.TrimPrefix(authHeader, "Bearer ")
        userID, err := validateToken(token)
        if err != nil {
            writeError(w, http.StatusUnauthorized, "invalid token")
            return
        }

        ctx := context.WithValue(r.Context(), userIDKey, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Retrieve in handlers
func currentUserID(r *http.Request) (string, bool) {
    id, ok := r.Context().Value(userIDKey).(string)
    return id, ok
}

Router Setup

// api/server.go
func newRouter(users *UserHandler) http.Handler {
    mux := http.NewServeMux()

    // Public routes
    mux.HandleFunc("POST /api/v1/auth/login", handleLogin)
    mux.HandleFunc("POST /api/v1/users", users.CreateUser)  // registration is public

    // Authenticated routes — wrap with authMiddleware
    authed := http.NewServeMux()
    authed.HandleFunc("GET /api/v1/users",        users.ListUsers)
    authed.HandleFunc("GET /api/v1/users/{id}",   users.GetUser)
    authed.HandleFunc("PATCH /api/v1/users/{id}", users.UpdateUser)
    authed.HandleFunc("DELETE /api/v1/users/{id}",users.DeleteUser)

    mux.Handle("/api/v1/", authMiddleware(authed))

    // Apply global middleware
    return chain(mux,
        requestLogger,
        recoveryMiddleware,
        corsMiddleware,
    )
}

Testing Handlers

Test handlers with httptest — no real server needed:

func TestCreateUser(t *testing.T) {
    svc := &fakeUserService{} // implements UserService
    h := &UserHandler{service: svc}

    tests := []struct {
        name       string
        body       string
        wantStatus int
        wantField  string
    }{
        {
            name:       "valid user",
            body:       `{"name":"Alice","email":"[email protected]"}`,
            wantStatus: http.StatusCreated,
            wantField:  "user",
        },
        {
            name:       "missing email",
            body:       `{"name":"Alice"}`,
            wantStatus: http.StatusUnprocessableEntity,
            wantField:  "fields",
        },
        {
            name:       "malformed JSON",
            body:       `{invalid}`,
            wantStatus: http.StatusBadRequest,
            wantField:  "error",
        },
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            req := httptest.NewRequest(http.MethodPost, "/api/v1/users",
                strings.NewReader(tc.body))
            req.Header.Set("Content-Type", "application/json")
            rec := httptest.NewRecorder()

            h.CreateUser(rec, req)

            if rec.Code != tc.wantStatus {
                t.Errorf("status = %d; want %d\nbody: %s",
                    rec.Code, tc.wantStatus, rec.Body)
            }

            var resp map[string]any
            json.NewDecoder(rec.Body).Decode(&resp)
            if _, ok := resp[tc.wantField]; !ok {
                t.Errorf("response missing field %q: %v", tc.wantField, resp)
            }
        })
    }
}

Common Mistakes

Returning 200 with {"error": "..."} in the body. HTTP clients check status codes, not response bodies. A 200 with an error body breaks every HTTP client library, monitoring system, and load balancer. Use the right status code.

Not handling http.MaxBytesReader. Without a size limit, a client can send a gigabyte payload that exhausts your memory. Set the limit before decoding.

Accepting any input without validation. Even a simple presence check (name != "") prevents downstream errors. For complex validation, return field-level errors so clients can show meaningful messages.

Panicking in handlers. Always wrap handlers in a recovery middleware — an unrecovered panic kills the entire server process.

Logging before returning 500. Log the internal error but return a generic message to the client — don’t leak database error messages, SQL, or stack traces to API consumers.

Summary

  • Design before coding: resource names, method semantics, status codes, versioning
  • Keep HTTP handlers thin — decode input, call service, encode output
  • Use a consistent envelope response format and central writeJSON / writeError helpers
  • Enforce request size limits with http.MaxBytesReader before decoding
  • Return field-level validation errors with 422 so clients can show specific messages
  • Authentication middleware extracts the user into context — handlers read from context, not headers
  • Test handlers with httptest.NewRecorder — faster and more isolated than starting a real server

Resources

Comments

👍 Was this article helpful?