エンティティのステータスや区分をenumで持つと、コードは一気に読みやすくなりますよね。ただ、いざDBに保存する段になると「数値で入って中身が読めない」「enumを並び替えたら既存データの意味がズレた」「レガシーDBの 'A' / 'B' みたいなコード値と対応づけたい」といった悩みにぶつかります。
この記事では、JPAでenumを永続化する2つの方法、@Enumerated と AttributeConverter を整理して、どちらを選べばいいかまで説明します。JSON応答でのenum表現や入力検証は永続化の話とは別レイヤーなので、ここでは扱いません。DBへの保存に集中しましょう。
enumをDBに保存する2つのアプローチ
やり方は大きく2つです。
@Enumerated… JPA標準。enum名(STRING)か宣言順の数値(ORDINAL)でそのまま保存する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 以降のインデックスが全部ずれる
}
コードを変更しただけで、ACTIVE は 1 から 2 へ、SHIPPED は 2 から 3 へずれました。ところがDBには古い数値がそのまま残っています。以前 ACTIVE(=1)として保存したレコードは、次に読み込むと PAID として解釈されてしまいます。マイグレーションを書かない限り、コードの一行変更が既存データを丸ごと破壊するわけです。
しかもDBに残るのは数値だけなので、SQLで直接テーブルを覗いても 1 が何を指すのか分かりません。運用上の可読性も最悪です。基本的に ORDINAL は避けましょう。
STRINGを既定にすべき理由と残る注意点
そこで実務では EnumType.STRING を既定にします。enum名で保存されるので並び替えや中間挿入に強く、SQLで見ても意味が一目で分かります。
ただし STRING なら無条件で安心、というわけでもありません。押さえておきたい点が2つあります。
- カラム長を確保する。 長い定数名が入りきらないと保存時にエラーになります。
@Column(length = 20)のように、enum名の最大長に余裕を持たせておきましょう。 - enum定数名のリファクタリングに注意。
ACTIVEをIN_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のシリアライズ記事 で扱っています。