Spring JPAを使用したアプリケーション開発において、AttributeConverterはエンティティ属性とデータベースカラム間の型変換を柔軟に制御するための重要なインターフェースです。本記事では、@ConverterアノテーションとそのautoApply設定、Enum変換パターン(文字列/数値)、JSON型カラムへのオブジェクト保存、暗号化フィールドの実装、Java 8日時型の変換(レガシーシステム対応)まで、AttributeConverterの実践的な活用方法を網羅的に解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Data JPA 3.4.x(Spring Boot Starterに含まれる)
Hibernate 6.6.x(Spring Data JPAに含まれる)
Jakarta Persistence 3.2
データベース H2 Database / PostgreSQL / MySQL
ビルドツール Maven または Gradle
IDE VS Code または IntelliJ IDEA

事前に以下の準備を完了してください。

  • JDK 17以上のインストール
  • Spring Boot + Spring Data JPAプロジェクトの基本構成
  • JPAエンティティとリポジトリの基本知識

AttributeConverterインターフェースの基本構造

AttributeConverterは、Jakarta Persistence(旧JPA)で定義されたインターフェースで、エンティティの属性とデータベースカラムの間で双方向の型変換を行います。

インターフェースの定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package jakarta.persistence;

public interface AttributeConverter<X, Y> {
    
    /**
     * エンティティ属性値をデータベース格納用の値に変換する
     * @param attribute エンティティ属性値
     * @return データベースに格納する値
     */
    Y convertToDatabaseColumn(X attribute);
    
    /**
     * データベースの値をエンティティ属性値に変換する
     * @param dbData データベースから取得した値
     * @return エンティティ属性値
     */
    X convertToEntityAttribute(Y dbData);
}

型パラメータの意味は以下の通りです。

型パラメータ 説明
X エンティティ側の属性型(Javaオブジェクト)
Y データベース側のカラム型(JDBC型)

変換フローの図解

flowchart LR
    subgraph Entity["エンティティ"]
        A["Java属性<br/>(型 X)"]
    end
    
    subgraph Converter["AttributeConverter"]
        B["convertToDatabaseColumn()"]
        C["convertToEntityAttribute()"]
    end
    
    subgraph Database["データベース"]
        D["カラム値<br/>(型 Y)"]
    end
    
    A -->|"INSERT/UPDATE時"| B
    B --> D
    D -->|"SELECT時"| C
    C --> A

@Converterアノテーションの設定方法

@Converterアノテーションは、変換クラスをJPAに登録するために使用します。このアノテーションにはautoApplyという重要な設定があります。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;

@Converter
public class BooleanToStringConverter implements AttributeConverter<Boolean, String> {
    
    @Override
    public String convertToDatabaseColumn(Boolean attribute) {
        if (attribute == null) {
            return null;
        }
        return attribute ? "Y" : "N";
    }
    
    @Override
    public Boolean convertToEntityAttribute(String dbData) {
        if (dbData == null) {
            return null;
        }
        return "Y".equals(dbData);
    }
}

autoApply設定の違い

autoApplyの値によって、変換の適用範囲が変わります。

設定 動作
autoApply = false(デフォルト) エンティティで@Convertを明示的に指定した属性にのみ適用
autoApply = true 同じ型のすべての属性に自動適用(一部例外あり)

autoApply = false の場合

1
2
3
4
@Converter  // autoApply = false がデフォルト
public class BooleanToStringConverter implements AttributeConverter<Boolean, String> {
    // 実装省略
}

エンティティ側で明示的に指定が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@Entity
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    @Convert(converter = BooleanToStringConverter.class)  // 明示的な指定が必要
    private Boolean active;
    
    // getter/setter省略
}

autoApply = true の場合

1
2
3
4
@Converter(autoApply = true)  // すべてのBoolean型属性に自動適用
public class BooleanToStringConverter implements AttributeConverter<Boolean, String> {
    // 実装省略
}

エンティティ側での指定は不要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Entity
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    
    private Boolean active;  // 自動的にBooleanToStringConverterが適用される
    
    private Boolean verified;  // これにも自動適用される
    
    // getter/setter省略
}

autoApplyの適用除外

