Bootcamp
A 60-minute Go bootcamp that ships a working HTTP service with graceful shutdown, JSON I/O, tests, and a Dockerfile. The smallest reproducible loop.
A 60-minute Go bootcamp
EXAMPLE
# ===== Objectives =====
# 1. Build a typed HTTP service
# 2. Parse + emit JSON safely
# 3. Add structured logging + graceful shutdown
# 4. Tests + race detector
# 5. Dockerfile + run
# ===== 0-5 min: scaffold =====
mkdir shop-api && cd shop-api
go mod init example.com/shop-api
# ===== 5-25 min: the service =====
# main.go
package main
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
type Order struct {
ID string \`json:"id"\`
Customer string \`json:"customer"\`
TotalCents int64 \`json:"total_cents"\`
Status string \`json:"status"\`
}
type Store struct {
mu sync.RWMutex
orders map[string]Order
}
func newStore() *Store { return &Store{orders: map[string]Order{}} }
func (s *Store) put(o Order) {
s.mu.Lock(); defer s.mu.Unlock()
s.orders[o.ID] = o
}
func (s *Store) get(id string) (Order, bool) {
s.mu.RLock(); defer s.mu.RUnlock()
o, ok := s.orders[id]
return o, ok
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
store := newStore()
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK); _, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
var o Order
if err := json.NewDecoder(r.Body).Decode(&o); err != nil {
http.Error(w, "bad json", http.StatusBadRequest); return
}
if o.ID == "" || o.TotalCents < 0 {
http.Error(w, "validation", http.StatusBadRequest); return
}
if o.Status == "" { o.Status = "new" }
store.put(o)
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(o)
})
mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
o, ok := store.get(id)
if !ok { http.NotFound(w, r); return }
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(o)
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
idle := make(chan struct{})
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
logger.Info("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
close(idle)
}()
logger.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("serve", "err", err)
os.Exit(1)
}
<-idle
}
# Run
go run .
# In another terminal
curl -X POST http://localhost:8080/orders -d '{"id":"o1","customer":"alice","total_cents":4995}'
curl http://localhost:8080/orders/o1
# ===== 25-40 min: tests =====
# main_test.go
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateAndGetOrder(t *testing.T) {
store := newStore()
mux := http.NewServeMux()
mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
var o Order
_ = json.NewDecoder(r.Body).Decode(&o)
if o.Status == "" { o.Status = "new" }
store.put(o)
w.WriteHeader(201)
})
mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
o, ok := store.get(r.PathValue("id"))
if !ok { http.NotFound(w, r); return }
_ = json.NewEncoder(w).Encode(o)
})
srv := httptest.NewServer(mux); defer srv.Close()
res, _ := http.Post(srv.URL+"/orders", "application/json",
strings.NewReader(\`{\"id\":\"o1\",\"total_cents\":4995}\`))
if res.StatusCode != 201 { t.Fatalf("want 201, got %d", res.StatusCode) }
res, _ = http.Get(srv.URL + "/orders/o1")
if res.StatusCode != 200 { t.Fatalf("want 200, got %d", res.StatusCode) }
var got Order
_ = json.NewDecoder(res.Body).Decode(&got)
if got.ID != "o1" { t.Fatalf("got %+v", got) }
}
# Run
# go test ./...
# go test -race ./...
# go test -cover -coverprofile=cover.out && go tool cover -html=cover.out
# ===== 40-55 min: Dockerfile =====
# Dockerfile
# FROM golang:1.22-alpine AS build
# WORKDIR /src
# COPY go.mod go.sum ./
# RUN go mod download
# COPY . .
# RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app .
#
# FROM gcr.io/distroless/static-debian12:nonroot
# COPY --from=build /out/app /app
# EXPOSE 8080
# ENTRYPOINT ["/app"]
#
# docker build -t shop-api:1.0 .
# docker run --rm -p 8080:8080 shop-api:1.0
# ===== 55-60 min: deploy =====
# - Cloud Run / Fly.io: 'gcloud run deploy' / 'fly deploy' takes the Dockerfile
# - Kubernetes: kubectl apply -f deployment.yaml
# - VPS: docker compose with reverse proxy (Caddy / Traefik)
# ===== Post-bootcamp checklist =====
# - Service listens, healthz works, JSON I/O works
# - Tests + race detector green
# - Graceful shutdown on SIGTERM
# - Static binary in a distroless image (< 20MB)
# - README with build + run instructions
# ===== Pitfalls =====
# - Forgetting context propagation -> shutdown does not cancel handlers
# - http.ListenAndServe with default mux + no timeouts -> Slowloris risk
# - JSON decode without size limit -> memory pressure
# - Mutating maps without a lock -> data race
Why it matters
Build the bootcamp once and the pattern fits every service: typed handlers, graceful shutdown, structured logging, race-detector-clean tests, distroless static binary. The same skeleton scales from a tiny tool to a fleet of microservices without rewrites.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Discussion
Loading…