この記事は「渡部 Advent Calendar 2025」の16日目の記事です。
ある日、次のようなコードを見かけました
// フロントエンドから送られてきたJSONボディ // ユーザー設定のような、不定形データ var data map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&data); err != nil { // エラーハンドリング } // そのままDBへ保存 jsonBytes, _ := json.Marshal(data) _, err := db.Exec("INSERT INTO user_settings (setting_json) VALUES (?)", jsonBytes)
一見すると「柔軟で便利そう」なコードです。 しかし、実際に運用されるシステムのコードとして見ると、かなり強い違和感がありました。
- このカラムには、何が入る前提なのかが分からない
- どこにも「正しい形」が定義されていない
- フロントエンドの都合が、そのまま永続化されている
この違和感はたいてい後になって正体を現します。「昔のデータが原因で Read が落ちる」「誰も仕様を説明できない JSON が残る」そういうタイプのトラブルの匂いがします。
JSON型は逃げではなく、設計上の選択肢
先に前提を整理しておきます。JSON 型を使うこと自体が悪いわけではありません。『SQLアンチパターン 第2版』では、属性が動的に増減する要件への対応として EAV(Entity-Attribute-Value)がアンチパターンとして紹介されています。
EAV は柔軟ですが、
- クエリが複雑化する
- 制約が効かず、整合性を保ちづらい
といった問題を抱えやすい設計です。その代替案の一つとして、同書では JSON や XML のような半構造化データ が挙げられています。実際、JSON 型が適しているケースはあります。
- 属性が頻繁に増減する
- サブタイプごとに構造が大きく異なる
- 外部 API のレスポンスをそのまま保存したい
つまり JSON 型は「妥協」ではなく、要件に対する正当な設計判断になり得ます。ただし、それは「アプリケーション側で責務を引き受ける覚悟がある場合に限って」です。
本当のアンチパターンは「何も決めないこと」
問題の本質は JSON 型そのものではありません。Map で受けて、何も検証せずに保存することです。
DBはJSONの中身を守ってくれない
通常のカラムであれば、
- INT に文字列を入れればエラーになる
- NOT NULL 制約が効く
といった最低限の安全装置があります。しかし JSON 型の中身について、DB は基本的に無関心です(CHECK 制約などを使わない限り)。
『SQLアンチパターン』でも触れられている通り、半構造化データでは 無効なデータを拒否する手段が失われがちです。つまり、整合性の責務は DB からアプリケーションへ移動します。
そのアプリも何も守っていない
ここで map[string]interface{} を使ってしまうと、
- 型は保証されない
- 必須項目も保証されない
- 想定外のフィールドも素通りする
という状態になります。
「フロントエンドでバリデーションしているから大丈夫」は、長期運用のシステムではほぼ信用できません。
アプリケーション側に「スキーマ」を取り戻す
JSON 型を使うなら、バックエンドのコード上では厳格な構造を持つべきです。DB が緩い分、アプリケーションで締めます。
- 構造体で「正解」を定義する
- まず map をやめて、構造体を定義します。
type UserSetting struct { Theme string `json:"theme" validate:"required,oneof=light dark system"` ShowProfile bool `json:"show_profile"` Notification NotificationConfig `json:"notification"` } type NotificationConfig struct { EmailEnabled bool `json:"email_enabled"` PushEnabled bool `json:"push_enabled"` }
これだけで、
- どんなフィールドが存在するのか
- どんな値が許可されているのか
がコードから読み取れるようになります。構造体自体が仕様書になります。
- 保存前に必ずバリデーションする
- 次に、保存前に必ず検証します。
var validate = validator.New() func CreateUserSetting(db *sql.DB, rawJSON []byte) error { var setting UserSetting // 構造チェック if err := json.Unmarshal(rawJSON, &setting); err != nil { return fmt.Errorf("invalid json structure: %w", err) } // 値の検証 if err := validate.Struct(setting); err != nil { return fmt.Errorf("validation failed: %w", err) } // 正規化して保存 saveBytes, err := json.Marshal(setting) if err != nil { return err } _, err = db.Exec( "INSERT INTO user_settings (setting_json) VALUES (?)", saveBytes, ) return err }
この形にすると、
- 型がおかしいデータは Unmarshal で落ちる
- 値がおかしいデータは Validation で落ちる
- 定義されていないフィールドは保存されない
という状態になります。
JSON 型を使いながらも、「何でも入る箱」にはならなくなります。