以下の属性にはautoApply = trueでも自動適用されません。

  • @Idが付与された属性
  • @Versionが付与された属性
  • リレーション属性(@OneToMany@ManyToOneなど)
  • @Enumeratedが明示的に付与された属性
  • @Temporalが明示的に付与された属性

特定の属性で自動適用を無効化したい場合は、disableConversionを使用します。

1
2
3
4
5
6
@Entity
public class User {
    
    @Convert(disableConversion = true)  // この属性では変換を無効化
    private Boolean legacyFlag;
}

Enumの文字列変換パターン

Enumをデータベースに保存する際、デフォルトでは序数(ordinal: 0, 1, 2…)または名前(name: 文字列)で保存されますが、独自のコード値で保存したいケースが多くあります。

標準的な@Enumeratedの問題点

1
2
3
4
5
6
public enum OrderStatus {
    PENDING,    // ordinal = 0
    CONFIRMED,  // ordinal = 1
    SHIPPED,    // ordinal = 2
    DELIVERED   // ordinal = 3
}

@Enumerated(EnumType.ORDINAL)を使用すると、Enum定数の順序変更で既存データとの整合性が崩れます。@Enumerated(EnumType.STRING)を使用しても、Enum定数名の変更で問題が発生します。

AttributeConverterによる解決策

業務コードをEnumに持たせ、AttributeConverterでコード変換を行います。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public enum OrderStatus {
    PENDING("PND", "保留中"),
    CONFIRMED("CNF", "確認済み"),
    SHIPPED("SHP", "発送済み"),
    DELIVERED("DLV", "配達完了"),
    CANCELLED("CAN", "キャンセル");
    
    private final String code;
    private final String displayName;
    
    OrderStatus(String code, String displayName) {
        this.code = code;
        this.displayName = displayName;
    }
    
    public String getCode() {
        return code;
    }
    
    public String getDisplayName() {
        return displayName;
    }
    
    /**
     * コード値からEnumを取得する
     */
    public static OrderStatus fromCode(String code) {
        if (code == null) {
            return null;
        }
        for (OrderStatus status : values()) {
            if (status.code.equals(code)) {
                return status;
            }
        }
        throw new IllegalArgumentException("Unknown OrderStatus code: " + code);
    }
}

対応するConverterを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;

@Converter(autoApply = true)
public class OrderStatusConverter implements AttributeConverter<OrderStatus, String> {
    
    @Override
    public String convertToDatabaseColumn(OrderStatus attribute) {
        if (attribute == null) {
            return null;
        }
        return attribute.getCode();
    }
    
    @Override
    public OrderStatus convertToEntityAttribute(String dbData) {
        return OrderStatus.fromCode(dbData);
    }
}

エンティティでの使用例は以下の通りです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Entity
@Table(name = "orders")
public class Order {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "order_number", unique = true)
    private String orderNumber;
    
    @Column(name = "status", length = 3)
    private OrderStatus status;  // "PND", "CNF" などのコード値で保存される
    
    @Column(name = "created_at")
    private LocalDateTime createdAt;
    
    // getter/setter省略
}

データベースには以下のように保存されます。

id order_number status created_at
1 ORD-001 PND 2026-01-13 10:00:00
2 ORD-002 CNF 2026-01-13 11:30:00
3 ORD-003 DLV 2026-01-13 14:00:00

Enumの数値変換パターン

