【第4回】データベース連携 ── database/sql・トランザクション・sqlc・テスト

Go
B!

シリーズ構成

  1. Go の基本文法
  2. CLI ツールを作る
  3. HTTP サーバーと REST API
  4. データベース連携(本記事)
  5. 実務的な周辺技術
  6. ポートフォリオを作る
  7. 実践知識を深める

この回の目標は 「PostgreSQL に接続し、安全で保守しやすいデータアクセス層を書けるようになる」 ことです。第3回のメモリストアを DB に置き換えます。

バックエンド開発の時間の多くはデータアクセスに費やされます。ここを丁寧に押さえることが、実務での生産性を直接決めます。


  1. 目次
  2. 1. 準備:PostgreSQL を起動する
  3. 2. スキーマとマイグレーション
    1. golang-migrate
    2. マイグレーションの原則
    3. Go からマイグレーションを実行する
  4. 3. database/sql の全体像
  5. 4. 接続とコネクションプール
    1. プールの考え方
    2. ヘルスチェックに組み込む
  6. 5. クエリの実行:Exec・QueryRow・Query
    1. 3 つの使い分け
    2. プレースホルダは絶対
    3. Exec
    4. QueryRow
    5. Query
    6. RETURNING で挿入した行を受け取る
  7. 6. NULL の扱い
    1. sql.Null 型
    2. ポインタで受ける
    3. COALESCE で DB 側で解決
  8. 7. リポジトリ層の実装
    1. 設計の要点
    2. ユーザーリポジトリ
  9. 8. トランザクション
    1. 基本形
    2. トランザクションヘルパー
    3. リポジトリをトランザクション対応にする
    4. 分離レベル
  10. 9. リポジトリをインターフェースにして HTTP と繋ぐ
    1. レスポンス用の型を分ける
    2. モックで HTTP 層をテストする
  11. 10. sqlc:SQL からコード生成
    1. 設定
    2. クエリを書く
    3. 生成されるコード
    4. 使い方
    5. sqlc の長所と短所
  12. 11. pgx をネイティブで使う
  13. 12. ORM(GORM)との比較
  14. 13. DB を含めたテスト
    1. 方針 A:Docker Compose で立てた DB を使う
    2. 方針 B:testcontainers でテストごとにコンテナを起動
    3. 方針 C:トランザクションで包んで Rollback
    4. テストで確認すべきこと
  15. 14. パフォーマンスの基本
    1. EXPLAIN ANALYZE を見る癖
    2. N+1 問題
    3. ページネーション
    4. プリペアドステートメント
    5. コネクション枯渇の検出
  16. 15. よくある落とし穴
  17. 16. 演習問題
    1. 基礎
    2. 中級
    3. 応用
    4. 次回予告

目次

  1. 準備:PostgreSQL を起動する
  2. スキーマとマイグレーション
  3. database/sql の全体像
  4. 接続とコネクションプール
  5. クエリの実行:Exec・QueryRow・Query
  6. NULL の扱い
  7. リポジトリ層の実装
  8. トランザクション
  9. リポジトリをインターフェースにして HTTP と繋ぐ
  10. sqlc:SQL からコード生成
  11. pgx をネイティブで使う
  12. ORM(GORM)との比較
  13. DB を含めたテスト
  14. パフォーマンスの基本
  15. よくある落とし穴
  16. 演習問題

1. 準備:PostgreSQL を起動する

Docker で立てるのが最も簡単です。

docker run -d --name pg \
  -e POSTGRES_USER=app \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=todo \
  -p 5432:5432 \
  postgres:16-alpine

# 接続確認
docker exec -it pg psql -U app -d todo -c "SELECT version();"

接続文字列(DSN)は環境変数に入れておきます。

export DATABASE_URL="postgres://app:secret@localhost:5432/todo?sslmode=disable"

MySQL を使いたい場合:ドライバを github.com/go-sql-driver/mysql に、プレースホルダを $1 → ? に変えるだけで、この記事の内容はほぼそのまま使えます。


2. スキーマとマイグレーション

スキーマは バージョン管理された SQL ファイル で管理します。「手で CREATE TABLE を打つ」は個人開発でも避けてください。

golang-migrate

brew install golang-migrate
# または
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
mkdir -p db/migrations
migrate create -ext sql -dir db/migrations -seq create_users
migrate create -ext sql -dir db/migrations -seq create_todos

db/migrations/000001_create_users.up.sql

