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”: Pagination for GET /links PUT /links/{id} to update the note Structured enough logging so you can trace a request without reading tea leaves 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. Update internal/links/store.go 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. Update internal/links/service.go 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.