数値コードでEnumを管理したい場合のパターンです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public enum Priority {
    LOW(1),
    MEDIUM(2),
    HIGH(3),
    CRITICAL(4);
    
    private final int value;
    
    Priority(int value) {
        this.value = value;
    }
    
    public int getValue() {
        return value;
    }
    
    public static Priority fromValue(Integer value) {
        if (value == null) {
            return null;
        }
        for (Priority priority : values()) {
            if (priority.value == value) {
                return priority;
            }
        }
        throw new IllegalArgumentException("Unknown Priority value: " + value);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Converter(autoApply = true)
public class PriorityConverter implements AttributeConverter<Priority, Integer> {
    
    @Override
    public Integer convertToDatabaseColumn(Priority attribute) {
        if (attribute == null) {
            return null;
        }
        return attribute.getValue();
    }
    
    @Override
    public Priority convertToEntityAttribute(Integer dbData) {
        return Priority.fromValue(dbData);
    }
}

汎用的なEnum Converterの実装

同様のパターンを複数のEnumに適用する場合、汎用的な基底クラスを作成できます。

1
2
3
4
5
6
/**
 * コード値を持つEnumのインターフェース
 */
public interface CodedEnum<T> {
    T getCode();
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public enum PaymentMethod implements CodedEnum<String> {
    CREDIT_CARD("CC"),
    BANK_TRANSFER("BT"),
    CASH_ON_DELIVERY("COD");
    
    private final String code;
    
    PaymentMethod(String code) {
        this.code = code;
    }
    
    @Override
    public String getCode() {
        return code;
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
 * CodedEnumの汎用コンバーター基底クラス
 */
public abstract class AbstractCodedEnumConverter<E extends Enum<E> & CodedEnum<C>, C>
        implements AttributeConverter<E, C> {
    
    private final Class<E> enumClass;
    
    protected AbstractCodedEnumConverter(Class<E> enumClass) {
        this.enumClass = enumClass;
    }
    
    @Override
    public C convertToDatabaseColumn(E attribute) {
        if (attribute == null) {
            return null;
        }
        return attribute.getCode();
    }
    
    @Override
    public E convertToEntityAttribute(C dbData) {
        if (dbData == null) {
            return null;
        }
        for (E enumConstant : enumClass.getEnumConstants()) {
            if (enumConstant.getCode().equals(dbData)) {
                return enumConstant;
            }
        }
        throw new IllegalArgumentException(
            "Unknown code '" + dbData + "' for enum " + enumClass.getSimpleName()
        );
    }
}
1
2
3
4
5
6
7
8
@Converter(autoApply = true)
public class PaymentMethodConverter 
        extends AbstractCodedEnumConverter<PaymentMethod, String> {
    
    public PaymentMethodConverter() {
        super(PaymentMethod.class);
    }
}

JSON型カラムへのオブジェクト保存

複雑なオブジェクトをJSON文字列としてデータベースに保存するパターンです。PostgreSQLのJSONB型やMySQLのJSON型と組み合わせて使用できます。

JSONコンバーターの実装

まず、Jacksonの依存関係を追加します(Spring Boot Starterに含まれています)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;

@Converter
public class JsonAttributeConverter<T> implements AttributeConverter<T, String> {
    
    private static final ObjectMapper objectMapper = new ObjectMapper();
    private final Class<T> targetClass;
    
    public JsonAttributeConverter(Class<T> targetClass) {
        this.targetClass = targetClass;
    }
    
    @Override
    public String convertToDatabaseColumn(T attribute) {
        if (attribute == null) {
            return null;
        }
        try {
            return objectMapper.writeValueAsString(attribute);
        } catch (JsonProcessingException e) {
            throw new IllegalArgumentException(
                "Error converting object to JSON: " + e.getMessage(), e
            );
        }
    }
    
    @Override
    public T convertToEntityAttribute(String dbData) {
        if (dbData == null || dbData.isEmpty()) {
            return null;
        }
        try {
            return objectMapper.readValue(dbData, targetClass);
        } catch (JsonProcessingException e) {
            throw new IllegalArgumentException(
                "Error converting JSON to object: " + e.getMessage(), e
            );
        }
    }
}

具体的な使用例:住所情報の保存

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
 * 住所情報を表す値オブジェクト
 */
public class Address {
    
    private String postalCode;
    private String prefecture;
    private String city;
    private String street;
    private String building;
    
    // デフォルトコンストラクタ(Jacksonのデシリアライズに必要)
    public Address() {
    }
    
    public Address(String postalCode, String prefecture, 
                   String city, String street, String building) {
        this.postalCode = postalCode;
        this.prefecture = prefecture;
        this.city = city;
        this.street = street;
        this.building = building;
    }
    
    // getter/setter省略
}
1
2
3
4
5
6
7
@Converter
public class AddressJsonConverter extends JsonAttributeConverter<Address> {
    
    public AddressJsonConverter() {
        super(Address.class);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Table(name = "customers")
public class Customer {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "name")
    private String name;
    
    @Column(name = "shipping_address", columnDefinition = "TEXT")
    @Convert(converter = AddressJsonConverter.class)
    private Address shippingAddress;
    
    @Column(name = "billing_address", columnDefinition = "TEXT")
    @Convert(converter = AddressJsonConverter.class)
    private Address billingAddress;
    
    // getter/setter省略
}

データベースには以下のように保存されます。

1
2
3
4
5
6
7
{
  "postalCode": "100-0001",
  "prefecture": "東京都",
  "city": "千代田区",
  "street": "丸の内1-1-1",
  "building": "東京ビル5F"
}

リスト型のJSON保存

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.List;

@Converter
public class StringListConverter implements AttributeConverter<List<String>, String> {
    
    private static final ObjectMapper objectMapper = new ObjectMapper();
    
    @Override
    public String convertToDatabaseColumn(List<String> attribute) {
        if (attribute == null || attribute.isEmpty()) {
            return null;
        }
        try {
            return objectMapper.writeValueAsString(attribute);
        } catch (Exception e) {
            throw new IllegalArgumentException("Error converting list to JSON", e);
        }
    }
    
    @Override
    public List<String> convertToEntityAttribute(String dbData) {
        if (dbData == null || dbData.isEmpty()) {
            return List.of();
        }
        try {
            return objectMapper.readValue(dbData, new TypeReference<List<String>>() {});
        } catch (Exception e) {
            throw new IllegalArgumentException("Error converting JSON to list", e);
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Entity
@Table(name = "products")
public class Product {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "name")
    private String name;
    
    @Column(name = "tags", columnDefinition = "TEXT")
    @Convert(converter = StringListConverter.class)
    private List<String> tags;  // ["electronics", "smartphone", "5G"]
    
    // getter/setter省略
}

暗号化フィールドの実装

機密情報をデータベースに保存する際、暗号化して保存し、取得時に復号化するパターンです。

AES暗号化コンバーターの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
import java.util.Base64;

@Converter
public class EncryptedStringConverter implements AttributeConverter<String, String> {
    
    private static final String ALGORITHM = "AES/GCM/NoPadding";
    private static final int GCM_IV_LENGTH = 12;
    private static final int GCM_TAG_LENGTH = 128;
    
    // 本番環境では環境変数やSecret Managerから取得すること
    private static final String SECRET_KEY = System.getenv("ENCRYPTION_KEY");
    
    private final SecretKeySpec keySpec;
    
    public EncryptedStringConverter() {
        if (SECRET_KEY == null || SECRET_KEY.length() != 32) {
            throw new IllegalStateException(
                "ENCRYPTION_KEY must be set and be 32 characters (256 bits)"
            );
        }
        this.keySpec = new SecretKeySpec(SECRET_KEY.getBytes(), "AES");
    }
    
    @Override
    public String convertToDatabaseColumn(String attribute) {
        if (attribute == null) {
            return null;
        }
        try {
            byte[] iv = new byte[GCM_IV_LENGTH];
            new SecureRandom().nextBytes(iv);
            
            Cipher cipher = Cipher.getInstance(ALGORITHM);
            GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
            cipher.init(Cipher.ENCRYPT_MODE, keySpec, parameterSpec);
            
            byte[] encryptedData = cipher.doFinal(attribute.getBytes());
            
            // IVと暗号化データを結合
            ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + encryptedData.length);
            byteBuffer.put(iv);
            byteBuffer.put(encryptedData);
            
            return Base64.getEncoder().encodeToString(byteBuffer.array());
        } catch (Exception e) {
            throw new RuntimeException("Encryption failed", e);
        }
    }
    
    @Override
    public String convertToEntityAttribute(String dbData) {
        if (dbData == null) {
            return null;
        }
        try {
            byte[] decoded = Base64.getDecoder().decode(dbData);
            
            ByteBuffer byteBuffer = ByteBuffer.wrap(decoded);
            byte[] iv = new byte[GCM_IV_LENGTH];
            byteBuffer.get(iv);
            byte[] encryptedData = new byte[byteBuffer.remaining()];
            byteBuffer.get(encryptedData);
            
            Cipher cipher = Cipher.getInstance(ALGORITHM);
            GCMParameterSpec parameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
            cipher.init(Cipher.DECRYPT_MODE, keySpec, parameterSpec);
            
            return new String(cipher.doFinal(encryptedData));
        } catch (Exception e) {
            throw new RuntimeException("Decryption failed", e);
        }
    }
}

エンティティでの使用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Table(name = "user_credentials")
public class UserCredential {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "user_id")
    private Long userId;
    
    @Column(name = "api_key", length = 512)
    @Convert(converter = EncryptedStringConverter.class)
    private String apiKey;  // 暗号化されて保存
    
    @Column(name = "secret_token", length = 512)
    @Convert(converter = EncryptedStringConverter.class)
    private String secretToken;  // 暗号化されて保存
    
    // getter/setter省略
}

Springの@Valueを使用した改善版

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;

@Converter
@Component
public class EncryptedStringConverter implements AttributeConverter<String, String> {
    
    private static SecretKeySpec keySpec;
    
    @Value("${app.encryption.key}")
    public void setEncryptionKey(String key) {
        if (key != null && key.length() == 32) {
            keySpec = new SecretKeySpec(key.getBytes(), "AES");
        }
    }
    
    // 暗号化/復号化メソッドは同様
}

Java 8日時型の変換(レガシーシステム対応)

Hibernate 6.x以降ではJava 8の日時型(LocalDateTimeLocalDateなど)はネイティブサポートされていますが、レガシーシステムとの連携で特殊な形式が必要な場合があります。

数値型タイムスタンプへの変換

エポックミリ秒で日時を保存するレガシーシステムとの連携例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;

@Converter
public class LocalDateTimeToEpochConverter 
        implements AttributeConverter<LocalDateTime, Long> {
    
    private static final ZoneId ZONE_ID = ZoneId.of("Asia/Tokyo");
    
    @Override
    public Long convertToDatabaseColumn(LocalDateTime attribute) {
        if (attribute == null) {
            return null;
        }
        return attribute.atZone(ZONE_ID).toInstant().toEpochMilli();
    }
    
    @Override
    public LocalDateTime convertToEntityAttribute(Long dbData) {
        if (dbData == null) {
            return null;
        }
        return LocalDateTime.ofInstant(
            Instant.ofEpochMilli(dbData), 
            ZONE_ID
        );
    }
}

文字列形式の日時変換

特定のフォーマットで日時を保存する必要がある場合の例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Converter
public class LocalDateTimeToStringConverter 
        implements AttributeConverter<LocalDateTime, String> {
    
    // レガシーシステムで使用されているフォーマット
    private static final DateTimeFormatter FORMATTER = 
        DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
    
    @Override
    public String convertToDatabaseColumn(LocalDateTime attribute) {
        if (attribute == null) {
            return null;
        }
        return attribute.format(FORMATTER);
    }
    
    @Override
    public LocalDateTime convertToEntityAttribute(String dbData) {
        if (dbData == null || dbData.isEmpty()) {
            return null;
        }
        return LocalDateTime.parse(dbData, FORMATTER);
    }
}

使用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Table(name = "legacy_transactions")
public class LegacyTransaction {
    
    @Id
    @Column(name = "transaction_id", length = 20)
    private String transactionId;
    
    @Column(name = "amount")
    private Long amount;
    
    @Column(name = "transaction_time")
    @Convert(converter = LocalDateTimeToEpochConverter.class)
    private LocalDateTime transactionTime;  // 1736755200000 のように保存
    
    @Column(name = "created_date", length = 14)
    @Convert(converter = LocalDateTimeToStringConverter.class)
    private LocalDateTime createdDate;  // "20260113170000" のように保存
    
    // getter/setter省略
}

よくある誤解とアンチパターン

誤解1: autoApplyは常に有効にすべき

autoApply = trueは便利ですが、意図しない属性にも適用されてしまうリスクがあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// アンチパターン: 広範囲に影響するautoApply
@Converter(autoApply = true)
public class StringTrimConverter implements AttributeConverter<String, String> {
    
    @Override
    public String convertToDatabaseColumn(String attribute) {
        return attribute != null ? attribute.trim() : null;
    }
    
    @Override
    public String convertToEntityAttribute(String dbData) {
        return dbData != null ? dbData.trim() : null;
    }
}
// すべてのString属性に適用され、意図しない副作用を引き起こす可能性がある

推奨アプローチは以下の通りです。

1
2
3
4
5
// 推奨: 特定の型専用のConverterを作成
@Converter(autoApply = true)
public class EmailAddressConverter implements AttributeConverter<EmailAddress, String> {
    // EmailAddress型にのみ適用される
}

誤解2: nullチェックの省略

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// アンチパターン: nullチェックの省略
@Converter
public class StatusConverter implements AttributeConverter<Status, String> {
    
    @Override
    public String convertToDatabaseColumn(Status attribute) {
        return attribute.getCode();  // NullPointerException発生の可能性
    }
    
    @Override
    public Status convertToEntityAttribute(String dbData) {
        return Status.fromCode(dbData);  // nullの場合に例外発生
    }
}

正しい実装は以下の通りです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 推奨: 適切なnullハンドリング
@Converter
public class StatusConverter implements AttributeConverter<Status, String> {
    
    @Override
    public String convertToDatabaseColumn(Status attribute) {
        if (attribute == null) {
            return null;
        }
        return attribute.getCode();
    }
    
    @Override
    public Status convertToEntityAttribute(String dbData) {
        if (dbData == null) {
            return null;  // またはデフォルト値を返す
        }
        return Status.fromCode(dbData);
    }
}

誤解3: 複雑なビジネスロジックの混入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// アンチパターン: ビジネスロジックの混入
@Converter
public class PriceConverter implements AttributeConverter<Price, Long> {
    
    @Autowired  // Converterでの依存注入は推奨されない
    private TaxService taxService;
    
    @Override
    public Long convertToDatabaseColumn(Price attribute) {
        // 税金計算などのビジネスロジックを含めるべきではない
        BigDecimal withTax = taxService.calculateWithTax(attribute.getAmount());
        return withTax.multiply(BigDecimal.valueOf(100)).longValue();
    }
}

Converterは純粋な型変換に徹するべきです。

誤解4: IDフィールドへのConverter適用

1
2
3
4
5
6
7
@Entity
public class Product {
    
    @Id
    @Convert(converter = UuidConverter.class)  // IDには適用されない
    private UUID id;
}

@Idが付与されたフィールドにはConverterは適用されません。UUIDをIDとして使用する場合は、Hibernateの型マッピングを使用します。

まとめと実践Tips

AttributeConverterの使いどころ

ユースケース 推奨度 備考
Enumのコード値変換 保守性と安全性が向上
JSON型カラムへの保存 PostgreSQLのJSONB型と組み合わせると効果的
暗号化フィールド Spring Security Cryptoも検討
レガシーシステム連携 日時型の形式変換など
単純な型変換 標準機能で対応可能なら不要

実践Tips

  1. Converterはステートレスに保つ: インスタンス変数を持たず、スレッドセーフにする
  2. 例外メッセージを明確に: 変換失敗時のデバッグを容易にする
  3. テストを必ず書く: 双方向変換の整合性を確認する
  4. autoApplyは慎重に: 影響範囲を把握した上で使用する
  5. パフォーマンスを考慮: JSONパースなど重い処理は適切にキャッシュする

テストコード例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class OrderStatusConverterTest {
    
    private final OrderStatusConverter converter = new OrderStatusConverter();
    
    @Test
    void convertToDatabaseColumn_正常変換() {
        assertThat(converter.convertToDatabaseColumn(OrderStatus.PENDING))
            .isEqualTo("PND");
        assertThat(converter.convertToDatabaseColumn(OrderStatus.CONFIRMED))
            .isEqualTo("CNF");
    }
    
    @Test
    void convertToDatabaseColumn_nullの場合() {
        assertThat(converter.convertToDatabaseColumn(null)).isNull();
    }
    
    @Test
    void convertToEntityAttribute_正常変換() {
        assertThat(converter.convertToEntityAttribute("PND"))
            .isEqualTo(OrderStatus.PENDING);
        assertThat(converter.convertToEntityAttribute("CNF"))
            .isEqualTo(OrderStatus.CONFIRMED);
    }
    
    @Test
    void convertToEntityAttribute_nullの場合() {
        assertThat(converter.convertToEntityAttribute(null)).isNull();
    }
    
    @Test
    void convertToEntityAttribute_不正なコード() {
        assertThatThrownBy(() -> converter.convertToEntityAttribute("INVALID"))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("Unknown OrderStatus code");
    }
    
    @Test
    void 双方向変換の整合性() {
        for (OrderStatus status : OrderStatus.values()) {
            String dbValue = converter.convertToDatabaseColumn(status);
            OrderStatus restored = converter.convertToEntityAttribute(dbValue);
            assertThat(restored).isEqualTo(status);
        }
    }
}

参考リンク