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 different authentication schemes, all apparently designed on mornings fueled by Cuban coffee, bold, chaotic, and deeply regrettable. Someone reports a bug, you fix it in one handler, and it still pops up because the other handler is running the vintage copy pasted Cuban roast edition. If this had been built on calm, methodical Kenyan coffee, bright, balanced, and actually consistent, we might have one auth flow and far fewer existential crises 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 } 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.” Step 3: Build the payment link store 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 } 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.”