CREATE TABLE users (
    id            BIGSERIAL PRIMARY KEY,
    email         TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

db/migrations/000001_create_users.down.sql

DROP TABLE users;

db/migrations/000002_create_todos.up.sql

CREATE TABLE todos (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title       TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
    description TEXT,
    done        BOOLEAN NOT NULL DEFAULT FALSE,
    due_date    DATE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_todos_user_id ON todos(user_id);
CREATE INDEX idx_todos_user_done ON todos(user_id, done);

db/migrations/000002_create_todos.down.sql

DROP TABLE todos;
migrate -database "$DATABASE_URL" -path db/migrations up
migrate -database "$DATABASE_URL" -path db/migrations version   # 現在のバージョン
migrate -database "$DATABASE_URL" -path db/migrations down 1    # 1 つ戻す

マイグレーションの原則

  • up と down を必ずペアで書く
  • 一度適用したファイルは変更しない。修正は新しいマイグレーションで
  • 本番では down を安易に実行しない(データが消える)
  • 大きなテーブルのカラム追加は NOT NULL DEFAULT の付け方でロック時間が変わる(PostgreSQL 11+ はデフォルト値つき追加が高速)
  • カラム名は snake_case、時刻は TIMESTAMPTZ

Go からマイグレーションを実行する

アプリ起動時に自動適用したい場合はライブラリとして使えます。

import (
    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

func runMigrations(dsn string) error {
    m, err := migrate.New("file://db/migrations", dsn)
    if err != nil {
        return err
    }
    defer m.Close()
    if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
        return err
    }
    return nil
}

チーム開発では CI/CD のデプロイステップで実行するのが一般的です。


3. database/sql の全体像

database/sql は ドライバに依存しない共通インターフェース です。

アプリコード
    ↓
database/sql(標準)── 接続プール、トランザクション、プレースホルダ
    ↓
ドライバ(pgx / go-sql-driver/mysql / ...)
    ↓
DB

ドライバは ブランクインポート で登録します。

go get github.com/jackc/pgx/v5
import (
    "database/sql"

    _ "github.com/jackc/pgx/v5/stdlib" // init() で "pgx" ドライバが登録される
)

db, err := sql.Open("pgx", dsn)

主な型:

型役割
*sql.DB接続プール。アプリ全体で 1 つ。goroutine 安全
*sql.Txトランザクション
*sql.Rows複数行の結果。必ず Close
*sql.Row1 行の結果
sql.ResultExec の結果(影響行数)
*sql.Stmtプリペアドステートメント

4. 接続とコネクションプール

sql.Open は 接続しません。最初のクエリ時に接続が張られます。起動時に Ping で確認します。

package repository

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

    _ "github.com/jackc/pgx/v5/stdlib"
)

func Open(ctx context.Context, dsn string) (*sql.DB, error) {
    db, err := sql.Open("pgx", dsn)
    if err != nil {
        return nil, fmt.Errorf("open db: %w", err)
    }

    // プール設定
    db.SetMaxOpenConns(25)                  // 同時接続の上限
    db.SetMaxIdleConns(25)                  // 待機接続数
    db.SetConnMaxLifetime(5 * time.Minute)  // 接続の寿命(LB や DB 再起動対策)
    db.SetConnMaxIdleTime(5 * time.Minute)

    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    if err := db.PingContext(ctx); err != nil {
        db.Close()
        return nil, fmt.Errorf("ping db: %w", err)
    }
    return db, nil
}

プールの考え方

  • *sql.DB は アプリ起動時に 1 つ作り、全体で共有 する。リクエストごとに Open しない
  • MaxOpenConns は DB 側の max_connections(PostgreSQL デフォルト 100)をアプリのインスタンス数で割った値以下にする
  • MaxIdleConns を MaxOpenConns と同じにすると、接続の張り直しが減る
  • ConnMaxLifetime を設定しないと、ネットワーク機器側で切られた接続を使い続けてエラーになることがある

ヘルスチェックに組み込む

mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()
    if err := db.PingContext(ctx); err != nil {
        writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "db unavailable"})
        return
    }
    writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})

5. クエリの実行:Exec・QueryRow・Query

3 つの使い分け

メソッド用途戻り値
ExecContextINSERT / UPDATE / DELETE(結果行が不要)sql.Result
QueryRowContext1 行だけ取得*sql.Row
QueryContext複数行取得*sql.Rows

必ず Context 付きのメソッドを使います。リクエストがキャンセルされたらクエリも止まります。

プレースホルダは絶対

// ✗ SQL インジェクション
query := "SELECT * FROM users WHERE email = '" + email + "'"

// ✓ プレースホルダ(PostgreSQL は $1, MySQL は ?)
db.QueryRowContext(ctx, "SELECT * FROM users WHERE email = $1", email)

fmt.Sprintf で SQL を組み立てるコードを見たら、それがどんな理由でも直してください。

Exec

