Files
go-simple-api/lessons/lesson-04-user-model-repository-pattern.md
T

341 lines
10 KiB
Markdown
Raw Normal View History

2026-07-16 10:13:46 +03:30
# 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:
```bash
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`**
```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:
```bash
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:
```go
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):
```go
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`:
```go
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:
```go
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
```bash
go run ./cmd/api
```
Check your logs for `"database migrated"`, then confirm the table exists:
```bash
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.