はじめに
入社して2年目となり、Goのプロジェクトに参画してからGORMと格闘する日々を送っています。
最初は「ORMって便利だな〜」くらいの認識でしたが、実際の開発を通じて様々な落とし穴に遭遇し、その度に公式ドキュメントや記事と睨めっこしてきました。
今回は、GORMの「あるある」な問題と、その解決方法を 架空の飲食店検索アプリ「FoodsApp」のエンジニア という設定でケーススタディ形式で共有したいと思います。同じような課題を感じている方の参考になれば幸いです!
🎯 ケース1:詳細画面が 404!
症状
/users/1 が 404 になる(ID=1 は存在する想定)
状況 / 背景
QA が E2E テスト中に発覚。DB には id=1 が確かに存在。
現状コード (Bad)
// RecordNotFound を拾えず 404 誤判定
if err := db.Where("id = ?", 1).Take(&user).Error; err != nil {
return echo.NewHTTPError(http.StatusNotFound)
}
発行したい SQL
SELECT * FROM users WHERE id = 1 LIMIT 1;
答え・解説
サンプルコード
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)
var user User
// email 条件を書き忘れ!
_ = db.Take(&user).Error
発行したい SQL
SELECT * FROM users WHERE email = 'taro@example.com' LIMIT 1;
答え・解説
サンプルコード
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)
var orders []Order
_ = db.Find(&orders).Error // 全件取得してから Go 側で status フィルタ
発行したい SQL
SELECT * FROM orders WHERE status = 'cooking';
答え・解説
サンプルコード
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)
// ユーザーフォームから ID が 0 じゃない値で来るケースがあり upsert 扱いに
if err := db.Save(&user).Error; err != nil {
return err
}
発行したい SQL
INSERT INTO users (name, email) VALUES ("Hanako", "hana@example.com");
答え
サンプルコード
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)
if err := db.Model(&user).Updates(&user).Error; err != nil {
return err
}
発行したい SQL
UPDATE users SET name = "Tanaka" WHERE id = 3;
答え・解説
サンプルコード
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)
// struct に DeletedAt フィールドが無い → 物理削除になってしまう
_ = db.Delete(&user).Error
発行したい SQL
UPDATE users SET deleted_at = NOW() WHERE id = 3;
答え・解説
サンプルコード
// 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)
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(擬似)
BEGIN;
INSERT INTO orders ...;
UPDATE stocks SET qty = qty - 1 WHERE item_id = 10;
COMMIT;
答え・解説
サンプルコード
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 が適用されない点に要注意。
終わりに
最後まで読んでいただき、ありがとうございました!何か質問や感想があれば、ぜひコメントください。