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