Build an HTTP Service in Go
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
A structured HTTP service does not require a framework. Since Go 1.22 ServeMux understands methods and path parameters, which covers most of what a REST API needs.
The API
| Method | Path | Behaviour |
|---|---|---|
| GET | /healthz |
liveness check |
| GET | /api/posts |
list |
| POST | /api/posts |
create (201 + Location) |
| GET | /api/posts/{id} |
one post, 404 as JSON |
Storage is an in-memory map guarded by sync.RWMutex; swapping in a database later touches only the store.
Layout
posts/
go.mod
main.go
internal/post/store.go
internal/post/handler.go
internal/httpx/respond.go
internal/ keeps these packages unimportable from outside the module.
One response shape
// internal/httpx/respond.go
package httpx
import (
"encoding/json"
"net/http"
)
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func JSON(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 Error(w http.ResponseWriter, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
JSON(w, status, body)
}
Set headers before WriteHeader; afterwards the status line is already on the wire and later header changes are dropped.
The store
// internal/post/store.go
package post
import (
"errors"
"sync"
)
var ErrNotFound = errors.New("post not found")
type Post struct {
ID int `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
}
type Store struct {
mu sync.RWMutex
seq int
items map[int]Post
}
func NewStore() *Store {
return &Store{items: make(map[int]Post)}
}
func (s *Store) Create(title, body string) Post {
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
p := Post{ID: s.seq, Title: title, Body: body}
s.items[p.ID] = p
return p
}
func (s *Store) List() []Post {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Post, 0, len(s.items))
for _, p := range s.items {
out = append(out, p)
}
return out
}
func (s *Store) Get(id int) (Post, error) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.items[id]
if !ok {
return Post{}, ErrNotFound
}
return p, nil
}
List returns a copy. Handing out the internal map means the caller ranges over shared state with no lock held, which is a data race waiting for traffic.
Routes
// main.go
func newRouter(store *post.Store) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /api/posts", func(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]any{"data": store.List()})
})
mux.HandleFunc("POST /api/posts", func(w http.ResponseWriter, r *http.Request) {
var input struct {
Title string `json:"title"`
Body string `json:"body"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(&input); err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid_json", "body is not valid JSON")
return
}
if strings.TrimSpace(input.Title) == "" {
httpx.Error(w, http.StatusUnprocessableEntity, "validation_failed", "title is required")
return
}
p := store.Create(strings.TrimSpace(input.Title), input.Body)
w.Header().Set("Location", fmt.Sprintf("/api/posts/%d", p.ID))
httpx.JSON(w, http.StatusCreated, map[string]any{"data": p})
})
mux.HandleFunc("GET /api/posts/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid_id", "id must be an integer")
return
}
p, err := store.Get(id)
if errors.Is(err, post.ErrNotFound) {
httpx.Error(w, http.StatusNotFound, "not_found", "post not found")
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"data": p})
})
return mux
}
What matters here:
"METHOD /path"is Go 1.22+ syntax. A known path with the wrong method gets an automatic 405 with anAllowheader, no extra code.{id}is read withr.PathValue("id"); no manual string splitting, no index arithmetic.http.MaxBytesReadercaps the body — without it a single request can allocate gigabytes.DisallowUnknownFieldsturns a typo like"titel"into a 400 instead of a silently empty field.400means the payload was not JSON;422means it parsed but failed validation. Clients can act on the difference.
Middleware
func withRequestID(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 = rand.Text() // Go 1.24: cryptographically random string
}
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r)
})
}
func withLogging(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", w.Header().Get("X-Request-Id"),
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
Wrapping ResponseWriter is the only way to learn the status code that was actually written. If you need Flush or Hijack through the wrapper (streaming, WebSockets), use http.NewResponseController(w).Flush() in the handler instead of asserting interfaces by hand — Go 1.20 added it precisely because wrappers hide the optional methods.
Starting the server with real timeouts
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
store := post.NewStore()
handler := withRequestID(withLogging(logger, newRouter(store)))
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
// ... serve, see below
}
All four timeouts matter, and all four default to zero (unlimited):
ReadHeaderTimeoutshuts down slow-header (Slowloris) clients;ReadTimeoutbounds reading the request body;WriteTimeoutbounds writing the response;IdleTimeoutbounds how long a keep-alive connection may sit unused.
Graceful shutdown
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server failed", "err", err)
stop()
}
}()
<-ctx.Done()
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("shutdown timed out", "err", err)
}
Shutdown stops accepting connections and waits for in-flight requests, up to the deadline. In Kubernetes that deadline must stay below terminationGracePeriodSeconds, otherwise the pod is killed mid-response.
Checking it by hand
go run .
curl -s localhost:8080/healthz
curl -s localhost:8080/api/posts
curl -s -i -X POST localhost:8080/api/posts \
-H 'Content-Type: application/json' \
-d '{"title":"first","body":"hello"}'
curl -s -i localhost:8080/api/posts/1
curl -s -i localhost:8080/api/posts/999
curl -s -i -X POST localhost:8080/api/posts -H 'Content-Type: application/json' -d '{"title":""}'
curl -s -i -X DELETE localhost:8080/api/posts/1
Confirm each: 201 carries Location, 404 is JSON, 422 carries validation_failed, an undefined method returns 405 with Allow, and every response has X-Request-Id.
Testing it
func TestCreatePost(t *testing.T) {
srv := httptest.NewServer(newRouter(post.NewStore()))
defer srv.Close()
body := strings.NewReader(`{"title":"hello"}`)
res, err := http.Post(srv.URL+"/api/posts", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", res.StatusCode)
}
}
httptest.NewServer binds an ephemeral port, so tests run in parallel with no port conflicts — and defer srv.Close() is not optional, or the listeners accumulate.
Common mistakes
1. Forgetting Content-Type
The browser or fetch sees plain text and res.json() throws, even though the bytes are valid JSON.
2. Letting a panic kill the connection
net/http recovers panics, logs the stack, and closes the connection — the client sees a reset, not a 500. Add a recover middleware that writes a JSON 500.
3. Reading r.Body unbounded
MaxBytesReader (or a io.LimitReader) is the difference between a bad request and an out-of-memory kill.
4. Using context.Background() inside a handler
That discards the client-disconnect and deadline signals. Always derive from r.Context().
5. Exiting with os.Exit to stop the server
In-flight requests get a connection reset. Use Shutdown with a deadline.
6. Treating http.ErrServerClosed as a failure
Shutdown makes ListenAndServe return it. Logging it as an error guarantees noise on every deploy.
7. Holding a lock across I/O
A RLock held while writing a response blocks every writer. Locks protect memory, not network calls.
8. Trusting {id} to be numeric
PathValue is a string. strconv.Atoi failing is a 400, not a panic or a 404.
Summary
"METHOD /path"plusPathValuecovers most routing, and 405 handling comes for free.- Route every response through
httpx.JSON/httpx.Errorso status codes and error shapes stay consistent. - Limit the body, reject unknown fields, and separate 400 (syntax) from 422 (semantics).
- Middleware owns request IDs and logging; wrap
ResponseWriterto capture the real status. - Four timeouts and a
Shutdownpath are the minimum for production.
That completes the Go track: the language basics, the intermediate material on modules, tests, context, concurrency and generics, and two end-to-end tools.