Enum Modelling — Codes, Converters and Filters
- 1. Overview
- 2. The Shape of a Coded Enum
- 3. Persistence: the AttributeConverter
- 4. Crossing to the DTO: MapStruct and Jackson
- 5. Filtering: resolving a
StringFilter - 6. The Two Boundaries of a Lookup Method
- 7. Limitations of the Current Design
- 8. Recipe: Add a New Coded Enum
- 9. Reference
- 10. Related Documentation
- 11. Change History
|
Related reading. This page is the connective tissue between several existing documents: Filterable List Pattern (the URL contract a filter must satisfy), Query Services (where the resolution happens), Race Number Lifecycle and Asynchronous Import (the two fullest worked examples of coded enums in the domain). |
1. Overview
An enumerated value in EMS is not simply a Java enum. It is a small contract that has to hold at four boundaries, and each boundary wants something different from it:
-
The database column — stores a short code, typically one or two characters, not the constant name.
-
The entity field — a typed Java
enum, read and written through a JPAAttributeConverter. -
The API response — the enum crosses to the DTO unchanged and Jackson serialises it as the constant name.
-
The API filter — a
StringFilteron aCriteriaobject, carrying a caller-supplied string that the query service must resolve back to the constant.
The interesting property is that boundaries 3 and 4 disagree. A response says "status":"CANCELLED"; the filter convention documented in Filterable List Pattern says ?state.in=S,I,U. A caller who reads a value out of a response and writes it back into a filter is doing the obvious thing, and the two vocabularies are not the same.
This page states the convention that keeps all four boundaries honest, and the rules for adding a new enumerated value.
It does not cover which enums exist — the domain catalogue is Domain Entities — nor the UI widget that renders an enum filter, which is in Filterable List Pattern.
2. The Shape of a Coded Enum
A coded enum carries two payloads beyond the constant itself: a short code for storage and a human name for display.
public enum OperationalBatchStatus {
/** Batch is accepting new transitions. Default on insert. */
OPEN('O', "Open"),
/** Batch is manually closed; report / manifest is frozen. */
CLOSED('C', "Closed"),
/** Batch was abandoned before closing. */
CANCELLED('X', "Cancelled");
private final char code;
private final String name;
public char getCode() { return code; }
public String getCodeString() { return Character.toString(code); }
public String getName() { return name; }
}
Three properties are load-bearing:
The code is opaque. It is a storage token, not an abbreviation the reader can derive. CANCELLED is X because C was already taken by CLOSED. Elsewhere in the domain OrderStatus.PENDING is D. Never infer a code from a constant name, and never write code that does — see [first-letter].
The code’s Java type varies: a single-character code may be declared char (as above) or String. Both are in use, so read the enum rather than assume — a resolver that calls getCode() expecting a char will not compile against the other form.
The code is immutable once data exists. Changing it silently reinterprets every stored row. A rename of the constant is safe; a change to the code is a data migration.
The name is for display, not for wire format. It is what an operator should read in a report. It is not what Jackson emits and not what a filter accepts.
|
Width follows the code, and the converter class name records it: a one-character code gets |
3. Persistence: the AttributeConverter
Codes reach the database through a jakarta.persistence.AttributeConverter in za.co.idealogic.model.convert, named <Enum>VarChar<width>Converter.
public class RaceNumberStateVarChar1Converter implements AttributeConverter<RaceNumberState, String> {
@Override
public String convertToDatabaseColumn(RaceNumberState attribute) {
if (attribute != null)
return Character.toString(attribute.getCode());
else
return null;
}
@Override
public RaceNumberState convertToEntityAttribute(String dbData) {
return RaceNumberState.fromChar(dbData);
}
}
The entity field names the converter explicitly. Converters are deliberately not registered with autoApply = true, so that reading a field tells you how it is stored:
@NotNull
@Column(name = "status", length = 1, nullable = false)
@Convert(converter = OperationalBatchStatusVarChar1Converter.class)
private OperationalBatchStatus status = OperationalBatchStatus.OPEN;
3.1. Converter or @Enumerated?
Both strategies are in use — roughly 27 converters against 16 @Enumerated(EnumType.STRING) fields — and the choice is not arbitrary.
AttributeConverter |
@Enumerated(EnumType.STRING) |
|
|---|---|---|
Stores |
A short code ( |
The constant name in full |
Unknown value in the column |
Handled by the enum’s lookup — returns a default rather than failing |
|
Use when |
The column is narrow, pre-dates the enum, or carries values this application did not write — legacy data, imported data, anything unconstrained |
The column is ours, constrained, and populated only by this application, and readability in the database matters more than width |
Cost |
A converter class per enum; the code is opaque in raw SQL |
A wide column; brittle against any value the enum does not know |
The rationale is recorded on the field itself where it matters most:
/**
* Read through a converter rather than {@code @Enumerated(EnumType.STRING)}: the column is an
* unconstrained varchar(10) carrying legacy values, and {@code Enum.valueOf} turns one such row
* into a hydration failure for every query touching the event.
*/
@Column(name = "identity_type")
@Convert(converter = IdentityTypeVarChar10Converter.class)
private IdentityType identityType;
Default to the converter for any domain enum stored against data with history. Reserve @Enumerated(EnumType.STRING) for columns introduced with the feature that owns them.
4. Crossing to the DTO: MapStruct and Jackson
The DTO declares the same enum type as the entity:
public class OperationalBatchDTO {
private OperationalBatchType type;
private OperationalBatchStatus status;
}
Consequently MapStruct performs an identity copy — no @ValueMapping, no converter, nothing to configure. This is the normal case and should stay that way: a DTO that redeclares the enum as a String loses type safety at the one boundary where a client is most likely to send something unexpected.
Jackson then serialises the constant by name(), because the enums carry no @JsonValue. So an API response looks like this:
{ "id": 42, "type": "STOCK_TAKE", "status": "CANCELLED" }
|
Responses carry names; the database carries codes. The Because the response value is the constant name, renaming a constant is a breaking change for API consumers — the wire contract is the identifier itself, even though the rename is database-safe (the converter stores the code, not the name). |
5. Filtering: resolving a StringFilter
JHipster criteria have no enum filter type. An enum column is filtered with a StringFilter, and the query service is responsible for turning each caller-supplied string into a constant before building the Specification.
public class OperationalBatchCriteria implements Serializable, Criteria {
private StringFilter status;
}
This is the boundary where the two vocabularies of Overview collide, and it has one rule:
|
Accept both vocabularies; reject everything else. A filter value may be the persisted code ( |
The reason is worth stating plainly. A filter that guesses does not return an error and does not return an empty set — it returns another value’s rows, and nothing in the response distinguishes that from a correct answer. An empty result makes a caller check their query; a plausible-looking wrong result does not.
5.1. The resolver pattern
private static final String ACCEPTED_STATUS_CODES = Arrays.stream(OperationalBatchStatus.values())
.map(OperationalBatchStatus::getCodeString).collect(Collectors.joining(", "));
private static final String ACCEPTED_STATUS_NAMES = Arrays.stream(OperationalBatchStatus.values())
.map(Enum::name).collect(Collectors.joining(", "));
private static OperationalBatchStatus resolveStatus(String raw) {
String value = raw == null ? "" : raw.trim();
if (value.length() == 1) { (1)
char code = Character.toUpperCase(value.charAt(0));
for (OperationalBatchStatus status : OperationalBatchStatus.values()) {
if (status.getCode() == code) return status;
}
} else if (!value.isEmpty()) { (2)
for (OperationalBatchStatus status : OperationalBatchStatus.values()) {
if (status.name().equalsIgnoreCase(value)) return status;
}
}
throw new ServiceBadRequestException( (3)
"Unrecognised OperationalBatch status filter value '" + raw + "'. Use a status code (" +
ACCEPTED_STATUS_CODES + ") or a status name (" + ACCEPTED_STATUS_NAMES + ").",
ENTITY_NAME,
"statusfiltervalueinvalid"
);
}
| 1 | Code branch, sized to the enum’s code width. A two-character code tests length() == 2. |
| 2 | Name branch, case-insensitive. Never falls through to a prefix or first-character match. |
| 3 | No default. Every path that is not an exact match raises. |
Four details make the difference between this working and merely appearing to:
Resolve eagerly. Call the resolver while building the Specification, not inside the lambda. A rejection raised inside the lambda surfaces when the query executes, which is a different and less predictable place to fail.
if (f.getEquals() != null) {
OperationalBatchStatus value = resolveStatus(f.getEquals()); // resolved here…
return (root, q, cb) -> cb.equal(root.get(OperationalBatch_.status), value); // …not here
}
Raise ServiceBadRequestException, not BadRequestAlertException. The latter lives in web.rest.errors, and ArchTest forbids the service package from depending on the web layer. ServiceBadRequestException exists for this case and ExceptionTranslator renders it as the same structured 400.
Derive the accepted-value lists from values(). Written out by hand, they drift the moment a constant is added or renamed, leaving the message advertising a vocabulary the resolver no longer honours.
Cover every branch. equals, notEquals, in, notIn all resolve; specified does not (it tests nullity and takes no value). A notIn branch left on a lenient lookup is the easiest one to miss and inverts the result set rather than shifting it.
5.2. Publishing the rejection
The 400 reaches API consumers only if it is annotated. Javadoc does not reach the published spec in this build — springdoc sees annotations only.
@GetMapping
@ApiResponses(
value = {
@ApiResponse(responseCode = "200", description = "A page of batches matching the criteria."),
@ApiResponse(
responseCode = "400",
description = "A status filter value is neither a status code (O, C, X) nor a status name "
+ "(OPEN, CLOSED, CANCELLED). Unrecognised values are rejected rather than resolved, "
+ "so the endpoint never answers a different question than the one asked."
),
}
)
public ResponseEntity<List<OperationalBatchDTO>> list(...)
Restate the success code — @ApiResponses replaces springdoc’s default rather than merging with it.
6. The Two Boundaries of a Lookup Method
Every coded enum exposes a lenient lookup — fromChar(String) or fromCode(String) — that returns a default constant rather than failing. That leniency is correct in one place and wrong in another, and the distinction is the single most important idea on this page.
| Converter read path | API filter path | |
|---|---|---|
Input comes from |
A column this application wrote |
A caller, over HTTP |
A value the enum does not know means |
Legacy or externally-written data — expected, and must not break the query |
A caller asked for something that does not exist |
Correct response |
Return a default and carry on |
Reject with |
Cost of the other choice |
One bad row fails every query touching the table |
The API answers a different question than the one asked, convincingly |
So fromChar is the right method for convertToEntityAttribute and the wrong method for a filter. Do not "fix" the shared lookup to be strict — it would reach every import path, where a lenient read of a spreadsheet cell is a deliberate design choice (see Membership Import). Put the strictness in the resolver that needs it.
7. Limitations of the Current Design
Codes are not first-letter mnemonics, and some collide with constant names. OrderStatus is UNPAID("U"), PENDING("D"), PAID("P"), REFUNDED("R"), CANCELLED("C"). A first-character read of "PENDING" yields P, which resolves to PAID — a wrong constant rather than a fallback. Any lookup that truncates before matching will silently mistranslate such a value. This is why the resolver above tests the whole string against the name, and only tests a single character when the input is a single character.
Strictness is a property of each resolver, not of the framework. Every enum filter in admin-service resolves strictly today, but nothing enforces it. A new filter written against the enum’s lenient lookup would compile, pass review that only reads it, and pass any test asserting row counts rather than returned values — while returning another value’s rows for a colliding name and the fallback constant’s rows for anything unrecognised. When adding or reviewing a filter over an enum column, check which behaviour it has rather than assuming.
convertToEntityAttribute(null) does not return null. The lenient lookups treat a null or empty input the same as an unknown one and return the default constant. For a nullable enum column this means a NULL row hydrates as the default rather than as null, and a specified=false filter and the hydrated entity can disagree. Nullable enum columns are rare in the schema; a converter for one should null-check explicitly rather than delegate to fromChar.
Lookup implementations are not uniform. Most read charAt(0); OperationalBatchType.fromCode matches the whole two-character string; MembershipStatus.fromChar switches on the whole string and has no null guard, so it falls through to its default for an untruncated name and throws on null. Read the specific enum before relying on its lookup’s behaviour.
8. Recipe: Add a New Coded Enum
-
Define the enum in
za.co.idealogic.event.enumeration(inadmin-service). Give each constant a code and a display name. Choose codes that do not collide, and javadoc each constant with what it means, not what it is called. -
Write the lookup —
fromCode(char)plus afromChar(String)wrapper — returning a sensible default for unknown input. This is the converter’s contract, not the API’s. -
Add the converter in
za.co.idealogic.model.convert, named<Enum>VarChar<width>Converter. Mapnulltonullon the write path. -
Add the column via a Liquibase changeset, with
lengthmatching the code width. See Development for changeset conventions. -
Wire the entity field with
@Column(length = …)and an explicit@Convert. Decide converter vs@Enumeratedusing Converter or@Enumerated?, and record the reason in javadoc if it is not the default choice. -
Declare the DTO field as the enum type. Let MapStruct identity-copy it; do not stringify it.
-
If the field is filterable, add a
StringFilterto theCriteria, write a strictresolveXxxin the query service following the resolver pattern above, coverequals/notEquals/in/notIn, and annotate the endpoint’s400with@ApiResponses. -
Test the collisions, not just the happy path. For each constant, assert that filtering by its name returns that constant’s rows — the failure mode is returning another constant’s rows, so a test that only checks "rows came back" passes against the bug. Add one unrecognised value and one blank value, both expecting rejection.
9. Reference
| File | Role |
|---|---|
|
The enum definitions: constants, codes, display names, lookups. |
|
JPA |
|
|
|
Strict resolution of filter values and |
|
Service-layer |
|
MapStruct entity-to-DTO mapping; enums cross as identity copies. |
10. Related Documentation
-
Filterable List Pattern — the URL contract an enum filter has to satisfy, and the
enumfilter facet and widget that emit it. -
Query Services — where filter resolution lives and how query services are structured against JHipster regeneration.
-
Race Number Lifecycle — the fullest worked example of a coded enum driving domain behaviour.
-
Asynchronous Import — coded and un-coded enums side by side, and the case for a plain
Stringwhere forward compatibility outranks type safety. -
Membership Import — the import path, where the lenient lookup is the deliberate choice.
-
Domain Entities — the catalogue of entities and the enums they carry.