12
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「GORM、なんとなくで使ってませんか?」── よくあるケースと正しい使い分けを整理してみた

Last updated at Posted at 2025-05-15

はじめに

入社して2年目となり、Goのプロジェクトに参画してからGORMと格闘する日々を送っています。
最初は「ORMって便利だな〜」くらいの認識でしたが、実際の開発を通じて様々な落とし穴に遭遇し、その度に公式ドキュメントや記事と睨めっこしてきました。

今回は、GORMの「あるある」な問題と、その解決方法を 架空の飲食店検索アプリ「FoodsApp」のエンジニア という設定でケーススタディ形式で共有したいと思います。同じような課題を感じている方の参考になれば幸いです!

🎯 ケース1:詳細画面が 404!

症状

/users/1 が 404 になる(ID=1 は存在する想定)

状況 / 背景

QA が E2E テスト中に発覚。DB には id=1 が確かに存在。

現状コード (Bad)

case1.go
// RecordNotFound を拾えず 404 誤判定
if err := db.Where("id = ?", 1).Take(&user).Error; err != nil {
    return echo.NewHTTPError(http.StatusNotFound)
}

発行したい SQL

case1.sql
SELECT * FROM users WHERE id = 1 LIMIT 1;

答え・解説

サンプルコード
sql.go
if err := db.First(&user, 1).Error; err != nil {
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return echo.NewHTTPError(http.StatusNotFound) // 本当に無ければ 404
    }
    return err // DB 障害
}
  • First(&obj, id) は主キー検索 + LIMIT 1 で最速

  • ErrRecordNotFound を判定しないと 500 になる

Take は単なる LIMIT 1 で順序が確定せず、インデックスも自動では使われません。主キー検索は First(&obj, id) が鉄則。また、ErrRecordNotFound を区別しないと「本当に 404 にすべきか」判断できず障害扱いになります。

🎯 ケース2:ログイン時に別ユーザーが出てくる

症状

メールアドレスで検索したはずが違うユーザーオブジェクトが返る

状況 / 背景

開発者がテスト環境で Take を多用。「1 件だけ取れれば OK」で条件を書き忘れた。

現状コード (Bad)

case2.go
var user User
// email 条件を書き忘れ!
_ = db.Take(&user).Error

発行したい SQL

case2.sql
SELECT * FROM users WHERE email = 'taro@example.com' LIMIT 1;

答え・解説

サンプルコード
sql.go
var user User
err := db.Where("email = ?", email).First(&user).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
    return err // DB 障害
}
  • 条件は Where → First の順でチェーン

  • 順序不要なら Take でも良いが 条件は必須

Take は便利ですが「条件を書き忘れる」と即バグにつながる罠。必ず Where→First/Take のセットで書くと可読性も安全性も向上します。

🎯 ケース3:オーダー一覧ページが激重

症状

ページネーション無しの一覧APIが遅く、レスポンス速度を秒速 2s → 400 ms に最適化したい

状況 / 背景

コードレビューで Find(&orders) のみを発見。ステータスでフィルタする UI なのに全部取っていた。

現状コード (Bad)

case3.go
var orders []Order
_ = db.Find(&orders).Error          // 全件取得してから Go 側で status フィルタ

発行したい SQL

case3.sql
SELECT * FROM orders WHERE status = 'cooking';

答え・解説

サンプルコード
sql.go
var orders []Order
err := db.Where("status = ?", "cooking").Find(&orders).Error
  • 絞り込みは 必ず DB で

  • ページングが必要なら Limit / Offset も DB レイヤで

アプリ側でフィルタやソートをすると メモリも遅延も直撃。一覧 API はとにかく DB で絞り込むのが鉄則です。

🎯 ケース4:INSERT が重複エラーになる

症状

ユーザー登録で duplicate key value が頻発

状況 / 背景

Save() を使っていて、主キーが 0 ではないケースが混ざり UPDATE 判定に。

現状コード (Bad)

case4.go
// ユーザーフォームから ID が 0 じゃない値で来るケースがあり upsert 扱いに
if err := db.Save(&user).Error; err != nil {
    return err
}

発行したい SQL

