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.

Go 1.24

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 an Allow header, no extra code.
  • {id} is read with r.PathValue("id"); no manual string splitting, no index arithmetic.
  • http.MaxBytesReader caps the body — without it a single request can allocate gigabytes.
  • DisallowUnknownFields turns a typo like "titel" into a 400 instead of a silently empty field.
  • 400 means the payload was not JSON; 422 means 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):

  • ReadHeaderTimeout shuts down slow-header (Slowloris) clients;
  • ReadTimeout bounds reading the request body;
  • WriteTimeout bounds writing the response;
  • IdleTimeout bounds 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" plus PathValue covers most routing, and 405 handling comes for free.
  • Route every response through httpx.JSON / httpx.Error so 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 ResponseWriter to capture the real status.
  • Four timeouts and a Shutdown path 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.