3-2. CRUD とトランザクション - database/sql 実戦編

所要時間: 45-60分 コミット内容: ~/learn/go/level3/day02/ に repository パターンの実装


このレッスンのゴール

  • QueryContext / QueryRowContext / ExecContext / PrepareContext を使い分けられる
  • defer rows.Close() + rows.Err() チェックで接続リーク防止
  • プレースホルダで SQL injection を完全防御
  • BeginTx + 名前付き戻り値 + defer の Rollback パターンが書ける
  • N+1 問題を JOIN または IN クエリで回避できる
  • sql.ErrNoRows をドメイン層の ErrNotFound に変換

なぜ学ぶか

CRUD は書けるが、5日後に DB が詰まる」「N+1で本番が落ちる」「SQL injection に脆弱」 - すべて本章で防げる。CRUD コードを書くたびに3つ問う癖: (1) SQL injection 大丈夫? (2) rows.Close() 書いた? (3) Tx 境界正しい?。これが脳内チェックリストに入ると「事故らない CRUD」が書ける。

前章とのつながり

3-1_database_sql*sql.DB を作った。本章でそれを 実際に使って CRUD を書く2-6_パッケージ分割Repository インターフェースの 本物の実装(PostgresRepo/MySQLRepo)。

これができると何が嬉しいか

  • SQL injection を1ミリも書かない コードがレビューで指摘されない
  • N+1 が一目で分かる - ループ内 DB クエリを見たら警戒
  • トランザクション境界が脳内モデル - お金の移動でも安心
  • Repository インターフェース で本番 DB なしに service テストできる

ストーリー導入: rows.Close() を忘れた1行が5日後に DB を殺す

for rows.Next() でループ。ある条件で early return。defer rows.Close() を書き忘れた。1リクエストで1接続漏れ。100req/s で1分=6000接続。5分後、MaxOpenConns 食い潰し → 全クエリ待ち行列 → 全リクエスト 30秒タイムアウト → サービス停止。接続リークは「ローカルで発覚しない、本番で5日後に出る」事故。本章で身につけるのは「defer rows.Close() を err チェック直後に書く」反射神経。


大前提: SQL を書く時に常に問う3つの問い

CRUD コードを書くたびに、自分にこう問うクセを付けるとレベルが一段上がります。

  1. 入力は SQL injection 対策できているか? — 文字列連結は1箇所もないか
  2. rows を必ず Close しているか? — リソースリークは即発見されにくく、本番で5日後に DB が詰まる
  3. トランザクションの境界は正しいか? — エラー時に Rollback、成功時に Commit、これが defer で漏れていないか

このレッスンは「書ける」ようになるのが目的ではなく、書けるようになった上で事故らないことが目的です。


セッション①: 基本の4関数とプレースホルダ(25-30分)

0. テーブル準備

-- MySQL の例
CREATE TABLE users (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    email      VARCHAR(255) NOT NULL UNIQUE,
    name       VARCHAR(100) NOT NULL,
    bio        TEXT,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
 
CREATE TABLE posts (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id    BIGINT UNSIGNED NOT NULL,
    title      VARCHAR(200) NOT NULL,
    body       TEXT NOT NULL,
    published  BOOLEAN NOT NULL DEFAULT FALSE,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_user_id (user_id)
);

1. 4つの実行関数を使い分ける

// 単一行の取得
var u User
err := db.QueryRowContext(ctx,
    "SELECT id, email, name FROM users WHERE id = ?", id).
    Scan(&u.ID, &u.Email, &u.Name)
 
// 複数行の取得
rows, err := db.QueryContext(ctx,
    "SELECT id, email, name FROM users WHERE created_at > ?", since)
if err != nil {
    return nil, err
}
defer rows.Close() // 必須
 
var users []User
for rows.Next() {
    var u User
    if err := rows.Scan(&u.ID, &u.Email, &u.Name); err != nil {
        return nil, err
    }
    users = append(users, u)
}
if err := rows.Err(); err != nil { // ループ後に必ずチェック
    return nil, err
}
 
// INSERT / UPDATE / DELETE
res, err := db.ExecContext(ctx,
    "INSERT INTO users (email, name) VALUES (?, ?)", email, name)
if err != nil {
    return 0, err
}
id, _ := res.LastInsertId()
affected, _ := res.RowsAffected()

4つの関数とその意図

関数いつ使う戻り値
QueryContext複数行を SELECT*sql.Rows
QueryRowContext1行だけ SELECT(PK・UNIQUE 引きなど)*sql.Row
ExecContextINSERT / UPDATE / DELETEsql.Result(LastInsertId, RowsAffected)
PrepareContext同じクエリを大量に繰り返す時*sql.Stmt

全部 Context 付きを使うこと。context 無し版(Query / QueryRow / Exec)はキャンセル不能で、スローSQL を踏むとリクエストがタイムアウトしても DB側で動き続ける。

rows.Close() を忘れると本番が詰む

*sql.Rows は内部で 接続プールから1つの接続を握ったまま イテレーションする。rows.Close() を呼ばずにループを抜けると、その接続は永久にプールに戻らない。

100リクエスト/秒のアプリで Close を漏らすと、5分後に MaxOpenConns を食い潰して全クエリが待ち行列に積まれる。defer を最初に書く

rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
    return err
}
defer rows.Close() // ← err チェックの直後に必ず書く

