Writing

Writing APIs in Go Without Any Libraries

“Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.” — Antoine de Saint-Exupéry

Go’s standard library is good enough to write a full production API. You don’t need Gin, Echo, Chi, or any router framework. This tutorial walks through exactly that, using goraw as the reference, a task management API with no external dependencies.

Here’s what the final module looks like:

go.mod           ← no dependencies
main.go          ← server setup and graceful shutdown
task.go          ← types and validation
store.go         ← storage interface + in-memory implementation
handlers.go      ← route handlers
middleware.go    ← logger, recovery, CORS

Project setup

mkdir goraw && cd goraw
go mod init github.com/yourname/goraw

go mod init creates the go.mod file that defines your module path and tracks dependencies. The module path is how Go identifies your project. Since we’re only using the standard library, no additional packages will ever be listed in it.


Define your types first

Before writing a single handler, define the data shapes the API works with. Doing this first forces clarity on what the API accepts and returns before you get into routing and logic.

// task.go
package main

import (
    "fmt"
    "time"
)

type Status string

const (
    StatusPending    Status = "pending"
    StatusInProgress Status = "in_progress"
    StatusCompleted  Status = "completed"
)

func (s Status) IsValid() bool {
    switch s {
    case StatusPending, StatusInProgress, StatusCompleted:
        return true
    }
    return false
}

type Task struct {
    ID          string    `json:"id"`
    Title       string    `json:"title"`
    Description string    `json:"description,omitempty"`
    Status      Status    `json:"status"`
    CreatedAt   time.Time `json:"created_at"`
    UpdatedAt   time.Time `json:"updated_at"`
}

type CreateTaskRequest struct {
    Title       string `json:"title"`
    Description string `json:"description,omitempty"`
}

type UpdateTaskRequest struct {
    Title       *string `json:"title,omitempty"`
    Description *string `json:"description,omitempty"`
    Status      *Status `json:"status,omitempty"`
}

UpdateTaskRequest uses pointers for every field. This is how you distinguish “field was not sent” from “field was sent as empty string”. A nil pointer means absent; a non-nil pointer means the client explicitly set that field.

Validation belongs on the request types, not in the handlers:

func (r CreateTaskRequest) Validate() error {
    if r.Title == "" {
        return fmt.Errorf("title is required")
    }
    if len(r.Title) > 255 {
        return fmt.Errorf("title must be 255 characters or fewer, got %d", len(r.Title))
    }
    return nil
}

func (r UpdateTaskRequest) Validate() error {
    if r.Title != nil && *r.Title == "" {
        return fmt.Errorf("title cannot be empty")
    }
    if r.Status != nil && !r.Status.IsValid() {
        return fmt.Errorf("invalid status %q; must be pending, in_progress, or completed", *r.Status)
    }
    return nil
}

The store interface

Define an interface for your storage layer before writing the implementation. In Go, an interface is just a set of method signatures. Any type that implements all of them satisfies the interface automatically, with no explicit declaration. This pattern keeps your handlers decoupled from the actual storage, so swapping in a real database later requires no changes to handler code.

// store.go
package main

import (
    crand "crypto/rand"
    "errors"
    "sync"
    "time"
)

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

type Store interface {
    CreateTask(req CreateTaskRequest) (Task, error)
    GetAllTasks() ([]Task, error)
    GetTaskByID(id string) (Task, error)
    UpdateTask(id string, req UpdateTaskRequest) (Task, error)
    DeleteTask(id string) error
}

The in-memory implementation uses a map protected by a sync.RWMutex. HTTP servers handle multiple requests concurrently, so without a mutex, two requests could read and write the map at the same time and corrupt data. RLock/RUnlock allow multiple concurrent reads, while Lock/Unlock provide exclusive access for writes.

type MemoryStore struct {
    mu    sync.RWMutex
    tasks map[string]Task
}

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

func (s *MemoryStore) CreateTask(req CreateTaskRequest) (Task, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    now := time.Now().UTC()
    task := Task{
        ID:          generateID(),
        Title:       req.Title,
        Description: req.Description,
        Status:      StatusPending,
        CreatedAt:   now,
        UpdatedAt:   now,
    }

    s.tasks[task.ID] = task
    return task, nil
}

func (s *MemoryStore) GetTaskByID(id string) (Task, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    task, exists := s.tasks[id]
    if !exists {
        return Task{}, ErrNotFound
    }
    return task, nil
}