res, err := db.ExecContext(ctx,
    `UPDATE todos SET done = TRUE, updated_at = NOW() WHERE id = $1 AND user_id = $2`,
    id, userID,
)
if err != nil {
    return fmt.Errorf("complete todo: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
    return err
}
if n == 0 {
    return ErrNotFound // 対象がなかった
}

QueryRow

var t Todo
err := db.QueryRowContext(ctx,
    `SELECT id, user_id, title, done, created_at FROM todos WHERE id = $1`, id,
).Scan(&t.ID, &t.UserID, &t.Title, &t.Done, &t.CreatedAt)

if errors.Is(err, sql.ErrNoRows) {
    return Todo{}, ErrNotFound
}
if err != nil {
    return Todo{}, fmt.Errorf("get todo %d: %w", id, err)
}

Scan の引数は SELECT の列と同じ順・同じ数 でなければなりません。SELECT * を使うとカラム追加で壊れるので、列名を明示 します。

Query

rows, err := db.QueryContext(ctx,
    `SELECT id, user_id, title, done, created_at
       FROM todos
      WHERE user_id = $1
      ORDER BY created_at DESC
      LIMIT $2 OFFSET $3`,
    userID, limit, offset,
)
if err != nil {
    return nil, fmt.Errorf("list todos: %w", err)
}
defer rows.Close() // 忘れると接続が返却されない

var todos []Todo
for rows.Next() {
    var t Todo
    if err := rows.Scan(&t.ID, &t.UserID, &t.Title, &t.Done, &t.CreatedAt); err != nil {
        return nil, fmt.Errorf("scan todo: %w", err)
    }
    todos = append(todos, t)
}
if err := rows.Err(); err != nil { // ループ中のエラーを確認
    return nil, fmt.Errorf("iterate todos: %w", err)
}
return todos, nil

rows.Close()、rows.Err()、Scan の順序 はテンプレートとして覚えます。

RETURNING で挿入した行を受け取る

var t Todo
err := db.QueryRowContext(ctx,
    `INSERT INTO todos (user_id, title) VALUES ($1, $2)
     RETURNING id, user_id, title, done, created_at`,
    userID, title,
).Scan(&t.ID, &t.UserID, &t.Title, &t.Done, &t.CreatedAt)

PostgreSQL では RETURNING で INSERT と SELECT を 1 往復にできます。MySQL では LastInsertId() を使います。


6. NULL の扱い

Go の string に SQL の NULL は入りません。Scan するとエラーになります。

sql.Null 型

type Todo struct {
    ID          int64
    Title       string
    Description sql.NullString  // NULL 許容
    DueDate     sql.NullTime
}

// 読み取り
if t.Description.Valid {
    fmt.Println(t.Description.String)
}

// 書き込み
desc := sql.NullString{String: "memo", Valid: true}
db.ExecContext(ctx, `UPDATE todos SET description = $1`, desc)

Go 1.22 からはジェネリックな sql.Null[T] もあります。

ポインタで受ける

JSON との相性が良いので、API 層に近いところではこちらが便利です。

type Todo struct {
    Description *string    `json:"description"` // nil → null
    DueDate     *time.Time `json:"due_date"`
}

var desc *string
rows.Scan(&t.ID, &desc)  // NULL なら nil のまま

COALESCE で DB 側で解決

SELECT id, COALESCE(description, '') AS description FROM todos

方針:スキーマ設計時に なるべく NOT NULL DEFAULT にする。NULL が本当に意味を持つ(「未設定」と「空」を区別する)場合だけ NULL 許容にします。


7. リポジトリ層の実装

SQL を書く場所を リポジトリ に集約します。ハンドラやサービスから SQL が見えない状態を作ります。

// internal/repository/todo.go
package repository

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

type Todo struct {
    ID          int64
    UserID      int64
    Title       string
    Description *string
    Done        bool
    DueDate     *time.Time
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

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

type TodoRepository struct {
    db *sql.DB
}

func NewTodoRepository(db *sql.DB) *TodoRepository {
    return &TodoRepository{db: db}
}

const todoColumns = `id, user_id, title, description, done, due_date, created_at, updated_at`

// scanTodo は行を Todo に詰める共通処理
func scanTodo(s interface{ Scan(...any) error }) (Todo, error) {
    var t Todo
    err := s.Scan(&t.ID, &t.UserID, &t.Title, &t.Description, &t.Done,
        &t.DueDate, &t.CreatedAt, &t.UpdatedAt)
    return t, err
}

type CreateTodoParams struct {
    UserID      int64
    Title       string
    Description *string
    DueDate     *time.Time
}

func (r *TodoRepository) Create(ctx context.Context, p CreateTodoParams) (Todo, error) {
    row := r.db.QueryRowContext(ctx,
        `INSERT INTO todos (user_id, title, description, due_date)
         VALUES ($1, $2, $3, $4)
         RETURNING `+todoColumns,
        p.UserID, p.Title, p.Description, p.DueDate,
    )
    t, err := scanTodo(row)
    if err != nil {
        return Todo{}, fmt.Errorf("create todo: %w", err)
    }
    return t, nil
}

func (r *TodoRepository) Get(ctx context.Context, userID, id int64) (Todo, error) {
    row := r.db.QueryRowContext(ctx,
        `SELECT `+todoColumns+` FROM todos WHERE id = $1 AND user_id = $2`,
        id, userID,
    )
    t, err := scanTodo(row)
    if errors.Is(err, sql.ErrNoRows) {
        return Todo{}, ErrNotFound
    }
    if err != nil {
        return Todo{}, fmt.Errorf("get todo %d: %w", id, err)
    }
    return t, nil
}

type ListTodosParams struct {
    UserID int64
    Done   *bool // nil なら絞り込みなし
    Limit  int
    Offset int
}

func (r *TodoRepository) List(ctx context.Context, p ListTodosParams) ([]Todo, error) {
    rows, err := r.db.QueryContext(ctx,
        `SELECT `+todoColumns+`
           FROM todos
          WHERE user_id = $1
            AND ($2::boolean IS NULL OR done = $2)
          ORDER BY created_at DESC, id DESC
          LIMIT $3 OFFSET $4`,
        p.UserID, p.Done, p.Limit, p.Offset,
    )
    if err != nil {
        return nil, fmt.Errorf("list todos: %w", err)
    }
    defer rows.Close()

    todos := make([]Todo, 0) // nil ではなく空スライス(JSON で [] にする)
    for rows.Next() {
        t, err := scanTodo(rows)
        if err != nil {
            return nil, fmt.Errorf("scan todo: %w", err)
        }
        todos = append(todos, t)
    }
    return todos, rows.Err()
}

func (r *TodoRepository) Count(ctx context.Context, userID int64, done *bool) (int, error) {
    var n int
    err := r.db.QueryRowContext(ctx,
        `SELECT COUNT(*) FROM todos WHERE user_id = $1 AND ($2::boolean IS NULL OR done = $2)`,
        userID, done,
    ).Scan(&n)
    return n, err
}

type UpdateTodoParams struct {
    UserID      int64
    ID          int64
    Title       string
    Description *string
    Done        bool
    DueDate     *time.Time
}

func (r *TodoRepository) Update(ctx context.Context, p UpdateTodoParams) (Todo, error) {
    row := r.db.QueryRowContext(ctx,
        `UPDATE todos
            SET title = $3, description = $4, done = $5, due_date = $6, updated_at = NOW()
          WHERE id = $1 AND user_id = $2
         RETURNING `+todoColumns,
        p.ID, p.UserID, p.Title, p.Description, p.Done, p.DueDate,
    )
    t, err := scanTodo(row)
    if errors.Is(err, sql.ErrNoRows) {
        return Todo{}, ErrNotFound
    }
    if err != nil {
        return Todo{}, fmt.Errorf("update todo %d: %w", p.ID, err)
    }
    return t, nil
}

func (r *TodoRepository) Delete(ctx context.Context, userID, id int64) error {
    res, err := r.db.ExecContext(ctx,
        `DELETE FROM todos WHERE id = $1 AND user_id = $2`, id, userID)
    if err != nil {
        return fmt.Errorf("delete todo %d: %w", id, err)
    }
    if n, _ := res.RowsAffected(); n == 0 {
        return ErrNotFound
    }
    return nil
}

設計の要点

  • user_id を常に WHERE に含める:他人のデータを触れないようにする(IDOR 脆弱性対策)
  • 引数が 3 つ以上なら Params 構造体:順序ミスを防ぎ、拡張しやすい
  • $2::boolean IS NULL OR done = $2:任意フィルタを 1 つの SQL で実現する PostgreSQL の書き方
  • エラーには操作名と ID を含める:ログから何が失敗したか追える
  • sql.ErrNoRows はリポジトリ内で ErrNotFound に変換:上の層が database/sql を import しなくてよい

ユーザーリポジトリ

// internal/repository/user.go
type User struct {
    ID           int64
    Email        string
    PasswordHash string
    CreatedAt    time.Time
}

var ErrEmailTaken = errors.New("email already taken")

type UserRepository struct{ db *sql.DB }

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

func (r *UserRepository) Create(ctx context.Context, email, passwordHash string) (User, error) {
    var u User
    err := r.db.QueryRowContext(ctx,
        `INSERT INTO users (email, password_hash) VALUES ($1, $2)
         RETURNING id, email, password_hash, created_at`,
        email, passwordHash,
    ).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.CreatedAt)

    if err != nil {
        // UNIQUE 制約違反を判別する
        var pgErr *pgconn.PgError
        if errors.As(err, &pgErr) && pgErr.Code == "23505" {
            return User{}, ErrEmailTaken
        }
        return User{}, fmt.Errorf("create user: %w", err)
    }
    return u, nil
}

func (r *UserRepository) GetByEmail(ctx context.Context, email string) (User, error) {
    var u User
    err := r.db.QueryRowContext(ctx,
        `SELECT id, email, password_hash, created_at FROM users WHERE email = $1`, email,
    ).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.CreatedAt)
    if errors.Is(err, sql.ErrNoRows) {
        return User{}, ErrNotFound
    }
    return u, err
}

