Go - Sample CRUD API (with Migrations)
          Beginner 10/10
          Teacher 10/10
          Architect 10/10
        
        
        
        
Overview
This sample extends the REST API with a PostgreSQL-backed CRUD layer and SQL migrations. It uses standard library database/sql for portability and clarity.
Project Layout
app/
  cmd/api/main.go
  internal/store/store.go
  internal/httpx/users.go
  db/migrations/
    0001_init.up.sql
    0001_init.down.sql
  go.mod
go.mod
module example.com/app
go 1.22.0
require (
  github.com/lib/pq v1.10.9 // postgres driver
)
db/migrations/0001_init.up.sql
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL
);
docker-compose.yml (Postgres)
version: "3.9"
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: app
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d app"]
      interval: 5s
      timeout: 3s
      retries: 5
Makefile (recommended)
DSN ?= postgres://user:pass@localhost:5432/app?sslmode=disable
.PHONY: compose-up compose-down migrate-up migrate-down seed run test lint
compose-up:
	docker compose up -d db
compose-down:
	docker compose down
migrate-up:
	docker run --rm --network host -v $$(pwd)/db/migrations:/migrations migrate/migrate -path=/migrations -database $(DSN) up
migrate-down:
	docker run --rm --network host -v $$(pwd)/db/migrations:/migrations migrate/migrate -path=/migrations -database $(DSN) down 1
seed:
	docker run --rm -i --network host -v $$(pwd)/db:/db postgres:16 psql $(DSN) -f /db/seed.sql
run:
	DSN=$(DSN) go run ./cmd/api
test:
	go test ./...
lint:
	go vet ./...
cmd/api/main.go
package main
import (
  "database/sql"
  "log"
  "net/http"
  "os"
  "time"
  "example.com/app/internal/httpx"
  "example.com/app/internal/store"
  _ "github.com/lib/pq"
)
func main(){
  dsn := getenv("DSN", "postgres://user:pass@localhost:5432/app?sslmode=disable")
  db, err := sql.Open("postgres", dsn)
  if err != nil { log.Fatal(err) }
  if err := db.Ping(); err != nil { log.Fatal(err) }
  st := store.New(db)
  mux := http.NewServeMux()
  mux.HandleFunc("/health", httpx.Health)
  mux.HandleFunc("/users", httpx.Users(st))
  srv := &http.Server{ Addr:":8080", Handler:mux, ReadHeaderTimeout:5*time.Second }
  log.Println("listening on", srv.Addr)
  log.Fatal(srv.ListenAndServe())
}
func getenv(k, def string) string { if v := os.Getenv(k); v != "" { return v }; return def }
internal/store/store.go
package store
import (
  "context"
  "database/sql"
)
type Store struct{ DB *sql.DB }
func New(db *sql.DB) *Store { return &Store{DB: db} }
type User struct{ ID int; Name, Email string }
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
  rows, err := s.DB.QueryContext(ctx, `SELECT id, name, email FROM users ORDER BY id`)
  if err != nil { return nil, err }
  defer rows.Close()
  var out []User
  for rows.Next(){
    var u User
    if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil { return nil, err }
    out = append(out, u)
  }
  return out, rows.Err()
}
func (s *Store) CreateUser(ctx context.Context, name, email string) (int, error) {
  var id int
  err := s.DB.QueryRowContext(ctx, `INSERT INTO users(name,email) VALUES($1,$2) RETURNING id`, name, email).Scan(&id)
  return id, err
}
internal/httpx/users.go
package httpx
import (
  "encoding/json"
  "net/http"
  "example.com/app/internal/store"
)
type userCreateReq struct{ Name, Email string }
func Users(st *store.Store) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request){
    switch r.Method {
    case http.MethodGet:
      list, err := st.ListUsers(r.Context())
      if err != nil { http.Error(w, err.Error(), 500); return }
      writeJSON(w, 200, list)
    case http.MethodPost:
      var req userCreateReq
      if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad json", 400); return }
      id, err := st.CreateUser(r.Context(), req.Name, req.Email)
      if err != nil { http.Error(w, err.Error(), 500); return }
      writeJSON(w, 201, map[string]any{"id": id})
    default:
      http.Error(w, "method not allowed", 405)
    }
  }
}
func writeJSON(w http.ResponseWriter, code int, v any){
  w.Header().Set("Content-Type","application/json")
  w.WriteHeader(code)
  _ = json.NewEncoder(w).Encode(v)
}
db/seed.sql
INSERT INTO users(name,email) VALUES
  ('Ada Lovelace','[email protected]'),
  ('Alan Turing','[email protected]');
Migrate and Run
# start postgres
make compose-up
# run migrations
make migrate-up
# run the API (ensure DSN is set if different)
make run
# test it
curl -s localhost:8080/users | jq
curl -s -X POST localhost:8080/users -d '{"name":"Ada","email":"[email protected]"}' -H 'Content-Type: application/json' | jq