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.gobootstraps the server -
internal/httpapirouting, middleware, helpers -
internal/linksstore and service logic
Step 2: Move the domain and store into internal/links
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.