pgconn.PgError は github.com/jackc/pgx/v5/pgconn にあります。エラーコード 23505 は unique_violation。DB 制約をアプリのエラーに変換する定番パターンです。


8. トランザクション

複数の書き込みを すべて成功 or すべて失敗 にしたいときに使います。

基本形

func (r *TodoRepository) MoveAll(ctx context.Context, fromUser, toUser int64) error {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback() // Commit 後の Rollback は無害(ErrTxDone を返すだけ)

    if _, err := tx.ExecContext(ctx,
        `UPDATE todos SET user_id = $1 WHERE user_id = $2`, toUser, fromUser); err != nil {
        return fmt.Errorf("move todos: %w", err)
    }
    if _, err := tx.ExecContext(ctx,
        `INSERT INTO audit_log (action, detail) VALUES ('move', $1)`,
        fmt.Sprintf("%d -> %d", fromUser, toUser)); err != nil {
        return fmt.Errorf("audit: %w", err)
    }

    return tx.Commit()
}

defer tx.Rollback() を最初に置くことで、途中の return err や panic でも確実にロールバックされます。

トランザクションヘルパー

毎回書くのは冗長なので、関数を受け取るヘルパーを用意します。

func WithTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) (err error) {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer func() {
        if p := recover(); p != nil {
            tx.Rollback()
            panic(p)
        }
        if err != nil {
            if rbErr := tx.Rollback(); rbErr != nil {
                err = errors.Join(err, rbErr)
            }
            return
        }
        err = tx.Commit()
    }()
    return fn(tx)
}

// 使い方
err := WithTx(ctx, db, func(tx *sql.Tx) error {
    if _, err := tx.ExecContext(ctx, `...`); err != nil {
        return err
    }
    return nil
})

リポジトリをトランザクション対応にする

*sql.DB と *sql.Tx は共通のメソッドを持つので、インターフェースで抽象化できます。

// DBTX は *sql.DB と *sql.Tx の両方が満たす
type DBTX interface {
    ExecContext(context.Context, string, ...any) (sql.Result, error)
    QueryContext(context.Context, string, ...any) (*sql.Rows, error)
    QueryRowContext(context.Context, string, ...any) *sql.Row
}

type TodoRepository struct {
    db DBTX
}

// トランザクション内で使うリポジトリを返す
func (r *TodoRepository) WithTx(tx *sql.Tx) *TodoRepository {
    return &TodoRepository{db: tx}
}
// サービス層でまとめる
err := WithTx(ctx, s.db, func(tx *sql.Tx) error {
    todoRepo := s.todos.WithTx(tx)
    userRepo := s.users.WithTx(tx)
    // 両方同じトランザクション内で操作
    return nil
})

この DBTX パターンは sqlc が生成するコードでも採用されています。

分離レベル

tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
    ReadOnly:  false,
})

通常はデフォルト(PostgreSQL は READ COMMITTED)で十分です。残高計算のような厳密さが必要な場面で SERIALIZABLE や SELECT ... FOR UPDATE を検討します。

SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;  -- 行ロック

9. リポジトリをインターフェースにして HTTP と繋ぐ

ハンドラ(第3回)は 具象の *TodoRepository ではなく、必要なメソッドだけを持つインターフェース に依存させます。

// internal/handler/todo.go
package handler

type TodoRepo interface {
    Create(ctx context.Context, p repository.CreateTodoParams) (repository.Todo, error)
    Get(ctx context.Context, userID, id int64) (repository.Todo, error)
    List(ctx context.Context, p repository.ListTodosParams) ([]repository.Todo, error)
    Update(ctx context.Context, p repository.UpdateTodoParams) (repository.Todo, error)
    Delete(ctx context.Context, userID, id int64) error
}

type TodoHandler struct {
    repo TodoRepo
}

func NewTodoHandler(repo TodoRepo) *TodoHandler {
    return &TodoHandler{repo: repo}
}

func (h *TodoHandler) create(w http.ResponseWriter, r *http.Request) {
    userID := auth.UserIDFrom(r.Context()) // 第5回で実装

    var in createTodoRequest
    if err := decodeJSON(r, &in); err != nil {
        writeError(w, http.StatusBadRequest, err.Error())
        return
    }
    if in.Title == "" {
        writeError(w, http.StatusUnprocessableEntity, "title is required")
        return
    }

    t, err := h.repo.Create(r.Context(), repository.CreateTodoParams{
        UserID:      userID,
        Title:       in.Title,
        Description: in.Description,
    })
    if err != nil {
        slog.ErrorContext(r.Context(), "create todo", "err", err)
        writeError(w, http.StatusInternalServerError, "internal error")
        return
    }
    writeJSON(w, http.StatusCreated, toResponse(t))
}

レスポンス用の型を分ける

DB の型(repository.Todo)をそのまま JSON にしない方が、スキーマ変更と API 仕様を独立させられます。

type todoResponse struct {
    ID          int64      `json:"id"`
    Title       string     `json:"title"`
    Description *string    `json:"description"`
    Done        bool       `json:"done"`
    DueDate     *string    `json:"due_date"`
    CreatedAt   time.Time  `json:"created_at"`
}

func toResponse(t repository.Todo) todoResponse {
    res := todoResponse{
        ID: t.ID, Title: t.Title, Description: t.Description,
        Done: t.Done, CreatedAt: t.CreatedAt,
    }
    if t.DueDate != nil {
        s := t.DueDate.Format("2006-01-02")
        res.DueDate = &s
    }
    return res
}

モックで HTTP 層をテストする

インターフェースにした恩恵で、DB なしでハンドラをテストできます。

