CRUD end-to-end
GET / POST / PATCH / DELETE — written by hand, line by line.
Four operations, four URLs, four handlers, four service methods. We'll do all of them for a Note resource — by hand, no generator. After this lesson, any resource you ever build is a variation of this template.
The model
package modelsimport "time"type Note struct {ID uint `gorm:"primaryKey"`UserID uint `gorm:"index;not null"`Title string `gorm:"not null"`Body string `gorm:"type:text"`CreatedAt time.TimeUpdatedAt time.Time}
Add it to db.AutoMigrate(&models.Note{}) so the table is created on next startup.
The four routes
authed.GET ("/notes", s.NoteHandler.List)authed.POST ("/notes", s.NoteHandler.Create)authed.PATCH ("/notes/:id", s.NoteHandler.Update)authed.DELETE("/notes/:id", s.NoteHandler.Delete)
REST conventions: noun is plural; collection vs. item via /:id; verb encoded as HTTP method. Don't invent /notes/create.
READ many — GET /api/notes
Handler
func (h *NoteHandler) List(c *gin.Context) {userID := c.GetUint("user_id") // set by RequireAuthpage, _ := strconv.Atoi(c.DefaultQuery("page", "1"))size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))notes, total, err := h.notes.List(c.Request.Context(), userID, page, size)if err != nil {c.JSON(500, gin.H{"error": gin.H{"code": "internal", "message": err.Error()}})return}c.JSON(200, gin.H{"data": notes,"meta": gin.H{"total": total, "page": page, "page_size": size},})}
Service
func (s *NoteService) List(ctx context.Context, userID uint, page, size int) ([]models.Note, int64, error) {tx := s.db.WithContext(ctx).Model(&models.Note{}).Where("user_id = ?", userID)var total int64if err := tx.Count(&total).Error; err != nil { return nil, 0, err }var notes []models.Noteerr := tx.Order("created_at DESC").Offset((page - 1) * size).Limit(size).Find(¬es).Errorreturn notes, total, err}
Always scope by user_id — a logged-in user reads only THEIR notes. Without this filter, your list endpoint leaks every user's notes to every user. This is the IDOR vulnerability in the OWASP Top 10.
CREATE — POST /api/notes
Input struct + validation
type CreateNoteInput struct {Title string `json:"title" binding:"required,max=200"`Body string `json:"body" binding:"max=10000"`}
The binding tags are read by c.ShouldBindJSON. If title is missing or longer than 200 chars, Gin returns an error you respond with as 400.
Handler
func (h *NoteHandler) Create(c *gin.Context) {var in services.CreateNoteInputif err := c.ShouldBindJSON(&in); err != nil {c.JSON(400, gin.H{"error": gin.H{"code": "invalid_body", "message": err.Error()}})return}userID := c.GetUint("user_id")note, err := h.notes.Create(c.Request.Context(), userID, in)if err != nil {c.JSON(500, gin.H{"error": gin.H{"code": "internal", "message": err.Error()}})return}c.JSON(201, gin.H{"data": note, "message": "Note created"})}
Service
func (s *NoteService) Create(ctx context.Context, userID uint, in CreateNoteInput) (models.Note, error) {note := models.Note{UserID: userID, Title: in.Title, Body: in.Body}if err := s.db.WithContext(ctx).Create(¬e).Error; err != nil {return models.Note{}, fmt.Errorf("create note: %w", err)}return note, nil}
Status code 201 (Created), not 200, on successful create. Tiny detail; pros notice.
UPDATE — PATCH /api/notes/:id
Why PATCH and not PUT? PATCH is for partial updates — the client sends only fields that change. PUT replaces the whole resource. Almost always you want PATCH.
type UpdateNoteInput struct {Title *string `json:"title"` // pointer = optionalBody *string `json:"body"`}func (h *NoteHandler) Update(c *gin.Context) {id, err := strconv.ParseUint(c.Param("id"), 10, 64)if err != nil {c.JSON(400, gin.H{"error": gin.H{"code": "invalid_id", "message": err.Error()}})return}var in services.UpdateNoteInputif err := c.ShouldBindJSON(&in); err != nil {c.JSON(400, gin.H{"error": gin.H{"code": "invalid_body", "message": err.Error()}})return}userID := c.GetUint("user_id")note, err := h.notes.Update(c.Request.Context(), userID, uint(id), in)switch {case errors.Is(err, services.ErrNotFound):c.JSON(404, gin.H{"error": gin.H{"code": "not_found"}})case errors.Is(err, services.ErrForbidden):c.JSON(403, gin.H{"error": gin.H{"code": "forbidden"}})case err != nil:c.JSON(500, gin.H{"error": gin.H{"code": "internal"}})default:c.JSON(200, gin.H{"data": note, "message": "Note updated"})}}
func (s *NoteService) Update(ctx context.Context, userID, id uint, in UpdateNoteInput) (models.Note, error) {var note models.Noteif err := s.db.WithContext(ctx).First(¬e, id).Error; err != nil {if errors.Is(err, gorm.ErrRecordNotFound) { return note, ErrNotFound }return note, err}if note.UserID != userID {return note, ErrForbidden // IDOR check: this user can't edit someone else's note}updates := map[string]any{}if in.Title != nil { updates["title"] = *in.Title }if in.Body != nil { updates["body"] = *in.Body }if len(updates) == 0 { return note, nil }if err := s.db.WithContext(ctx).Model(¬e).Updates(updates).Error; err != nil {return note, err}return note, nil}
Three patterns to absorb:
- Pointer fields for optional input.
*stringdistinguishes "not provided" (nil) from "provided as empty string" (""). Critical for PATCH semantics. - Two-step: load + authorize. First load by ID; then check the owner; only then update. This is the IDOR defence.
- Map-based Updates. Pass
db.Model(¬e).Updates(map)with only the set fields — GORM updates exactly those columns.
DELETE — DELETE /api/notes/:id
func (h *NoteHandler) Delete(c *gin.Context) {id, _ := strconv.ParseUint(c.Param("id"), 10, 64)userID := c.GetUint("user_id")err := h.notes.Delete(c.Request.Context(), userID, uint(id))switch {case errors.Is(err, services.ErrNotFound):c.JSON(404, gin.H{"error": gin.H{"code": "not_found"}})case errors.Is(err, services.ErrForbidden):c.JSON(403, gin.H{"error": gin.H{"code": "forbidden"}})case err != nil:c.JSON(500, gin.H{"error": gin.H{"code": "internal"}})default:c.Status(204) // No Content — successful delete, nothing to return}}func (s *NoteService) Delete(ctx context.Context, userID, id uint) error {var note models.Noteif err := s.db.WithContext(ctx).First(¬e, id).Error; err != nil {if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotFound }return err}if note.UserID != userID { return ErrForbidden }return s.db.WithContext(ctx).Delete(¬e).Error}
204 No Content is the right status for a delete with nothing to return. The browser/client knows the delete worked; no body needed.
WHERE user_id = ? or check note.UserID != userID. Skip this and your delete endpoint becomes "delete ANYONE's note if you know the ID". This is IDOR — silent, catastrophic, common. Always scope. Always check.Testing the whole cycle
# Register and capture tokenTOKEN=$(curl -s -X POST http://localhost:8080/api/auth/register \-H 'Content-Type: application/json' \-d '{"email":"a@b.com","password":"secret123","name":"A"}' \| jq -r .data.token)# Createcurl -X POST http://localhost:8080/api/notes \-H "Authorization: Bearer $TOKEN" \-H 'Content-Type: application/json' \-d '{"title":"First","body":"hello"}'# Listcurl http://localhost:8080/api/notes -H "Authorization: Bearer $TOKEN"# Updatecurl -X PATCH http://localhost:8080/api/notes/1 \-H "Authorization: Bearer $TOKEN" \-H 'Content-Type: application/json' \-d '{"title":"Renamed"}'# Deletecurl -X DELETE http://localhost:8080/api/notes/1 \-H "Authorization: Bearer $TOKEN"
Playground challenge
Hand-write CRUD for Task
In the playground, build the four endpoints for a Task resource (id, user_id, title, done). Follow the exact pattern from this lesson. Time yourself — aim to do it in 20 minutes. The point is the muscle memory of handler → service → GORM.
Quick check
Try it
For chapter 5's assignment, hand-write a Note resource:
- Add the model + auto-migrate.
- Wire all four routes in
routes.go. - Write the handler + service for all four operations following this lesson's pattern.
- Test all four via curl, INCLUDING the IDOR check: log in as User A, create a note, then log in as User B and try to PATCH it — must return 403.
- Now run
grit generate resource Note user_id:uint title:string body:stringin a fresh project. DIFF the generated files against yours. Note where the generated version differs (it probably does some things better, some things you preferred yours). - One paragraph in notes.md on what surprised you.
What's next
Chapter 6 — The Batteries. The included services that turn this clean CRUD foundation into a production-ready API: cache, file storage, email, jobs, AI.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub