1. A domain type in Java and one column in the DB
Almost any mature data model eventually runs into a conflict between two worlds. In Java, we want meaningful types: ProductStatus, Sku, Email, PhoneNumber, OrderNumber — so the code reads like the domain, not like a “club of string lovers”. In the database, we often want one column: status, sku, email. And this is where two classic distortions begin: either we give up and store everything as strings, or we create entities where no entity is needed.
If you store domain values “any old way” (for example, status as String), the code turns into a “guess the format” series. In one place someone writes "ACTIVE", in another "active", in the third "A", and then you will be debugging this at 2 a.m. and wondering why IT did not go into gardening instead. If you try to make a separate entity for status with its own id, you get an extra lifecycle, extra relationships, and extra SQL questions — all for something that is conceptually one value.
We need a tool that honestly says: “in Java this is type X, in the DB this is type Y and one column, and I will describe the conversion explicitly”. In JPA, this tool is called AttributeConverter<X, Y>.
Before that, we looked at values that split into several columns, like Money and Address. For them, the natural tool is @Embeddable. But status, SKU, or an email code is the same typification question, just with a different storage shape: in Java, you want a meaningful type, while the table needs one column. That is where the @Enumerated / AttributeConverter branch begins.
And before we get there, it is useful to recall the simplest case — enum mapping.
2. Enum mapping: @Enumerated(EnumType.STRING)
When it comes to statuses, the first thing that comes to mind is an enum. And that is a good idea: a status is most often indeed limited to a finite set of values. But it is important how this enum will be stored in the database. In practice, JPA has two modes: ORDINAL and STRING. And one of them is like “using a chainsaw to open a packet of cookies”.
Let us start with the normal, safe option — EnumType.STRING. It stores the enum constant name as a string. Yes, it takes a little more space in the column, but it is much more stable and readable.
Mini example:
public enum CustomerStatus {
// The string "ACTIVE" or "BLOCKED" will be stored in the DB (if we use EnumType.STRING)
ACTIVE,
BLOCKED
}
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
// We explicitly tell JPA: store the constant name (not its ordinal number)
@Enumerated(EnumType.STRING)
private CustomerStatus status;
If you enable SQL trace, you will see something like:
update customer set status='BLOCKED' where id=...
And this is readable not only to Hibernate, but also to a person who “just opened the table to take a look”.
Now about ORDINAL. It stores the number (0, 1, 2...) of the enum constant. This is convenient right up until someone adds a new status in the middle or changes the order of the constants. Then your “BLOCKED” suddenly turns into “ACTIVE”, and “ACTIVE” into “DELETED”, and you get a business apocalypse without a single exception. It is like storing employee positions by their number in a list: today 0 is the director, and tomorrow you accidentally sort the list, and 0 becomes an intern.
So the basic course rule is: if you store an enum directly, then @Enumerated(EnumType.STRING) is almost always the best starting point.
When @Enumerated does not fit: codes instead of names
With EnumType.STRING, everything is fine as long as storing full names works for you. But the real world sometimes brings special cases. For example, the schema already has a status char(1) column with values A, H, D. Or product requirements say: “the table must contain exactly two characters, because that is the convention in this domain”. Or you want to store compact codes because they are part of an external contract with reports/integrations.
And here you have a dilemma. Either you make status a string and manually map the codes back and forth throughout the codebase (bad option). Or you keep working with an enum in Java, but teach Hibernate to save it to the database by code, not by name. That is the typical use case for AttributeConverter.
To make it easier to remember, keep this short intuition: @Enumerated means “enum as is”, while a converter means “enum (or any domain type) through a translator”.
3. AttributeConverter<X, Y> as a “translator” X↔Y
AttributeConverter<X, Y> is a JPA interface that says: “in the entity I have type X, but in the database I store type Y”. In practice, Y is most often something primitive for the JDBC world: String, Integer, Long, BigDecimal, LocalDate, and so on. And X is your domain type: an enum with a code, a value-like wrapper, sometimes even a small object (but strictly in one column).
The most important idea here is: a converter does not create a new table and does not turn the value into an entity. It simply helps Hibernate understand what to write into the column and how to restore the value when reading.
It works roughly like this:
flowchart LR
A["Entity field: X (domain type)"] -->|convertToDatabaseColumn| B["Column: Y (storage type)"]
B -->|convertToEntityAttribute| A
The interface has only two methods, and their names are as self-explanatory as possible:
convertToDatabaseColumn(X attribute) — what to write to the DB
convertToEntityAttribute(Y dbData) — what to get in Java when reading
Important: a converter is a technical mapping layer, not a place for business logic. It should translate values, validate the format, and fail with a clear error if the database contains garbage. But it should not “fix” data or make smart domain decisions.
4. Example: coded status and ProductStatusConverter
Let us make the example as close to the project as possible: Product has a status. For simplicity, let us take two states: the product is active and the product is hidden. In Java, we want to write ProductStatus.ACTIVE, and in the DB we want to store a short code (A or H). This is exactly the scenario a converter exists for.
Enum with a stable code
public enum ProductStatus {
// Stable code for storing in the DB (does not depend on the constant name)
ACTIVE("A"),
HIDDEN("H");
private final String code;
// The code is assigned once when the constant is declared
ProductStatus(String code) { this.code = code; }
public String getCode() { return code; }
}
To be able to restore the enum from the database, we need a reverse translation. In Java 25, it is convenient to use switch:
public static ProductStatus fromCode(String code) {
// Translate the value from the DB into the domain enum
return switch (code) {
case "A" -> ACTIVE;
case "H" -> HIDDEN;
// If an unknown code is stored in the DB, it is better to fail immediately and explicitly
default -> throw new IllegalArgumentException("Unknown status code: " + code);
};
}
Here we do something important: if an unknown code is stored in the DB, we do not silently return null; instead, we fail with a clear error. Yes, this may “break the query”, but then you will immediately know that the data in the database is inconsistent. In the deep-dive course, that counts as a plus: we want to see the problem, not sweep it under the rug.
Converter
Now let us write the converter itself. Note that this is jakarta.persistence, because we are on a modern stack (Spring Boot 4, Hibernate 7.2).
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter
public class ProductStatusConverter implements AttributeConverter<ProductStatus, String> {
@Override
public String convertToDatabaseColumn(ProductStatus status) {
// Java enum -> the value that will actually be written to the column (for example, "A")
return status == null ? null : status.getCode(); // ACTIVE -> "A"
}
@Override
public ProductStatus convertToEntityAttribute(String dbValue) {
// Value from the DB -> domain enum (for example, "A" -> ACTIVE)
return dbValue == null ? null : ProductStatus.fromCode(dbValue); // "A" -> ACTIVE
}
}
There are two small but important decisions here. First, we handle null correctly, because in real life columns can be nullable (especially in the early stages of a project). Second, we do not try to “improve” the data: if dbValue is unknown, let fromCode(dbValue) throw an exception.
Attaching the converter to an entity field
Now on the Product entity we specify which converter to use:
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@Entity
public class Product {
@Id
private Long id;
// Explicitly attach the converter to this field
@Convert(converter = ProductStatusConverter.class)
private ProductStatus status;
}
If you now save a product with status ACTIVE, you will see something like this in SQL:
insert into product (status, id) values ('A', ?)
So in Java you continue living in the world of ProductStatus, while the database stores exactly what the schema needs.
5. Attaching a converter: @Convert and autoApply
Once you understand the mechanics, the next practical question appears: “Do I have to put @Convert on every field? Or can I declare the converter once and have it applied everywhere?” Both options are valid — but each has its own character and side effects.
If you specify the converter explicitly with @Convert(converter = ...), the code becomes a bit more verbose, but also maximally predictable: you open the entity — and immediately see what happens to the field when it is saved. That is a good option for learning and for projects where transparency matters.
If you want the converter to be applied “by default” to all fields of a certain type, you can set autoApply = true:
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter(autoApply = true)
public class ProductStatusConverter implements AttributeConverter<ProductStatus, String> {
public String convertToDatabaseColumn(ProductStatus a) { return a == null ? null : a.getCode(); }
public ProductStatus convertToEntityAttribute(String db) { return db == null ? null : ProductStatus.fromCode(db); }
}
Then you no longer need to write @Convert on every field of type ProductStatus. Hibernate will see: “Aha, there is an auto-apply converter for ProductStatus — so we apply it.”
To avoid the feeling that this is a matter of “religion”, let us compare it from an engineering perspective:
| Approach | What it looks like in code | Main benefit | Main risk |
|---|---|---|---|
| @Convert on the field | Explicit on each field | Very transparent, easy to review | More annotations |
| @Converter(autoApply = true) | In one place | Less noise in the entity | May be applied accidentally where you did not expect it |
In a training project (and especially at your stage), I would prefer explicitness: first @Convert, and only when you are sure the type is really always stored the same way — then think about autoApply.
6. Converter and dirty checking: the connection with immutability
You might think: “Well, a converter just translates a value, what does dirty checking have to do with it?” But there is a connection, and it is quite practical. Hibernate decides whether to do an UPDATE by comparing the current field value with what was loaded (the snapshot). For basic fields, this often looks like “equals by value”.
If your domain type is immutable, everything is great: a new value means a new object, equals is usually honest, Hibernate sees the change, does the update, and you are happy.
If your type is mutable (setters) and you change its internals “quietly”, you risk very unpleasant effects. In one place, changes may not be noticed at all (if equals does not reflect the internal state); in another place, accidental updates may appear because the object mutated “somewhere on the side”. In the previous lecture, we already talked about how value objects are better handled with whole-value replacement — and that rule also applies to converter-based types.
Here is an example of “how not to do it” — a mutable domain type that you want to store in one column:
public class Sku {
private String value;
public Sku(String value) { this.value = value; }
public void setValue(String value) { this.value = value; } // dangerous
}
And here is an example of a “calm” option: an immutable wrapper. In Java 25, the convenient minimalism is record:
public record Sku(String value) {
public Sku {
// Validation of the domain value happens in one place
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("SKU is blank");
}
}
}
And the converter for it becomes simple and safe:
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter(autoApply = true)
public class SkuConverter implements AttributeConverter<Sku, String> {
public String convertToDatabaseColumn(Sku sku) { return sku == null ? null : sku.value(); }
public Sku convertToEntityAttribute(String db) { return db == null ? null : new Sku(db); }
}
Now Product.sku can become type Sku, while the database still stores an ordinary varchar. This makes the domain model more expressive without complicating the schema. And, especially nice for our course, it keeps you in control of dirty checking: new SKU — new Sku, no “tweaked the string inside the object”.
7. Choosing between embeddable, converter, @Enumerated, String
After three lectures in a row about “typification”, it is easy to go to extremes: “Let us wrap everything in the world, and we will have a type ProductName, a type Country, a type PostalCode, a type Street, a type House...” Theoretically you can, but practical value begins where the type really reduces mistakes and makes the code more readable.
It is useful to keep a simple map in your head:
| What you are modeling | How many columns in the DB | What to use | Example from the project |
|---|---|---|---|
| Group of related fields | 2+ | @Embeddable | Money(amount, currency), Address(...) |
| One value, one column | 1 | AttributeConverter<X, Y> | Sku ↔ varchar, “coded” status ↔ char(1) |
| Enum without a special format | 1 | @Enumerated(EnumType.STRING) | simple status where the enum name is acceptable |
| Free text | 1 | String (sometimes with validation) | Product.name, ProductDetails.description |
Pay attention to an important detail: a converter does not replace embeddable, and vice versa. These are just two different tools for two different storage shapes. You should not try to cram Money into one column via a converter “in the format 100.00|USD” — that would be inconvenient, bad for queries, and painful for migrations. But Sku or a “coded status” are ideal candidates for a converter, because they have a natural representation in one column.
8. Typical mistakes when working with AttributeConverter
Mistake #1: mixing @Convert and @Enumerated on the same field.
Sometimes you want to play it safe and put both on there: like, let it be both enum and converter. JPA does not work that way: a field must have one clear mapping strategy. If you use a converter for an enum, remove @Enumerated. If you use @Enumerated(EnumType.STRING), remove @Convert. Otherwise you will get either a mapping error or behavior that is hard to explain even to your future “self”.
Mistake #2: using EnumType.ORDINAL “because it takes less space”.
The saved bytes are later paid for with hours of investigation when the constant order changes and the database stays the same. If you really need compact storage, it is better to make a “coded enum” and a converter. Then the compactness will be stable and meaningful.
Mistake #3: hiding business logic in the converter.
A converter is not a service and not a domain model. It should not decide what to do with an unknown status (“let us just treat it as ACTIVE”). That is like hiding salary calculation rules in the toString() method: formally you can do it, but then nobody will understand why your payroll lives in such an unexpected place. The converter should only convert and validate the format.
Mistake #4: not handling null and getting an NPE in an unexpected place.
Even if you plan to make the column NOT NULL, first that must be expressed in the migration and in the data. Until then, the converter must be careful with null. Otherwise, you will get an NPE when reading old data or when saving an entity you are only just creating.
Mistake #5: enabling autoApply = true too early and “catching” unexpected fields.
Auto-apply is a convenient thing, but it makes the magic broader. In a training project, this easily leads to the situation “why was it applied here too?”. If you are not sure that the type is always stored the same way, start with @Convert on the field. It is not as trendy, but it is 100 times easier to debug.
GO TO FULL VERSION