Build a CRUD REST API in Go With the Standard Library
Build a CRUD API in Go with the standard library: a natural key, PUT upserts, ETag and If-Match locking, and a store contract tested on memory and SQLite.

Most CRUD tutorials create a record with POST, hand back a generated id and update with PUT or PATCH on the last write wins. That works until two people edit the same record. The second save silently erases the first, nobody notices, and the bug report arrives two weeks later as "my changes keep disappearing". The standard defense is old, cheap and built into HTTP: a version tag on every representation and a precondition on every write. Using it is a matter of a few headers and one extra column.
Tested with Go 1.25.6 and modernc.org/sqlite v1.50.0, macOS on Apple Silicon (arm64)
This project builds a small book catalogue API that does it properly. It uses a natural key (the ISBN) so that PUT is the right verb, it protects updates and deletes with ETag and If-Match, and it puts the storage behind an interface whose behavior is defined by a single contract test that both an in-memory store and a SQLite store must pass. It complements the cornerstone JSON API, which uses generated ids and PATCH, and follows the rules in REST API design. The conditional-request mechanics are explained in conditional GET and ETags for APIs. Everything ran on Go 1.25.6 with modernc.org/sqlite v1.50.0.
The domain
A book has an ISBN, a title, an author, a year and a version. The ISBN is both the identity and a value with its own validity rule: thirteen digits whose weighted sum (weights 1 and 3, alternating) ends in zero. Validating the check digit rejects the typo that a length check lets through.
package main
import (
"errors"
"strings"
"unicode/utf8"
)
var (
ErrNotFound = errors.New("book not found")
ErrConflict = errors.New("the book changed since you read it")
)
type Book struct {
ISBN string `json:"isbn"`
Title string `json:"title"`
Author string `json:"author"`
Year int `json:"year"`
Version int `json:"version"` // starts at 1 and grows on every replace: the optimistic lock
}
// ValidISBN13 checks length, digits and the check digit (weights 1 and 3 alternate; the sum must end in 0).
func ValidISBN13(s string) bool {
if len(s) != 13 {
return false
}
sum := 0
for i, r := range s {
if r < '0' || r > '9' {
return false
}
w := 1
if i%2 == 1 {
w = 3
}
sum += int(r-'0') * w
}
return sum%10 == 0
}
// WithCheckDigit completes a 12 digit prefix; handy for tests and examples.
func WithCheckDigit(prefix string) string {
sum := 0
for i, r := range prefix {
w := 1
if i%2 == 1 {
w = 3
}
sum += int(r-'0') * w
}
return prefix + string(rune('0'+(10-sum%10)%10))
}
func (b Book) Validate() map[string]string {
f := map[string]string{}
if !ValidISBN13(b.ISBN) {
f["isbn"] = "must be a valid 13 digit ISBN"
}
if n := utf8.RuneCountInString(strings.TrimSpace(b.Title)); n < 1 || n > 200 {
f["title"] = "must be 1 to 200 characters"
}
if n := utf8.RuneCountInString(strings.TrimSpace(b.Author)); n < 1 || n > 100 {
f["author"] = "must be 1 to 100 characters"
}
if b.Year < 1450 || b.Year > 2100 {
f["year"] = "must be between 1450 and 2100"
}
return f
}Version is the optimistic lock. It starts at 1 when a book is created and increases by one on every replacement. Clients never set it; the handler zeroes whatever they send and the store assigns it. WithCheckDigit is a helper for tests and examples, so none of the sample ISBNs in the program is a made-up number that happens to be valid.
Why PUT, and why a natural key
When the client chooses the identifier, the URL is known before the first request: /v1/books/9780134190440. That makes PUT the natural verb. It means "make the resource at this URL look like this", it creates when nothing is there and replaces when something is, and sending the same request twice gives the same result, which is exactly what HTTP idempotency requires for safe retries. With server-generated ids you use POST /v1/books instead, and a retry after a timeout can create a duplicate, the problem idempotency keys exist to solve. If you have a natural key, you get that safety for free.
The cost is that PUT is a full replacement: the body must be the whole book. For partial changes, PATCH is the tool. The handler also rejects a body whose isbn disagrees with the URL (a 422), since two names for one book is a bug waiting to happen.
The storage contract
The handlers depend on an interface that states what storage must do, including the awkward cases.
package main
import "context"
// BookStore is everything the handlers need. Put is an upsert; ifVersion, when not nil, must match the stored version.
type BookStore interface {
Get(ctx context.Context, isbn string) (Book, error)
// Put stores b under b.ISBN. It reports whether the book was created. A non-nil ifVersion that does not match the
// stored version returns ErrConflict; a non-nil ifVersion for a missing book returns ErrNotFound.
Put(ctx context.Context, b Book, ifVersion *int) (stored Book, created bool, err error)
Delete(ctx context.Context, isbn string, ifVersion *int) error
// List returns up to limit books with ISBN greater than after, in ISBN order.
List(ctx context.Context, after string, limit int) ([]Book, error)
}The comment on Put is the specification: it reports whether the book was created, ifVersion must match the stored version if given, a mismatch is ErrConflict, and a version given for a book that does not exist is ErrNotFound. Delete follows the same rule. List is ordered by ISBN and resumes after a given ISBN, which gives keyset paging for free.
Two implementations follow. The in-memory store guards a map with a mutex and does the read, check and write inside one critical section:
package main
import (
"context"
"sort"
"sync"
)
type MemStore struct {
mu sync.Mutex
books map[string]Book
}
func NewMemStore() *MemStore { return &MemStore{books: map[string]Book{}} }
func (m *MemStore) Get(_ context.Context, isbn string) (Book, error) {
m.mu.Lock()
defer m.mu.Unlock()
b, ok := m.books[isbn]
if !ok {
return Book{}, ErrNotFound
}
return b, nil
}
func (m *MemStore) Put(_ context.Context, b Book, ifVersion *int) (Book, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
old, exists := m.books[b.ISBN]
switch {
case ifVersion != nil && !exists:
return Book{}, false, ErrNotFound
case ifVersion != nil && old.Version != *ifVersion:
return Book{}, false, ErrConflict
}
b.Version = old.Version + 1
m.books[b.ISBN] = b
return b, !exists, nil
}
func (m *MemStore) Delete(_ context.Context, isbn string, ifVersion *int) error {
m.mu.Lock()
defer m.mu.Unlock()
old, ok := m.books[isbn]
switch {
case !ok:
return ErrNotFound
case ifVersion != nil && old.Version != *ifVersion:
return ErrConflict
}
delete(m.books, isbn)
return nil
}
func (m *MemStore) List(_ context.Context, after string, limit int) ([]Book, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []Book
for isbn, b := range m.books {
if isbn > after {
out = append(out, b)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ISBN < out[j].ISBN })
if len(out) > limit {
out = out[:limit]
}
return out, nil
}The SQL store does the same inside a transaction:
package main
import (
"context"
"database/sql"
"errors"
_ "modernc.org/sqlite"
)
type SQLStore struct{ db *sql.DB }
func OpenSQLStore(dsn string) (*SQLStore, error) {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS books (
isbn TEXT PRIMARY KEY, title TEXT NOT NULL, author TEXT NOT NULL, year INTEGER NOT NULL, version INTEGER NOT NULL)`)
if err != nil {
db.Close()
return nil, err
}
return &SQLStore{db: db}, nil
}
func (s *SQLStore) Close() error { return s.db.Close() }
func (s *SQLStore) Get(ctx context.Context, isbn string) (Book, error) {
var b Book
err := s.db.QueryRowContext(ctx, `SELECT isbn, title, author, year, version FROM books WHERE isbn = ?`, isbn).
Scan(&b.ISBN, &b.Title, &b.Author, &b.Year, &b.Version)
if errors.Is(err, sql.ErrNoRows) {
return Book{}, ErrNotFound
}
return b, err
}
// Put runs in a transaction: read the current version, check it, write the next one.
func (s *SQLStore) Put(ctx context.Context, b Book, ifVersion *int) (Book, bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Book{}, false, err
}
defer tx.Rollback()
var old int
err = tx.QueryRowContext(ctx, `SELECT version FROM books WHERE isbn = ?`, b.ISBN).Scan(&old)
exists := err == nil
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return Book{}, false, err
}
switch {
case ifVersion != nil && !exists:
return Book{}, false, ErrNotFound
case ifVersion != nil && old != *ifVersion:
return Book{}, false, ErrConflict
}
b.Version = old + 1
if _, err := tx.ExecContext(ctx,
`INSERT INTO books (isbn, title, author, year, version) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(isbn) DO UPDATE SET title = excluded.title, author = excluded.author, year = excluded.year, version = excluded.version`,
b.ISBN, b.Title, b.Author, b.Year, b.Version); err != nil {
return Book{}, false, err
}
return b, !exists, tx.Commit()
}
func (s *SQLStore) Delete(ctx context.Context, isbn string, ifVersion *int) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var old int
if err := tx.QueryRowContext(ctx, `SELECT version FROM books WHERE isbn = ?`, isbn).Scan(&old); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if ifVersion != nil && old != *ifVersion {
return ErrConflict
}
if _, err := tx.ExecContext(ctx, `DELETE FROM books WHERE isbn = ?`, isbn); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLStore) List(ctx context.Context, after string, limit int) ([]Book, error) {
rows, err := s.db.QueryContext(ctx, `SELECT isbn, title, author, year, version FROM books WHERE isbn > ? ORDER BY isbn LIMIT ?`, after, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Book
for rows.Next() {
var b Book
if err := rows.Scan(&b.ISBN, &b.Title, &b.Author, &b.Year, &b.Version); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}Both do the version check and the write as one atomic step. That is the point of the design. A "read the version, compare it in the handler, then write" approach has a window in between where another request can slip in, which is the lost update again. In the SQL store the transaction closes the window (with SQLite's single writer it is enough; on PostgreSQL the equivalent is UPDATE books SET ..., version = version + 1 WHERE isbn = $1 AND version = $2 and checking the number of rows affected, which needs no explicit transaction at all). The upsert uses ON CONFLICT ... DO UPDATE, available in SQLite and PostgreSQL, so create and replace share one statement.
The handlers
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
)
type Server struct{ store BookStore }
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/books", s.list)
mux.HandleFunc("GET /v1/books/{isbn}", s.get)
mux.HandleFunc("PUT /v1/books/{isbn}", s.put)
mux.HandleFunc("DELETE /v1/books/{isbn}", s.remove)
return mux
}
func write(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func problem(w http.ResponseWriter, status int, msg string, fields map[string]string) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
body := map[string]any{"status": status, "title": msg}
if len(fields) > 0 {
body["errors"] = fields // left out entirely when empty, never null
}
json.NewEncoder(w).Encode(body)
}
func etag(v int) string { return fmt.Sprintf(`"v%d"`, v) }
// ifMatch parses If-Match: "v3". It returns (nil, false) when the header is absent and ok=false for garbage.
func ifMatch(r *http.Request) (version *int, present, ok bool) {
h := r.Header.Get("If-Match")
if h == "" {
return nil, false, true
}
n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(h, `"v`), `"`))
if err != nil || !strings.HasPrefix(h, `"v`) {
return nil, true, false
}
return &n, true, true
}
func (s *Server) get(w http.ResponseWriter, r *http.Request) {
b, err := s.store.Get(r.Context(), r.PathValue("isbn"))
if errors.Is(err, ErrNotFound) {
problem(w, http.StatusNotFound, "Book not found", nil)
return
}
if err != nil {
problem(w, http.StatusInternalServerError, "Internal error", nil)
return
}
w.Header().Set("ETag", etag(b.Version))
if r.Header.Get("If-None-Match") == etag(b.Version) {
w.WriteHeader(http.StatusNotModified)
return
}
write(w, http.StatusOK, b)
}
// put is an upsert. A create returns 201, a replace 200. Replacing an existing book requires If-Match, so two
// editors cannot silently overwrite each other.
func (s *Server) put(w http.ResponseWriter, r *http.Request) {
var b Book
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16))
dec.DisallowUnknownFields()
if err := dec.Decode(&b); err != nil {
problem(w, http.StatusBadRequest, "Malformed body", nil)
return
}
if b.ISBN != "" && b.ISBN != r.PathValue("isbn") {
problem(w, http.StatusUnprocessableEntity, "Validation failed", map[string]string{"isbn": "must match the isbn in the URL"})
return
}
b.ISBN, b.Version = r.PathValue("isbn"), 0 // the URL names the book; clients do not set the version
if f := b.Validate(); len(f) > 0 {
problem(w, http.StatusUnprocessableEntity, "Validation failed", f)
return
}
version, present, ok := ifMatch(r)
if !ok {
problem(w, http.StatusBadRequest, "If-Match must look like \"v3\"", nil)
return
}
if !present {
if _, err := s.store.Get(r.Context(), b.ISBN); err == nil {
problem(w, http.StatusPreconditionRequired, "Send If-Match with the version you read", nil)
return
}
}
stored, created, err := s.store.Put(r.Context(), b, version)
switch {
case errors.Is(err, ErrConflict):
problem(w, http.StatusPreconditionFailed, "The book changed since you read it; fetch it again", nil)
case errors.Is(err, ErrNotFound):
problem(w, http.StatusNotFound, "Book not found", nil)
case err != nil:
problem(w, http.StatusInternalServerError, "Internal error", nil)
default:
w.Header().Set("ETag", etag(stored.Version))
status := http.StatusOK
if created {
status = http.StatusCreated
w.Header().Set("Location", "/v1/books/"+stored.ISBN)
}
write(w, status, stored)
}
}
func (s *Server) remove(w http.ResponseWriter, r *http.Request) {
version, _, ok := ifMatch(r)
if !ok {
problem(w, http.StatusBadRequest, "If-Match must look like \"v3\"", nil)
return
}
switch err := s.store.Delete(r.Context(), r.PathValue("isbn"), version); {
case errors.Is(err, ErrNotFound):
problem(w, http.StatusNotFound, "Book not found", nil)
case errors.Is(err, ErrConflict):
problem(w, http.StatusPreconditionFailed, "The book changed since you read it; fetch it again", nil)
case err != nil:
problem(w, http.StatusInternalServerError, "Internal error", nil)
default:
w.WriteHeader(http.StatusNoContent)
}
}
func (s *Server) list(w http.ResponseWriter, r *http.Request) {
limit := 10
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 50 {
problem(w, http.StatusBadRequest, "limit must be 1 to 50", nil)
return
}
limit = n
}
books, err := s.store.List(r.Context(), r.URL.Query().Get("after"), limit)
if err != nil {
problem(w, http.StatusInternalServerError, "Internal error", nil)
return
}
if books == nil {
books = []Book{}
}
write(w, http.StatusOK, map[string]any{"data": books})
}Four details in the handlers carry the design.
ETag is the version. A GET returns ETag: "v2", and a client that cached the book sends If-None-Match: "v2" to revalidate and gets a 304 with no body when nothing changed. The tag is derived from the version column, so computing it needs no hashing of the body. These are strong validators, because the version changes whenever any field changes.
Updates require a precondition. A PUT to an existing book without If-Match gets 428 Precondition Required: the server is telling the client it must say which version it read. A PUT with the wrong version gets 412 Precondition Failed. A PUT to a book that does not exist and carries no If-Match is a create, and gets 201 with a Location. Requiring the header on updates is a design choice. Making it optional is kinder to simple clients and loses the protection exactly where it matters, so the server's refusal is the feature. Both codes are in RFC 9110.
The version is not part of the input. The handler sets b.Version = 0 before validation, and the store assigns the real number. If a client could send "version": 99, the lock would be advisory.
Errors are problem documents, with the field map only when there are fields: my first version printed "errors": null for the others, which is noise clients have to special-case, so the helper now omits the key when it is empty.
The same API on two stores
The program below runs the same sequence of calls against the API backed by each store and prints what comes back.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
func main() {
for _, impl := range []string{"memory", "sqlite"} {
var store BookStore = NewMemStore()
if impl == "sqlite" {
s, err := OpenSQLStore("file::memory:")
if err != nil {
panic(err)
}
defer s.Close()
store = s
}
srv := httptest.NewServer((&Server{store: store}).Routes())
fmt.Printf("== %s store\n", impl)
isbn := WithCheckDigit("978013419044")
call := func(label, method, path, body string, hdr ...string) *http.Response {
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
for i := 0; i+1 < len(hdr); i += 2 {
req.Header.Set(hdr[i], hdr[i+1])
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
out := strings.TrimSpace(string(b))
if len(out) > 100 {
out = out[:100] + "..."
}
fmt.Printf("%-34s %3d etag=%-5s %s\n", label, res.StatusCode, res.Header.Get("ETag"), out)
return res
}
book := fmt.Sprintf(`{"title":"The Go Programming Language","author":"Donovan and Kernighan","year":2015}`)
call("PUT a new book", "PUT", "/v1/books/"+isbn, book)
call("PUT again, no If-Match", "PUT", "/v1/books/"+isbn, book)
call("PUT with the right version", "PUT", "/v1/books/"+isbn, strings.Replace(book, "2015", "2016", 1), "If-Match", `"v1"`)
call("PUT with the stale version", "PUT", "/v1/books/"+isbn, book, "If-Match", `"v1"`)
call("GET", "GET", "/v1/books/"+isbn, "")
call("GET with If-None-Match", "GET", "/v1/books/"+isbn, "", "If-None-Match", `"v2"`)
call("PUT, bad ISBN in the URL", "PUT", "/v1/books/9780134190441", book)
call("PUT, invalid fields", "PUT", "/v1/books/"+WithCheckDigit("978000000000"), `{"title":"","author":"x","year":3}`)
call("DELETE with a stale version", "DELETE", "/v1/books/"+isbn, "", "If-Match", `"v1"`)
call("DELETE with the current version", "DELETE", "/v1/books/"+isbn, "", "If-Match", `"v2"`)
call("GET after delete", "GET", "/v1/books/"+isbn, "")
call("list is empty", "GET", "/v1/books", "")
srv.Close()
}
}go run .== memory store
PUT a new book 201 etag="v1" {"isbn":"9780134190440","title":"The Go Programming Language","author":"Donovan and Kernighan","year...
PUT again, no If-Match 428 etag= {"status":428,"title":"Send If-Match with the version you read"}
PUT with the right version 200 etag="v2" {"isbn":"9780134190440","title":"The Go Programming Language","author":"Donovan and Kernighan","year...
PUT with the stale version 412 etag= {"status":412,"title":"The book changed since you read it; fetch it again"}
GET 200 etag="v2" {"isbn":"9780134190440","title":"The Go Programming Language","author":"Donovan and Kernighan","year...
GET with If-None-Match 304 etag="v2"
PUT, bad ISBN in the URL 422 etag= {"errors":{"isbn":"must be a valid 13 digit ISBN"},"status":422,"title":"Validation failed"}
PUT, invalid fields 422 etag= {"errors":{"title":"must be 1 to 200 characters","year":"must be between 1450 and 2100"},"status":42...
DELETE with a stale version 412 etag= {"status":412,"title":"The book changed since you read it; fetch it again"}
DELETE with the current version 204 etag=
GET after delete 404 etag= {"status":404,"title":"Book not found"}
list is empty 200 etag= {"data":[]}
== sqlite store
PUT a new book 201 etag="v1" {"isbn":"9780134190440","title":"The Go Programming Language","author":"Donovan and Kernighan","year...
PUT again, no If-Match 428 etag= {"status":428,"title":"Send If-Match with the version you read"}
PUT with the right version 200 etag="v2" {"isbn":"9780134190440","title":"The Go Programming Language","author":"Donovan and Kernighan","year...
PUT with the stale version 412 etag= {"status":412,"title":"The book changed since you read it; fetch it again"}
GET 200 etag="v2" {"isbn":"9780134190440","title":"The Go Programming Language","author":"Donovan and Kernighan","year...
GET with If-None-Match 304 etag="v2"
PUT, bad ISBN in the URL 422 etag= {"errors":{"isbn":"must be a valid 13 digit ISBN"},"status":422,"title":"Validation failed"}
PUT, invalid fields 422 etag= {"errors":{"title":"must be 1 to 200 characters","year":"must be between 1450 and 2100"},"status":42...
DELETE with a stale version 412 etag= {"status":412,"title":"The book changed since you read it; fetch it again"}
DELETE with the current version 204 etag=
GET after delete 404 etag= {"status":404,"title":"Book not found"}
list is empty 200 etag= {"data":[]}Both stores print identical lines, which is the claim of the design. Walk through one run. The first PUT creates the book: 201, ETag: "v1". The second PUT, sent without If-Match, is refused with 428. A PUT with If-Match: "v1" succeeds with 200 and a new tag, "v2". Repeating that same request, still claiming "v1", is 412: the book has moved on, which is the lost update being caught. A GET returns the current version and its tag, and a conditional GET with the current tag is a 304.
A syntactically valid ISBN with a wrong check digit in the URL (9780134190441) is a 422 that names the field. A body with an empty title and an impossible year gets both problems in one response. A delete with a stale version is a 412, a delete with the current version is a 204, the book is then a 404, and the list is an empty array, not null.
Tests: one contract, every implementation
The tests are the part I would copy into any project that has more than one implementation of an interface, or expects to.
package main
import (
"context"
"errors"
"sync"
"testing"
)
// runContract is the specification of a BookStore. Every implementation must pass it unchanged.
func runContract(t *testing.T, name string, newStore func(t *testing.T) BookStore) {
ctx := context.Background()
isbn := func(n string) string { return WithCheckDigit("978" + n) }
book := func(n string) Book { return Book{ISBN: isbn(n), Title: "T" + n, Author: "A", Year: 2020} }
t.Run(name+"/put creates then replaces and counts versions", func(t *testing.T) {
s := newStore(t)
first, created, err := s.Put(ctx, book("013419044"), nil)
if err != nil || !created || first.Version != 1 {
t.Fatalf("create: %+v created=%v err=%v", first, created, err)
}
second, created, err := s.Put(ctx, book("013419044"), nil)
if err != nil || created || second.Version != 2 {
t.Fatalf("replace: %+v created=%v err=%v", second, created, err)
}
})
t.Run(name+"/if-version guards the write", func(t *testing.T) {
s := newStore(t)
s.Put(ctx, book("013419044"), nil)
stale, fresh := 7, 1
if _, _, err := s.Put(ctx, book("013419044"), &stale); !errors.Is(err, ErrConflict) {
t.Fatalf("stale write: %v", err)
}
if _, _, err := s.Put(ctx, book("013419044"), &fresh); err != nil {
t.Fatalf("fresh write: %v", err)
}
if _, _, err := s.Put(ctx, book("000000000"), &fresh); !errors.Is(err, ErrNotFound) {
t.Fatalf("versioned write to a missing book: %v", err)
}
})
t.Run(name+"/delete respects versions and reports missing books", func(t *testing.T) {
s := newStore(t)
s.Put(ctx, book("013419044"), nil)
wrong := 9
if err := s.Delete(ctx, isbn("013419044"), &wrong); !errors.Is(err, ErrConflict) {
t.Fatalf("stale delete: %v", err)
}
if err := s.Delete(ctx, isbn("013419044"), nil); err != nil {
t.Fatal(err)
}
if err := s.Delete(ctx, isbn("013419044"), nil); !errors.Is(err, ErrNotFound) {
t.Fatalf("second delete: %v", err)
}
if _, err := s.Get(ctx, isbn("013419044")); !errors.Is(err, ErrNotFound) {
t.Fatalf("get after delete: %v", err)
}
})
t.Run(name+"/list is ordered and resumable", func(t *testing.T) {
s := newStore(t)
for _, n := range []string{"300000000", "100000000", "200000000", "400000000"} {
s.Put(ctx, book(n), nil)
}
page, _ := s.List(ctx, "", 2)
rest, _ := s.List(ctx, page[len(page)-1].ISBN, 10)
if len(page) != 2 || len(rest) != 2 || page[0].ISBN > page[1].ISBN || page[1].ISBN > rest[0].ISBN {
t.Fatalf("page %+v rest %+v", page, rest)
}
})
t.Run(name+"/concurrent versioned writes: exactly one wins", func(t *testing.T) {
s := newStore(t)
s.Put(ctx, book("013419044"), nil)
var wg sync.WaitGroup
var mu sync.Mutex
wins, conflicts := 0, 0
for i := 0; i < 12; i++ {
wg.Add(1)
go func() {
defer wg.Done()
v := 1
_, _, err := s.Put(ctx, book("013419044"), &v)
mu.Lock()
defer mu.Unlock()
if err == nil {
wins++
} else if errors.Is(err, ErrConflict) {
conflicts++
}
}()
}
wg.Wait()
if wins != 1 || conflicts != 11 {
t.Fatalf("wins=%d conflicts=%d, want 1 and 11", wins, conflicts)
}
})
}
func TestMemStoreMeetsTheContract(t *testing.T) {
runContract(t, "memory", func(*testing.T) BookStore { return NewMemStore() })
}
func TestSQLStoreMeetsTheContract(t *testing.T) {
runContract(t, "sqlite", func(t *testing.T) BookStore {
s, err := OpenSQLStore("file::memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
})
}go test -race -count=1 -v ./... 2>&1 | grep -E "^(--- |ok|FAIL|PASS)"--- PASS: TestMemStoreMeetsTheContract (0.00s)
--- PASS: TestSQLStoreMeetsTheContract (0.03s)
PASS
ok example.test/lab 1.363srunContract takes a constructor for a fresh store and runs five behaviors against it: a put creates and then replaces while counting versions; a versioned write is guarded and a versioned write to a missing book is not-found; deletes respect versions and report missing books; listing is ordered and resumable; and twelve concurrent versioned writes produce exactly one winner and eleven conflicts. Two test functions then call it with the memory store and the SQLite store. A third implementation, PostgreSQL say, is one more constructor and the same five behaviors; if it passes, it is a drop-in replacement, and if it does not, the failing subtest names the difference.
This is more valuable than it looks. A mock only checks that the handler calls the store the way you expected. A contract suite checks that the stores themselves behave the way the handler assumes, and that two supposedly interchangeable implementations really are. The concurrency test would catch the classic bug of checking the version outside the critical section, and it ran under the race detector.
How clients should use the version
The server side is half the protocol, and clients need to know their half. A client that wants to edit a book reads it (GET), keeps the ETag from the response, shows the form, and on save sends PUT with If-Match set to that tag. If the answer is 412, it must not retry blindly: someone else changed the book, so the client re-fetches, shows the user what changed, and lets them merge or overwrite deliberately. Overwriting means fetching the new version and sending the edit again with the new tag, which is a conscious decision instead of an accident. A mobile client that queues edits offline needs the same discipline, with the added complexity of storing the version alongside the queued change. Write this flow into the API documentation next to the endpoints, because it is the part developers get wrong when they have not met optimistic concurrency before, and a client library that implements it once saves every consumer the trouble.
A last point on observability. Count 412 and 428 responses as their own metrics. A rising rate of 412 means people really are editing the same records, which may be a product signal, and a rising rate of 428 means clients that have not adopted the precondition and are about to hit the same wall in production. Both are cheap to graph, because the status codes are already on every request.
Questions this design raises
Why a version number and not a hash of the body? A content hash is a valid ETag and needs no column, but it costs a read and a hash on every write check, and two logically identical bodies with different whitespace hash differently. A version counter is cheap, monotonic and explicit, and it shows in logs as a number a person can reason about ("you were editing v3, the current one is v5"). Use a hash when the resource has no place to keep a counter, as with static files.
What about If-Match: *? The header can also say "any current version", meaning "only if it exists". The handler here accepts only the "vN" form; supporting * is a few lines and useful for clients that want to update but do not care which version. Parsing the full header grammar, which allows lists of tags, is RFC 9110's rule, and a library is warranted if you need all of it.
Should deleting a deleted book be a 404 or a 204? I return 404, because it tells the caller its picture is out of date. Both are defensible; the property that matters is that the server state is the same after one call or ten. Pick one, document it, and keep it consistent across resources.
What about HEAD, OPTIONS and CORS? Go's GET pattern also answers HEAD automatically, which is how a client checks the ETag without the body. OPTIONS and cross-origin access are a separate layer: if browsers call this API from another origin, add CORS middleware (CORS in Go) and expose the ETag header to scripts with Access-Control-Expose-Headers, or browser clients cannot read the version they need to send back.
From here
Swap the store. To use PostgreSQL, implement BookStore over pgx or database/sql, run runContract against a real database (see integration tests), and change one line in main. Nothing in the handlers or the API tests moves.
Page the list. The after query parameter already provides keyset paging by ISBN; wrap it in an opaque cursor and a next_cursor as in pagination.
Authorise. None of this checks who is calling. Add authentication middleware (API authentication) and, if books belong to owners, scope every store call by owner.
Cache. The ETag makes GET cacheable by clients and proxies. Pair it with Cache-Control (cache headers) so intermediate caches know what they may keep.
Document it. Describe the headers, the 412 and 428 behavior and the version semantics in an OpenAPI file (OpenAPI), because the preconditions are exactly the part a client developer will not guess. The standard library facilities used throughout are in the net/http documentation.