ただし、ループを最後まで回せば rows.Next() が false を返したタイミングで内部的に Close される。defer rows.Close() は「途中で return / error した時の保険」として効く。ループ完走と defer の二重防御 が定石。

rows.Err() を忘れない

for rows.Next() {
    // Scan処理
}
if err := rows.Err(); err != nil {
    return err // ← これを書かないと、イテレーション中のエラーを見逃す
}

rows.Next()false を返した時、それは「全行読み終わった」のか「途中でエラーが起きた」のか区別できない。rows.Err() で必ず確認 する。

2. プレースホルダ(Prepared Statement の入口)

// 正しい: プレースホルダを使う
db.QueryRowContext(ctx, "SELECT id FROM users WHERE email = ?", email)
 
// MySQL/SQLite は ?、PostgreSQL は $1, $2 ...
db.QueryRowContext(ctx, "SELECT id FROM users WHERE email = $1", email)

プレースホルダ( ?$1)の本質

プレースホルダは「ここに値を入れる」というマーカー。SQL構文と値が完全に分離される ので、値部分に '; DROP TABLE users; -- のような攻撃文字列が含まれていてもデータとして扱われる。

内部的には:

  1. ドライバが SQL を DB サーバーに送って コンパイル(パース・実行計画作成)
  2. 値だけを別途送って バインド
  3. DB はコンパイル済みクエリを実行

値は構文の一部にならない。つまり、プレースホルダ = SQL injection 完全防御(プレースホルダで渡せる部分について)。

文字列連結は SQL injection の入口

// 絶対NG
q := "SELECT id FROM users WHERE email = '" + email + "'"
db.QueryRowContext(ctx, q)

攻撃者が email = "x' OR '1'='1" を送ると:

SELECT id FROM users WHERE email = 'x' OR '1'='1'

全ユーザーが引かれる。これは「2000年代のWebセキュリティ事故の最頻出パターン」で、いまだに新規プロダクトで起きている。

「自分は信用できる入力源からしか取らない」と思ってはいけない。コードが寿命を迎えるまでに、必ずどこかから不審な入力が流れ込む。プレースホルダ以外は受け付けない、と決め打ちする。

例外: テーブル名・カラム名はプレースホルダで渡せない。動的にカラム名を組み立てる場合は ホワイトリスト検証 が必要。

allowedColumns := map[string]bool{"name": true, "created_at": true}
if !allowedColumns[sortBy] {
    return errors.New("不正なソートキー")
}
q := fmt.Sprintf("SELECT * FROM users ORDER BY %s", sortBy)

3. NULL 対応

// NULL を含む可能性のあるカラム
var bio sql.NullString
err := db.QueryRowContext(ctx,
    "SELECT bio FROM users WHERE id = ?", id).Scan(&bio)
 
if bio.Valid {
    fmt.Println(bio.String)
}
 
// もう一つの選択肢: ポインタ
var bioPtr *string
err = db.QueryRowContext(ctx, "...").Scan(&bioPtr)
if bioPtr != nil {
    fmt.Println(*bioPtr)
}

NULL を Go の型に受ける2つの方法