case4.sql
INSERT INTO users (name, email) VALUES ("Hanako", "hana@example.com");

答え

サンプルコード
sql.go
if err := db.Create(&user).Error; err != nil {
    if strings.Contains(err.Error(), "duplicate") {
        return echo.NewHTTPError(http.StatusConflict)
    }
    return err
}
  • 純粋な INSERT は Create 一択

  • Save は「主キーで存在チェック→ UPDATE」の挙動を理解して使う

Save は便利ですが「ID が 0 かどうか」で INSERT/UPDATE を決めます。リクエスト由来の構造体は ID ゼロ保証がない ので Create に統一した方が安全。

🎯 ケース5:プロフィール編集で NULL 上書き事故

症状

名前(name)だけ変更しようとしたところ、意図せず年齢(age) が NULL になった

状況 / 背景

Updates(&user) を使ったが、構造体の age=0 がゼロ値扱いされずに NULL 書き込み。

現状コード (Bad)

case5.go
if err := db.Model(&user).Updates(&user).Error; err != nil {
    return err
}

発行したい SQL

case5.go
UPDATE users SET name = "Tanaka" WHERE id = 3;

答え・解説

サンプルコード
sql.go
if err := db.Model(&user).Update("name", "Tanaka").Error; err != nil {
    return err
}
  • 単一フィールドなら Update が安全

  • 複数カラムを部分更新したいなら Select("col1","col2").Updates(&obj)

Updates は便利ですが、ポインタ or gorm:"column;default" タグ次第でゼロ値がそのまま書き込まれることがあります。フィールド単独更新には Update を推奨。

🎯 ケース6:退会なのにログが残らない / 物理削除された

症状

ユーザー退会 API でデータが完全に消えてしまう or 消えない

状況 / 背景

DeletedAt タグを付け忘れていた / 付けたまま物理削除したいケースが混在。

現状コード (Bad)

case6.go
// struct に DeletedAt フィールドが無い → 物理削除になってしまう
_ = db.Delete(&user).Error

発行したい SQL

case6.sql
UPDATE users SET deleted_at = NOW() WHERE id = 3;

答え・解説

サンプルコード
sql.go
// gorm.Model を埋め込む or DeletedAt フィールドを追加

type User struct {
    gorm.Model // ID, CreatedAt, UpdatedAt, DeletedAt
    Name string
}

_ = db.Delete(&user).Error // ソフトデリート
  • ソフトデリートは DeletedAt フィールド必須

  • struct 共通基底に gorm.Model を使うと楽

GORM のデリート挙動は "DeletedAt 欄の有無" で決まります。タグ忘れ=物理削除事故に直結するのでモデル定義時に必ず入れておく。

🎯 ケース7:在庫だけ減って注文レコードが無い

症状

注文API実行時、在庫だけ引き落とされ注文が INSERT されないことが稀に発生

状況 / 背景

2つのDB操作を個別に実行、途中エラーで片方だけ成功していた。

現状コード (Bad)

case7.go
if err := db.Create(&order).Error; err != nil {
    return err
}
if err := db.Model(&stock).Update("qty", gorm.Expr("qty - ?", 1)).Error; err != nil {
    return err
}

発行したい SQL(擬似)

case7.sql
BEGIN;
INSERT INTO orders ...;
UPDATE stocks SET qty = qty - 1 WHERE item_id = 10;
COMMIT;

答え・解説

サンプルコード
sql.go
err := db.Transaction(func(tx *gorm.DB) error {
    if err := tx.Create(&order).Error; err != nil {
        return err // rollback
    }
    if err := tx.Model(&stock).Where("item_id = ?", 10).
              Update("qty", gorm.Expr("qty - ?", 1)).Error; err != nil {
        return err // rollback
    }
    return nil // commit
})
  • トランザクション中は tx を必ず伝搬

  • rollback / commit は return 値で自動

GORM の Transaction ラッパは 関数ブロックで自動 commit/rollback してくれる便利 API。外側の db を使うと別接続になり、rollback が適用されない点に要注意。

終わりに

最後まで読んでいただき、ありがとうございました!何か質問や感想があれば、ぜひコメントください。

12
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?