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

Routing in Go Part 2: Upgrading the server so it behaves like a grown up

In part 1 we built a small payment links API. In part 2 we are going to do the things everyone promises they will do “after launch” and then never do until something catches fire.

Part 2: Upgrading the server

Tests, auth middleware, a service layer, and request timeouts

In part 1 we built a small payment links API.

In part 2 we are going to do the things everyone promises they will do “after launch” and then never do until something catches fire.

We will add:

  • A service layer so handlers stop doing business decisions
  • Auth middleware with a simple API key
  • Request scoped timeouts so slow work does not hang forever
  • Proper handler tests with httptest

Same rule as before. No magic. Code that compiles.


Step 1: Restructure into a sane layout

Keep it small, but separate responsibilities.

Create this structure:

mkdir -p cmd/api internal/httpapi internal/links

You will end up with:

  • cmd/api/main.go bootstraps the server
  • internal/httpapi routing, middleware, helpers
  • internal/links store and service logic

Create internal/links/model.go

package links

import "time"

type PayLink struct {
	ID        int       `json:"id"`
	Amount    int       `json:"amount"`
	Currency  string    `json:"currency"`
	Note      string    `json:"note"`
	CreatedAt time.Time `json:"created_at"`
}

Create internal/links/store.go

package links

import (
	"sync"
	"time"
)

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

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) List() []PayLink {
	s.mu.RLock()
	defer s.mu.RUnlock()

	out := make([]PayLink, 0, len(s.links))
	for _, l := range s.links {
		out = append(out, l)
	}
	return out
}

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

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

Step 3: Add a service layer that owns the rules

This is where you put “currency must be 3 letters” and “amount must be positive”.

Create internal/links/service.go

package links

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

var (
	ErrInvalidAmount   = errors.New("amount must be greater than zero")
	ErrInvalidCurrency = errors.New("currency must be a 3 letter code like USD")
	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
	}

	// Pretend we might do more work here later.
	time.Sleep(5 * time.Millisecond)

	return s.store.Create(amount, currency, note), nil
}

func (s *Service) List(ctx context.Context) ([]PayLink, error) {
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	default:
	}
	return s.store.List(), 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
}

Notice what happened.

Handlers no longer need to know the rules. They only translate HTTP into method calls.

That is how you stop your routing from turning into a soap opera.


Step 4: HTTP helpers and middleware

Create 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) {
	WriteJSON(w, status, map[string]string{"error": msg})
}

Create internal/httpapi/middleware.go

package httpapi

import (
	"log"
	"math/rand"
	"net"
	"net/http"
	"strconv"
	"strings"
	"time"
)

type Middleware func(http.Handler) http.Handler

func Chain(h http.Handler, m ...Middleware) http.Handler {
	for i := len(m) - 1; i >= 0; i-- {
		h = m[i](h)
	}
	return h
}

func RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-Id")
		if id == "" {
			id = newRequestID()
		}
		w.Header().Set("X-Request-Id", id)
		next.ServeHTTP(w, r)
	})
}

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)
			logger.Printf("%s %s from=%s dur=%s",
				r.Method,
				r.URL.Path,
				clientIP(r),
				time.Since(start).Round(time.Millisecond),
			)
		})
	}
}

func APIKeyAuth(expected string) Middleware {
	expected = strings.TrimSpace(expected)
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Let health stay public. Your uptime monitor is not your enemy.
			if r.URL.Path == "/health" {
				next.ServeHTTP(w, r)
				return
			}

			if expected == "" {
				// If no key is configured, do not pretend you are secure.
				WriteError(w, http.StatusInternalServerError, "server missing API key configuration")
				return
			}

			got := strings.TrimSpace(r.Header.Get("X-API-Key"))
			if got != expected {
				WriteError(w, http.StatusUnauthorized, "unauthorized")
				return
			}

			next.ServeHTTP(w, r)
		})
	}
}

func clientIP(r *http.Request) string {
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		return r.RemoteAddr
	}
	return host
}

func newRequestID() string {
	return strconv.FormatInt(time.Now().UnixNano(), 36) + "-" + strconv.Itoa(rand.Intn(1000000))
}

