Files
go-simple-api/lessons/lesson-04-user-model-repository-pattern.md
T
2026-07-16 10:13:46 +03:30

10 KiB

Lesson 4 — User Model & Repository Pattern

New Go concepts in this lesson: applying pointers and pointer receivers for real (not just toy examples), sentinel errors in practice, QueryRowContext vs QueryContext. Make sure you've done the pointers section of 00-go-basics-2-functions-structs-pointers.md and the errors section of 00-go-basics-3-...md before this lesson — everything here depends on both.

Quick pointer refresher, applied

Two rules from Go Basics you'll use constantly in this lesson:

  1. If a function needs to write a result back into the caller's variable, it must take a pointer (*Book), and write through it (b.ID = ...).
  2. If a struct wraps something stateful/shared (like a database connection pool), methods on it should use a pointer receiver (func (r *BookRepository) ...), so every call operates on the same underlying resource instead of an accidental copy.

Keep those two rules in mind as you read the code below — they explain almost every * you'll see in this lesson.

Part A — standalone playground

We'll practice the repository pattern: separating "how do I talk to the database" from "what does my business logic do."

Reuse the MySQL container from Lesson 3, or start fresh:

docker run --name mysql-demo2 -e MYSQL_ROOT_PASSWORD=devpass -e MYSQL_DATABASE=demo -p 3306:3306 -d mysql:9

mkdir ~/go-playground/repo-demo && cd ~/go-playground/repo-demo
go mod init repo-demo
go get github.com/go-sql-driver/mysql@latest

main.go

package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"
	"time"

	_ "github.com/go-sql-driver/mysql"
)

// 1. The domain model - a plain struct representing one "thing" in your
// app. No database code here at all - this is just data.
type Book struct {
	ID        int
	Title     string
	Author    string
	CreatedAt time.Time
}

// 2. The repository - a struct that wraps *sql.DB and knows how to turn
// SQL rows into Book structs, and Book structs into SQL writes.
type BookRepository struct {
	db *sql.DB
}

// Constructor function - Go convention: NewXxx returns a *Xxx
func NewBookRepository(db *sql.DB) *BookRepository {
	return &BookRepository{db: db}
}

var ErrNotFound = errors.New("book not found")

// 3. Pointer receiver: (r *BookRepository) - because we don't want to
// copy the struct (it holds a *sql.DB) on every single method call.
func (r *BookRepository) Create(ctx context.Context, b *Book) error {
	res, err := r.db.ExecContext(ctx,
		"INSERT INTO books (title, author, created_at) VALUES (?, ?, ?)",
		b.Title, b.Author, time.Now(),
	)
	if err != nil {
		return fmt.Errorf("insert book: %w", err)
	}
	id, err := res.LastInsertId()
	if err != nil {
		return fmt.Errorf("get last insert id: %w", err)
	}
	b.ID = int(id) // write the new ID back into the caller's Book
	return nil
}

func (r *BookRepository) FindByID(ctx context.Context, id int) (*Book, error) {
	var b Book
	err := r.db.QueryRowContext(ctx,
		"SELECT id, title, author, created_at FROM books WHERE id = ?", id,
	).Scan(&b.ID, &b.Title, &b.Author, &b.CreatedAt)

	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("find book: %w", err)
	}
	return &b, nil
}

func main() {
	db, err := sql.Open("mysql", "root:devpass@tcp(127.0.0.1:3306)/demo?parseTime=true")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	ctx := context.Background()
	if _, err := db.ExecContext(ctx, `
		CREATE TABLE IF NOT EXISTS books (
			id INT AUTO_INCREMENT PRIMARY KEY,
			title VARCHAR(255) NOT NULL,
			author VARCHAR(255) NOT NULL,
			created_at DATETIME NOT NULL
		)`); err != nil {
		log.Fatal(err)
	}

	repo := NewBookRepository(db)

	b := &Book{Title: "The Go Programming Language", Author: "Donovan & Kernighan"}
	if err := repo.Create(ctx, b); err != nil {
		log.Fatal(err)
	}
	log.Printf("created book with id %d", b.ID)

	found, err := repo.FindByID(ctx, b.ID)
	if err != nil {
		log.Fatal(err)
	}
	log.Printf("found: %+v", found)

	_, err = repo.FindByID(ctx, 999999)
	if errors.Is(err, ErrNotFound) {
		log.Println("correctly got ErrNotFound for missing book")
	}
}

Run it:

go run .

