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

## はじめに

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

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


## 🎯 ケース1：詳細画面が 404！

### 症状

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

### 状況 / 背景

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

### 現状コード (Bad)

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

### 発行したい SQL
```sql:case1.sql
SELECT * FROM users WHERE id = 1 LIMIT 1;
```

### 答え・解説
<details><summary>サンプルコード</summary>

```go: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 にすべきか」判断できず障害扱いになります。
</details>



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

### 症状

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

### 状況 / 背景

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

### 現状コード (Bad)
```go:case2.go
var user User
// email 条件を書き忘れ！
_ = db.Take(&user).Error
```

### 発行したい SQL
```sql:case2.sql
SELECT * FROM users WHERE email = 'taro@example.com' LIMIT 1;
```
### 答え・解説

<details><summary>サンプルコード</summary>

```go: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 のセットで書くと可読性も安全性も向上します。
</details>


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

### 症状

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

### 状況 / 背景

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

### 現状コード (Bad)

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

### 発行したい SQL
```sql:case3.sql
SELECT * FROM orders WHERE status = 'cooking';
```

### 答え・解説
<details><summary>サンプルコード</summary>

```go:sql.go
var orders []Order
err := db.Where("status = ?", "cooking").Find(&orders).Error
```
- 絞り込みは 必ず DB で

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

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



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

### 症状

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

### 状況 / 背景

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

### 現状コード (Bad)

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

### 発行したい SQL
```sql:case4.sql
INSERT INTO users (name, email) VALUES ("Hanako", "hana@example.com");
```

### 答え
<details><summary>サンプルコード</summary>

```go: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 に統一した方が安全。
</details>




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

### 症状

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

### 状況 / 背景

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

### 現状コード (Bad)
```go:case5.go
if err := db.Model(&user).Updates(&user).Error; err != nil {
    return err
}
```

### 発行したい SQL
```sql:case5.go
UPDATE users SET name = "Tanaka" WHERE id = 3;
```

### 答え・解説

<details><summary>サンプルコード</summary>

```go: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 を推奨。
</details>


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

### 症状

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

### 状況 / 背景

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

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

### 発行したい SQL
```sql:case6.sql
UPDATE users SET deleted_at = NOW() WHERE id = 3;
```

### 答え・解説
<details><summary>サンプルコード</summary>

```go: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 欄の有無" で決まります。タグ忘れ＝物理削除事故に直結するのでモデル定義時に必ず入れておく。
</details>


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

### 症状

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

### 状況 / 背景

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

### 現状コード (Bad)
```go: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（擬似）
```sql:case7.sql
BEGIN;
INSERT INTO orders ...;
UPDATE stocks SET qty = qty - 1 WHERE item_id = 10;
COMMIT;
```
### 答え・解説

<details><summary>サンプルコード</summary>

```go: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 が適用されない点に要注意。
</details>



## 終わりに

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