Build a Blog Backend With GORM Associations and Preload
Build a blog API in Go with GORM and net/http: authors, posts, tags and comments, a keyset list in 3 queries, shared tags and tests against a real PostgreSQL

A blog backend is the standard test of whether you understand an ORM's associations. It has a one to many (author to posts), another one to many (post to comments), a many to many (posts and tags), and the usual pressure: lists that must not run a query per row, writes that touch several tables, and ids that clients can guess. This article builds the backend with GORM and the standard library's net/http, then runs a test suite against a real PostgreSQL 18.6 so every claim is a passing test and not a hope.
Tested with Go 1.25.6, GORM v1.31.2, gorm.io/driver/postgres v1.6.3, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It puts the earlier GORM articles to work: associations, Preload and Joins, transactions and error handling. The HTTP side follows the Go HTTP server patterns, and the plain SQL version of a similar store is discussed in the repository pattern.
What it does
| Method and path | Result |
|---|---|
POST /posts | Create a post with an author and a list of tag names. 201, or 409 for a used slug, 422 for a bad body |
GET /posts/{slug} | One post with its author, tags and comments. 404 if absent |
GET /posts?limit=&before= | Newest first, keyset paged, without bodies |
POST /posts/{slug}/comments | Add a comment. 201, 404 if the post is absent |
DELETE /posts/{slug} | Remove the post, its comments and tag links. 204, or 404 |
The code is five files in one main package: models, store, server, a main that wires it, and the tests. Everything runs on the standard library plus GORM.
The models
package main
import "time"
type Author struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"-" gorm:"uniqueIndex"`
}
type Tag struct {
ID uint `json:"-"`
Name string `json:"name" gorm:"uniqueIndex"`
}
type Comment struct {
ID uint `json:"id"`
PostID uint `json:"-" gorm:"index"`
Name string `json:"name"`
Body string `json:"body"`
CreatedAt time.Time `json:"-"`
}
type Post struct {
ID uint `json:"id"`
AuthorID uint `json:"-"`
Author Author `json:"author"`
Title string `json:"title"`
Slug string `json:"slug" gorm:"uniqueIndex"`
Body string `json:"body,omitempty"`
Tags []Tag `json:"tags" gorm:"many2many:post_tags"`
Comments []Comment `json:"comments,omitempty"`
CreatedAt time.Time `json:"-"`
}Decisions in the models:
- JSON tags live on the models. That is a shortcut for a small project. In a larger one, map to response types at the edge so a database column can change without changing the API.
Emailcarriesjson:"-"so it never leaves the server, which is the reason to think about response types early. Slughas a unique index. It is the public identifier, so the URL does not expose sequential ids. The index also turns a race between two creates into a database error, which the store maps to a conflict.Tagsismany2many:post_tags, andCommentsis a plain has many throughPostID.Comment.PostIDis indexed because every post read filters on it.- Tag names are unique. The same word is one row, which is what makes tag archives and counts possible.
The store
package main
import (
"context"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)
type Store struct{ db *gorm.DB }
func NewStore(db *gorm.DB) (*Store, error) {
return &Store{db}, db.AutoMigrate(&Author{}, &Tag{}, &Post{}, &Comment{})
}
func translate(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, gorm.ErrRecordNotFound):
return ErrNotFound
case errors.Is(err, gorm.ErrDuplicatedKey):
return ErrConflict
}
return err
}
type NewPost struct {
Author Author `json:"author"`
Title string `json:"title"`
Slug string `json:"slug"`
Body string `json:"body"`
Tags []string `json:"tags"`
}
// CreatePost saves the author, the tags and the post in one transaction. Tags are resolved first
// so two posts sharing a tag never insert it twice.
func (s *Store) CreatePost(ctx context.Context, in NewPost) (*Post, error) {
var post Post
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
author := in.Author
if err := tx.Where(Author{Email: author.Email}).Attrs(Author{Name: author.Name}).FirstOrCreate(&author).Error; err != nil {
return err
}
var tags []Tag
for _, name := range in.Tags {
t := Tag{Name: name}
if err := tx.Where(Tag{Name: name}).FirstOrCreate(&t).Error; err != nil {
return err
}
tags = append(tags, t)
}
post = Post{AuthorID: author.ID, Title: in.Title, Slug: in.Slug, Body: in.Body, Tags: tags}
return tx.Omit("Author").Create(&post).Error
})
if err != nil {
return nil, translate(err)
}
return s.PostBySlug(ctx, post.Slug)
}
func (s *Store) PostBySlug(ctx context.Context, slug string) (*Post, error) {
var p Post
err := s.db.WithContext(ctx).
Joins("Author").
Preload("Tags").
Preload("Comments", func(tx *gorm.DB) *gorm.DB { return tx.Order("comments.id") }).
Where("posts.slug = ?", slug).
Take(&p).Error
if err != nil {
return nil, translate(err)
}
return &p, nil
}
// ListPosts pages newest first with a keyset cursor and leaves the body out of the list.
func (s *Store) ListPosts(ctx context.Context, before uint, limit int) ([]Post, error) {
if limit < 1 || limit > 50 {
limit = 20
}
q := s.db.WithContext(ctx).
Select("posts.id", "posts.author_id", "posts.title", "posts.slug").
Joins("Author").
Preload("Tags").
Order("posts.id desc").
Limit(limit)
if before > 0 {
q = q.Where("posts.id < ?", before)
}
var ps []Post
return ps, q.Find(&ps).Error
}
func (s *Store) AddComment(ctx context.Context, slug, name, body string) (*Comment, error) {
var post Post
if err := s.db.WithContext(ctx).Select("id").Where("slug = ?", slug).Take(&post).Error; err != nil {
return nil, translate(err)
}
c := Comment{PostID: post.ID, Name: name, Body: body}
return &c, s.db.WithContext(ctx).Create(&c).Error
}
func (s *Store) DeletePost(ctx context.Context, slug string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var p Post
if err := tx.Select("id").Where("slug = ?", slug).Take(&p).Error; err != nil {
return translate(err)
}
if err := tx.Where("post_id = ?", p.ID).Delete(&Comment{}).Error; err != nil {
return err
}
if err := tx.Model(&p).Association("Tags").Clear(); err != nil {
return err
}
return tx.Clauses(clause.Returning{}).Delete(&p).Error
})
}
func (s *Store) CountPostsByTag(ctx context.Context) (map[string]int, error) {
var rows []struct {
Name string
Count int
}
err := s.db.WithContext(ctx).Table("tags").
Select("tags.name, count(post_tags.post_id) as count").
Joins("left join post_tags on post_tags.tag_id = tags.id").
Group("tags.id").Order("tags.name").Scan(&rows).Error
out := map[string]int{}
for _, r := range rows {
out[r.Name] = r.Count
}
return out, err
}Creating a post
CreatePost does four things in one transaction, and the order is the lesson:
FirstOrCreatethe author by email.Attrssets the name only when the row is new, so an existing author's name is not overwritten by a second post.- For each tag name,
FirstOrCreatethe tag. Resolving them one at a time, before the post exists, gives everyTaga real id. - Build the post with the resolved tags and
Createit withOmit("Author"), so GORM does not try to upsert the author again. - Commit, then read the post back with
PostBySlug, so the response has exactly the shape a laterGETreturns.
Step 2 is what the associations article found to be the failure point of nested creates: two new tags with the same name in one Create produced a foreign key violation. The first test creates two posts that share the tag go, and both return 201.
Reading one post
PostBySlug uses Joins("Author") for the belongs to side (one row, one query), Preload("Tags") for the many to many, and Preload("Comments", ...) with an explicit order for the has many. The qualified column names (posts.slug, comments.id) matter once a join is in the statement: an unqualified slug or id would be ambiguous. Take returns ErrRecordNotFound when nothing matches, which translate turns into the store's own ErrNotFound.
Listing
ListPosts selects only the columns a list needs, so the body is never loaded: the test checks that a list row has no body key. It orders by posts.id desc and applies posts.id < before when a cursor is given. The cursor is the last id of the previous page. Keyset paging avoids the cost of deep offsets and keeps pages stable when new posts arrive, as measured in the pagination article.
Select with Joins("Author") needs the foreign key posts.author_id in the column list, otherwise GORM cannot fill the author. That is easy to forget when trimming columns.
Deleting
DeletePost removes children before the parent: the comments, then the tag links with Association("Tags").Clear() (which deletes rows in the join table and leaves the tags), then the post. Without those steps the delete would fail on the foreign keys, as shown in associations. The alternative is ON DELETE CASCADE on the constraints, which does the same work in the database in one statement and is a reasonable choice when no application code needs to see the deletes. It is written out here because the explicit version teaches what the relationships are.
The HTTP layer
package main
import (
"encoding/json"
"errors"
"net/http"
"strconv"
)
type Server struct{ store *Store }
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /posts", s.createPost)
mux.HandleFunc("GET /posts", s.listPosts)
mux.HandleFunc("GET /posts/{slug}", s.getPost)
mux.HandleFunc("DELETE /posts/{slug}", s.deletePost)
mux.HandleFunc("POST /posts/{slug}/comments", s.addComment)
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 fail(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
write(w, http.StatusNotFound, map[string]string{"error": "not found"})
case errors.Is(err, ErrConflict):
write(w, http.StatusConflict, map[string]string{"error": "slug or email already used"})
default:
write(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}
func (s *Server) createPost(w http.ResponseWriter, r *http.Request) {
var in NewPost
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" || in.Slug == "" {
write(w, http.StatusUnprocessableEntity, map[string]string{"error": "title and slug are required"})
return
}
p, err := s.store.CreatePost(r.Context(), in)
if err != nil {
fail(w, err)
return
}
write(w, http.StatusCreated, p)
}
func (s *Server) getPost(w http.ResponseWriter, r *http.Request) {
p, err := s.store.PostBySlug(r.Context(), r.PathValue("slug"))
if err != nil {
fail(w, err)
return
}
write(w, http.StatusOK, p)
}
func (s *Server) listPosts(w http.ResponseWriter, r *http.Request) {
before, _ := strconv.Atoi(r.URL.Query().Get("before"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
ps, err := s.store.ListPosts(r.Context(), uint(before), limit)
if err != nil {
fail(w, err)
return
}
next := uint(0)
if len(ps) > 0 {
next = ps[len(ps)-1].ID
}
write(w, http.StatusOK, map[string]any{"posts": ps, "next": next})
}
func (s *Server) addComment(w http.ResponseWriter, r *http.Request) {
var in struct{ Name, Body string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Body == "" {
write(w, http.StatusUnprocessableEntity, map[string]string{"error": "body is required"})
return
}
c, err := s.store.AddComment(r.Context(), r.PathValue("slug"), in.Name, in.Body)
if err != nil {
fail(w, err)
return
}
write(w, http.StatusCreated, c)
}
func (s *Server) deletePost(w http.ResponseWriter, r *http.Request) {
if err := s.store.DeletePost(r.Context(), r.PathValue("slug")); err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}Go 1.22's ServeMux supports methods and path parameters ("GET /posts/{slug}" and r.PathValue("slug")), which is enough for this API without a router package. The handlers do three things each: decode and validate, call the store with r.Context(), and map the result. fail maps the store's domain errors to status codes, and anything else becomes a 500 with a generic message. The raw error is never sent to the client.
Passing r.Context() into every store call means a client that disconnects cancels its queries (context timeouts). For a real deployment, add a request timeout middleware, a body size limit (http.MaxBytesReader), and authentication on the write routes; they are left out so the article stays about the data layer.
package main
import (
"log"
"net/http"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func main() {
db, err := gorm.Open(postgres.Open("host=127.0.0.1 port=55432 user=postgres password=secret dbname=lab sslmode=disable"), &gorm.Config{TranslateError: true})
if err != nil {
log.Fatal(err)
}
store, err := NewStore(db)
if err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", (&Server{store}).Routes()))
}main opens the connection with TranslateError: true, which is what lets the store match gorm.ErrDuplicatedKey. It is not run by the tests, so the listener is only for a developer to try by hand.
The tests
The tests are the proof. Each test gets its own PostgreSQL schema (so they cannot affect each other), a query counter registered as a GORM callback, and a handler. They drive the API through httptest, with no network:
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var schemas atomic.Int64
// newServer gives each test its own schema, a query counter and a running handler.
func newServer(t *testing.T) (http.Handler, *atomic.Int64) {
t.Helper()
base := "host=127.0.0.1 port=55432 user=postgres password=secret dbname=lab sslmode=disable"
admin, err := gorm.Open(postgres.Open(base), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatal(err)
}
schema := fmt.Sprintf("l0733_t%d", schemas.Add(1))
admin.Exec("drop schema if exists " + schema + " cascade")
admin.Exec("create schema " + schema)
t.Cleanup(func() {
admin.Exec("drop schema " + schema + " cascade")
sqlDB, _ := admin.DB()
sqlDB.Close()
})
db, err := gorm.Open(postgres.Open(base+" search_path="+schema), &gorm.Config{Logger: logger.Discard, TranslateError: true})
if err != nil {
t.Fatal(err)
}
var queries atomic.Int64
db.Callback().Query().Before("gorm:query").Register("count", func(*gorm.DB) { queries.Add(1) })
store, err := NewStore(db)
if err != nil {
t.Fatal(err)
}
return (&Server{store}).Routes(), &queries
}
func do(t *testing.T, h http.Handler, method, path string, body any) (int, map[string]any) {
t.Helper()
var rd bytes.Buffer
if body != nil {
json.NewEncoder(&rd).Encode(body)
}
req := httptest.NewRequest(method, path, &rd)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var out map[string]any
json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
func post(slug string, tags ...string) NewPost {
return NewPost{Author: Author{Name: "Mira", Email: "[email protected]"}, Title: "Title " + slug, Slug: slug, Body: "text of " + slug, Tags: tags}
}
func TestCreateAndReadWithSharedTags(t *testing.T) {
h, _ := newServer(t)
code, _ := do(t, h, "POST", "/posts", post("one", "go", "gorm"))
t.Logf("create first post: %d", code)
code2, _ := do(t, h, "POST", "/posts", post("two", "go"))
t.Logf("create second post sharing the tag go: %d", code2)
if code != 201 || code2 != 201 {
t.Fatal("create failed")
}
code, got := do(t, h, "GET", "/posts/one", nil)
tags := got["tags"].([]any)
author := got["author"].(map[string]any)["name"]
t.Logf("get /posts/one: %d, %d tags, author %v", code, len(tags), author)
if code != 200 || len(tags) != 2 {
t.Fatalf("unexpected: %d %v", code, got)
}
}
func TestConflictAndNotFound(t *testing.T) {
h, _ := newServer(t)
do(t, h, "POST", "/posts", post("dup"))
code, _ := do(t, h, "POST", "/posts", post("dup"))
t.Logf("duplicate slug: %d", code)
if code != 409 {
t.Fatal("want 409")
}
code, _ = do(t, h, "GET", "/posts/missing", nil)
t.Logf("missing slug: %d", code)
if code != 404 {
t.Fatal("want 404")
}
code, _ = do(t, h, "POST", "/posts", map[string]string{"title": "no slug"})
t.Logf("invalid body: %d", code)
if code != 422 {
t.Fatal("want 422")
}
}
func TestListDoesNotRunAQueryPerPost(t *testing.T) {
h, queries := newServer(t)
for i := 1; i <= 12; i++ {
do(t, h, "POST", "/posts", post(fmt.Sprintf("p%02d", i), "go", fmt.Sprintf("t%d", i%3)))
}
for _, n := range []int{3, 12} {
queries.Store(0)
code, out := do(t, h, "GET", fmt.Sprintf("/posts?limit=%d", n), nil)
t.Logf("list limit=%d: %d, %d posts, %d queries", n, code, len(out["posts"].([]any)), queries.Load())
if queries.Load() > 3 {
t.Fatalf("expected at most 3 queries, got %d", queries.Load())
}
}
// Body is left out of list rows.
_, out := do(t, h, "GET", "/posts?limit=1", nil)
first := out["posts"].([]any)[0].(map[string]any)
_, hasBody := first["body"]
t.Logf("list row carries a body: %v", hasBody)
}
func TestKeysetPaging(t *testing.T) {
h, _ := newServer(t)
for i := 1; i <= 7; i++ {
do(t, h, "POST", "/posts", post(fmt.Sprintf("k%d", i)))
}
seen, before, pages := 0, 0, 0
for {
_, out := do(t, h, "GET", fmt.Sprintf("/posts?limit=3&before=%d", before), nil)
ps := out["posts"].([]any)
if len(ps) == 0 {
break
}
seen += len(ps)
pages++
before = int(out["next"].(float64))
}
t.Logf("keyset walk: %d posts in %d pages", seen, pages)
if seen != 7 {
t.Fatal("lost or duplicated posts")
}
}
func TestCommentsAndDelete(t *testing.T) {
h, _ := newServer(t)
do(t, h, "POST", "/posts", post("c", "go"))
code, _ := do(t, h, "POST", "/posts/c/comments", map[string]string{"name": "Ada", "body": "nice"})
t.Logf("add comment: %d", code)
code, _ = do(t, h, "POST", "/posts/ghost/comments", map[string]string{"body": "hi"})
t.Logf("comment on a missing post: %d", code)
_, got := do(t, h, "GET", "/posts/c", nil)
t.Logf("comments on the post: %d", len(got["comments"].([]any)))
code, _ = do(t, h, "DELETE", "/posts/c", nil)
t.Logf("delete: %d", code)
code, _ = do(t, h, "GET", "/posts/c", nil)
t.Logf("get after delete: %d", code)
if code != 404 {
t.Fatal("want 404")
}
code, _ = do(t, h, "DELETE", "/posts/c", nil)
t.Logf("second delete: %d", code)
}=== RUN TestCreateAndReadWithSharedTags
main_test.go:69: create first post: 201
main_test.go:71: create second post sharing the tag go: 201
main_test.go:78: get /posts/one: 200, 2 tags, author Mira
--- PASS: TestCreateAndReadWithSharedTags (0.11s)
=== RUN TestConflictAndNotFound
main_test.go:88: duplicate slug: 409
main_test.go:93: missing slug: 404
main_test.go:98: invalid body: 422
--- PASS: TestConflictAndNotFound (0.08s)
=== RUN TestListDoesNotRunAQueryPerPost
main_test.go:112: list limit=3: 200, 3 posts, 3 queries
main_test.go:112: list limit=12: 200, 12 posts, 3 queries
main_test.go:121: list row carries a body: false
--- PASS: TestListDoesNotRunAQueryPerPost (0.15s)
=== RUN TestKeysetPaging
main_test.go:140: keyset walk: 7 posts in 3 pages
--- PASS: TestKeysetPaging (0.10s)
=== RUN TestCommentsAndDelete
main_test.go:150: add comment: 201
main_test.go:152: comment on a missing post: 404
main_test.go:154: comments on the post: 1
main_test.go:156: delete: 204
main_test.go:158: get after delete: 404
main_test.go:163: second delete: 404
--- PASS: TestCommentsAndDelete (0.10s)
PASS
ok example.test/lab 1.963sReading the results:
Shared tags. The first post with tags go and gorm and the second with go both returned 201, and GET /posts/one returned 200 with 2 tags and the author Mira. The go tag exists once.
Status codes. A repeated slug returned 409, a missing slug 404 and a body with no slug 422. Each status comes from a domain error or a validation check in the handler, not from a driver message.
The query count. The list test seeds 12 posts and asks for 3, then 12. Both cost 3 queries: the posts joined with their authors, the post_tags rows for those post ids, and the tags themselves. The assertion is <= 3 because a constant bound across two sizes is what rules out a query per post (the Preload article). A many to many preload costs two queries by itself, so 3 is the right bound here and 2 would have failed.
Keyset paging. Seven posts at limit=3 came back as 3 pages and 7 posts in total. The loop stops on an empty page. If the cursor used <= instead of <, a post would repeat on each page boundary, and the count check would catch it.
Comments and deletes. A comment on c returned 201, a comment on a missing post 404, the post read back with 1 comment, DELETE returned 204, a read afterwards 404, and a second DELETE 404.
Running it by hand
With the lab database running, go run . starts the server on port 8080. A few requests show the shape of the API:
curl -s -X POST localhost:8080/posts -d '{"author":{"name":"Mira","email":"[email protected]"},"title":"Pointers","slug":"pointers","body":"text","tags":["go","memory"]}'
curl -s localhost:8080/posts/pointers
curl -s 'localhost:8080/posts?limit=10'
curl -s -X POST localhost:8080/posts/pointers/comments -d '{"name":"Ada","body":"Clear."}'
curl -s -X DELETE localhost:8080/posts/pointersThe first call returns the stored post with its author and tags, because CreatePost reads it back. The list call returns a next cursor equal to the id of the last post on the page; pass it as before to get the following page. Reading the responses is a useful check that nothing private leaks: the author's email is absent because of its json:"-" tag.
Design choices worth defending
Why the slug and not the id in URLs. A sequential id tells a visitor how many posts exist and invites enumeration. A slug is chosen once, appears in links, and is unique. The trade is that a slug must be kept stable, so an edit of the title must not rewrite it, a rule that comes from the same reasoning as permanent URLs on any site.
Why the transaction wraps author and tag creation. If the post insert fails (a duplicate slug), the author and tags created a moment earlier are rolled back with it. Without the transaction, a rejected request would still leave new tags behind, and repeated bad requests would fill the tags table with names nobody uses.
Why Omit("Author"). GORM saves associations by default. Passing a post that carries an Author struct would upsert that author again, an extra statement per post and a way to overwrite a name. Omitting it keeps the write to the rows you meant to change.
Why a store and not GORM in the handlers. The handlers know nothing about GORM. They call PostBySlug and handle ErrNotFound. That is what lets the tests drive HTTP while the store is the only code with SQL knowledge, and it is the seam where you would swap the implementation (migrating a store to sqlc uses exactly this).
Why keyset paging for a blog. A blog list is the case where offset seems fine, and it stops being fine with a feed that clients walk to the end, with a crawler or a mobile app. Keyset paging costs nothing extra to build now, and removing offsets later breaks clients.
Response types
The models carry JSON tags to keep the lab short. For a public API, define response structs and map to them:
type PostSummary struct {
Slug string `json:"slug"`
Title string `json:"title"`
Author string `json:"author"`
Tags []string `json:"tags"`
}The mapping function is twenty lines, it is the only place that decides what the outside world sees, and it prevents the day a new database column (a private note, an internal flag) shows up in the JSON. It also lets the API keep working while the schema changes underneath, which is where a generated layer or a rename would otherwise hurt.
What this version leaves out
- Authentication and authorisation. Anyone can write. Add middleware and check that the author owns the post before an update or delete.
- Updates. A
PATCHshould useUpdateswith a map or a struct of pointers, to tell "unchanged" from "set to empty", as in the tutorial. - Publishing state and soft delete. A draft flag and a
deleted_atchange every read. See soft deletes for what they do to unique slugs. - Migrations. The store calls
AutoMigratefor convenience. A deployed service should use versioned migrations (goose and Atlas). - Tag archives.
CountPostsByTagin the store shows the grouped join pattern for counts. A tag page would addJoins("Tags")and a filter. - Rate limits and spam control on comments.
Extending it
Before adding features, make the tests part of the build: run them in CI against a PostgreSQL service container, with the race detector on, so a change to the store that adds a query per row or breaks a status code fails a pull request instead of a deployment. The whole suite ran in under two seconds here, which is cheap enough to run on every push.
The shape generalises. A new relation follows the same checklist: declare it on the model, decide how it is created (resolve shared children first), how it is read (Joins for one, Preload for many), what a list loads, and how it is deleted. Then write the test that counts queries.
Checklist
- Resolve shared children (tags, authors) before creating the parent.
- Use
Joinsfor belongs to andPreloadfor collections, and count queries in a test. - Select only list columns, and keep the foreign keys the joins need.
- Page with a keyset cursor and an explicit order.
- Delete children before parents, or let the database cascade.
- Map errors to domain errors in the store, and domain errors to statuses in the handlers.
- Give each test its own schema and run it against the database you deploy on.
The standard library features used are documented in the net/http package and the routing patterns in the Go 1.22 routing enhancements.


