Dev Skills

Step 5 — Expenses, locked to their owner

CRUD again, but now every query has to know whose data it is.

Topic 70 of 98 · 10 min to read · 50 min to build
After this topic you can
  • Write repository methods where every query is filtered by user ID.
  • Explain why another user's record should return 404, not 403.
  • Build partial updates using pointer fields for optional input.

Every command for this step

terminal — run these in order
touch internal/expense/expense.go internal/expense/repository.go
touch internal/expense/service.go internal/expense/handler.go
go build ./... && go run ./cmd/api
# create one
curl -s -X POST localhost:8080/v1/expenses -H "Authorization: Bearer $TOKEN" \
-d '{"amount_cents":1250,"category":"food","note":"lunch","spent_on":"2026-09-08"}' | jq
# the cross-user check — register a second user first
B=$(curl -s -X POST localhost:8080/v1/auth/register \
-d '{"name":"B","email":"b@x.c","password":"password123"}' >/dev/null; \
curl -s -X POST localhost:8080/v1/auth/login \
-d '{"email":"b@x.c","password":"password123"}' | jq -r .access_token)
# all three MUST be 404
curl -si -H "Authorization: Bearer $B" localhost:8080/v1/expenses/1 | head -1
curl -si -X PATCH -H "Authorization: Bearer $B" localhost:8080/v1/expenses/1 -d '{"note":"x"}' | head -1
curl -si -X DELETE -H "Authorization: Bearer $B" localhost:8080/v1/expenses/1 | head -1

The rule this whole project exists for

Requests now carry a user id. Every query must use it. There are two places you could do that check, and only one of them holds up.

the wrong way and the right way
// WRONG — two statements, and a check somebody will forget to write
e, err := repo.GetByID(ctx, expenseID)
if e.UserID != userID {
return ErrForbidden
}
// RIGHT — one query. Not yours and not there give the same answer.
err = db.QueryRowContext(ctx,
`SELECT id, amount_cents, category FROM expenses WHERE id = $1 AND user_id = $2`,
expenseID, userID).Scan(&e.ID, &e.AmountCents, &e.Category)
Watch out: The wrong version has a name: IDOR, Insecure Direct Object Reference. Someone changes /expenses/41 to /expenses/42 and reads a stranger’s spending. Under the name Broken Object Level Authorization it is first on the OWASP API Security Top 10. Putting AND user_id = $2 in the SQL means you cannot forget it.

Why 404 and not 403

When somebody asks for an expense that is not theirs, return 404 Not Found, not 403 Forbidden. A 403 confirms the row exists, so an attacker can map out which ids are real without reading any of them. Not yours and not there should be indistinguishable.

File 1 — the type

internal/expense/expense.go — create this file
package expense
type Expense struct {
ID int64 `json:"id"`
UserID int64 `json:"-"` // internal; the client knows who it is
AmountCents int64 `json:"amount_cents"`
Category string `json:"category"`
Note string `json:"note"`
SpentOn time.Time `json:"spent_on"`
CreatedAt time.Time `json:"created_at"`
}
type CreateRequest struct {
AmountCents int64 `json:"amount_cents"`
Category string `json:"category"`
Note string `json:"note"`
SpentOn string `json:"spent_on"` // "2026-09-08"
}
// UpdateRequest uses pointers so "not sent" and "sent as empty" are different.
type UpdateRequest struct {
AmountCents *int64 `json:"amount_cents"`
Category *string `json:"category"`
Note *string `json:"note"`
}
UserID has json:"-" because the client never sets it and never needs to see it. It comes from the token, not the body.

File 2 — the repository, every method taking a userID

