| name | server-go |
| description | Patterns and anti-patterns for the Go net/http server defined in spec 0004. Use when implementing or reviewing any file in servers/go/. Trigger phrases: "go server", "net/http", "modernc sqlite", "/server-go".
|
| applyTo | ["servers/go/**"] |
Server: Go + net/http + modernc.org/sqlite (spec 0004)
Build command (from Makefile)
cd servers/go && CGO_ENABLED=0 GOARCH=arm64 GOOS=linux \
go build -ldflags="-s -w" -o server .
All three env vars are mandatory:
CGO_ENABLED=0 — pure-Go SQLite (modernc), no C compiler needed
GOOS=linux GOARCH=arm64 — cross-compile for Raspberry Pi 5
Without them, go build produces a binary for the host machine.
Verify: file server should show ELF 64-bit LSB executable, ARM aarch64.
Module name (from spec 0004, RF7)
module github.com/woliveiras/2026-lang-self-hosted-pi/servers/go
go.mod dependencies
require modernc.org/sqlite v1.50.0
Module layout (from spec 0004)
servers/go/
├── go.mod
├── go.sum
├── main.go # entry: reads env, opens DB, registers routes, starts server
├── handlers.go # HTTP handler functions (one per endpoint)
├── db.go # DB init and query helpers
└── server # compiled binary (gitignored)
Routing — Go 1.22+ path parameters
Go 1.22 introduced native path parameter support in net/http. Go 1.26 (project version) supports it.
mux := http.NewServeMux()
mux.HandleFunc("GET /items", listItems(db))
mux.HandleFunc("GET /items/{id}", getItem(db))
mux.HandleFunc("POST /items", createItem(db))
mux.HandleFunc("DELETE /items/{id}", deleteItem(db))
Extract path param:
func getItem(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
}
}
JSON responses — always set Content-Type first
Content-Type header must be set before calling w.WriteHeader() or writing the body.
Once WriteHeader is called, headers are sent and cannot be changed.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(item)
w.WriteHeader(http.StatusCreated)
w.Header().Set("Content-Type", "application/json")
Never use http.Error() — it sets text/plain; charset=utf-8.
Helper for JSON responses
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
Request body decoding
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if strings.TrimSpace(body.Name) == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
Database — sql.ErrNoRows → 404
func getItemByID(db *sql.DB, id int64) (*Item, error) {
row := db.QueryRow("SELECT id, name, created_at FROM items WHERE id = ?", id)
var item Item
err := row.Scan(&item.ID, &item.Name, &item.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &item, err
}
item, err := getItemByID(db, id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
if item == nil {
writeError(w, http.StatusNotFound, "not found")
return
}
created_at — UTC with Z suffix
import "time"
createdAt := time.Now().UTC().Format("2006-01-02T15:04:05Z")
createdAt := time.Now().UTC().Format(time.RFC3339)
Go's reference time: Mon Jan 2 15:04:05 MST 2006. The Z at the end of the
format string is a literal character, not a timezone designator.
Database connection from env
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "../../data/benchmark.db"
}
db, err := sql.Open("sqlite", dbPath)
Port binding from env
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Fatal(http.ListenAndServe("0.0.0.0:"+port, mux))
204 DELETE — no body
w.WriteHeader(http.StatusNoContent)
writeJSON(w, http.StatusNoContent, nil)
Anti-patterns
| Anti-pattern | Problem | Fix |
|---|
http.Error(w, msg, code) | Sets text/plain Content-Type | Use writeError helper |
Setting header after WriteHeader | Header silently dropped | Set headers first |
time.RFC3339 for created_at | Produces +00:00 not Z | Use "2006-01-02T15:04:05Z" format |
sql.ErrNoRows not checked | Returns 500 instead of 404 | Check with errors.Is(err, sql.ErrNoRows) |
| Opening DB per request | Exhausts file descriptors, slow | Open once in main, pass *sql.DB |
Building without CGO_ENABLED=0 GOOS=linux GOARCH=arm64 | Wrong binary for Pi | Always include all three flags |