Go From Zero, Part 12: Building a REST API with the Standard Library

This is Part 12 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 11: Best Practices for Production-Ready Go is the place to catch up.

This is it, the final part. In Part 11 we collected the habits that make Go code production-ready. Today we use all of them, plus nearly everything else from the series, to build a real REST API from scratch.

We’ll build a small task tracker API. It’s deliberately simple in what it does, so we can focus on how it’s built. And we’ll use only the standard library. Since Go 1.22, net/http supports method-based routing and path parameters, so for many services you genuinely don’t need a web framework.

Here’s what the finished API does:

  • GET /health returns a health check
  • GET /tasks lists all tasks
  • POST /tasks creates a task
  • GET /tasks/{id} fetches one task
  • PATCH /tasks/{id} marks a task done or not done
  • DELETE /tasks/{id} deletes a task

The architecture

Layers of the task REST API

The design follows a simple layered flow, the same one you’ll find in many production Go services:

  1. HTTP layer (httpapi): decodes requests, calls the service, encodes responses, maps errors to status codes.
  2. Service layer (task.Service): business rules, like “a title can’t be empty”.
  3. Storage layer (task.Store): an interface, with an in-memory implementation for now.

Each layer only knows about the one below it, and the storage is hidden behind a small interface (Part 4). Swapping the in-memory store for PostgreSQL later means writing one new type. Nothing else changes.

Project layout

Following Part 6:

tasks/
├── go.mod
├── cmd/
│   └── api/
│       └── main.go            # wiring + graceful shutdown
└── internal/
    ├── task/
    │   ├── task.go            # domain types, errors, service
    │   └── memstore.go        # in-memory Store
    └── httpapi/
        ├── handler.go         # routes and handlers
        ├── json.go            # JSON + error helpers
        ├── middleware.go      # logging + panic recovery
        └── handler_test.go    # tests

Create it with:

mkdir tasks && cd tasks
go mod init example.com/tasks
mkdir -p cmd/api internal/task internal/httpapi

Step 1: the domain and service

This is the heart of the app. Notice the sentinel errors (Part 5), the small Store interface (Part 4), and that every method takes a context.Context first (Part 8).

// file: internal/task/task.go
// Package task holds the business logic for to-do tasks.
package task

import (
    "context"
    "errors"
    "fmt"
    "strings"
    "time"
)

var (
    ErrNotFound = errors.New("task not found")
    ErrInvalid  = errors.New("invalid task")
)

type Task struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}

// Store is everything the service needs from storage.
type Store interface {
    Create(ctx context.Context, t Task) (Task, error)
    Get(ctx context.Context, id int) (Task, error)
    List(ctx context.Context) ([]Task, error)
    Update(ctx context.Context, t Task) error
    Delete(ctx context.Context, id int) error
}

type Service struct {
    store Store
}

func NewService(s Store) *Service {
    return &Service{store: s}
}

func (s *Service) Create(ctx context.Context, title string) (Task, error) {
    title, err := validateTitle(title)
    if err != nil {
        return Task{}, err
    }
    return s.store.Create(ctx, Task{Title: title, CreatedAt: time.Now().UTC()})
}

func (s *Service) Get(ctx context.Context, id int) (Task, error) {
    return s.store.Get(ctx, id)
}

func (s *Service) List(ctx context.Context) ([]Task, error) {
    return s.store.List(ctx)
}

func (s *Service) SetDone(ctx context.Context, id int, done bool) (Task, error) {
    t, err := s.store.Get(ctx, id)
    if err != nil {
        return Task{}, err
    }
    t.Done = done
    if err := s.store.Update(ctx, t); err != nil {
        return Task{}, fmt.Errorf("updating task %d: %w", id, err)
    }
    return t, nil
}

func (s *Service) Delete(ctx context.Context, id int) error {
    return s.store.Delete(ctx, id)
}

func validateTitle(title string) (string, error) {
    title = strings.TrimSpace(title)
    if title == "" {
        return "", fmt.Errorf("%w: title is required", ErrInvalid)
    }
    if len(title) > 200 {
        return "", fmt.Errorf("%w: title must be at most 200 characters", ErrInvalid)
    }
    return title, nil
}

Validation errors wrap ErrInvalid with %w, so the HTTP layer can recognise them with errors.Is while users still get a helpful message like invalid task: title is required.