方法特徴
sql.NullXxxsql.NullStringsql.NullInt64sql.NullTimeValid bool で判定。標準パッケージ。
ポインタ型*string*int64*time.Timenil 判定で判別。JSONエンコードと相性が良い

JSON API のレスポンスにそのまま流したいなら ポインタ型 が便利。sql.NullStringMarshalJSON を独自実装してくれないので {"Valid":true,"String":"..."} のような変な形になる(Go 1.18 以降は database/sql 側で改善されたが、それでも素直なフィールドにはならない)。

Postgres + pgx ならネイティブAPIで pgtype.Text などより表現力のある型が使える。

アンチパターン: 単純な string で受けて NULL を踏む

var bio string
err := db.QueryRowContext(ctx, "SELECT bio FROM users WHERE id = ?", id).Scan(&bio)
// bio が NULL の行で sql: Scan error on column index 0

NULL を非ポインタ型に Scan しようとするとエラー。bio カラムが NULL 許容なら必ず sql.NullString*string


セッション②: トランザクション・バルク・N+1(25-30分)

4. トランザクション

func TransferFunds(ctx context.Context, db *sql.DB, from, to int64, amount int64) (err error) {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin: %w", err)
    }
    defer func() {
        if err != nil {
            _ = tx.Rollback()
        }
    }()
 
    if _, err = tx.ExecContext(ctx,
        "UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, from); err != nil {
        return err
    }
    if _, err = tx.ExecContext(ctx,
        "UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, to); err != nil {
        return err
    }
    return tx.Commit()
}

トランザクションは「全部成功か、全部なかったことか」を保証する仕組み

銀行振込の例: 「from の残高を減らす」+「to の残高を増やす」の 両方が成功したら永続化、片方失敗なら両方なかったことに

Go では以下の3ステップ:

  1. db.BeginTx(ctx, nil) でトランザクション開始 → *sql.Tx を取得
  2. その *sql.Tx 経由でクエリ実行(tx.ExecContext, tx.QueryContext …)
  3. 成功なら tx.Commit()、失敗なら tx.Rollback()

ポイント: *sql.DB のメソッドを使ったクエリは別のコネクションで実行されるので、トランザクションに含まれない。必ず tx 経由

名前付き戻り値 + defer の rollback パターン

上のコード例は Go の名前付き戻り値とdeferの組み合わせ の代表例。

  • 関数シグネチャの (err error)err を名前付き戻り値にする
  • defer の中でその err を参照
  • エラーで return された時だけ Rollback する

明示的に if err != nil { tx.Rollback(); return err } を書いてもいいが、忘れやすい。defer に集約する ことで漏れを防ぐ。Go コミュニティの定石。

アンチパターン: エラー時に Commit してしまう

// NG
_, err := tx.Exec("UPDATE ...")
tx.Commit() // err が non-nil でも commit してる
if err != nil {
    return err
}

部分成功でデータが壊れる。Commit 前に必ず err チェック、もしくは defer + 名前付き戻り値で集約。

トランザクションの分離レベル

BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) で分離レベルを指定できる。

  • READ UNCOMMITTED: 他の未コミットを読める。事実上使わない
  • READ COMMITTED: コミット済みを読む。PostgreSQL のデフォルト
  • REPEATABLE READ: トランザクション中は同じ読み結果。MySQL InnoDB のデフォルト
  • SERIALIZABLE: 直列化。最強だが競合多発で遅い

ほとんどの場合デフォルトでよい。お金や在庫の更新では SERIALIZABLE か、SELECT … FOR UPDATE で行ロック

5. Prepared Statement(再利用と性能)

stmt, err := db.PrepareContext(ctx,
    "INSERT INTO logs (user_id, action, created_at) VALUES (?, ?, NOW())")
if err != nil {
    return err
}
defer stmt.Close()
 
for _, log := range logs {
    if _, err := stmt.ExecContext(ctx, log.UserID, log.Action); err != nil {
        return err
    }
}

Prepare の意義は2つ

  1. SQL injection 防止: プレースホルダ使うので QueryContext と同じ防御効果
  2. 性能: 同じクエリを繰り返し実行する時、DB側でパース・最適化のキャッシュが効く

ただし、db.QueryContext("...", args) を毎回呼んでも内部で自動的に Prepare/Execute されるので、1回しか使わないクエリで明示的に Prepare する必要は無い

明示 Prepare のメリット: 同じステートメントを 数千〜数万回回す時(バルク INSERT、ETL処理)。サーバー側キャッシュが効くため、パース回数が劇的に減る。

6. バルク INSERT

// 悪い例: 1行ずつ INSERT
for _, u := range users {
    db.ExecContext(ctx, "INSERT INTO users (email, name) VALUES (?, ?)", u.Email, u.Name)
}
 
// 良い例: 1回でバルク INSERT
func bulkInsert(ctx context.Context, db *sql.DB, users []User) error {
    if len(users) == 0 {
        return nil
    }
 
    placeholders := make([]string, 0, len(users))
    args := make([]any, 0, len(users)*2)
    for _, u := range users {
        placeholders = append(placeholders, "(?, ?)")
        args = append(args, u.Email, u.Name)
    }
 
    q := "INSERT INTO users (email, name) VALUES " + strings.Join(placeholders, ",")
    _, err := db.ExecContext(ctx, q, args...)
    return err
}

バルク INSERT のオーダーが違う

1000 行を 1行ずつ INSERT する場合:

  • パケット往復 1000回 × ネットワーク RTT
  • WAL/binlog 書き出し 1000回

バルク INSERT:

  • パケット往復 1回
  • WAL/binlog 書き出し 1回(場合により)

実測で 20-100倍の差が出る。ETL や初期データ投入で1行ずつ INSERT すると半日かかるところが、バルクなら数分で終わる。

プレースホルダの数の上限

MySQL は1ステートメントあたり 65535 個のプレースホルダ上限がある。PostgreSQL も同様の制約。

カラム N 個 × 行数 M で、N × M がこの上限を超えるとエラー。バッチを 1000行 ずつに区切る のが定石。

PostgreSQL + pgx なら COPY プロトコルが使える。これはさらに桁違いに速く、数百万行を秒で投入できる。

7. N+1 問題

// 悪い例: N+1
posts, _ := getPosts(ctx, db)
for i, p := range posts {
    var u User
    db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", p.UserID).
        Scan(&u.Name)
    posts[i].AuthorName = u.Name
}
// posts 100件なら 1 (posts取得) + 100 (各 user取得) = 101 クエリ

N+1 はバックエンド障害の頂点

1回のリクエストで「1 + N」回 DB クエリを投げてしまうパターン。N が1万件のページで起きると、1リクエストで1万回のクエリになる。

開発環境(データ少ない)では気付かず、本番投入後にユーザーが増えるとレイテンシが線形悪化する。Go の database/sql + ループ + 別クエリ という素朴な書き方で簡単に発生する

ORM だと「Eager Loading(事前ロード)」を1行指定するだけで防げるが、書き手が知らないと N+1 のまま出荷される

// 良い例1: JOIN で1クエリにまとめる
rows, err := db.QueryContext(ctx, `
    SELECT p.id, p.title, p.body, u.id, u.name
    FROM posts p
    INNER JOIN users u ON u.id = p.user_id
    WHERE p.published = TRUE
`)
 
// 良い例2: 2クエリ + in_memory join
posts, _ := getPosts(ctx, db)
 
userIDs := make([]int64, 0, len(posts))
for _, p := range posts {
    userIDs = append(userIDs, p.UserID)
}
users, _ := getUsersByIDs(ctx, db, userIDs) // IN クエリで一括取得
userByID := make(map[int64]User)
for _, u := range users {
    userByID[u.ID] = u
}
for i, p := range posts {
    posts[i].AuthorName = userByID[p.UserID].Name
}

N+1 の3つの回避戦略

戦略適している場面落とし穴
JOIN関連データが小さく、SELECT に簡単に乗る1対多 JOIN は行が膨らむ(直積になる)
IN クエリ + メモリ join1対多、データ量が中規模IN の値が多すぎるとプランナが壊れる(数千個まで)
DataLoader パターンGraphQL や複雑な解決実装複雑化、Go では graph-gophers/dataloader が使える

8. IN クエリの動的バインド