type fakeTodoRepo struct {
    TodoRepo  // 埋め込みで未実装メソッドを補う(呼ぶと nil panic するので注意)
    createFn func(ctx context.Context, p repository.CreateTodoParams) (repository.Todo, error)
}

func (f *fakeTodoRepo) Create(ctx context.Context, p repository.CreateTodoParams) (repository.Todo, error) {
    return f.createFn(ctx, p)
}

func TestCreateTodo_RepoError(t *testing.T) {
    repo := &fakeTodoRepo{
        createFn: func(_ context.Context, _ repository.CreateTodoParams) (repository.Todo, error) {
            return repository.Todo{}, errors.New("db down")
        },
    }
    h := NewTodoHandler(repo)

    req := httptest.NewRequest("POST", "/todos", strings.NewReader(`{"title":"x"}`))
    rec := httptest.NewRecorder()
    h.create(rec, req)

    if rec.Code != http.StatusInternalServerError {
        t.Errorf("status = %d", rec.Code)
    }
}

10. sqlc:SQL からコード生成

Scan の手書きは列が増えると辛くなります。sqlc は SQL を書くだけで型安全な Go コードを生成します。Go 界隈で最も勢いのある選択肢です。

brew install sqlc

設定

sqlc.yaml

version: "2"
sql:
  - engine: "postgresql"
    queries: "db/queries"
    schema: "db/migrations"
    gen:
      go:
        package: "sqlc"
        out: "internal/db/sqlc"
        sql_package: "pgx/v5"
        emit_json_tags: true
        emit_pointers_for_null_types: true
        emit_interface: true
        overrides:
          - db_type: "timestamptz"
            go_type: "time.Time"

クエリを書く

db/queries/todos.sql

-- name: CreateTodo :one
INSERT INTO todos (user_id, title, description, due_date)
VALUES ($1, $2, $3, $4)
RETURNING *;

-- name: GetTodo :one
SELECT * FROM todos
WHERE id = $1 AND user_id = $2;

-- name: ListTodos :many
SELECT * FROM todos
WHERE user_id = $1
  AND (sqlc.narg('done')::boolean IS NULL OR done = sqlc.narg('done'))
ORDER BY created_at DESC, id DESC
LIMIT $2 OFFSET $3;

-- name: CountTodos :one
SELECT COUNT(*) FROM todos WHERE user_id = $1;

-- name: UpdateTodo :one
UPDATE todos
SET title = $3, description = $4, done = $5, due_date = $6, updated_at = NOW()
WHERE id = $1 AND user_id = $2
RETURNING *;

-- name: DeleteTodo :execrows
DELETE FROM todos WHERE id = $1 AND user_id = $2;

db/queries/users.sql

-- name: CreateUser :one
INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING *;

-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1;

コメントの :one / :many / :exec / :execrows が戻り値の形を決めます。

sqlc generate

生成されるコード

// internal/db/sqlc/todos.sql.go(抜粋)
type CreateTodoParams struct {
    UserID      int64        `json:"user_id"`
    Title       string       `json:"title"`
    Description *string      `json:"description"`
    DueDate     pgtype.Date  `json:"due_date"`
}

func (q *Queries) CreateTodo(ctx context.Context, arg CreateTodoParams) (Todo, error) {
    row := q.db.QueryRow(ctx, createTodo, arg.UserID, arg.Title, arg.Description, arg.DueDate)
    var i Todo
    err := row.Scan(&i.ID, &i.UserID, &i.Title, ...)
    return i, err
}

emit_interface: true で Querier インターフェースも生成され、モックに使えます。

使い方

import "github.com/jackc/pgx/v5/pgxpool"

pool, err := pgxpool.New(ctx, dsn)
queries := sqlc.New(pool)

todo, err := queries.CreateTodo(ctx, sqlc.CreateTodoParams{
    UserID: 1, Title: "Go を学ぶ",
})

// トランザクション
tx, err := pool.Begin(ctx)
defer tx.Rollback(ctx)
q := queries.WithTx(tx)
q.CreateTodo(ctx, ...)
q.CreateTodo(ctx, ...)
tx.Commit(ctx)

sqlc の長所と短所

長所

  • SQL がそのまま見える。パフォーマンス調整がしやすい
  • コンパイル時に SQL の構文・列名・型が検証される
  • 生成コードは読める。ブラックボックスがない

短所

  • 動的な条件(フィルタの組み合わせが多い)は narg や CASE で工夫が必要
  • JOIN 結果の構造体は自動生成されるが、ネストした構造にはならない
  • スキーマ変更後は sqlc generate を忘れないこと(CI で差分チェックすると安全)

11. pgx をネイティブで使う

database/sql を経由せず pgx を直接使うと、PostgreSQL 固有の機能(配列、JSONB、COPY、LISTEN/NOTIFY)や高速なバイナリプロトコルが使えます。

import (
    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

pool, err := pgxpool.New(ctx, dsn)
defer pool.Close()

// 複数行を構造体スライスに一発で
rows, _ := pool.Query(ctx, `SELECT id, title FROM todos WHERE user_id = $1`, userID)
todos, err := pgx.CollectRows(rows, pgx.RowToStructByName[Todo])

// 1 行
todo, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Todo])