Step 2: an in-memory store

A map guarded by a mutex, exactly like the SafeCounter from Part 8. Many HTTP requests run concurrently, so this lock isn’t optional.

// file: internal/task/memstore.go
package task

import (
    "context"
    "slices"
    "sync"
)

// Compile-time check that *MemoryStore satisfies Store.
var _ Store = (*MemoryStore)(nil)

// MemoryStore is an in-memory Store, safe for concurrent use.
type MemoryStore struct {
    mu     sync.Mutex
    nextID int
    tasks  map[int]Task
}

func NewMemoryStore() *MemoryStore {
    return &MemoryStore{nextID: 1, tasks: make(map[int]Task)}
}

func (m *MemoryStore) Create(_ context.Context, t Task) (Task, error) {
    m.mu.Lock()
    defer m.mu.Unlock()
    t.ID = m.nextID
    m.nextID++
    m.tasks[t.ID] = t
    return t, nil
}

func (m *MemoryStore) Get(_ context.Context, id int) (Task, error) {
    m.mu.Lock()
    defer m.mu.Unlock()
    t, ok := m.tasks[id]
    if !ok {
        return Task{}, ErrNotFound
    }
    return t, nil
}

func (m *MemoryStore) List(_ context.Context) ([]Task, error) {
    m.mu.Lock()
    defer m.mu.Unlock()
    out := make([]Task, 0, len(m.tasks))
    for _, t := range m.tasks {
        out = append(out, t)
    }
    slices.SortFunc(out, func(a, b Task) int { return a.ID - b.ID })
    return out, nil
}

func (m *MemoryStore) Update(_ context.Context, t Task) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    if _, ok := m.tasks[t.ID]; !ok {
        return ErrNotFound
    }
    m.tasks[t.ID] = t
    return nil
}

func (m *MemoryStore) Delete(_ context.Context, id int) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    if _, ok := m.tasks[id]; !ok {
        return ErrNotFound
    }
    delete(m.tasks, id)
    return nil
}

The line var _ Store = (*MemoryStore)(nil) is a handy trick you’ll see in many codebases. It costs nothing at runtime, but it makes the compiler prove that MemoryStore satisfies Store. If someone changes the interface and forgets the implementation, the build fails right here with a clear message.

Step 3: JSON helpers and error mapping

Small helpers keep handlers short. The most important function here is writeError: it’s the one place where domain errors become HTTP status codes. Handlers never decide status codes for errors themselves.

// file: internal/httpapi/json.go
package httpapi

import (
    "encoding/json"
    "errors"
    "fmt"
    "log/slog"
    "net/http"

    "example.com/tasks/internal/task"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if err := json.NewEncoder(w).Encode(v); err != nil {
        slog.Error("encoding response", "err", err)
    }
}

func readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    if err := dec.Decode(dst); err != nil {
        return fmt.Errorf("%w: %v", errBadJSON, err)
    }
    return nil
}

var errBadJSON = errors.New("malformed JSON")

// writeError maps errors to HTTP status codes in one place.
func writeError(w http.ResponseWriter, r *http.Request, err error) {
    status := http.StatusInternalServerError
    msg := "internal server error"

    switch {
    case errors.Is(err, task.ErrNotFound):
        status, msg = http.StatusNotFound, err.Error()
    case errors.Is(err, task.ErrInvalid), errors.Is(err, errBadJSON):
        status, msg = http.StatusBadRequest, err.Error()
    default:
        // unexpected: log the details, but never leak them to the client
        slog.ErrorContext(r.Context(), "request failed",
            "method", r.Method, "path", r.URL.Path, "err", err)
    }
    writeJSON(w, status, map[string]string{"error": msg})
}

A few production details worth pointing out:

  • http.MaxBytesReader caps the request body so nobody can send a 5 GB payload.
  • DisallowUnknownFields rejects typos like "tittle" instead of silently ignoring them.
  • Unexpected errors are logged with full detail but the client only sees internal server error. Never leak internal errors to users.
  • This is the “handle each error once” rule from Part 5 in action: handlers pass errors up, writeError logs only the unexpected ones.

Step 4: routes and handlers

Look at the route patterns: "GET /tasks/{id}". The method and the path variable are right there in the pattern, and r.PathValue("id") reads it. That’s the Go 1.22+ router.

// file: internal/httpapi/handler.go
// Package httpapi exposes the task service over HTTP.
package httpapi