func (s *MemoryStore) UpdateTask(id string, req UpdateTaskRequest) (Task, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    task, exists := s.tasks[id]
    if !exists {
        return Task{}, ErrNotFound
    }

    if req.Title != nil {
        task.Title = *req.Title
    }
    if req.Description != nil {
        task.Description = *req.Description
    }
    if req.Status != nil {
        task.Status = *req.Status
    }
    task.UpdatedAt = time.Now().UTC()

    s.tasks[id] = task
    return task, nil
}

func (s *MemoryStore) DeleteTask(id string) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, exists := s.tasks[id]; !exists {
        return ErrNotFound
    }
    delete(s.tasks, id)
    return nil
}

For IDs, skip UUIDs and use 8 random characters. Simple, collision-resistant enough for most cases.

func generateID() string {
    const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    b := make([]byte, 8)
    if _, err := crand.Read(b); err != nil {
        panic("crypto/rand unavailable: " + err.Error())
    }
    for i, v := range b {
        b[i] = chars[int(v)%len(chars)]
    }
    return string(b)
}

Routing and handlers

Before Go 1.22, net/http’s ServeMux had no support for method-based routing or path parameters, which pushed most Go projects toward third-party routers. That changed in Go 1.22. You now get GET /tasks/{id} syntax built in, no extra packages required.

// handlers.go
package main

import (
    "encoding/json"
    "errors"
    "net/http"
    "strings"
)

type Handler struct {
    store Store
}

func NewHandler(store Store) *Handler {
    return &Handler{store: store}
}

func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
    mux.HandleFunc("GET /tasks",        h.listTasks)
    mux.HandleFunc("POST /tasks",       h.createTask)
    mux.HandleFunc("GET /tasks/{id}",   h.getTask)
    mux.HandleFunc("PUT /tasks/{id}",   h.updateTask)
    mux.HandleFunc("DELETE /tasks/{id}", h.deleteTask)
}

Write two small helpers so every handler writes JSON the same way:

type apiError struct {
    Error string `json:"error"`
}

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

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, apiError{Error: msg})
}

func pathID(r *http.Request) string {
    return strings.TrimSpace(r.PathValue("id"))
}

r.PathValue("id") is the Go 1.22 way to get {id} out of the URL.

Now the handlers are short and uniform. Here’s the full set:

func (h *Handler) listTasks(w http.ResponseWriter, r *http.Request) {
    tasks, err := h.store.GetAllTasks()
    if err != nil {
        writeError(w, http.StatusInternalServerError, "Failed to retrieve tasks")
        return
    }
    if tasks == nil {
        tasks = []Task{}
    }
    writeJSON(w, http.StatusOK, tasks)
}

func (h *Handler) createTask(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit
    var req CreateTaskRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "Invalid request body")
        return
    }
    if err := req.Validate(); err != nil {
        writeError(w, http.StatusUnprocessableEntity, err.Error())
        return
    }
    task, err := h.store.CreateTask(req)
    if err != nil {
        writeError(w, http.StatusInternalServerError, "Failed to create task")
        return
    }
    writeJSON(w, http.StatusCreated, task)
}

func (h *Handler) getTask(w http.ResponseWriter, r *http.Request) {
    task, err := h.store.GetTaskByID(pathID(r))
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            writeError(w, http.StatusNotFound, "Task not found")
            return
        }
        writeError(w, http.StatusInternalServerError, "Failed to retrieve task")
        return
    }
    writeJSON(w, http.StatusOK, task)
}

func (h *Handler) updateTask(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
    var req UpdateTaskRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "Invalid request body")
        return
    }
    if err := req.Validate(); err != nil {
        writeError(w, http.StatusBadRequest, err.Error())
        return
    }
    task, err := h.store.UpdateTask(pathID(r), req)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            writeError(w, http.StatusNotFound, "Task not found")
            return
        }
        writeError(w, http.StatusInternalServerError, "Failed to update task")
        return
    }
    writeJSON(w, http.StatusOK, task)
}

func (h *Handler) deleteTask(w http.ResponseWriter, r *http.Request) {
    if err := h.store.DeleteTask(pathID(r)); err != nil {
        if errors.Is(err, ErrNotFound) {
            writeError(w, http.StatusNotFound, "Task not found")
            return
        }
        writeError(w, http.StatusInternalServerError, "Failed to delete task")
        return
    }
    writeJSON(w, http.StatusNoContent, nil)
}