// 大量 INSERT は COPY が速い
_, err = pool.CopyFrom(ctx, pgx.Identifier{"todos"},
    []string{"user_id", "title"},
    pgx.CopyFromRows(rowsData),
)

pgx.RowToStructByName は列名と構造体フィールド(db タグ)を自動でマッピングします。

選択の指針:DB が PostgreSQL 固定なら pgx ネイティブ + sqlc。将来 MySQL も視野に入るなら database/sql。


12. ORM(GORM)との比較

import "gorm.io/gorm"

type Todo struct {
    gorm.Model
    UserID uint
    Title  string
    Done   bool
}

db.Create(&Todo{UserID: 1, Title: "x"})
db.Where("user_id = ? AND done = ?", 1, false).Find(&todos)
db.Model(&todo).Update("done", true)
db.Preload("User").Find(&todos)  // JOIN の代わり

GORM を選ぶ場面:プロトタイプを最速で作りたい、Rails/Django 出身でモデル中心の書き方に慣れている、CRUD が単純。

sqlc / 素の SQL を選ぶ場面:クエリのパフォーマンスを制御したい、複雑な JOIN や集計が多い、チームが SQL に強い。

Go コミュニティの傾向としては 「SQL は隠さない」 が主流です。学習の順序としては、素の database/sql → sqlc を経て、必要なら GORM を試す形をおすすめします。


13. DB を含めたテスト

リポジトリのテストは 本物の PostgreSQL で行うのが最も信頼できます。モックでは SQL のミスを検出できません。

方針 A:Docker Compose で立てた DB を使う

環境変数で DSN を渡し、なければスキップします。

// internal/repository/testing.go
package repository

import (
    "context"
    "database/sql"
    "os"
    "testing"
)

func testDB(t *testing.T) *sql.DB {
    t.Helper()
    dsn := os.Getenv("TEST_DATABASE_URL")
    if dsn == "" {
        t.Skip("TEST_DATABASE_URL not set")
    }
    db, err := Open(context.Background(), dsn)
    if err != nil {
        t.Fatalf("open test db: %v", err)
    }
    t.Cleanup(func() { db.Close() })

    // テスト間の独立性:テーブルを空にする
    if _, err := db.Exec(`TRUNCATE todos, users RESTART IDENTITY CASCADE`); err != nil {
        t.Fatalf("truncate: %v", err)
    }
    return db
}
// internal/repository/todo_test.go
func TestTodoRepository_CreateAndGet(t *testing.T) {
    db := testDB(t)
    users := NewUserRepository(db)
    todos := NewTodoRepository(db)
    ctx := context.Background()

    u, err := users.Create(ctx, "a@example.com", "hash")
    if err != nil {
        t.Fatal(err)
    }

    created, err := todos.Create(ctx, CreateTodoParams{UserID: u.ID, Title: "test"})
    if err != nil {
        t.Fatal(err)
    }

    got, err := todos.Get(ctx, u.ID, created.ID)
    if err != nil {
        t.Fatal(err)
    }
    if got.Title != "test" || got.Done {
        t.Errorf("got %+v", got)
    }

    // 他人の ID では見えない
    _, err = todos.Get(ctx, u.ID+1, created.ID)
    if !errors.Is(err, ErrNotFound) {
        t.Errorf("expected ErrNotFound, got %v", err)
    }
}

func TestUserRepository_DuplicateEmail(t *testing.T) {
    db := testDB(t)
    users := NewUserRepository(db)
    ctx := context.Background()

    users.Create(ctx, "dup@example.com", "h")
    _, err := users.Create(ctx, "dup@example.com", "h")
    if !errors.Is(err, ErrEmailTaken) {
        t.Errorf("expected ErrEmailTaken, got %v", err)
    }
}
TEST_DATABASE_URL="postgres://app:secret@localhost:5432/todo_test?sslmode=disable" go test ./internal/repository/

方針 B:testcontainers でテストごとにコンテナを起動

CI で DB の準備が不要になります。起動に数秒かかるので TestMain で 1 回だけ立てます。

go get github.com/testcontainers/testcontainers-go/modules/postgres
var testDSN string

func TestMain(m *testing.M) {
    ctx := context.Background()
    pg, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("test"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").WithOccurrence(2)),
    )
    if err != nil {
        log.Fatal(err)
    }
    testDSN, _ = pg.ConnectionString(ctx, "sslmode=disable")

    runMigrations(testDSN)
    code := m.Run()

    pg.Terminate(ctx)
    os.Exit(code)
}

方針 C:トランザクションで包んで Rollback

各テストを BEGIN で始めて終わりに ROLLBACK すれば TRUNCATE 不要で速くなります。リポジトリが DBTX インターフェースに依存していれば *sql.Tx を渡すだけです。

func testTx(t *testing.T, db *sql.DB) *sql.Tx {
    tx, err := db.Begin()
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { tx.Rollback() })
    return tx
}

repo := &TodoRepository{db: testTx(t, db)}