func getUsersByIDs(ctx context.Context, db *sql.DB, ids []int64) ([]User, error) {
    if len(ids) == 0 {
        return nil, nil
    }
 
    placeholders := make([]string, len(ids))
    args := make([]any, len(ids))
    for i, id := range ids {
        placeholders[i] = "?"
        args[i] = id
    }
    q := fmt.Sprintf("SELECT id, name FROM users WHERE id IN (%s)", strings.Join(placeholders, ","))
 
    rows, err := db.QueryContext(ctx, q, args...)
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    // ... 通常の Scan
}

IN クエリは sqlx の In ヘルパーが便利

q, args, err := sqlx.In("SELECT * FROM users WHERE id IN (?)", ids)
q = db.Rebind(q) // ? → $1, $2 ... に変換

database/sql だけだと毎回手作りになるので、ここから sqlx を使う動機が生まれる。

9. Repository パターン

package repository
 
import (
    "context"
    "database/sql"
)
 
type User struct {
    ID    int64
    Email string
    Name  string
}
 
// UserRepository インターフェース。テストでモック差し替え可能
type UserRepository interface {
    GetByID(ctx context.Context, id int64) (*User, error)
    Create(ctx context.Context, u *User) (int64, error)
}
 
// SQLImpl: database/sql 実装
type UserSQLRepo struct {
    DB *sql.DB
}
 
func (r *UserSQLRepo) GetByID(ctx context.Context, id int64) (*User, error) {
    var u User
    err := r.DB.QueryRowContext(ctx,
        "SELECT id, email, name FROM users WHERE id = ?", id).
        Scan(&u.ID, &u.Email, &u.Name)
    if err == sql.ErrNoRows {
        return nil, ErrNotFound
    }
    if err != nil {
        return nil, err
    }
    return &u, nil
}
 
func (r *UserSQLRepo) Create(ctx context.Context, u *User) (int64, error) {
    res, err := r.DB.ExecContext(ctx,
        "INSERT INTO users (email, name) VALUES (?, ?)", u.Email, u.Name)
    if err != nil {
        return 0, err
    }
    return res.LastInsertId()
}

Repository パターンの意義

「ビジネスロジックを DB の都合から分離する」設計パターン。

  • 上位レイヤー(ハンドラ、ユースケース)は interface に依存
  • 実装(SQL書く、Redis書く、メモリ実装)は 下位 で差し替え可能
  • テスト時に メモリ実装モック を注入できる

Go では「インターフェースを定義する場所は使う側」というイディオムがあり、UserRepository インターフェースを domain パッケージに置き、repository パッケージに具象実装を置くのがよくある構成。

3-3_テスト基礎.md / 3-4_テーブル駆動テスト.md で、このインターフェースをモックして単体テストする方法をやる。

sql.ErrNoRows を業務エラーに変換する

QueryRowContext().Scan() は「0件」だった場合 sql.ErrNoRows を返す。これを そのまま上位に伝播させない

var ErrNotFound = errors.New("user not found")
 
if err == sql.ErrNoRows {
    return nil, ErrNotFound
}

理由: ハンドラ側が sql.ErrNoRows をハンドリングすると、DB実装に依存する。Redis 実装に切り替えた瞬間にエラー判定が壊れる。業務ドメインのエラー型 に変換するのが綺麗。

Go 1.13+ なら errors.Is(err, sql.ErrNoRows) で判定するのが推奨。


練習課題

mkdir -p ~/learn/go/level3/day02
cd ~/learn/go/level3/day02
go mod init example.com/crud
  1. SQLite で users テーブルを作成(modernc.org/sqlite を使うと CGO 不要)
  2. UserRepository インターフェースを定義
  3. Create, GetByID, ListByCreatedAfter, UpdateName, Delete を実装
  4. 各メソッドに context.Context を必ず第一引数で渡す
  5. トランザクションで「ユーザー作成 + ログ書き込み」を実装し、ログ書き込みエラー時にユーザー作成も Rollback されることを確認

締め: git で証跡を残す

cd ~/learn/go/level3/day02
git add .
git commit -m "feat(go): repository パターンとトランザクションを実装"

アンチパターン集 - やらかし事例

CRUD 実装のやらかし

1. 文字列連結で SQL を組み立て

q := "SELECT * FROM users WHERE email = '" + email + "'"  // ← SQLi 直行便

