エンティティのステータスや区分をenumで持つと、コードは一気に読みやすくなりますよね。ただ、いざDBに保存する段になると「数値で入って中身が読めない」「enumを並び替えたら既存データの意味がズレた」「レガシーDBの 'A' / 'B' みたいなコード値と対応づけたい」といった悩みにぶつかります。

この記事では、JPAでenumを永続化する2つの方法、@EnumeratedAttributeConverter を整理して、どちらを選べばいいかまで説明します。JSON応答でのenum表現や入力検証は永続化の話とは別レイヤーなので、ここでは扱いません。DBへの保存に集中しましょう。

enumをDBに保存する2つのアプローチ

やり方は大きく2つです。

  1. @Enumerated … JPA標準。enum名(STRING)か宣言順の数値(ORDINAL)でそのまま保存する
  2. AttributeConverter … 自分で変換ロジックを書く。'A' のような独自コード値とenumを双方向にマッピングできる

ざっくり言うと、新規スキーマを自分で設計するなら @Enumerated(EnumType.STRING) で十分。既存DBのコード値や短縮コードに合わせたいなら AttributeConverter の出番です。まずは標準の @Enumerated から見ていきましょう。

@Enumeratedの基本:STRINGとORDINALで保存値がどう変わるか

注文ステータスをenumで持つエンティティを例にします。

public enum OrderStatus {
    PENDING, ACTIVE, SHIPPED, CANCELLED
}

@Entity
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(EnumType.STRING)
    @Column(length = 20)
    private OrderStatus status;

    // getter / setter
}

@Enumerated には2つのモードがあります。EnumType.STRING はenumの名前をそのまま文字列で保存し、EnumType.ORDINAL は宣言順のインデックス(0始まりの数値)で保存します。同じ ACTIVE でも、保存される値はまったく違います。

-- EnumType.STRING の場合
| id | status    |
|----|-----------|
| 1  | ACTIVE    |
| 2  | SHIPPED   |

-- EnumType.ORDINAL の場合
| id | status |
|----|--------|
| 1  | 1      |
| 2  | 2      |

ここで一番の注意点は、@Enumerated を省略したときのデフォルトが ORDINAL である ことです。何も付けずにenumフィールドを書くと、意図せず数値保存になってしまいます。これが次の落とし穴につながります。

ORDINALの落とし穴:並び替え・中間挿入でデータが壊れる

ORDINAL はenumの「宣言順」に依存します。つまり、あとからenumの定義をいじると、DBに残っている数値の意味が静かにズレます。

たとえば先ほどのenumに、あとから PAID を途中へ挿入したとします。

public enum OrderStatus {
    PENDING, PAID, ACTIVE, SHIPPED, CANCELLED
    // PAID を挿入した瞬間、ACTIVE 以降のインデックスが全部ずれる
}

コードを変更しただけで、ACTIVE1 から 2 へ、SHIPPED2 から 3 へずれました。ところがDBには古い数値がそのまま残っています。以前 ACTIVE(=1)として保存したレコードは、次に読み込むと PAID として解釈されてしまいます。マイグレーションを書かない限り、コードの一行変更が既存データを丸ごと破壊するわけです。

しかもDBに残るのは数値だけなので、SQLで直接テーブルを覗いても 1 が何を指すのか分かりません。運用上の可読性も最悪です。基本的に ORDINAL は避けましょう。

STRINGを既定にすべき理由と残る注意点

そこで実務では EnumType.STRING を既定にします。enum名で保存されるので並び替えや中間挿入に強く、SQLで見ても意味が一目で分かります。

ただし STRING なら無条件で安心、というわけでもありません。押さえておきたい点が2つあります。

  • カラム長を確保する。 長い定数名が入りきらないと保存時にエラーになります。@Column(length = 20) のように、enum名の最大長に余裕を持たせておきましょう。
  • enum定数名のリファクタリングに注意。 ACTIVEIN_PROGRESS に改名すると、DBに残っている 'ACTIVE' という文字列とは一致しなくなります。定数名がそのままDBの値になるので、改名はデータ移行とセットで考える必要があります。

この「名前を変えるとDB値と不整合になる」問題を根本から切り離したいなら、次の AttributeConverter が有効です。

AttributeConverterで独自コード値を保存する