Step 5: Handlers that use the service layer

Create internal/httpapi/handlers.go

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)

	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:
		items, err := a.Links.List(ctx)
		if err != nil {
			WriteError(w, http.StatusRequestTimeout, "request timed out")
			return
		}
		WriteJSON(w, http.StatusOK, items)
		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)
}

Step 6: Wire it all together in `cmd/api/main.go`

Create cmd/api/main.go

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

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

func main() {
	logger := log.New(os.Stdout, "", log.LstdFlags)

	apiKey := os.Getenv("API_KEY")
	store := links.NewMemoryStore()
	service := links.NewService(store)

	api := &httpapi.API{Links: service}
	routes := api.Routes()

	handler := httpapi.Chain(
		routes,
		httpapi.RequestID,
		httpapi.Logging(logger),
		httpapi.APIKeyAuth(apiKey),
	)

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	go func() {
		logger.Println("listening on http://localhost:8080")
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			logger.Fatalf("server error: %v", err)
		}
	}()

	waitForShutdown(logger, srv)
}

func waitForShutdown(logger *log.Logger, srv *http.Server) {
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

	<-stop
	logger.Println("shutting down...")

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		logger.Printf("shutdown error: %v", err)
	}
	logger.Println("bye")
}

Run it:

API_KEY=supersecret go run ./cmd/api

Try without auth:

curl -s http://localhost:8080/links

You should get unauthorized.

Try with auth:

curl -s http://localhost:8080/links -H "X-API-Key: supersecret"

Now it works.

This is the exact point where a lot of people tell themselves they have “security”. You do not. You have a locked door. It is still a wooden door. But it is better than a sign that says “please do not hack”.


Step 7: Write tests that prove your routes behave

This is where confidence comes from. Not vibes.

Create internal/httpapi/handlers_test.go

package httpapi

import (
	"bytes"
	"net/http"
	"net/http/httptest"
	"testing"

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

func newTestServer() http.Handler {
	store := links.NewMemoryStore()
	service := links.NewService(store)
	api := &API{Links: service}
	return api.Routes()
}

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

	req := httptest.NewRequest(http.MethodGet, "/health", nil)
	rec := httptest.NewRecorder()

	h.ServeHTTP(rec, req)

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

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

	// Create
	body := []byte(`{"amount":5000,"currency":"kes","note":"invoice 183"}`)
	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())
	}

	// Get
	req2 := httptest.NewRequest(http.MethodGet, "/links/1", nil)
	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())
	}
}

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

	body := []byte(`{"amount":0,"currency":"usd"}`)
	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.StatusBadRequest {
		t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
	}
}

Run tests:

go test ./...

If it passes, congrats. You now have a codebase that can be changed without praying.


Step 8: Test auth middleware without hating your life

Right now tests call api.Routes() directly, so they bypass middleware.

That is good. You should test handlers and middleware separately.

Create internal/httpapi/middleware_test.go

package httpapi

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestAPIKeyAuth(t *testing.T) {
	final := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})

	h := APIKeyAuth("secret")(final)

	// Missing key
	req := httptest.NewRequest(http.MethodGet, "/links", nil)
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("expected 401, got %d", rec.Code)
	}

	// Correct key
	req2 := httptest.NewRequest(http.MethodGet, "/links", nil)
	req2.Header.Set("X-API-Key", "secret")
	rec2 := httptest.NewRecorder()
	h.ServeHTTP(rec2, req2)

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

	// Health is public
	req3 := httptest.NewRequest(http.MethodGet, "/health", nil)
	rec3 := httptest.NewRecorder()
	h.ServeHTTP(rec3, req3)

	if rec3.Code != http.StatusOK {
		t.Fatalf("expected 200 for health, got %d", rec3.Code)
	}
}

Run:

go test ./...

What we fixed, in human terms

You now have:

  • Routing that stays clean because handlers are thin
  • Business rules that live in a service, not scattered around
  • Middleware that centralizes behavior like auth
  • Timeouts so requests do not hang forever
  • Tests that turn fear into evidence

Also, you avoided the classic trap where the router becomes your entire application.

Your router is now just traffic control. As it should be.

Share this writing