Back to Writing
Growth status: Growing GrowingUpdated: Mar 1, 202611 min read

Routing in Go Part 3: The last upgrade

By now we have a small Go API that is not embarrassing. For the last part, we are going to add the stuff that turns “cute demo” into “this could actually run”

Part 3: The last upgrade

Pagination, update endpoint, request scoped logging, and lightweight OpenAPI

By now we have a small Go API that is not embarrassing.

We have routes, a service layer, middleware, timeouts, and tests. That is already better than most code that ships.

For the last part, we are going to add the stuff that turns “cute demo” into “this could actually run”:

  1. Pagination for GET /links
  2. PUT /links/{id} to update the note
  3. Structured enough logging so you can trace a request without reading tea leaves
  4. A small OpenAPI spec so other people do not have to guess your API

Still simple. Still standard library.


Step 1: Add pagination to the store and service

We will do offset pagination because it is easy to understand. Cursor pagination is better at scale, but you can add it later when you actually have scale. Most people add it early as a personality trait.

Replace the Store interface and add a paginated list method. Also update the memory store.

package links

import (
	"sort"
	"sync"
	"time"
)

type Store interface {
	Create(amount int, currency, note string) PayLink
	Get(id int) (PayLink, bool)
	UpdateNote(id int, note string) (PayLink, bool)
	List(offset, limit int) []PayLink
	Count() int
}

type MemoryStore struct {
	mu    sync.RWMutex
	next  int
	links map[int]PayLink
}

func NewMemoryStore() *MemoryStore {
	return &MemoryStore{
		next:  1,
		links: make(map[int]PayLink),
	}
}

func (s *MemoryStore) Create(amount int, currency, note string) PayLink {
	s.mu.Lock()
	defer s.mu.Unlock()

	l := PayLink{
		ID:        s.next,
		Amount:    amount,
		Currency:  currency,
		Note:      note,
		CreatedAt: time.Now().UTC(),
	}
	s.links[l.ID] = l
	s.next++
	return l
}

func (s *MemoryStore) Get(id int) (PayLink, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	l, ok := s.links[id]
	return l, ok
}

func (s *MemoryStore) UpdateNote(id int, note string) (PayLink, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()

	l, ok := s.links[id]
	if !ok {
		return PayLink{}, false
	}
	l.Note = note
	s.links[id] = l
	return l, true
}

func (s *MemoryStore) Count() int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return len(s.links)
}

func (s *MemoryStore) List(offset, limit int) []PayLink {
	s.mu.RLock()
	defer s.mu.RUnlock()

	if offset < 0 {
		offset = 0
	}
	if limit <= 0 {
		limit = 20
	}
	if limit > 100 {
		limit = 100
	}

	ids := make([]int, 0, len(s.links))
	for id := range s.links {
		ids = append(ids, id)
	}
	sort.Ints(ids)

	if offset >= len(ids) {
		return []PayLink{}
	}

	end := offset + limit
	if end > len(ids) {
		end = len(ids)
	}

	out := make([]PayLink, 0, end-offset)
	for _, id := range ids[offset:end] {
		out = append(out, s.links[id])
	}
	return out
}

Now update the service so handlers do not have to know pagination rules.

Add these methods and errors.

package links

import (
	"context"
	"errors"
	"strings"
)

var (
	ErrInvalidAmount   = errors.New("amount must be greater than zero")
	ErrInvalidCurrency = errors.New("currency must be a 3 letter code like USD")
	ErrInvalidLimit    = errors.New("limit must be between 1 and 100")
	ErrInvalidOffset   = errors.New("offset must be 0 or greater")
	ErrNotFound        = errors.New("link not found")
)

type Service struct {
	store Store
}

func NewService(store Store) *Service {
	return &Service{store: store}
}

