CRUD end-to-end

GET / POST / PATCH / DELETE — written by hand, line by line.

10 minmedium

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

apps/api/internal/models/note.go
package models
import "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.Time
UpdatedAt time.Time
}

Add it to db.AutoMigrate(&models.Note{}) so the table is created on next startup.

The four routes

apps/api/internal/routes/routes.go (add to the authed group)
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

apps/api/internal/handlers/note_handler.go
func (h *NoteHandler) List(c *gin.Context) {
userID := c.GetUint("user_id") // set by RequireAuth
page, _ := 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

apps/api/internal/services/note_service.go
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 int64
if err := tx.Count(&total).Error; err != nil { return nil, 0, err }
var notes []models.Note
err := tx.Order("created_at DESC").
Offset((page - 1) * size).
Limit(size).
Find(&notes).Error
return 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.CreateNoteInput
if 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(&note).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 = optional
Body *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.UpdateNoteInput
if 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.Note
if err := s.db.WithContext(ctx).First(&note, 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(&note).Updates(updates).Error; err != nil {
return note, err
}
return note, nil
}

Three patterns to absorb:

  • Pointer fields for optional input. *string distinguishes "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(&note).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.Note
if err := s.db.WithContext(ctx).First(&note, 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(&note).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.

Always scope by user_id. Three of the four endpoints above include 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 token
TOKEN=$(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)
# Create
curl -X POST http://localhost:8080/api/notes \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"First","body":"hello"}'
# List
curl http://localhost:8080/api/notes -H "Authorization: Bearer $TOKEN"
# Update
curl -X PATCH http://localhost:8080/api/notes/1 \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"Renamed"}'
# Delete
curl -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.

Open the playground

Quick check

Why does the Update handler use *string fields in its input struct instead of plain string?

Try it

For chapter 5's assignment, hand-write a Note resource:

  1. Add the model + auto-migrate.
  2. Wire all four routes in routes.go.
  3. Write the handler + service for all four operations following this lesson's pattern.
  4. 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.
  5. Now run grit generate resource Note user_id:uint title:string body:string in 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).
  6. 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