import (
    "fmt"
    "net/http"
    "strconv"

    "example.com/tasks/internal/task"
)

type Handler struct {
    svc *task.Service
}

// Routes builds the router. Method and path patterns need Go 1.22+.
func Routes(svc *task.Service) http.Handler {
    h := &Handler{svc: svc}
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", h.health)
    mux.HandleFunc("GET /tasks", h.list)
    mux.HandleFunc("POST /tasks", h.create)
    mux.HandleFunc("GET /tasks/{id}", h.get)
    mux.HandleFunc("PATCH /tasks/{id}", h.update)
    mux.HandleFunc("DELETE /tasks/{id}", h.delete)
    return recoverPanics(logRequests(mux))
}

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

func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
    tasks, err := h.svc.List(r.Context())
    if err != nil {
        writeError(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, tasks)
}

func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
    var req struct {
        Title string `json:"title"`
    }
    if err := readJSON(w, r, &req); err != nil {
        writeError(w, r, err)
        return
    }
    t, err := h.svc.Create(r.Context(), req.Title)
    if err != nil {
        writeError(w, r, err)
        return
    }
    w.Header().Set("Location", fmt.Sprintf("/tasks/%d", t.ID))
    writeJSON(w, http.StatusCreated, t)
}

func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
    id, err := pathID(r)
    if err != nil {
        writeError(w, r, err)
        return
    }
    t, err := h.svc.Get(r.Context(), id)
    if err != nil {
        writeError(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, t)
}

func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
    id, err := pathID(r)
    if err != nil {
        writeError(w, r, err)
        return
    }
    var req struct {
        Done *bool `json:"done"` // pointer: tells "false" apart from "missing"
    }
    if err := readJSON(w, r, &req); err != nil {
        writeError(w, r, err)
        return
    }
    if req.Done == nil {
        writeError(w, r, fmt.Errorf("%w: done is required", task.ErrInvalid))
        return
    }
    t, err := h.svc.SetDone(r.Context(), id, *req.Done)
    if err != nil {
        writeError(w, r, err)
        return
    }
    writeJSON(w, http.StatusOK, t)
}

func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
    id, err := pathID(r)
    if err != nil {
        writeError(w, r, err)
        return
    }
    if err := h.svc.Delete(r.Context(), id); err != nil {
        writeError(w, r, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

func pathID(r *http.Request) (int, error) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil || id < 1 {
        return 0, fmt.Errorf("%w: id must be a positive number", task.ErrInvalid)
    }
    return id, nil
}

One subtle detail: in update, Done is a *bool. With a plain bool, a request body of {} would decode to false and quietly mark the task as not done. The pointer lets us tell “the client sent false” apart from “the client sent nothing”. It’s a classic Go JSON trick.

Step 5: middleware

Middleware in Go is just a function that takes an http.Handler and returns a new one that wraps it. No framework magic. We add two: structured request logging with slog (Part 11) and panic recovery (Part 5).

// file: internal/httpapi/middleware.go
package httpapi

import (
    "log/slog"
    "net/http"
    "time"
)

// statusRecorder remembers the status code a handler wrote.
type statusRecorder struct {
    http.ResponseWriter
    status int
}

func (s *statusRecorder) WriteHeader(code int) {
    s.status = code
    s.ResponseWriter.WriteHeader(code)
}

// logRequests logs one structured line per request.
func logRequests(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)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", rec.status,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    })
}