func (s *Service) Create(ctx context.Context, amount int, currency, note string) (PayLink, error) {
	select {
	case <-ctx.Done():
		return PayLink{}, ctx.Err()
	default:
	}

	currency = strings.ToUpper(strings.TrimSpace(currency))
	note = strings.TrimSpace(note)

	if amount <= 0 {
		return PayLink{}, ErrInvalidAmount
	}
	if len(currency) != 3 {
		return PayLink{}, ErrInvalidCurrency
	}
	return s.store.Create(amount, currency, note), nil
}

type Page struct {
	Items  []PayLink `json:"items"`
	Total  int       `json:"total"`
	Offset int       `json:"offset"`
	Limit  int       `json:"limit"`
}

func (s *Service) ListPage(ctx context.Context, offset, limit int) (Page, error) {
	select {
	case <-ctx.Done():
		return Page{}, ctx.Err()
	default:
	}

	if offset < 0 {
		return Page{}, ErrInvalidOffset
	}
	if limit <= 0 || limit > 100 {
		return Page{}, ErrInvalidLimit
	}

	return Page{
		Items:  s.store.List(offset, limit),
		Total:  s.store.Count(),
		Offset: offset,
		Limit:  limit,
	}, nil
}

func (s *Service) Get(ctx context.Context, id int) (PayLink, error) {
	select {
	case <-ctx.Done():
		return PayLink{}, ctx.Err()
	default:
	}
	l, ok := s.store.Get(id)
	if !ok {
		return PayLink{}, ErrNotFound
	}
	return l, nil
}

func (s *Service) UpdateNote(ctx context.Context, id int, note string) (PayLink, error) {
	select {
	case <-ctx.Done():
		return PayLink{}, ctx.Err()
	default:
	}

	note = strings.TrimSpace(note)
	if note == "" {
		// Keep it simple. You can allow empty later if needed.
		return PayLink{}, errors.New("note is required")
	}

	l, ok := s.store.UpdateNote(id, note)
	if !ok {
		return PayLink{}, ErrNotFound
	}
	return l, nil
}

Step 2: Add request scoped logging that actually helps

Right now we log method, path, and duration. Good start.

Let us add request ID into the logs and also return it in error responses. Because when a user reports a bug, you want them to give you one string, not a full emotional experience.

Update `internal/httpapi/helpers.go`

package httpapi

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

func WriteJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

func WriteError(w http.ResponseWriter, status int, msg string) {
	reqID := w.Header().Get("X-Request-Id")
	if reqID == "" {
		WriteJSON(w, status, map[string]string{"error": msg})
		return
	}
	WriteJSON(w, status, map[string]string{"error": msg, "request_id": reqID})
}

Update `internal/httpapi/middleware.go`

Change the logging middleware to include request id.

func Logging(logger *log.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			next.ServeHTTP(w, r)

			reqID := w.Header().Get("X-Request-Id")
			if reqID == "" {
				reqID = "-"
			}

			logger.Printf("rid=%s %s %s from=%s dur=%s",
				reqID,
				r.Method,
				r.URL.Path,
				clientIP(r),
				time.Since(start).Round(time.Millisecond),
			)
		})
	}
}

Now your logs are traceable.


Step 3: Update handlers for pagination and PUT

Update `internal/httpapi/handlers.go`

Replace the collection handler and add a new update handler.

package httpapi

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
	"time"

	"example.com/paylinks/internal/links"
)

type API struct {
	Links *links.Service
}

func (a *API) Routes() http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
	})

	mux.HandleFunc("/links", a.linksCollection)
	mux.HandleFunc("GET /links/{id}", a.linksGet)
	mux.HandleFunc("PUT /links/{id}", a.linksUpdateNote)

	return mux
}