Notice listTasks explicitly returns []Task{} instead of nil when the store is empty. Without that, json.Encode would output null instead of [], which is annoying to work with on the client.

Also notice errors.Is(err, ErrNotFound). Always prefer this over a direct == comparison. If any layer wraps the error using fmt.Errorf("...: %w", err), a direct equality check breaks silently. errors.Is unwraps the chain and keeps the check working regardless.


Middleware

Middleware in Go is any function with the signature func(http.Handler) http.Handler. Each middleware receives the next handler, wraps it, and runs code before or after calling it.

// middleware.go
package main

type Middleware func(http.Handler) http.Handler

func ChainMiddleware(middlewares ...Middleware) Middleware {
    return func(next http.Handler) http.Handler {
        for i := len(middlewares) - 1; i >= 0; i-- {
            next = middlewares[i](next)
        }
        return next
    }
}

The reverse loop matters. You’re wrapping handlers from the inside out, so the first middleware in the list ends up being the outermost (first to run on a request, last to run on the way out).

Logger wraps the ResponseWriter to capture the status code after the handler writes it:

type responseWriter struct {
    http.ResponseWriter
    status int
}

func (rw *responseWriter) WriteHeader(status int) {
    rw.status = status
    rw.ResponseWriter.WriteHeader(status)
}

func LoggerMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(rw, r)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", rw.status,
            "duration", time.Since(start),
        )
    })
}

slog is Go’s built-in structured logging package, added in 1.21. It logs as key-value pairs, which most log aggregation tools can query directly.

Recovery catches any panics from your handlers and returns a 500 response instead of crashing the whole server:

func RecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                slog.Error("panic recovered", "err", rec)
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusInternalServerError)
                w.Write([]byte(`{"error":"internal server error"}`))
            }
        }()
        next.ServeHTTP(w, r)
    })
}

CORS sets the headers needed for cross-origin requests. Without these, browsers will block any request that comes from a different origin:

func CORSMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type")

        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }

        next.ServeHTTP(w, r)
    })
}

The OPTIONS early return handles preflight requests. Before sending a cross-origin request with a body or custom headers, browsers first send an OPTIONS request to check what the server allows. Returning early here avoids passing that preflight to your actual handler.


Wiring it together with graceful shutdown

// main.go
package main

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

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = "42069"
    }

    store := NewMemoryStore()
    handler := NewHandler(store)

    mux := http.NewServeMux()

    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"status":"ok"}`))
    })

    handler.RegisterRoutes(mux)

    chain := ChainMiddleware(
        LoggerMiddleware,
        RecoveryMiddleware,
        CORSMiddleware,
    )

    server := &http.Server{
        Addr:         ":" + port,
        Handler:      chain(mux),
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    go func() {
        slog.Info("server starting", "addr", "http://localhost:"+port)
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            slog.Error("server error", "err", err)
            os.Exit(1)
        }
    }()

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

    <-ctx.Done()
    slog.Info("shutdown signal received, draining connections")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := server.Shutdown(shutdownCtx); err != nil {
        slog.Error("graceful shutdown failed", "err", err)
        os.Exit(1)
    }

    slog.Info("server gracefully stopped")
}

A few things worth noting here:

Always set timeouts on http.Server. The zero values mean no timeout, which means a slow client can hold connections open indefinitely. ReadTimeout covers reading the request, WriteTimeout covers writing the response, IdleTimeout covers keep-alive connections between requests.

Graceful shutdown waits for in-flight requests to finish before exiting. The pattern:

  1. Start the server in a goroutine
  2. Block on a channel waiting for SIGINT or SIGTERM
  3. When the signal arrives, call server.Shutdown() with a deadline
  4. Shutdown stops accepting new connections and waits for active ones to complete

Without this, a kill or Ctrl+C kills requests mid-flight.


Run it

go run .
# create a task
curl -X POST http://localhost:42069/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "write the tutorial"}'

# list tasks
curl http://localhost:42069/tasks

# get one
curl http://localhost:42069/tasks/<id>

# update status
curl -X PUT http://localhost:42069/tasks/<id> \
  -H "Content-Type: application/json" \
  -d '{"status": "completed"}'

# delete
curl -X DELETE http://localhost:42069/tasks/<id>

The full source is at github.com/namanthanki/goraw. When you’re ready to persist data, write a Postgres implementation of the Store interface and swap it in at startup. Every handler, middleware, and route stays exactly as written.