テストで確認すべきこと

  • CRUD の往復
  • ErrNotFound が返るケース
  • 他ユーザーのデータにアクセスできないこと
  • UNIQUE 制約や CHECK 制約の違反が適切なエラーになること
  • ページネーションの境界(0 件、limit ちょうど、offset 超過)
  • トランザクションが途中で失敗したときロールバックされること

14. パフォーマンスの基本

EXPLAIN ANALYZE を見る癖

EXPLAIN ANALYZE
SELECT * FROM todos WHERE user_id = 1 AND done = false ORDER BY created_at DESC LIMIT 20;

Seq Scan が出たらインデックスを検討します。WHERE と ORDER BY に使う列の組み合わせで複合インデックスを作ります。

N+1 問題

// ✗ ユーザーごとに todos を取る = N+1 回のクエリ
for _, u := range users {
    todos, _ := repo.ListByUser(ctx, u.ID)
}

// ✓ IN 句で一括取得してから Go 側でグルーピング
rows, _ := db.QueryContext(ctx,
    `SELECT user_id, id, title FROM todos WHERE user_id = ANY($1)`, userIDs)

PostgreSQL では = ANY($1) にスライスを渡せます(pgx が配列に変換)。

ページネーション

OFFSET は大きくなると遅くなります。ID や作成日時で カーソル方式 にすると一定速度です。

SELECT * FROM todos
WHERE user_id = $1 AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 20;

プリペアドステートメント

同じ SQL を大量に実行するなら PrepareContext でパースコストを削減できます。pgx は自動でキャッシュするので、通常は意識不要です。

コネクション枯渇の検出

stats := db.Stats()
slog.Info("db pool", "open", stats.OpenConnections, "in_use", stats.InUse, "wait", stats.WaitCount)

WaitCount が増え続けていたら MaxOpenConns が足りないか、接続を返し忘れています(rows.Close() 漏れが典型)。


15. よくある落とし穴

rows.Close() 忘れ
接続がプールに戻らず枯渇します。defer rows.Close() を Query 直後に書きます。

rows.Err() を見ない
ネットワーク切断などが検出できません。

SELECT * と Scan の列数不一致
カラム追加で全クエリが壊れます。列名を明示するか sqlc を使います。

sql.ErrNoRows を 500 にしている
「見つからない」は正常なケース。ErrNotFound に変換して 404 にします。

リクエストごとに sql.Open
*sql.DB はプールです。起動時に 1 つ作って共有します。

context を渡さない Exec / Query
リクエスト中断時にクエリが止まりません。常に 〜Context 版を使います。

defer tx.Rollback() を書かない
エラー時にトランザクションが開いたまま接続を占有します。

時刻を TIMESTAMP(タイムゾーンなし)で保存
サーバーと DB のタイムゾーンが違うとずれます。TIMESTAMPTZ を使い、Go 側は UTC で扱います。

マイグレーションファイルを後から編集
適用済みの環境と差分が出ます。新しいマイグレーションを追加します。

user_id を WHERE に含めない
URL の ID を変えるだけで他人のデータが見える IDOR 脆弱性になります。


16. 演習問題

基礎

  1. Update の部分更新:title だけ、done だけを更新できる Patch メソッドを実装する。COALESCE($2, title) を使って NULL なら既存値を維持する
  2. タグ機能:tags テーブルと todo_tags 中間テーブルをマイグレーションで追加し、トランザクション内で Todo と タグを同時に登録する CreateWithTags を実装する
  3. 検索:title ILIKE '%' || $1 || '%' による部分一致検索を List に追加する。SQL インジェクションが起きないことを確認するテストを書く

中級

  1. sqlc 移行:手書きの TodoRepository を sqlc 生成コードに置き換える。既存のテストが通ることを確認する
  2. カーソルページネーション:(created_at, id) を使ったカーソル方式を実装し、next_cursor を返す。1000 件入れて全ページ走査するテストを書く
  3. 楽観ロック:version カラムを追加し、UPDATE ... WHERE id = $1 AND version = $2 で更新競合を検出する。競合時は ErrConflict を返して 409 にする

応用

  1. testcontainers 化:TestMain で PostgreSQL コンテナを起動し、マイグレーションを適用してからテストを走らせる。go test ./... 一発で DB テストが動く状態にする
  2. 統計クエリ:ユーザーごとの「未完了数・完了数・期限超過数」を 1 つの SQL で返す Stats メソッドを書く(COUNT(*) FILTER (WHERE ...) を使う)
  3. バルクインサート:1 万件の Todo を pgx.CopyFrom と 1 件ずつ INSERT の両方で入れ、ベンチマークで速度差を計測する
  4. 監査ログ:全ての書き込み操作を audit_logs テーブルに記録する。リポジトリをラップする AuditingTodoRepository を作り、同一トランザクションで記録されることをテストする

次回予告

第5回では、この API を 本番で動かすための周辺技術──Docker、設定管理、構造化ログ、JWT 認証、そして goroutine / channel / context を使った並行処理──を扱います。

B!
← 一覧へ戻る