func (a *API) linksCollection(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	switch r.Method {
	case http.MethodGet:
		offset := parseIntQuery(r, "offset", 0)
		limit := parseIntQuery(r, "limit", 20)

		page, err := a.Links.ListPage(ctx, offset, limit)
		if err != nil {
			switch {
			case errors.Is(err, links.ErrInvalidOffset), errors.Is(err, links.ErrInvalidLimit):
				WriteError(w, http.StatusBadRequest, err.Error())
			case errors.Is(err, context.DeadlineExceeded):
				WriteError(w, http.StatusRequestTimeout, "request timed out")
			default:
				WriteError(w, http.StatusInternalServerError, "server error")
			}
			return
		}

		WriteJSON(w, http.StatusOK, page)
		return

	case http.MethodPost:
		var body struct {
			Amount   int    `json:"amount"`
			Currency string `json:"currency"`
			Note     string `json:"note"`
		}
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			WriteError(w, http.StatusBadRequest, "invalid JSON body")
			return
		}

		l, err := a.Links.Create(ctx, body.Amount, body.Currency, body.Note)
		if err != nil {
			switch {
			case errors.Is(err, links.ErrInvalidAmount), errors.Is(err, links.ErrInvalidCurrency):
				WriteError(w, http.StatusBadRequest, err.Error())
			case errors.Is(err, context.DeadlineExceeded):
				WriteError(w, http.StatusRequestTimeout, "request timed out")
			default:
				WriteError(w, http.StatusInternalServerError, "server error")
			}
			return
		}

		WriteJSON(w, http.StatusCreated, l)
		return

	default:
		WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
		return
	}
}

func (a *API) linksGet(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	idStr := r.PathValue("id")
	id, err := strconv.Atoi(idStr)
	if err != nil || id <= 0 {
		WriteError(w, http.StatusBadRequest, "invalid link id")
		return
	}

	l, err := a.Links.Get(ctx, id)
	if err != nil {
		switch {
		case errors.Is(err, links.ErrNotFound):
			WriteError(w, http.StatusNotFound, "link not found")
		case errors.Is(err, context.DeadlineExceeded):
			WriteError(w, http.StatusRequestTimeout, "request timed out")
		default:
			WriteError(w, http.StatusInternalServerError, "server error")
		}
		return
	}

	WriteJSON(w, http.StatusOK, l)
}

