Designing with Java Enums — When They're the Right Model and When They're Not

by Arif Ikhsanudin, Backend Developer

What makes enums worth taking seriously as a design tool

Java enums are full classes. They can have fields, constructors, methods, and implement interfaces. Each constant is a singleton instance of the enum class. This means behavior can live on the constant rather than in switch statements scattered across the codebase.

The shift in thinking: instead of asking "what should happen when the status is PENDING?" in five different places, the behavior belongs on Status.PENDING itself.

Behavior on constants — the pattern that eliminates switch statements

The naive enum:

public enum OrderStatus {
    PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED
}

Every time business logic branches on this enum, a switch statement appears somewhere. As the number of statuses grows, every switch must be updated. The compiler doesn't help — it won't tell you that a new status was added and four switch statements need updating.

The behavioral enum:

public enum OrderStatus {
    PENDING {
        @Override
        public boolean canTransitionTo(OrderStatus next) {
            return next == PROCESSING || next == CANCELLED;
        }

        @Override
        public String customerMessage() {
            return "Your order is awaiting processing.";
        }
    },
    PROCESSING {
        @Override
        public boolean canTransitionTo(OrderStatus next) {
            return next == SHIPPED || next == CANCELLED;
        }

        @Override
        public String customerMessage() {
            return "Your order is being prepared.";
        }
    },
    SHIPPED {
        @Override
        public boolean canTransitionTo(OrderStatus next) {
            return next == DELIVERED;
        }

        @Override
        public String customerMessage() {
            return "Your order is on its way.";
        }
    },
    DELIVERED {
        @Override
        public boolean canTransitionTo(OrderStatus next) {
            return false;
        }

        @Override
        public String customerMessage() {
            return "Your order has been delivered.";
        }
    },
    CANCELLED {
        @Override
        public boolean canTransitionTo(OrderStatus next) {
            return false;
        }

        @Override
        public String customerMessage() {
            return "Your order has been cancelled.";
        }
    };

    public abstract boolean canTransitionTo(OrderStatus next);
    public abstract String customerMessage();
}

Adding a new status now requires implementing canTransitionTo and customerMessage — the compiler enforces it. The transition logic and messaging live with the status definition. No switch statements.

The caller:

if (!order.getStatus().canTransitionTo(newStatus)) {
    throw new IllegalStateTransitionException(order.getStatus(), newStatus);
}

Enums with fields and constructors

When constants carry data, fields and constructors clean it up:

public enum HttpStatus {
    OK(200, "OK"),
    CREATED(201, "Created"),
    BAD_REQUEST(400, "Bad Request"),
    UNAUTHORIZED(401, "Unauthorized"),
    NOT_FOUND(404, "Not Found"),
    INTERNAL_SERVER_ERROR(500, "Internal Server Error");

    private final int code;
    private final String reason;

    HttpStatus(int code, String reason) {
        this.code = code;
        this.reason = reason;
    }

    public int code()   { return code; }
    public String reason() { return reason; }

    public boolean isSuccess()     { return code >= 200 && code < 300; }
    public boolean isClientError() { return code >= 400 && code < 500; }
    public boolean isServerError() { return code >= 500; }

    public static Optional<HttpStatus> fromCode(int code) {
        return Arrays.stream(values())
            .filter(s -> s.code == code)
            .findFirst();
    }
}

The fields are final — enum constants are singletons and their state should be immutable. The fromCode factory is the lookup pattern you'll write for almost every enum with a persistent representation: given the stored value, get the constant.

Implementing interfaces — enums as strategy objects

Enums can implement interfaces. This is the pattern that makes enums genuinely substitutable in polymorphic contexts:

public interface DiscountStrategy {
    BigDecimal apply(BigDecimal price);
    String description();
}

public enum CustomerDiscount implements DiscountStrategy {
    NONE {
        @Override
        public BigDecimal apply(BigDecimal price) { return price; }

        @Override
        public String description() { return "No discount"; }
    },
    LOYALTY_TEN_PERCENT {
        @Override
        public BigDecimal apply(BigDecimal price) {
            return price.multiply(BigDecimal.valueOf(0.90));
        }

        @Override
        public String description() { return "10% loyalty discount"; }
    },
    EMPLOYEE_THIRTY_PERCENT {
        @Override
        public BigDecimal apply(BigDecimal price) {
            return price.multiply(BigDecimal.valueOf(0.70));
        }

        @Override
        public String description() { return "30% employee discount"; }
    };
}

The calling code works against DiscountStrategy, not CustomerDiscount:

public BigDecimal calculateTotal(List<LineItem> items, DiscountStrategy discount) {
    BigDecimal subtotal = items.stream()
        .map(LineItem::price)
        .reduce(BigDecimal.ZERO, BigDecimal::add);
    return discount.apply(subtotal);
}

