Go is the default backend of this curriculum because it gets you from idea to a deployed, fast service with the least ceremony. This track moves from “hello, server” to graceful shutdown, tests, Postgres, and a 15 MB container — tagged by level so you can read only as deep as you need.
Install Go and create your module
BeginnerInstall the Go toolchain, then make a folder and run go mod init with your repo path.
Why a module, and why that name
A module is Go’s unit of dependencies and versioning. The path you pass to go mod init (normally the
repo URL) becomes the import prefix for every package in the project, so packages elsewhere — and your own
sub-packages — import it by that path. The repo doesn’t have to exist yet; pick the real name now to avoid
a rename later.
Initialise the module
# Verify the toolchain (1.22 or newer for this track)
go version
mkdir aurora-api && cd aurora-api
go mod init github.com/you/aurora-apiWrite the smallest real HTTP server
BeginnerCreate cmd/api/main.go with a net/http server that answers GET /healthz with ok.
Why net/http and no framework
Go’s standard library is a production HTTP server — not a toy. Starting framework-free teaches the real
primitives (http.Server, ServeMux, http.Handler) that every framework wraps. You can add a router
later if you outgrow it, but most services never need to.
A minimal server
// cmd/api/main.go
package main
import (
"log/slog"
"net/http"
"os"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("listening", "addr", ":8080")
if err := http.ListenAndServe(":8080", mux); err != nil {
logger.Error("server failed", "err", err)
os.Exit(1)
}
}go run ./cmd/api
# in another terminal:
curl -i localhost:8080/healthzAgent prompt — paste into an agent with repo access
Role: Senior Go engineer working in this repo.
Context: Fresh module github.com/you/aurora-api, Go 1.22+, empty except go.mod.
Task: Create a minimal, idiomatic net/http server in ./cmd/api/main.go.
Requirements:
- Use http.NewServeMux and the 1.22 method-prefixed pattern "GET /healthz".
- /healthz returns 200 with body "ok".
- Use log/slog with a JSON handler writing to stdout.
- No third-party dependencies.
Tests / acceptance:
- `go vet ./...` and `go build ./...` pass with no output.
- `curl -s -o /dev/null -w "%{http_code}" localhost:8080/healthz` prints 200.
Output: a unified diff plus a one-paragraph summary of the design.Add real routes with Go 1.22 patterns
BeginnerAdd a GET /products/{id} route and read the path value with r.PathValue("id").
What changed in Go 1.22
Before 1.22 you needed a third-party router for method matching and path variables. Go 1.22 added both to
the standard ServeMux: patterns can include a method (GET , POST …) and wildcards like {id}, read
back with r.PathValue("id"). For typical CRUD services this removes the most common reason to add a
router dependency.
A route with a path variable
mux.HandleFunc("GET /products/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"` + id + `"}`))
})Return JSON properly with encoding/json
BeginnerDefine a struct, set the Content-Type, and encode it with json.NewEncoder(w).Encode(v).
Struct tags control the wire shape
Go marshals exported struct fields to JSON. Field tags (json:"unit_price") decouple your Go names from
the API’s names, and omitempty drops zero values. Encoding straight to the ResponseWriter streams the
body without building an intermediate buffer.
Encode a struct to JSON
type Product struct {
ID string `json:"id"`
Name string `json:"name"`
UnitPrice int64 `json:"unit_price"` // store money as integer cents
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}Chat prompt — paste into a chat to get the code
Role: Go teacher. The reader has no repo access here — return complete code.
Task: Show an idiomatic handler that decodes a JSON request body into a struct, validates it, and
responds with 201 + the created object, or 422 + a JSON error on invalid input.
Requirements:
- Use encoding/json with a size-limited body (http.MaxBytesReader, 1 MB).
- Reject unknown fields (Decoder.DisallowUnknownFields).
- Money is int64 cents; name is required and non-empty.
Tests / acceptance (describe, since no repo):
- POST with {"name":"","unit_price":10} returns 422.
- POST with an extra field returns 400.
Output: the complete handler file, no commentary.Write table-driven tests with httptest
IntermediateTest the handler in memory with httptest.NewRecorder() and a slice of cases.
Why table-driven, and why httptest
Table-driven tests are the Go idiom: one loop over a slice of {name, input, want} structs keeps cases
dense and easy to extend. net/http/httptest exercises a handler without binding a socket — fast and
deterministic. Name sub-tests with t.Run so failures point at the exact case.
A handler test
// cmd/api/main_test.go
func TestHealthz(t *testing.T) {
cases := []struct {
name string
method string
path string
want int
}{
{"ok", "GET", "/healthz", 200},
{"wrong method", "POST", "/healthz", 405},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
rec := httptest.NewRecorder()
newRouter().ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Fatalf("got %d, want %d", rec.Code, tc.want)
}
})
}
}go test ./... -run TestHealthz -vAgent prompt — paste into an agent with repo access
Role: Senior Go engineer in this repo.
Context: Handlers live in ./cmd/api. Extract routing into a newRouter() *http.ServeMux so it is testable.
Task: Refactor to newRouter() and add table-driven tests for /healthz and /products/{id}.
Requirements:
- newRouter returns the configured mux; main calls it.
- Tests use net/http/httptest, t.Run sub-tests, no network sockets.
- Cover: healthz 200, wrong method 405, products happy path 200, missing id behaviour.
Tests / acceptance:
- `go test ./... -v` passes.
- `go vet ./...` clean.
Output: a unified diff plus a summary of the cases covered.Handle errors the Go way
IntermediateReturn errors as values, wrap them with %w, and inspect with errors.Is / errors.As.
Wrapping preserves the chain
Go has no exceptions; functions return error as the last value. Wrapping with fmt.Errorf("loading product: %w", err)
adds context while keeping the original error reachable. Callers test causes with errors.Is(err, ErrNotFound)
or extract typed errors with errors.As. Define sentinel errors (var ErrNotFound = errors.New("not found"))
at the boundary so handlers can map them to status codes.
Sentinel + wrap + map to status
var ErrNotFound = errors.New("not found")
func (s *Store) Product(ctx context.Context, id string) (Product, error) {
p, ok := s.byID[id]
if !ok {
return Product{}, fmt.Errorf("product %q: %w", id, ErrNotFound)
}
return p, nil
}
// in the handler:
if errors.Is(err, ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}Fan out work with goroutines and channels
IntermediateLaunch concurrent work with go, collect results through a channel, and bound it with a context.
Concurrency is a language feature, not a library
A goroutine is a cheap, scheduler-managed thread (a few KB to start). Channels pass values between
goroutines so you “share memory by communicating”. context.Context carries deadlines and cancellation
down the call tree — when a request is cancelled, the work it spawned should stop. Use errgroup to fan
out and collect the first error cleanly.
Bounded fan-out with errgroup
import "golang.org/x/sync/errgroup"
func enrich(ctx context.Context, ids []string) ([]Product, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // cap concurrency
out := make([]Product, len(ids))
for i, id := range ids {
i, id := i, id
g.Go(func() error {
p, err := fetch(ctx, id)
if err != nil {
return err
}
out[i] = p
return nil
})
}
return out, g.Wait()
}go get golang.org/x/sync/errgroup
go test ./... -race # the race detector catches data racesAgent prompt — paste into an agent with repo access
Role: Senior Go engineer in this repo.
Context: We need to fetch N products concurrently with a concurrency cap and proper cancellation.
Task: Implement enrich(ctx, ids) using golang.org/x/sync/errgroup with SetLimit(8).
Requirements:
- Results preserve input order; no shared-slice data race (each goroutine writes its own index).
- Respect ctx cancellation; return the first error.
- Add a test using a fake fetch that sleeps, asserting it cancels on ctx timeout.
Tests / acceptance:
- `go test ./... -race` passes.
Output: a unified diff plus a one-paragraph rationale for the concurrency bound.Talk to PostgreSQL with pgx
IntermediateAdd jackc/pgx/v5, open a pool, and run a query with context and parameters.
Why pgx over database/sql here
pgx is the most widely used PostgreSQL driver for Go. Its native pool (pgxpool) is fast and exposes
Postgres features (e.g. COPY, listen/notify) that the generic database/sql interface hides. Always pass
a context, and always use parameters ($1, $2) — never string-concatenate SQL, which invites
injection.
Connect and query
go get github.com/jackc/pgx/v5import "github.com/jackc/pgx/v5/pgxpool"
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil { return err }
defer pool.Close()
var price int64
err = pool.QueryRow(ctx,
`SELECT unit_price FROM products WHERE id = $1`, id,
).Scan(&price)Agent prompt — paste into an agent with repo access
Role: Senior Go engineer in this repo.
Context: Postgres is reachable via env DATABASE_URL. We use github.com/jackc/pgx/v5 with pgxpool.
Task: Add a ProductStore backed by pgxpool with Get(ctx, id) and List(ctx, limit, offset).
Requirements:
- Parameterised queries only ($1, $2); never string-concatenate input.
- Map pgx.ErrNoRows to the repo's ErrNotFound.
- Pool created once at startup, closed on shutdown; every method takes a context.
Tests / acceptance:
- Unit-test the error mapping with a fake row source (no live DB needed in CI).
- `go vet ./...` clean.
Output: a unified diff plus notes on connection-pool sizing.Shut down gracefully
AdvancedTrap SIGINT/SIGTERM with signal.NotifyContext, then call server.Shutdown(ctx) with a timeout.
Why graceful shutdown matters in production
When an orchestrator (Cloud Run, Kubernetes) replaces your instance it sends SIGTERM. If you exit
immediately you drop in-flight requests. signal.NotifyContext gives you a context cancelled on those
signals; http.Server.Shutdown then stops accepting new connections and waits (up to a deadline) for
active ones to finish. This is the single most impactful operability upgrade for a Go service.
Signal-aware server lifecycle
func run() error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: newRouter()}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("listen", "err", err)
}
}()
<-ctx.Done() // blocks until a signal
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}Agent prompt — paste into an agent with repo access
Role: Senior Go engineer in this repo.
Context: main currently calls http.ListenAndServe directly.
Task: Refactor to a run() error using signal.NotifyContext and http.Server.Shutdown with a 10s deadline.
Requirements:
- main calls run() and os.Exit(1) on error.
- ListenAndServe's http.ErrServerClosed is treated as a clean stop, not an error.
- A 10-second shutdown deadline; log the shutdown.
Tests / acceptance:
- `go build ./...` passes; manual: Ctrl-C drains rather than dropping connections.
Output: a unified diff plus a one-paragraph explanation of the lifecycle.Ship a 15 MB static container
AdvancedCompile a static binary with CGO_ENABLED=0 and copy it into a scratch/distroless image in a multi-stage build.
Why the binary gets so small
Go links everything into one binary. With CGO_ENABLED=0 there’s no libc dependency, so the binary runs
on an empty base image. A multi-stage Dockerfile builds in the full SDK image, then copies just the binary
into gcr.io/distroless/static (or scratch) — no shell, no package manager, a tiny attack surface, and
fast cold starts on Cloud Run.
Multi-stage Dockerfile
FROM golang:1.22 AS build
WORKDIR /src
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app ./cmd/api
FROM gcr.io/distroless/static:nonroot
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]docker build -t aurora-api .
docker run -p 8080:8080 -e DATABASE_URL=... aurora-apiFind the slow path with pprof and benchmarks
AdvancedAdd net/http/pprof, write a Benchmark function, and profile CPU and allocations.
Measure before you optimise
Go ships first-class profiling. Importing net/http/pprof exposes /debug/pprof/* for live CPU, heap,
and goroutine profiles. go test -bench runs micro-benchmarks; -benchmem reports allocations per
operation, the usual culprit behind GC pressure. Optimise only what a profile proves is hot — guessing
wastes time and obscures the code.
Benchmark and profile
func BenchmarkEncode(b *testing.B) {
p := Product{ID: "p1", Name: "Widget", UnitPrice: 1999}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = json.NewEncoder(io.Discard).Encode(p)
}
}go test -bench=. -benchmem ./...
go tool pprof -http=:9000 cpu.out # flame graph in the browserAgent prompt — paste into an agent with repo access
Role: Senior Go performance engineer in this repo.
Context: The JSON encode path appears hot under load.
Task: Add a benchmark for the product encode path and reduce allocations per op.
Requirements:
- Benchmark uses b.ReportAllocs() and io.Discard.
- Propose one concrete allocation reduction (e.g. reuse a buffer / sync.Pool) only if the benchmark shows it helps.
- Do not change behaviour; existing tests must still pass.
Tests / acceptance:
- `go test -bench=. -benchmem ./...` runs; report before/after allocs/op in the summary.
- `go test ./...` still green.
Output: a unified diff plus the before/after benchmark numbers.Where to take it next
- Wire this service to a real database in Aurora Commerce, where Go fronts PostgreSQL transactions for checkout.
- Compare Go against its alternatives on the Compare page.
- Going lower-level for raw speed or strict memory guarantees? See Rust.