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,
QueryRowContextvsQueryContext. Make sure you've done the pointers section of00-go-basics-2-functions-structs-pointers.mdand the errors section of00-go-basics-3-...mdbefore this lesson — everything here depends on both.
Quick pointer refresher, applied
Two rules from Go Basics you'll use constantly in this lesson:
- 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 = ...). - 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:
Bookstruct has zero database knowledge — it's pure data. Your handlers/business logic will work withBook, never with raw SQL rows directly.BookRepositorywraps*sql.DBand 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;NewXxxreturning*Xxxis purely a naming convention, but the entire ecosystem follows it, so you should too.func (r *BookRepository) Create(...)— pointer receiver, per the refresher above.ris the repository itself; inside,r.dbaccesses the wrapped connection pool.b.ID = int(id)— sinceCreatetakesb *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(singularRow) is for when you expect exactly one result, like a lookup by ID. It skips therows.Next()/rows.Close()dance from Lesson 3 since there's at most one row.errors.Is(err, sql.ErrNoRows)—sql.ErrNoRowsis the driver's own sentinel error for "query matched zero rows." We translate it into our ownErrNotFoundso callers ofFindByIDdon'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 checkerrors.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.