地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【Go】JSON型カラムとGoでの実装戦略

この記事は「渡部 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 は柔軟ですが、

  • クエリが複雑化する
  • 制約が効かず、整合性を保ちづらい

といった問題を抱えやすい設計です。その代替案の一つとして、同書では JSONXML のような半構造化データ が挙げられています。実際、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 型を使いながらも、「何でも入る箱」にはならなくなります。