Enum Modelling — Codes, Converters and Filters

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:

  1. The database column — stores a short code, typically one or two characters, not the constant name.

  2. The entity field — a typed Java enum, read and written through a JPA AttributeConverter.

  3. The API response — the enum crosses to the DTO unchanged and Jackson serialises it as the constant name.

  4. The API filter — a StringFilter on a Criteria object, 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 varchar(1) and a …VarChar1Converter, a two-character code gets varchar(2) and a …VarChar2Converter. Keeping the three in step is what makes a mismatch obvious in review.

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 (varchar(1), varchar(2), …)

The constant name in full

Unknown value in the column

Handled by the enum’s lookup — returns a default rather than failing

Enum.valueOf throws, and the hydration failure takes down every query touching the row

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 getCode() value never appears in a REST payload, and the constant name never appears in a database column. Any code that assumes otherwise is wrong at one end or the other.

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 (X) or the constant name (CANCELLED). Anything that is neither must fail the request with a 400. It must never be resolved to a default.

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 400

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

  1. Define the enum in za.co.idealogic.event.enumeration (in admin-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.

  2. Write the lookupfromCode(char) plus a fromChar(String) wrapper — returning a sensible default for unknown input. This is the converter’s contract, not the API’s.

  3. Add the converter in za.co.idealogic.model.convert, named <Enum>VarChar<width>Converter. Map null to null on the write path.

  4. Add the column via a Liquibase changeset, with length matching the code width. See Development for changeset conventions.

  5. Wire the entity field with @Column(length = …) and an explicit @Convert. Decide converter vs @Enumerated using Converter or @Enumerated?, and record the reason in javadoc if it is not the default choice.

  6. Declare the DTO field as the enum type. Let MapStruct identity-copy it; do not stringify it.

  7. If the field is filterable, add a StringFilter to the Criteria, write a strict resolveXxx in the query service following the resolver pattern above, cover equals/notEquals/in/notIn, and annotate the endpoint’s 400 with @ApiResponses.

  8. 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

admin-serviceza.co.idealogic.event.enumeration.*

The enum definitions: constants, codes, display names, lookups.

admin-serviceza.co.idealogic.model.convert.*VarChar*Converter

JPA AttributeConverter per enum; the code-to-constant mapping at the persistence boundary.

admin-service…service.criteria.*Criteria

StringFilter declaration for a filterable enum column.

admin-service…service.*QueryService

Strict resolution of filter values and Specification construction.

admin-service…service.errors.ServiceBadRequestException

Service-layer 400 carrying entityName + errorKey, rendered by ExceptionTranslator.

admin-service…service.mapper.*Mapper

MapStruct entity-to-DTO mapping; enums cross as identity copies.

  • Filterable List Pattern — the URL contract an enum filter has to satisfy, and the enum filter 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 String where 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.

11. Change History

Date Change

2026-09-01

Initial version. Records the code/name convention, the converter contract, the converter-vs-@Enumerated rule, the entity-to-DTO crossing, and the strict filter-resolution pattern.