skip to content

When designing behavior that varies per enum constant, when should you use a switch (or EnumMap of handlers) versus constant-specific method bodies (polymorphism on the enum)?

level: principalimportance: nice to knowfreq 30%

answer

  1. intrinsic+everywhere -> behavior on the enum (abstract method/constructor strategy)
  2. caller/layer-specific -> switch or EnumMap at call site
  3. abstract method = compile-time completeness for new constants
  4. exhaustive switch expression recovers completeness
  5. add constants -> on-enum; add behaviors -> call-site (expression problem)

basics

~20 s

If the behavior naturally belongs to the enum itself and every constant must define it, give the enum an abstract method and override it per constant — the compiler then forces each new constant to provide behavior. If the behavior belongs to the caller or varies by use site, use a switch or an EnumMap of handlers there.

solid answer

~50 s

Two idioms compete. Constant-specific methods (an abstract method on the enum, overridden per constant, or a strategy passed in the constructor) keep behavior with the data and make it impossible to add a constant without defining its behavior — the compiler enforces completeness. That is ideal when the behavior is intrinsic to the constant and shared across the codebase. Switches (or an EnumMap from constant to handler) put the behavior at the call site, which is right when the behavior is specific to one caller, when the enum should not depend on the caller's types/libraries, or when many unrelated behaviors would bloat the enum. The switch's weakness is completeness: a new constant can slip past unless you use an exhaustive switch expression that fails to compile when a case is missing. A good rule: intrinsic, ubiquitous behavior -> polymorphic enum; localized or layering-sensitive behavior -> exhaustive switch or EnumMap of strategies.

code

java · 14 lines
java
// On-enum polymorphism: compiler forces every constant to define apply()
enum Operation {
    PLUS  { public int apply(int a, int b) { return a + b; } },
    MINUS { public int apply(int a, int b) { return a - b; } };
    public abstract int apply(int a, int b);
}

// Call-site dispatch: exhaustive switch fails to compile if a case is missing
String render(Operation o) {
    return switch (o) {
        case PLUS  -> "+";
        case MINUS -> "-";
    };
}

go deeper

for a junior

Can write a switch over an enum and knows an enum can have methods, but may not weigh the trade-offs.

for a middle

Knows constant-specific method bodies exist and that a switch can miss new constants; picks one but reasons mostly locally.

for a senior

Chooses deliberately by where behavior belongs, uses exhaustive switch expressions or constructor strategies, and protects EnumMap dispatch from missing keys.

for a principal

Frames it as the expression problem (adding constants vs behaviors), enforces layering (keep call-site behavior out of domain enums), and sets conventions/lints (exhaustive switches, startup coverage checks) across teams.

## The choice You have an enum and behavior that differs per constant. There are two main ways to express it. ### Option A — constant-specific behavior on the enum (polymorphism) Java lets each enum constant override methods. Two flavors: **Abstract method overridden per constant:** ```java enum Operation { PLUS { public int apply(int a, int b) { return a + b; } }, MINUS { public int apply(int a, int b) { return a - b; } }; public abstract int apply(int a, int b); } ``` Declaring `apply` abstract forces **every** constant — including any added later — to supply an implementation, or the code does not compile. Behavior lives with the data. **Strategy via constructor (cleaner for shared logic):** ```java enum Operation { PLUS("+", Integer::sum), MINUS("-", (a, b) -> a - b); private final String symbol; private final IntBinaryOperator op; Operation(String s, IntBinaryOperator op) { this.symbol = s; this.op = op; } public int apply(int a, int b) { return op.applyAsInt(a, b); } } ``` ### Option B — behavior at the call site (switch or EnumMap of handlers) ```java int apply(Operation o, int a, int b) { return switch (o) { // exhaustive switch expression case PLUS -> a + b; case MINUS -> a - b; }; } ``` or an **EnumMap of strategies** built once and looked up: ```java EnumMap<Operation, IntBinaryOperator> ops = new EnumMap<>(Operation.class); ops.put(Operation.PLUS, Integer::sum); ops.put(Operation.MINUS, (a, b) -> a - b); ``` ## The decisive criteria 1. **Where does the behavior belong?** If it is **intrinsic** to the constant and the same everywhere it is used (a tax rate per `Country`, the arithmetic of an `Operation`), it belongs **on the enum** (Option A): one definition, reused everywhere. If it is **specific to one caller or layer** (how the *rendering* module draws each `Shape`, how the *billing* service prices each `PlanType`), it belongs at the **call site** (Option B) so the enum stays neutral. 2. **Layering / dependencies.** Putting behavior on the enum makes the enum depend on whatever that behavior needs. If that would drag UI, persistence, or framework types into a core domain enum, keep the behavior out (use a switch/EnumMap in the appropriate layer). 3. **Completeness guarantee.** Option A's abstract method gives a **compile-time guarantee** that every constant — present and future — has behavior. A classic switch gives no such guarantee (a new constant silently misses). An **exhaustive switch expression** over an enum recovers much of this: it fails to compile if a case is missing and there is no `default`, so adding a constant forces you to update each switch. 4. **Number of behaviors.** One behavior shared by all callers -> put it on the enum. Many distinct, caller-specific behaviors -> do not bloat the enum with all of them; localize each. 5. **Open/closed pressure.** If you frequently add **behaviors** (new operations over the same constants), call-site dispatch scales better. If you frequently add **constants**, on-enum polymorphism scales better because the compiler herds you. (This is the classic 'expression problem' tension.) ## EnumMap-of-handlers as a middle ground An `EnumMap<E, Handler>` is the call-site option made data-driven: O(1) dispatch, declaration-order iteration, and the registry sits in the layer that owns those handlers. Its risk mirrors the switch's: a missing entry returns null at runtime rather than failing to compile — so validate the map covers all constants (e.g., assert `keySet().equals(EnumSet.allOf(E.class))` at startup) if completeness matters. ## Rule of thumb - Intrinsic + ubiquitous behavior, want compiler-enforced completeness -> **abstract method / constructor strategy on the enum.** - Caller-specific or layering-sensitive behavior, or many behaviors -> **exhaustive switch expression** (for compile-time completeness) **or EnumMap of handlers** (for data-driven, validated dispatch).

  • How does an exhaustive switch expression mitigate the classic 'a new constant slipped past my switch' bug?
    A switch expression over an enum with no default must cover every constant; if you add a constant, every such switch stops compiling until you add the case, turning a silent runtime gap into a compile error.
  • If you use an EnumMap of handlers, how do you guard against a missing constant?
    Validate coverage at startup, e.g. assert that map.keySet() equals EnumSet.allOf(E.class), so a missing handler fails fast instead of returning null deep in a request.

Like deciding whether a recipe lives in the ingredient (each fruit knows how to be juiced) or in the kitchen station (the bar decides how to use each fruit): intrinsic skills go on the ingredient, station-specific uses go on the station.

saying these in an interview costs you the question

  • Putting caller/UI/persistence-specific behavior on a core domain enum, dragging unwanted dependencies into it.
  • Relying on a classic switch with default for per-constant behavior and silently mishandling new constants.
  • Bloating an enum with many unrelated behaviors that belong in different layers.
  • Assuming an EnumMap of handlers is complete without validating coverage (missing key returns null).

context