internal/expense/repository.go — create this file
// Notice: EVERY method takes userID. There is no way to call one without it.
func (r *Repository) GetByID(ctx context.Context, id, userID int64) (*Expense, error) {
var e Expense
err := r.db.QueryRowContext(ctx,
`SELECT id, user_id, amount_cents, category, note, spent_on, created_at
FROM expenses WHERE id = $1 AND user_id = $2`,
id, userID,
).Scan(&e.ID, &e.UserID, &e.AmountCents, &e.Category, &e.Note, &e.SpentOn, &e.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound // covers both "not there" and "not yours"
}
if err != nil {
return nil, err
}
return &e, nil
}
func (r *Repository) Delete(ctx context.Context, id, userID int64) error {
res, err := r.db.ExecContext(ctx,
`DELETE FROM expenses WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
Making userID a required parameter of every method is a small design choice that makes the mistake hard to make.

File 3 — the service, with the rules

internal/expense/service.go — create this file
func (s *Service) Create(ctx context.Context, userID int64, req CreateRequest) (*Expense, error) {
if req.AmountCents <= 0 {
return nil, fmt.Errorf("%w: amount must be more than zero", ErrValidation)
}
if strings.TrimSpace(req.Category) == "" {
return nil, fmt.Errorf("%w: category is required", ErrValidation)
}
spentOn, err := time.Parse("2006-01-02", req.SpentOn)
if err != nil {
return nil, fmt.Errorf("%w: spent_on must look like 2026-09-08", ErrValidation)
}
if spentOn.After(time.Now()) {
return nil, fmt.Errorf("%w: cannot record a future expense", ErrValidation)
}
e := &Expense{
UserID: userID, // from the token, never from the body
AmountCents: req.AmountCents,
Category: strings.ToLower(strings.TrimSpace(req.Category)),
Note: req.Note,
SpentOn: spentOn,
}
return e, s.repo.Create(ctx, e)
}
The Go date layout is "2006-01-02": you write the reference time (Mon Jan 2 15:04:05 MST 2006) in the shape you want, instead of codes like %Y. It reads oddly and you will look it up more than once.

File 4 — the handler

internal/expense/handler.go — create this file
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserID(r.Context())
if !ok {
httpx.Error(w, 401, "unauthorized")
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
httpx.Error(w, 400, "invalid id")
return
}
e, err := h.svc.GetByID(r.Context(), id, userID) // both ids, always
if err != nil {
httpx.FromError(w, err) // ErrNotFound becomes 404
return
}
httpx.JSON(w, 200, e)
}

Partial updates and why they need pointers

PUT replaces a whole resource. PATCH changes some of it. The problem in Go is telling "the client set the note to empty" apart from "the client did not mention the note". Both arrive as "".

A pointer answers it. nil means the field was absent from the JSON; a pointer to an empty string means they sent it and meant it.

internal/expense/service.go
func (s *Service) Update(ctx context.Context, id, userID int64, req UpdateRequest) (*Expense, error) {
e, err := s.repo.GetByID(ctx, id, userID) // scoped read first
if err != nil {
return nil, err
}
if req.AmountCents != nil {
if *req.AmountCents <= 0 {
return nil, fmt.Errorf("%w: amount must be more than zero", ErrValidation)
}
e.AmountCents = *req.AmountCents
}
if req.Category != nil {
e.Category = strings.ToLower(*req.Category)
}
if req.Note != nil {
e.Note = *req.Note // "" is a real value here
}
return e, s.repo.Update(ctx, e, userID)
}

The test you must run by hand

terminal
# as user A
A=$(login a@x.com); ID=$(create_expense $A)
# as user B — all three must be 404
curl -si -H "Authorization: Bearer $B" localhost:8080/v1/expenses/$ID | head -1
curl -si -X PATCH -H "Authorization: Bearer $B" localhost:8080/v1/expenses/$ID | head -1
curl -si -X DELETE -H "Authorization: Bearer $B" localhost:8080/v1/expenses/$ID | head -1
The short version
  • Put ownership in the SQL: WHERE id = $1 AND user_id = $2. Never a separate check in Go.
  • Return 404 for someone else’s row. A 403 confirms it exists.
  • Make userID a required parameter of every repository method so it cannot be skipped.
  • Pointer fields in a PATCH request separate "not sent" from "sent as empty".
Remember: If the ownership check lives in Go, someone will eventually write a query that skips it. If it lives in the WHERE clause, they cannot.
Build this before moving on
Build all five expense endpoints. Then create an expense as user A and try to read, change and delete it as user B. All three must return 404.
Then try these in your own editor
  1. 1Remove AND user_id = $2 from one query and re-run that test to watch it fail.
  2. 2Try to create an expense with someone else’s user_id in the JSON body and confirm it is ignored.
  3. 3Send a future date and confirm it is rejected.
  4. 4PATCH with {}, then {"note":""}, then {"note":"x"} — three different outcomes from one pointer field.
Check yourselfTake the Project 2 — Expense tracker test