// recoverPanics turns a panic in one handler into a 500, instead of crashing the server.
func recoverPanics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if v := recover(); v != nil {
                slog.Error("panic recovered", "panic", v, "path", r.URL.Path)
                writeJSON(w, http.StatusInternalServerError,
                    map[string]string{"error": "internal server error"})
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Middleware wrapping the router

Step 6: main, with timeouts and graceful shutdown

main stays thin: it configures logging, wires the layers together, and runs the server with proper timeouts and graceful shutdown, exactly as in Part 11. I like the run() error pattern: main only handles the exit code, and everything else can return errors normally.

// file: cmd/api/main.go
package main

import (
    "context"
    "errors"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "example.com/tasks/internal/httpapi"
    "example.com/tasks/internal/task"
)

func main() {
    if err := run(); err != nil {
        slog.Error("fatal", "err", err)
        os.Exit(1)
    }
}

func run() error {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    slog.SetDefault(logger)

    addr := os.Getenv("ADDR")
    if addr == "" {
        addr = ":8080"
    }

    // wiring: storage -> service -> HTTP
    store := task.NewMemoryStore()
    svc := task.NewService(store)

    srv := &http.Server{
        Addr:              addr,
        Handler:           httpapi.Routes(svc),
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      15 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    errCh := make(chan error, 1)
    go func() {
        slog.Info("listening", "addr", addr)
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            errCh <- err
        }
        close(errCh)
    }()

    select {
    case err := <-errCh:
        return err
    case <-ctx.Done():
    }

    slog.Info("shutting down")
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    return srv.Shutdown(shutdownCtx)
}

Run it

go run ./cmd/api

In another terminal:

curl -i -X POST localhost:8080/tasks -d '{"title":"Learn Go"}'
HTTP/1.1 201 Created
Content-Type: application/json
Location: /tasks/1

{"id":1,"title":"Learn Go","done":false,"created_at":"2026-11-13T04:50:07.3249Z"}
curl -X POST localhost:8080/tasks -d '{"title":"Publish the blog series"}'
curl -X PATCH localhost:8080/tasks/1 -d '{"done":true}'
curl localhost:8080/tasks
[{"id":1,"title":"Learn Go","done":true,...},{"id":2,"title":"Publish the blog series","done":false,...}]

And the error cases:

curl -X POST localhost:8080/tasks -d '{"title":""}'
# {"error":"invalid task: title is required"}

curl localhost:8080/tasks/42
# {"error":"task not found"}

Meanwhile, the server logs one structured JSON line per request:

{"time":"...","level":"INFO","msg":"request","method":"POST","path":"/tasks","status":201,"duration_ms":0}
{"time":"...","level":"INFO","msg":"request","method":"GET","path":"/tasks/42","status":404,"duration_ms":0}

Press Ctrl+C and you’ll see shutting down as the server finishes in-flight requests and exits cleanly.

Step 7: tests

Using Part 10’s techniques: httptest.NewServer spins up the real router on a random port, t.Helper and t.Cleanup keep things tidy, and a table-driven test covers validation.

// file: internal/httpapi/handler_test.go
package httpapi

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"

    "example.com/tasks/internal/task"
)

func newTestServer(t *testing.T) *httptest.Server {
    t.Helper()
    srv := httptest.NewServer(Routes(task.NewService(task.NewMemoryStore())))
    t.Cleanup(srv.Close)
    return srv
}

func do(t *testing.T, method, url, body string) *http.Response {
    t.Helper()
    req, err := http.NewRequest(method, url, strings.NewReader(body))
    if err != nil {
        t.Fatalf("building request: %v", err)
    }
    req.Header.Set("Content-Type", "application/json")
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        t.Fatalf("%s %s: %v", method, url, err)
    }
    t.Cleanup(func() { res.Body.Close() })
    return res
}

func TestTaskLifecycle(t *testing.T) {
    srv := newTestServer(t)

    // create
    res := do(t, http.MethodPost, srv.URL+"/tasks", `{"title":"Learn Go"}`)
    if res.StatusCode != http.StatusCreated {
        t.Fatalf("create status = %d, want %d", res.StatusCode, http.StatusCreated)
    }
    var created task.Task
    if err := json.NewDecoder(res.Body).Decode(&created); err != nil {
        t.Fatalf("decoding: %v", err)
    }
    if created.ID != 1 || created.Title != "Learn Go" || created.Done {
        t.Fatalf("created = %+v", created)
    }

    // mark done
    res = do(t, http.MethodPatch, srv.URL+"/tasks/1", `{"done":true}`)
    if res.StatusCode != http.StatusOK {
        t.Fatalf("patch status = %d", res.StatusCode)
    }

    // delete, then it should be gone
    res = do(t, http.MethodDelete, srv.URL+"/tasks/1", "")
    if res.StatusCode != http.StatusNoContent {
        t.Fatalf("delete status = %d", res.StatusCode)
    }
    res = do(t, http.MethodGet, srv.URL+"/tasks/1", "")
    if res.StatusCode != http.StatusNotFound {
        t.Fatalf("get after delete status = %d, want 404", res.StatusCode)
    }
}