func (a *API) linksUpdateNote(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	idStr := r.PathValue("id")
	id, err := strconv.Atoi(idStr)
	if err != nil || id <= 0 {
		WriteError(w, http.StatusBadRequest, "invalid link id")
		return
	}

	var body struct {
		Note string `json:"note"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		WriteError(w, http.StatusBadRequest, "invalid JSON body")
		return
	}

	l, err := a.Links.UpdateNote(ctx, id, body.Note)
	if err != nil {
		switch {
		case errors.Is(err, links.ErrNotFound):
			WriteError(w, http.StatusNotFound, "link not found")
		default:
			WriteError(w, http.StatusBadRequest, err.Error())
		}
		return
	}

	WriteJSON(w, http.StatusOK, l)
}

func parseIntQuery(r *http.Request, key string, fallback int) int {
	raw := r.URL.Query().Get(key)
	if raw == "" {
		return fallback
	}
	v, err := strconv.Atoi(raw)
	if err != nil {
		return fallback
	}
	return v
}

Now you have:

  • GET /links?offset=0&limit=20
  • PUT /links/1 with body {"note":"new note"}

Step 4: Update tests for the new behavior

Update internal/httpapi/handlers_test.go by adding these tests.

func TestPagination(t *testing.T) {
	h := newTestServer()

	// Create 3 items
	for i := 0; i < 3; i++ {
		body := []byte(`{"amount":100,"currency":"usd","note":"x"}`)
		req := httptest.NewRequest(http.MethodPost, "/links", bytes.NewReader(body))
		req.Header.Set("Content-Type", "application/json")
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)

		if rec.Code != http.StatusCreated {
			t.Fatalf("expected 201, got %d body=%s", rec.Code, rec.Body.String())
		}
	}

	// Fetch page with limit=2
	req := httptest.NewRequest(http.MethodGet, "/links?offset=0&limit=2", nil)
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
	}
}

func TestUpdateNote(t *testing.T) {
	h := newTestServer()

	// Create
	body := []byte(`{"amount":5000,"currency":"kes","note":"old"}`)
	req := httptest.NewRequest(http.MethodPost, "/links", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusCreated {
		t.Fatalf("expected 201, got %d body=%s", rec.Code, rec.Body.String())
	}

	// Update
	up := []byte(`{"note":"new note"}`)
	req2 := httptest.NewRequest(http.MethodPut, "/links/1", bytes.NewReader(up))
	req2.Header.Set("Content-Type", "application/json")
	rec2 := httptest.NewRecorder()
	h.ServeHTTP(rec2, req2)

	if rec2.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d body=%s", rec2.Code, rec2.Body.String())
	}
}

Run:

go test ./...

Step 5: Add an OpenAPI spec without turning into a bureaucracy

This is where teams often go wrong.

They either have no docs and everyone guesses, or they treat OpenAPI like a sacred ritual and stop shipping.

We will do a lightweight OpenAPI file by hand. Just enough so someone can integrate without calling you.

Create openapi.yaml at the project root.

openapi: 3.0.3
info:
  title: PayLinks API
  version: 1.0.0
servers:
  - url: http://localhost:8080
paths:
  /health:
    get:
      summary: Health check
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
  /links:
    get:
      summary: List payment links
      parameters:
        - in: query
          name: offset
          schema:
            type: integer
            minimum: 0
          required: false
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
          required: false
      responses:
        "200":
          description: Paginated list
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: "#/components/schemas/PayLink"
                  total:
                    type: integer
                  offset:
                    type: integer
                  limit:
                    type: integer
    post:
      summary: Create a payment link
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, currency]
              properties:
                amount:
                  type: integer
                  minimum: 1
                currency:
                  type: string
                  example: USD
                note:
                  type: string
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PayLink"
  /links/{id}:
    get:
      summary: Get one payment link
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: Found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PayLink"
        "404":
          description: Not found
    put:
      summary: Update the note for a payment link
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: integer
            minimum: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [note]
              properties:
                note:
                  type: string
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PayLink"
components:
  schemas:
    PayLink:
      type: object
      properties:
        id:
          type: integer
        amount:
          type: integer
        currency:
          type: string
        note:
          type: string
        created_at:
          type: string
          format: date-time

Now your API has a contract.

Not perfect. Not automatic. But good enough to stop people from guessing.


Quick manual smoke test for Part 3

Run the server:

API_KEY=supersecret go run ./cmd/api

Create a few links:

curl -s -X POST http://localhost:8080/links \
  -H "X-API-Key: supersecret" \
  -H "Content-Type: application/json" \
  -d '{"amount":1200,"currency":"usd","note":"coffee"}'

curl -s -X POST http://localhost:8080/links \
  -H "X-API-Key: supersecret" \
  -H "Content-Type: application/json" \
  -d '{"amount":9000,"currency":"kes","note":"invoice 22"}'

List with pagination:

curl -s "http://localhost:8080/links?offset=0&limit=1" -H "X-API-Key: supersecret"

Update note:

curl -s -X PUT http://localhost:8080/links/1 \
  -H "X-API-Key: supersecret" \
  -H "Content-Type: application/json" \
  -d '{"note":"coffee, but make it urgent"}'

The real takeaway

If you remember one thing from all three parts, make it this:

Routing is not the hard part. The hard part is not letting routing become your entire application.

Keep handlers thin. Put rules in a service. Put shared behavior in middleware. Add tests early. Add docs before you forget what your API does.

This is how you build a Go server that does not require a rewrite the moment it gets real traffic.

And yes, you will still rewrite parts of it later. But it will be because you chose to, not because the code forced you into a corner with a chair.

Share this writing