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 bodyPOST— create, returns 201 with the created resourcePUT— full replacement of a resourcePATCH— partial updateDELETE— 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
enveloperesponse format and centralwriteJSON/writeErrorhelpers - Enforce request size limits with
http.MaxBytesReaderbefore 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
- net/http package
- RESTful API design guide
- HTTP status codes reference
- Let’s Go Further (Alex Edwards) — the definitive book on production Go APIs
Comments