This is testable with any DiscountStrategy implementation. The enum constants are the production implementations; tests can provide their own.

EnumMap and EnumSet — the performance case

When you're mapping from enum constants to values or working with subsets of enum constants, EnumMap and EnumSet are significantly faster than HashMap and HashSet.

EnumMap uses an array indexed by ordinal under the hood — no hashing, no collision resolution, cache-friendly access:

EnumMap<OrderStatus, String> statusLabels = new EnumMap<>(OrderStatus.class);
statusLabels.put(OrderStatus.PENDING,    "Awaiting Processing");
statusLabels.put(OrderStatus.PROCESSING, "In Progress");
statusLabels.put(OrderStatus.SHIPPED,    "Shipped");

EnumSet uses a single long bitmask for enums with 64 or fewer constants — set operations (union, intersection, complement) become bitwise operations:

EnumSet<OrderStatus> activeStatuses = EnumSet.of(
    OrderStatus.PENDING,
    OrderStatus.PROCESSING,
    OrderStatus.SHIPPED
);

EnumSet<OrderStatus> terminalStatuses = EnumSet.complementOf(activeStatuses);

For any code that processes enum-keyed collections in a hot path, EnumMap and EnumSet are the correct default over their generic counterparts.

Where enums break down

Combinatorial states. An enum represents a fixed set of named constants. If your "status" is actually a combination of independent flags — (active | inactive) × (verified | unverified) × (trial | paid | churned) — the combinatorial explosion of constant names is unmanageable. A set of boolean fields or bitflags is cleaner. Enums model discrete named states, not combinations.

Extensibility without recompilation. Enums are closed. You can't add a new constant at runtime, load them from a database, or let a plugin add one. If the set of valid values is truly open — user-defined categories, tenant-specific statuses, plugin-contributed types — an enum is the wrong model. A database table with a string code column and a lookup service is more appropriate.

Persistence and serialization across versions. Persisting OrderStatus.PENDING by ordinal (0, 1, 2...) is fragile — reordering constants breaks existing data. Persisting by name is safer but brittle to rename. The standard practice: persist a stable string code stored as a field:

public enum OrderStatus {
    PENDING("pending"),
    PROCESSING("processing");

    private final String code;

    OrderStatus(String code) { this.code = code; }

    public String code() { return code; }

    public static OrderStatus fromCode(String code) {
        return Arrays.stream(values())
            .filter(s -> s.code.equals(code))
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException("Unknown status: " + code));
    }
}

Store code() in the database, not name() or ordinal(). This decouples the Java identifier from the stored representation — you can rename PENDING to AWAITING_PROCESSING without a data migration.

Enums as data carriers for large datasets. An enum with 50 constants, each with 10 fields, loaded at class initialization, held in memory for the process lifetime, is a smell. If the "constants" are really reference data that changes, belongs in a database, or is configurable per environment, they shouldn't be enums.

The design test

Two questions determine whether an enum is the right model:

Is the set of values fixed at compile time and stable across deployments? If no — if values come from configuration, a database, or user input — it's not an enum.

Does behavior vary by constant in a way that's currently expressed as switch statements or if-else chains? If yes, the behavior belongs on the enum.

Both conditions together make a strong case for a behavioral enum. The first alone suggests a simple enum is fine. Neither suggests you want something else entirely — a strategy pattern with a lookup map, a polymorphic type hierarchy, or a database-backed reference table.

The enum as a design tool earns its place when the domain has a genuinely finite, named set of states with behaviors that belong on those states. Forcing that model onto extensible or combinatorial domains is where enums become a liability.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Lazy vs Eager Loading in JPA — What Gets Loaded and When

JPA's fetch type determines when associated data is loaded from the database. Getting it wrong in either direction — too eager or too lazy — produces either unnecessary data transfer or N+1 queries. Here is the model and the correct defaults.

Read more

Hibernate Schema Generation and Validation — What ddl-auto Actually Does in Production

The spring.jpa.hibernate.ddl-auto setting controls whether Hibernate modifies your database schema at startup. Most teams use create or update in development and then wonder why production behaves differently. Here is what each setting does and what belongs in production.

Read more

How to Give Code Feedback Without Making It Personal

Code review feedback that feels like criticism of the person rather than the code creates defensiveness, damages collaboration, and produces worse outcomes than no feedback at all. The mechanics of giving it well are learnable.

Read more

When Even Senior Developers Can’t Replace a Tech Lead

“We don’t need a tech lead—we have senior developers.” It sounds reasonable… until decisions start going nowhere.

Read more