func TestCreateValidation(t *testing.T) {
    srv := newTestServer(t)
    tests := []struct {
        name string
        body string
        want int
    }{
        {name: "empty title", body: `{"title":"   "}`, want: http.StatusBadRequest},
        {name: "bad json", body: `{"title":`, want: http.StatusBadRequest},
        {name: "unknown field", body: `{"title":"x","owner":"me"}`, want: http.StatusBadRequest},
        {name: "ok", body: `{"title":"Write tests"}`, want: http.StatusCreated},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            res := do(t, http.MethodPost, srv.URL+"/tasks", tt.body)
            if res.StatusCode != tt.want {
                t.Errorf("status = %d, want %d", res.StatusCode, tt.want)
            }
        })
    }
}

func TestBadID(t *testing.T) {
    srv := newTestServer(t)
    res := do(t, http.MethodGet, srv.URL+"/tasks/abc", "")
    if res.StatusCode != http.StatusBadRequest {
        t.Errorf("status = %d, want 400", res.StatusCode)
    }
}
go test -race ./...
# ok    example.com/tasks/internal/httpapi  1.021s

Where to take it next

This is a solid foundation. Here’s how you’d grow it into a full production service, and which part of the series covers the skills:

  • Swap in a real database. Write a PostgresStore that satisfies task.Store, using database/sql with the pgx driver. Pass ctx to every query so cancelled requests stop their queries (Parts 4 and 8).
  • Add configuration for the address, database URL and timeouts from environment variables (Part 11).
  • Add authentication middleware that checks a token and stores the user ID in the request context (Part 8).
  • Add pagination to GET /tasks with ?limit= and ?offset= query parameters.
  • Containerise it with the multi-stage Dockerfile from Part 11.
  • Profile it under load with pprof before optimising anything (Part 11).

What you’ve learned in this series

Look back at how much ground we’ve covered:

  1. Why Go exists, and how to run and build programs.
  2. Variables, types, control flow and functions with multiple returns.
  3. Slices, maps and structs, and how slices share memory.
  4. Methods, small interfaces and composition through embedding.
  5. Errors as values: wrapping, errors.Is and errors.As.
  6. Packages, modules, dependencies and project layout.
  7. Goroutines, channels and select.
  8. Mutexes, context, worker pools, pipelines and errgroup.
  9. Generics, and when not to use them.
  10. Table-driven tests, fakes, benchmarks and fuzzing.
  11. Production habits: tooling, logging, timeouts, graceful shutdown and profiling.
  12. A complete, tested REST API with only the standard library.

That’s a genuinely complete picture of Go. The rest comes from writing code. Pick a small project you actually care about, like a CLI for something you do every day or an API for a side project, and build it. You’ll be surprised how quickly Go starts to feel natural.

Thanks for reading all the way through. It’s been a pleasure writing this series.

Further reading: A Tour of Go, Effective Go, Go Code Review Comments, the Go blog, and Go by Example.


Where to go from here

⬅️ Previous: Part 11: Best Practices for Production-Ready Go

🎉 That was the final part. Go build something.

The full Go From Zero series

  1. Part 1: Why Go, Setting It Up, and Your First Program
  2. Part 2: Variables, Types, Control Flow, and Functions
  3. Part 3: Arrays, Slices, Maps, and Structs
  4. Part 4: Methods, Interfaces, and Composition
  5. Part 5: Error Handling the Go Way
  6. Part 6: Packages, Modules, and Project Layout
  7. Part 7: Goroutines, Channels, and select
  8. Part 8: sync, context, and Concurrency Patterns
  9. Part 9: Generics Without the Headache
  10. Part 10: Testing, Benchmarks, and Fuzzing
  11. Part 11: Best Practices for Production-Ready Go
  12. Part 12: Building a REST API with the Standard Library (you are here)

If this helped you, share it with a friend who is learning Go, or subscribe to get the next posts by email. Questions are welcome in the comments.

comments powered by Disqus

Releted Posts

Go From Zero, Part 11: Best Practices for Production-Ready Go

This is Part 11 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 10: Testing, Benchmarks, and Fuzzing is the place to catch up.

Read more

Go From Zero, Part 10: Testing, Benchmarks, and Fuzzing

This is Part 10 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 9: Generics Without the Headache is the place to catch up.

Read more

Go From Zero, Part 9: Generics Without the Headache

This is Part 9 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 8: sync, context, and Concurrency Patterns is the place to catch up.

Read more