プレースホルダ ?$1 必須。

2. rows.Close() 忘れ 接続リークで5日後に本番停止。defer をエラーチェック直後に書く。

3. rows.Err() 忘れ イテレーション中エラーを見逃す。rows.Next() ループ後に必ずチェック。

4. ループ内で個別クエリ(N+1)

for _, p := range posts {
    db.QueryRow("SELECT name FROM users WHERE id=?", p.UserID)  // ← 100件で101クエリ
}

JOIN か IN クエリ + メモリ join に置き換え。

5. Tx エラー時に Commit してしまう

_, err := tx.Exec(...)
tx.Commit()  // ← err 確認前にコミット

名前付き戻り値 + defer Rollback パターンで集約。

6. NULL を非ポインタ型で受ける

var bio string  // NULL 行で Scan エラー

sql.NullString*string で。

7. sql.ErrNoRows を上位に漏らす ハンドラが DB 実装に依存。ErrNotFound に変換。

対比表で違いを明確化

4つの実行関数の選び分け

関数用途戻り値
QueryContext複数行 SELECT*sql.Rows
QueryRowContext1行 SELECT(PK/UNIQUE)*sql.Row
ExecContextINSERT/UPDATE/DELETEsql.Result
PrepareContext大量繰り返し*sql.Stmt

NULL の受け方

方法用途
sql.NullStringValid bool で判定標準パッケージで完結
ポインタ *stringnil 判定JSON との相性◎
pgtype(pgx)pgtype.TextPostgreSQL ネイティブ

N+1 回避の3戦略

戦略適用落とし穴
JOIN関連データ小、1対11対多で行膨らみ
IN クエリ + メモリ join1対多、中規模IN 値が数千で遅化
DataLoaderGraphQL実装複雑

自己評価チェックリスト

手を動かせた

  • QueryRowContext().Scan() で1行取得
  • QueryContext + defer rows.Close() + rows.Err()
  • ExecContext + LastInsertId() / RowsAffected()
  • BeginTx + 名前付き戻り値 + defer Rollback
  • バルク INSERT(プレースホルダ動的展開)
  • IN クエリで N+1 を解消
  • sql.NullString で NULL を受けた
  • errors.Is(err, sql.ErrNoRows) で 0 件判定

説明できる

  • プレースホルダが SQL injection を防ぐ仕組み
  • rows.Close() を書かないと本番がどう壊れるか
  • トランザクション分離レベル4種(READ COMMITTED 等)
  • N+1 の3戦略の使い分け

やらかし回避

  • 文字列連結で SQL 組み立てない
  • defer rows.Close() を err チェック直後に
  • エラー時に Commit しない
  • DB 実装エラー (sql.ErrNoRows) を上位に漏らさない

詰まった時のチートシート

やりたいこと書き方
1行 SELECTdb.QueryRowContext(ctx, q, args...).Scan(&dest)
複数行 SELECTdb.QueryContext(ctx, q, args...) + for rows.Next() + defer rows.Close()
INSERT/UPDATE/DELETEdb.ExecContext(ctx, q, args...)
LastInsertIdres.LastInsertId()
TX 開始tx, _ := db.BeginTx(ctx, nil)
Commit/Rollbacktx.Commit() / tx.Rollback()
NULL対応sql.NullString*string
0件チェックerrors.Is(err, sql.ErrNoRows)

「実務OK」基準

  • SQL injection を1ミリも書かない: 文字列連結を見たら即指摘できる
  • N+1 を見抜ける: ループ内で DB クエリを見たら警戒する
  • トランザクション境界を語れる: なぜここで Begin、ここで Commit/Rollback か
  • 接続リークしない: rows.Close + tx.Rollback を反射的に書ける
  • Repository をモックして単体テストできる構造を組める

さらに深掘りするなら


次のレッスン

3-3 testingパッケージ で testing パッケージの基礎、t.Run、ベンチマーク、t.Parallel の落とし穴を扱う。

つながりの予告

  • 本章の UserRepository インターフェースを モック実装 で次章テスト
  • errors.Is(err, ErrNotFound) の判定が次章のアサーションで再活用
  • トランザクションは 3-4_テーブル駆動テスト で「副作用ありテスト」設計に発展