レガシーDBだと、ステータスが 'A'(Active)、'C'(Cancelled)のような1文字コードで格納されていることがよくあります。こういう独自コード値とenumを対応づけたいときは、AttributeConverter を実装します。

まず、enum自身にコード値を持たせます。

public enum OrderStatus {
    ACTIVE("A"),
    SHIPPED("S"),
    CANCELLED("C");

    private final String code;

    OrderStatus(String code) {
        this.code = code;
    }

    public String getCode() {
        return code;
    }
}

次に、enumとDB値を双方向変換するConverterを書きます。convertToDatabaseColumn が保存時(enum → DB値)、convertToEntityAttribute が読み込み時(DB値 → enum)です。

@Converter
public class OrderStatusConverter
        implements AttributeConverter<OrderStatus, String> {

    @Override
    public String convertToDatabaseColumn(OrderStatus status) {
        return status == null ? null : status.getCode();
    }

    @Override
    public OrderStatus convertToEntityAttribute(String code) {
        if (code == null) {
            return null;
        }
        return Arrays.stream(OrderStatus.values())
                .filter(s -> s.getCode().equals(code))
                .findFirst()
                .orElseThrow(() ->
                        new IllegalArgumentException("未知のコード値: " + code));
    }
}

適用の仕方は2通りあります。フィールド単位で明示的に付けるか、そのenum型すべてに自動適用するかです。

// フィールド単位で適用する
@Convert(converter = OrderStatusConverter.class)
private OrderStatus status;
// enum型が登場する全フィールドに自動適用する
@Converter(autoApply = true)
public class OrderStatusConverter
        implements AttributeConverter<OrderStatus, String> { /* ... */ }

autoApply = true にすると OrderStatus を使う全カラムへ自動で効くので @Convert の付け忘れがなくなります。一方、同じenumでも場所によって別の保存方式にしたいなら、フィールド単位の @Convert のほうが柔軟です。なお、変換先の型は String に限りません。AttributeConverter<OrderStatus, Integer> にすれば、1 / 2 のような数値コードにもそのまま応用できます。

未知コード・null値のハンドリング

Converterで意外と忘れがちなのが、想定外の値やnullの扱いです。

まず nullは素通しする のが基本です。上のコード例のように、入力がnullなら変換せずnullを返します。ここで例外を投げると、NULL許可カラムなのに読み込みで落ちる、といった事故になります。

次に、DBに定義外のコード(たとえば 'X')が入っていた場合。ここは方針が2つに分かれます。

  • 例外を投げる … 不正データを早期に検知したいとき。上の例のように orElseThrow で失敗させ、ログに残せば混入源の調査がしやすくなります
  • nullを返す … 多少の欠損は許容し、アプリを止めたくないとき。ただし静かにnullになるので、後続処理でのnullチェックが前提になります

どちらが正解ということはなく、データ品質をどこまで信頼するかで決めます。個人的には、原因不明のnullが後工程まで流れるより、変換時点で例外にして気づけるほうが安全だと思います。

@EnumeratedとAttributeConverterの使い分け基準

最後に選択の目安を表で整理します。

状況推奨
新規スキーマをDDL主導で設計する@Enumerated(EnumType.STRING)
レガシーDBの既存コード値に合わせるAttributeConverter
短縮コード('A')や数値コードで保存したいAttributeConverter
enum名の変更とDB値を切り離したいAttributeConverter
とにかくシンプルに済ませたい@Enumerated(EnumType.STRING)

ポイントは、DB側の値を自分で決められるかどうか です。ゼロから作るなら素直に STRING。既存の値や短縮コードに合わせる、あるいはenum名とDB値を独立して育てたいなら AttributeConverter を選ぶ、という判断でだいたい迷いません。

まとめ

enumの永続化は、デフォルトは @Enumerated(EnumType.STRING)、独自コード値が必要なら AttributeConverter と覚えておけば大丈夫です。ORDINAL は並び替えでデータが壊れるので原則避けましょう。

エンティティ設計をさらに詰めるなら、リレーションの張り方をまとめた エンティティのリレーションマッピング や、検索処理の JPAクエリメソッド も合わせてどうぞ。今回スコープ外にしたJSON応答でのenum表現は Jacksonのシリアライズ記事 で扱っています。