What's new here:

  • Book struct has zero database knowledge — it's pure data. Your handlers/business logic will work with Book, never with raw SQL rows directly.
  • BookRepository wraps *sql.DB and is the only place SQL queries live. Swap MySQL for Postgres later, and you change this one file, not every handler.
  • NewBookRepository(db) constructor — Go has no classes/constructors as a language feature; NewXxx returning *Xxx is purely a naming convention, but the entire ecosystem follows it, so you should too.
  • func (r *BookRepository) Create(...) — pointer receiver, per the refresher above. r is the repository itself; inside, r.db accesses the wrapped connection pool.
  • b.ID = int(id) — since Create takes b *Book (a pointer), it can write the newly generated ID directly back into the caller's struct. This is rule #1 from the refresher, applied for real.
  • QueryRowContext(...).Scan(...) — new: QueryRowContext (singular Row) is for when you expect exactly one result, like a lookup by ID. It skips the rows.Next()/rows.Close() dance from Lesson 3 since there's at most one row.
  • errors.Is(err, sql.ErrNoRows)sql.ErrNoRows is the driver's own sentinel error for "query matched zero rows." We translate it into our own ErrNotFound so callers of FindByID don't need to know or care that the underlying storage is SQL at all — this is the sentinel error pattern from Go Basics Part 3, put to real use.
  • var ErrNotFound = errors.New(...) — a package-level sentinel error, so callers can check errors.Is(err, ErrNotFound) without caring what's underneath.

Try inserting a second book, querying it, then calling FindByID with an ID you know doesn't exist and confirm you get ErrNotFound, not a crash.

Part B — apply it to the project

internal/models/user.go — the domain struct:

package models

import "time"

type User struct {
	ID           int
	Email        string
	PasswordHash string
	GoogleID     string // empty if the user registered with a password
	CreatedAt    time.Time
}

PasswordHash, not Password — we will never store or handle plaintext passwords beyond the brief moment they're hashed (Lesson 5). GoogleID is here now so Lesson 7 (Google OAuth) doesn't require restructuring this struct later.

internal/database/migrate.go — creates the table on startup (fine for a learning project; a real project would use a dedicated migration tool):

package database

import (
	"context"
	"database/sql"
	"fmt"
)

func Migrate(ctx context.Context, db *sql.DB) error {
	_, err := db.ExecContext(ctx, `
		CREATE TABLE IF NOT EXISTS users (
			id INT AUTO_INCREMENT PRIMARY KEY,
			email VARCHAR(255) NOT NULL UNIQUE,
			password_hash VARCHAR(255) NOT NULL DEFAULT '',
			google_id VARCHAR(255) NOT NULL DEFAULT '',
			created_at DATETIME NOT NULL
		)`)
	if err != nil {
		return fmt.Errorf("migrate users table: %w", err)
	}
	return nil
}

internal/models/user_repository.go — same pattern as BookRepository:

package models

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"
)

var ErrUserNotFound = errors.New("user not found")

type UserRepository struct {
	db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
	return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, u *User) error {
	res, err := r.db.ExecContext(ctx,
		"INSERT INTO users (email, password_hash, google_id, created_at) VALUES (?, ?, ?, ?)",
		u.Email, u.PasswordHash, u.GoogleID, time.Now(),
	)
	if err != nil {
		return fmt.Errorf("create user: %w", err)
	}
	id, err := res.LastInsertId()
	if err != nil {
		return fmt.Errorf("get last insert id: %w", err)
	}
	u.ID = int(id)
	return nil
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) {
	var u User
	err := r.db.QueryRowContext(ctx,
		"SELECT id, email, password_hash, google_id, created_at FROM users WHERE email = ?", email,
	).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)

	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrUserNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("find user by email: %w", err)
	}
	return &u, nil
}

func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
	var u User
	err := r.db.QueryRowContext(ctx,
		"SELECT id, email, password_hash, google_id, created_at FROM users WHERE id = ?", id,
	).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.GoogleID, &u.CreatedAt)

	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrUserNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("find user by id: %w", err)
	}
	return &u, nil
}

FindByEmail is used during login (users log in with an email, not an ID). FindByID is used later once sessions store just the user's ID (Lesson 6+).

Update cmd/api/main.go — run the migration and construct the repository on startup:

	if err := database.Migrate(ctx, db); err != nil {
		logger.Error("failed to migrate database", "error", err)
		os.Exit(1)
	}
	logger.Info("database migrated")

	userRepo := models.NewUserRepository(db)
	_ = userRepo // used starting Lesson 5 - silences "declared but not used" for now

(Add "git.hamidsoltani.com/hamid/go-simple-api/internal/models" to the import block.)

_ = userRepo — remember from Go Basics Part 1, the blank identifier _ discards a value so the compiler doesn't complain about an unused variable. We're not wiring userRepo into any handler yet (that's Lesson 5), so this line is a temporary placeholder — delete it once userRepo is actually passed into router.New(...).

Try it

go run ./cmd/api

Check your logs for "database migrated", then confirm the table exists:

docker exec -it mysql-api mysql -uroot -pdevpass go_simple_api -e "DESCRIBE users;"

Once both parts run, move to Lesson 5 — password-based register/login with bcrypt, which is where userRepo finally gets used for real.