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

Routing in Go Part 1: A small server you can grow without rewriting at midnight

So in this note, we are going to build a Go server with routing that stays boring and predictable. Boring is good. Boring survives.

A small server you can grow without rewriting at midnight

Let me tell you how most “simple” Go servers die.

Day one: you ship a tiny API. One file. Three routes. Clean. You feel like a responsible adult.

Day fourteen: product asks for “just one more endpoint.” You add it.

Day twenty: you add auth. You copy paste auth into every handler because it is faster than thinking. You promise to clean it up later.

Day thirty: you have ten endpoints and three kinds of authentication logic, depending on which coffee you were on that morning. Someone reports a bug, you fix it in one handler, and it still happens because the other handler uses the older copy pasted version.

Then you do the most dangerous thing an engineer can do.

You say, “It is fine, it is just a small server.”

That phrase is how small servers become unmaintainable servers. Not because Go is hard. Because routing becomes a junk drawer.

So in this note, we are going to build a Go server with routing that stays boring and predictable. Boring is good. Boring survives.

The case we are building

You run a tiny payments product. Merchants hit your API to create payment links, list them, and fetch one link.

You have these routes:

  • GET /health so uptime monitors stop emailing you at 3am
  • POST /links create a payment link
  • GET /links list payment links
  • GET /links/{id} fetch one payment link

You are going to do this using the standard library, plus a small amount of structure so you do not end up with spaghetti handlers.

Everything compiles. No hidden magic.


Step 0: Create the project

mkdir go-routing-paylinks
cd go-routing-paylinks
go mod init example.com/paylinks

Create a file called main.go.


Step 1: Start with a server that is not allergic to production

Most examples online start with this:

http.ListenAndServe(":8080", nil)

That is fine for a tutorial. It is also how you end up with timeouts that do not exist and shutdowns that happen by accident.

We are going to start with sane defaults.

Paste this full program first. Run it. Confirm it works. Then we will add routes.

package main

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"math/rand"
	"net"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"
)

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

	mux := http.NewServeMux()

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

	handler := chain(
		mux,
		requestIDMiddleware,
		loggingMiddleware(logger),
	)

	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")
}

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})
}

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 requestIDMiddleware(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 loggingMiddleware(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 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))
}

Run it:

go run .

Test it:

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

If you get {"status":"ok"}, you are alive. The server is alive. Your monitoring tool is calm. Your inbox is safe for now.


Step 2: The pain we are fixing

Here is the most common routing mistake.

You start adding endpoints and you mix routing with business logic in one place. Then you add validation. Then you add auth. Then you add “just a quick special case.” Then it becomes a bowl of logic noodles.

We are going to avoid that by doing something boring:

  • define our data model
  • define a store
  • build handlers that only do HTTP work
  • keep shared behavior in middleware

This is not “enterprise architecture.” This is “I would like to sleep.”


We will use an in memory store for the tutorial, but we will write it like it could become a database later.

Add this under the imports in main.go, after the imports.

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"`
}

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

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

func (s *linkStore) 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 *linkStore) 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 *linkStore) get(id int) (PayLink, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()

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

Important: you need to add sync to your imports.

So your imports should include:

"sync"

Step 4: Add routes that do not turn into a crime scene

Now we wire up handlers.

Modern Go’s ServeMux supports patterns like GET /links/{id} and lets you extract {id} using r.PathValue("id").

Inside main(), add the store:

store := newLinkStore()

Then add these routes after the health route.

mux.HandleFunc("/links", func(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		writeJSON(w, http.StatusOK, store.list())
		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
		}

		body.Currency = strings.ToUpper(strings.TrimSpace(body.Currency))
		body.Note = strings.TrimSpace(body.Note)

		if body.Amount <= 0 {
			writeError(w, http.StatusBadRequest, "amount must be greater than zero")
			return
		}
		if body.Currency == "" {
			writeError(w, http.StatusBadRequest, "currency is required")
			return
		}
		if len(body.Currency) != 3 {
			writeError(w, http.StatusBadRequest, "currency must be a 3 letter code like USD")
			return
		}

		l := store.create(body.Amount, body.Currency, body.Note)
		writeJSON(w, http.StatusCreated, l)
		return

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

mux.HandleFunc("GET /links/{id}", func(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")
	id, err := strconv.Atoi(idStr)
	if err != nil || id <= 0 {
		writeError(w, http.StatusBadRequest, "invalid link id")
		return
	}

	l, ok := store.get(id)
	if !ok {
		writeError(w, http.StatusNotFound, "link not found")
		return
	}

	writeJSON(w, http.StatusOK, l)
})

You will also need to add strings to imports.

So imports should include:

"strings"

At this point you have all the routes you need. But let’s not pretend we are done. We still need the full file to compile.

To keep you from hunting missing imports, here is the full working main.go in one piece.


Full working code

Copy this whole file and run go run .

package main

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"math/rand"
	"net"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"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"`
}

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

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

func (s *linkStore) 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 *linkStore) 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 *linkStore) get(id int) (PayLink, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()

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

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

	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", func(w http.ResponseWriter, r *http.Request) {
		switch r.Method {
		case http.MethodGet:
			writeJSON(w, http.StatusOK, store.list())
			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
			}

			body.Currency = strings.ToUpper(strings.TrimSpace(body.Currency))
			body.Note = strings.TrimSpace(body.Note)

			if body.Amount <= 0 {
				writeError(w, http.StatusBadRequest, "amount must be greater than zero")
				return
			}
			if body.Currency == "" {
				writeError(w, http.StatusBadRequest, "currency is required")
				return
			}
			if len(body.Currency) != 3 {
				writeError(w, http.StatusBadRequest, "currency must be a 3 letter code like USD")
				return
			}

			l := store.create(body.Amount, body.Currency, body.Note)
			writeJSON(w, http.StatusCreated, l)
			return

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

	mux.HandleFunc("GET /links/{id}", func(w http.ResponseWriter, r *http.Request) {
		idStr := r.PathValue("id")
		id, err := strconv.Atoi(idStr)
		if err != nil || id <= 0 {
			writeError(w, http.StatusBadRequest, "invalid link id")
			return
		}

		l, ok := store.get(id)
		if !ok {
			writeError(w, http.StatusNotFound, "link not found")
			return
		}

		writeJSON(w, http.StatusOK, l)
	})

	handler := chain(
		mux,
		requestIDMiddleware,
		loggingMiddleware(logger),
	)

	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")
}

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})
}

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 requestIDMiddleware(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 loggingMiddleware(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 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: Test the story, not just the code

Run:

go run .

Create a link:

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

List links:

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

Fetch one:

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

You now have a small API that demonstrates clean routing, basic validation, a store layer, middleware, and graceful shutdown.

And importantly, you did not create a handler that is half router, half therapist.


Why this structure stays sane

The rule is simple.

Handlers should be boring.

A handler should do three things:

  • parse and validate input
  • call a store or service
  • write a response

Middleware should handle cross cutting behavior like logging, request IDs, and auth.

If you keep that boundary, your routes do not rot. If you blur it, you will eventually spend a Saturday rewriting a server that “was just a small server.”

Share this writing