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:
- Start the server in a goroutine
- Block on a channel waiting for
SIGINTorSIGTERM - When the signal arrives, call
server.Shutdown()with a deadline Shutdownstops 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.