GORM Relations in Practice: Comment Model, CRUD & Preload
๐ Series Navigation
This series has seven parts:
- GORM Crash Course: Building a Book Management API with Gin + GORM โ single-table CRUD, soft delete, the zero-value trap, a complete runnable project
- GORM Relations in Practice: Comment Model, CRUD & Preload โ second table
comments, comment CRUD, on-demand detail loading - GORM Media & Query Enhancement: Cover Upload, Pagination/Search & Comment Count โ uploads & static serving, pagination/search/sort, comment-count aggregation
- GORM Data Engineering: Batch Import, Request DTOs & Validation-Error Translation โ batch import, request DTOs, validation-error translation
- GORM Many-to-Many in Practice: Books & Tags โ many2many join table, tag filtering, association add/remove
- GORM Engineering in Practice (Part 1): Layering, Dependency Injection & Testability โ Repository/Service layering, constructor injection, table-driven tests (P4, optional reading)
- GORM Engineering in Practice (Part 2): Reliability & Production Readiness โ unified errors, security hardening, object storage, connection pool (P4, optional reading)
Prerequisites: finish the crash course โ the project with a single
bookstable and the five CRUD handlers (plus an optional hard-delete routeDELETE /books/:id/permanent; register it or not, per Part 9’s finalmain.go). Conventions are the same as the crash course (WithContexton every DB call,errors.Isfor 404, and the 400 ยท 404 ยท 201 semantics).
A real book API can’t live with only a books table. This part adds a second table, comments, and turns “one-to-many” into a real model.
1. Adding a Second Table: comments (One-to-Many)
Goal: model the one-to-many relationship between Book and Comment, so “a book has many comments” becomes a real model.
Why comments for the second table? In the book domain, “book โ comments” is the most natural and easiest to relate to; one-to-many + foreign key + Preload is the first rung on the multi-table ladder. The third table (tags, many-to-many) gets its own dedicated part โ GORM Many-to-Many in Practice. This part teaches only one new table.
1.1 The Comment Model
Create models/comment.go:
package models
import "gorm.io/gorm"
type Comment struct {
gorm.Model
BookID uint `json:"bookId" gorm:"not null;index"` // foreign key: which book this comment belongs to
Nickname string `json:"nickname"` // commenter name (no users/auth table yet)
Content string `json:"content" gorm:"not null"`
}
Field notes:
BookIDis the foreign key;indexkeeps it indexed โ looking up comments by book is the hottest query in this domain;- The commenter is a plain
Nicknamestring โ deliberately no user table or auth (that is another topic); gorm.Modelbrings soft delete for free โ comments are soft-deletable too, behaving exactly like the crash course.
1.2 Adding the Relationship Field to Book
Append to models/book.go:
type Book struct {
gorm.Model
Title string `json:"title" gorm:"not null"`
Author string `json:"author" gorm:"not null"`
Price int `json:"price"`
Comments []Comment `json:"comments,omitempty"` // relationship declaration, not a table column; only used by Preload
}
Why doesn’t this field need a
foreignKeytag? GORM’s has-many default convention is “parent type name + parent primary-key field name” (Book+ID=BookID) โComment.BookIDhits that convention, so the association resolves automatically. When must you writegorm:"foreignKey:..."explicitly? When the child’s foreign-key field drifts from the convention (say, you name itBookRef). And note: this field must not be removed โ it’s the relationship declaration thatPreload("Comments")resolves by name.Why doesn’t
Commentsappear in list responses? If list endpoints loaded every book’s comments by default, payloads would balloon and you’d invite N+1 queries.json:"comments,omitempty"plus no default Preload โ comments load only in the detail endpoint, on demand (Section 3). That’s the standard practice in real projects.
1.3 Migration Update & Verification
Change main.go’s AutoMigrate to create both tables at once:
if err := db.DB.AutoMigrate(&models.Book{}, &models.Comment{}); err != nil {
log.Fatal("migration failed:", err)
}
booksalready exists, and this part adds no columns to it โComments []Commentis a relationship declaration, not a column, so AutoMigrate leaves thebookstable alone;commentsis created on its first migration, together with the foreign key (book_id โ books.id) and theindex.
Verify:
\d comments
-- you should see book_id indexed, a foreign-key constraint pointing at books(id),
-- and the usual gorm.Model columns (deleted_at etc.)
โ ๏ธ Soft-deleting the parent does NOT cascade to children: soft-deleting a
booksrow only stampsbooks.deleted_atโcomments.deleted_atis untouched, so the comments stay visible and queryable. The crash course taught “soft delete = the framework rewrites your SQL”; here’s the other side: soft delete on the parent never cascades to the child table. If you want “deleting a book hides its comments”, write it yourself (e.g.db.Model(&models.Comment{}).Where("book_id = ?", id).Update("deleted_at", time.Now())โ illustrative only,WithContextand error checks omitted) โ this part doesn’t cover it.
2. Comment Management: CRUD on the Second Table
Goal: write the CRUD for comments (create, list per book with pagination, delete) and register the routes โ a faithful replay of the crash course’s six-step skeleton on a second table. No Preload yet (that’s Section 3).
2.1 Creating a Comment
handlers/comment.go:
package handlers
import (
"errors"
"gin-demo/db"
"gin-demo/models"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// CreateComment creates a comment for the given book
func CreateComment(c *gin.Context) {
id := c.Param("id")
// 1. The book must exist
var book models.Book
result := db.DB.WithContext(c.Request.Context()).First(&book, id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "book not found"})
return
}
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
// 2. Bind the comment body (nickname optional, content required)
var input struct {
Nickname string `json:"nickname"`
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "please send valid JSON"})
return
}
// 3. Insert, with the foreign key pointing at the current book
comment := models.Comment{
BookID: book.ID,
Nickname: input.Nickname,
Content: input.Content,
}
if err := db.DB.WithContext(c.Request.Context()).Create(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create comment"})
return
}
c.JSON(http.StatusCreated, comment)
}
Notice step 3 does not
Create(&input)directly โinputis the request DTO,commentis the model. Separating request structs from models graduates here from the crash course’s “local experiment” (its Part 5 advanced section already usedcreateBookInput, back when the two structs mostly overlapped) into the main-line rule of this series; the dedicated DTO article formalizes it (GORM Data Engineering).
Testing:
curl -X POST http://localhost:8080/books/1/comments \
-H "Content-Type: application/json" \
-d '{"nickname":"Alice","content":"Very clearly written!"}'
# โ 201, returns the comment. Keys are uppercase (ID / CreatedAt โ gorm.Model has no
# json tags; see the crash course), not lowercase id
2.2 Listing a Book’s Comments with Pagination
ListComments โ a pagination skeleton worth memorizing (it parses params with strconv.Atoi; if handlers/comment.go doesn’t import strconv yet after 2.1, add it):
// ListComments lists a book's comments page by page, newest first.
// Returns {items, total, page, pageSize}; total is the filtered count.
func ListComments(c *gin.Context) {
id := c.Param("id")
// 1. Parse the pagination params
// 1.1 page: which page, default 1
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
// 1.2 pageSize: items per page, default 10
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
// 2. Validate (defensive: bad values fall back to defaults)
// 2.1 page starts at 1
if page < 1 {
page = 1
}
// 2.2 pageSize falls back to 10
if pageSize < 1 {
pageSize = 10
}
// 2.3 pageSize caps at 100: stop a client pulling too much at once
if pageSize > 100 {
pageSize = 100
}
// 3. Build the query: only this book's comments
var comments []models.Comment
query := db.DB.WithContext(c.Request.Context()).
Model(&models.Comment{}).
Where("book_id = ?", id)
// 4. Count the filtered total first
var total int64
if err := query.Count(&total).Error; err != nil {
_ = c.Error(err) // specific error goes to Gin's log; the client message stays uniform
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query comments"})
return
}
// 5. Then fetch the current page (newest first)
if err := query.
Order("created_at DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&comments).Error; err != nil {
_ = c.Error(err) // specific error goes to Gin's log; the client message stays uniform
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query comments"})
return
}
// 6. Return the page result
c.JSON(http.StatusOK, gin.H{
"items": comments,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
This is the series’ first paginated endpoint โ read the full shape and memorize the Count + Order + Offset + Limit combination. (The next part, GORM Media & Query Enhancement, reuses the same skeleton on the book list and folds this parsing into a parsePagination helper.)
One coding detail worth noticing: strconv.Atoi errors are dropped with _, and invalid values fall back to defaults โ defensive parsing.
Why are the error messages uniform? Both
CountandFindreturning “failed to query comments” is deliberate โ the client sees a 500 and doesn’t need to know which step died (and leaking internals outward is unsafe). What must be distinguished is the server-side log:_ = c.Error(err)hands the specific error to Gin’s logging middleware. In production this upgrades toslog+ a unified error middleware (landed in GORM Engineering in Practice (Part 2)).
Testing:
curl "http://localhost:8080/books/1/comments?page=1&pageSize=10"
# {"items":[...],"total":1,"page":1,"pageSize":10}
The semantic difference from create/delete:
CreateComment/DeleteCommentfirst check that the book exists (they return 404 once the book is soft-deleted), butListCommentsdoesn’t โ after a book is soft-deleted,GET /books/:id/commentsstill returns 200 with the historical comments. Deliberate: child data stays queryable after the parent’s soft delete (echoing the โ ๏ธ box in 1.3). A list endpoint fetches rows by condition; it doesn’t decide whether the resource exists.
2.3 Deleting a Comment
// DeleteComment soft-deletes a comment. The delete condition is the comment's
// primary key AND an owning book that matches โ deleting by cid alone would be
// a horizontal-privilege hole: the URL is /books/:id/comments/:cid, so we must
// ensure the comment belongs to THIS book, otherwise /books/1/comments/99 could
// delete book 2's comment.
func DeleteComment(c *gin.Context) {
id := c.Param("id") // the book's id (ownership check)
cid := c.Param("cid") // the comment's id (primary key)
// Primary key + Where combine into: DELETE ... WHERE id = cid AND book_id = id
result := db.DB.WithContext(c.Request.Context()).
Where("book_id = ?", id).
Delete(&models.Comment{}, cid)
// Check the error first, then the row count: a non-numeric cid errors during
// primary-key conversion (500); a condition that matches nothing (no such
// comment, or not this book's) is the 404 โ same two-layer check as the reads
if result.Error != nil {
_ = c.Error(result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete comment"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "comment deleted"})
}
Delete(&models.Comment{}, cid) is isomorphic to deleting a book in the crash course โ another instantiation of “run the operation + check RowsAffected” from the six-step skeleton.
Testing:
curl -X DELETE http://localhost:8080/books/1/comments/1
# โ 200; deleting the same comment again โ 404
2.4 Route Registration
Put these three lines into main.go’s route block (all other routes stay as in the crash course):
r.POST("/books/:id/comments", handlers.CreateComment)
r.GET("/books/:id/comments", handlers.ListComments)
r.DELETE("/books/:id/comments/:cid", handlers.DeleteComment)
3. Reading Relationships: Preload for One-to-Many
Goal: make the detail endpoint load a book’s comments on demand with Preload("Comments") โ the heart of reading one-to-many, and the genuinely new knowledge of this part.
3.1 Loading Comments On Demand in the Detail Endpoint
GetBook in handlers/book.go gains one Preload line:
// Returns one book with its comments, loaded on demand
func GetBook(c *gin.Context) {
id := c.Param("id")
var book models.Book
result := db.DB.WithContext(c.Request.Context()).Preload("Comments").First(&book, id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "book not found"})
return
}
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, book)
}
Preload("Comments") makes a single First also fetch all of that book’s comments: GORM queries books first, then fetches comments by book_id in one bulk query and assembles the result โ no N+1 (two SQL statements assembled once, not row-by-row queries).
List endpoints don’t Preload:
GetBooksstays exactly as it is (no comments); only the detail loads them. The API contract is decided by what the data is for, not by what the ORM can do.
Testing:
curl http://localhost:8080/books/1
# the detail response should include "comments":[...]
Wrap-Up
- New routes in this part:
| Method | Path | Handler |
|---|---|---|
| POST | /books/:id/comments |
CreateComment |
| GET | /books/:id/comments |
ListComments |
| DELETE | /books/:id/comments/:cid |
DeleteComment |
| GET | /books/:id (modified, now loads Comments) |
GetBook |
- Your project now:
books+commentstwo tables, comment CRUD, and a detail endpoint that loads comments on demand; - Next part, GORM Media & Query Enhancement: add book covers (upload + static serving), then upgrade the list into pagination / search / sort, plus a comment count per book.

