Custom Type Conversions: AttributeConverter and Enums
Introduction
JPA handles most Java types automatically — String, Integer, LocalDate, BigDecimal. But what about a Java enum? A List<String> stored as JSON? A custom Money type? JPA’s AttributeConverter and @Enumerated let you control exactly how any Java type maps to a database column.
Mapping Enums with @Enumerated
Java enums are common in domain models. JPA maps them with @Enumerated:
public enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
public enum ProductStatus {
ACTIVE, INACTIVE, DISCONTINUED
}
@Column(length = 20, nullable = false)
@Enumerated(EnumType.STRING)
private OrderStatus status;
EnumType.STRING vs. EnumType.ORDINAL
EnumType.STRING | EnumType.ORDINAL | |
|---|---|---|
| Stored as | "PENDING", "SHIPPED" etc. | 0, 1, 2 etc. |
| Readable in DB | Yes | No |
| Safe on reorder | Yes | No — reordering breaks data |
| Default | No | Yes (JPA default) |
Always use EnumType.STRING. Never use EnumType.ORDINAL — if you add a new enum value in the middle, every existing ordinal shifts and your data is corrupted.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 20, nullable = false)
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.PENDING;
}
AttributeConverter: Full Control Over Type Mapping
AttributeConverter<X, Y> converts between a Java type X and a database column type Y. Implement this interface for any type JPA can’t handle natively.
Example 1: Storing a List as a comma-separated column
@Converter
public class StringListConverter implements AttributeConverter<List<String>, String> {
@Override
public String convertToDatabaseColumn(List<String> list) {
if (list == null || list.isEmpty()) return "";
return String.join(",", list);
}
@Override
public List<String> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return new ArrayList<>();
return Arrays.asList(dbData.split(","));
}
}
Apply it on the field:
@Entity
public class Product {
@Convert(converter = StringListConverter.class)
@Column(name = "search_keywords", length = 500)
private List<String> searchKeywords;
}
Stored in DB: "electronics,gadgets,sale"
Returned to Java: ["electronics", "gadgets", "sale"]
Example 2: Storing a custom Money value object
public class Money {
private final BigDecimal amount;
private final String currency;
public Money(BigDecimal amount, String currency) {
this.amount = amount;
this.currency = currency;
}
// getters, equals, hashCode
}
@Converter
public class MoneyConverter implements AttributeConverter<Money, String> {
@Override
public String convertToDatabaseColumn(Money money) {
if (money == null) return null;
return money.getAmount() + " " + money.getCurrency();
}
@Override
public Money convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
String[] parts = dbData.split(" ");
return new Money(new BigDecimal(parts[0]), parts[1]);
}
}
@Convert(converter = MoneyConverter.class)
@Column(name = "price", length = 30)
private Money price;
Example 3: Enum with a database code
Sometimes an enum must map to a specific database code, not its name:
public enum PaymentMethod {
CREDIT_CARD("CC"),
DEBIT_CARD("DC"),
BANK_TRANSFER("BT"),
PAYPAL("PP");
private final String code;
PaymentMethod(String code) { this.code = code; }
public String getCode() { return code; }
public static PaymentMethod fromCode(String code) {
return Arrays.stream(values())
.filter(pm -> pm.code.equals(code))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown code: " + code));
}
}
@Converter
public class PaymentMethodConverter implements AttributeConverter<PaymentMethod, String> {
@Override
public String convertToDatabaseColumn(PaymentMethod method) {
return method == null ? null : method.getCode();
}
@Override
public PaymentMethod convertToEntityAttribute(String code) {
return code == null ? null : PaymentMethod.fromCode(code);
}
}
@Convert(converter = PaymentMethodConverter.class)
@Column(name = "payment_method", length = 2)
private PaymentMethod paymentMethod;
Stored in DB as "CC", "DC", etc. — compact and meaningful.
Auto-Apply Converters
Adding autoApply = true to @Converter makes it apply automatically to all fields of that type — no @Convert annotation needed on each field:
@Converter(autoApply = true)
public class PaymentMethodConverter implements AttributeConverter<PaymentMethod, String> {
// same implementation
}
Now any PaymentMethod field in any entity is automatically converted without @Convert:
@Entity
public class Order {
private PaymentMethod paymentMethod; // auto-converted — no @Convert needed
}
Use autoApply = true when you have one canonical converter for a type and want it applied everywhere.
Date and Time Mappings
Spring Boot 3 with Hibernate 6 handles java.time types natively — no @Temporal annotation needed:
| Java type | SQL type | Notes |
|---|---|---|
LocalDate | DATE | Date only |
LocalTime | TIME | Time only |
LocalDateTime | DATETIME | No timezone |
ZonedDateTime | DATETIME + conversion | Timezone-aware |
Instant | DATETIME | UTC instant |
OffsetDateTime | DATETIME | With offset |
Year | SMALLINT | Hibernate 6+ |
YearMonth | VARCHAR / converter | Needs converter |
@Column(name = "ordered_at", nullable = false)
private LocalDateTime orderedAt;
@Column(name = "delivery_date")
private LocalDate deliveryDate;
Hibernate 6 maps these automatically. You only need @Temporal for the old java.util.Date type — avoid that in new code.
JSON Column Mapping (MySQL JSON type)
MySQL 5.7+ supports a native JSON column type. With Hibernate 6, you can map a Java object directly to JSON:
// For Hibernate 6 with MySQL JSON columns, use @JdbcTypeCode
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "JSON")
private Map<String, Object> metadata;
Or map a custom POJO:
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "JSON")
private ProductAttributes attributes;
This requires Jackson on the classpath (included by spring-boot-starter-web).
Boolean Mapping
MySQL’s TINYINT(1) is the standard for booleans:
@Column(name = "is_active", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 1")
private boolean active = true;
Hibernate maps Java boolean/Boolean to TINYINT(1) automatically for MySQL. No converter needed.
Applying Converters in Practice
The Product entity with enum and converter:
@Entity
@Table(name = "products")
@Getter @Setter @NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
private String name;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
// Enum mapped as STRING
@Column(length = 20, nullable = false)
@Enumerated(EnumType.STRING)
private ProductStatus status = ProductStatus.ACTIVE;
// List<String> stored as comma-separated value
@Convert(converter = StringListConverter.class)
@Column(name = "tags", length = 1000)
private List<String> tags = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
Key Takeaways
- Always use
@Enumerated(EnumType.STRING)—EnumType.ORDINALis dangerous and should be avoided AttributeConverter<JavaType, DbType>gives you full control over type mapping for any Java type@Converter(autoApply = true)applies a converter globally to all fields of that type- Hibernate 6 handles all
java.timetypes natively — no@Temporalneeded - For JSON columns in MySQL, use
@JdbcTypeCode(SqlTypes.JSON)with acolumnDefinition = "JSON" - Custom enum converters using a
codefield keep database values stable even when enum names change
What’s Next
Article 9 starts Part 3: Entity Relationships — beginning with @OneToOne, the simplest relationship type, covering both unidirectional and bidirectional mapping, foreign key placement, and cascade behaviour.