Back to Behavioral: Strategy, Observer, Command, State, Template
Q4Question 4 of 12

Modeling state and behavior cleanly: guarded enum transitions vs the State pattern, inheritance vs composition, and thin services

Key Concepts
  • Use a guarded enum for simple lifecycles where behaviour per state is thin; use the State pattern when per-state behaviour is rich and varied.
  • A guarded transition method checks the current state and throws if the move is illegal, preventing invalid state changes.
  • Prefer composition over inheritance for type variants unless there is a genuine is-a relationship with meaningful shared behaviour.
  • Entities own their state transitions so business rules live close to the data; the service stays thin and only orchestrates.
  • The anaemic domain model anti-pattern scatters business rules into fat services and lets callers bypass guards with raw setters.
  • Refactoring from a guarded enum to the State pattern does not change the service because the service only calls high-level methods.

Definition

Use a guarded enum for simple lifecycles and the State pattern for rich ones, prefer composition over inheritance for variants, and let entities own their transitions while the service stays thin.
  • Guarded enum – model states as an enum; transition methods check the current state and reject illegal moves.
  • State pattern – each state is its own class implementing state-dependent behaviour; the context delegates to the current state object.
  • Composition over inheritance – for type variants (loan types, vehicle types), hold a type value or strategy object rather than creating a subclass hierarchy.
  • Rich domain model – entities own their transitions and enforce invariants; the service only orchestrates (load, call behaviour, save).
  • Anaemic domain model – the anti-pattern where entities are getters and setters and logic lives in fat services that bypass guards.

Think of a loan at National Bank of India. A loan moves through stages: applied, under review, approved, disbursed, closed, or rejected. Nobody should jump from applied straight to disbursed. If the stages are simple, you keep a status label and a small rule saying which moves are allowed: that is the guarded enum. If each stage has rich, very different behaviour, you give each stage its own expert desk: that is the State pattern. The loan itself should know how to move from approved to disbursed; the manager just says "disburse it," keeping the manager thin.

Understanding the Concepts

What is the guarded enum approach?

Model the states as an enum and put guarded transition methods on the entity. Each transition checks the current state and throws if the move is illegal.

JAVA
package com.bank.model;

import java.math.BigDecimal;

public enum LoanStatus {
    APPLIED, UNDER_REVIEW, APPROVED, DISBURSED, CLOSED, REJECTED
}

public class Loan {
    private LoanStatus status = LoanStatus.APPLIED;
    private BigDecimal amount;

    public void submitForReview() {
        if (status != LoanStatus.APPLIED) {
            throw new IllegalStateException(
                "Can only submit for review from APPLIED, current: " + status);
        }
        status = LoanStatus.UNDER_REVIEW;
    }

    public void approve() {
        if (status != LoanStatus.UNDER_REVIEW) {
            throw new IllegalStateException(
                "Can only approve from UNDER_REVIEW, current: " + status);
        }
        status = LoanStatus.APPROVED;
    }

    public void disburse() {
        if (status != LoanStatus.APPROVED) {
            throw new IllegalStateException(
                "Can only disburse from APPROVED, current: " + status);
        }
        status = LoanStatus.DISBURSED;
    }

    public LoanStatus getStatus() { return status; }
}

When do you upgrade to the State pattern?

Upgrade when each state carries substantial, genuinely different behaviour. If a long if-else on the status keeps appearing in many methods, or adding a new state means editing switches everywhere, the State pattern is cleaner.

JAVA
package com.bank.state;

import java.math.BigDecimal;

public interface LoanState {
    void approve(LoanContext ctx);
    void disburse(LoanContext ctx);
    void reject(LoanContext ctx);
}

public class UnderReviewState implements LoanState {
    @Override
    public void approve(LoanContext ctx) {
        System.out.println("Loan approved, moving to APPROVED");
        ctx.setState(new ApprovedState());
    }

    @Override
    public void disburse(LoanContext ctx) {
        throw new IllegalStateException("Cannot disburse a loan under review");
    }

    @Override
    public void reject(LoanContext ctx) {
        System.out.println("Loan rejected");
        ctx.setState(new RejectedState());
    }
}

public class ApprovedState implements LoanState {
    @Override
    public void approve(LoanContext ctx) {
        throw new IllegalStateException("Already approved");
    }

    @Override
    public void disburse(LoanContext ctx) {
        System.out.println("Disbursing loan of INR " + ctx.getAmount());
        ctx.setState(new DisbursedState());
    }

    @Override
    public void reject(LoanContext ctx) {
        throw new IllegalStateException("Cannot reject an approved loan");
    }
}

How do you decide between guarded enum and State pattern?

| Aspect | Guarded Enum | State Pattern |
|---|---|---|
| States have | Different transitions only | Rich, different behaviour |
| Complexity | Low | Higher (more classes) |
| New state means | Edit the entity | Add a new class |
| Switch statements | Acceptable if few | Signal to upgrade |
| Best for | Simple 3-5 state lifecycle | Complex workflow with per-state logic |

JAVA
// Guarded enum: simple, few states, thin behaviour
loan.approve(); // guard + status change

// State pattern: rich behaviour per state
loan.approve(); // delegates to current state object

When do you use inheritance vs composition for type variants?

Prefer composition over inheritance. Inheritance is appropriate only for a genuine is-a relationship with meaningful shared behaviour that honours Liskov Substitution.

JAVA
// Inheritance: tempting but rigid
public class HomeLoan extends Loan { } // single inheritance, tight coupling
public class CarLoan extends Loan { }  // what about a home + car combo?

// Composition: flexible, preferred
public class Loan {
    private final LoanType type;           // value object
    private final InterestStrategy interest; // pluggable strategy

    public BigDecimal calculateInterest(int days) {
        return interest.calculate(getAmount(), days);
    }
}

public enum LoanType { HOME, CAR, PERSONAL }

| Aspect | Inheritance | Composition |
|---|---|---|
| Flexibility | Fixed at compile time | Changeable at runtime |
| Combinations | Awkward (Java single inheritance) | Mix freely |
| Coupling | Subclass tightly coupled to superclass | Loose coupling via interface |
| Testing | Must test whole hierarchy | Test each piece independently |

Why should entities own their state transitions?

The anaemic domain model anti-pattern puts entities as getters and setters and puts all logic in a fat service. This scatters business rules and lets callers bypass guards.

JAVA
// Anaemic model (bad): service sets status directly
public class LoanService {
    public void approveLoan(Loan loan) {
        loan.setStatus(LoanStatus.APPROVED); // no guard, any status can be set
    }
}

// Rich domain model (good): entity owns its guard
public class Loan {
    public void approve() {
        if (status != LoanStatus.UNDER_REVIEW) {
            throw new IllegalStateException("Cannot approve from " + status);
        }
        status = LoanStatus.APPROVED;
    }
}

What does a thin service look like?

The service orchestrates the use case but does not contain business rules. It loads the entity, calls a behaviour, saves it, and publishes an event.

JAVA
package com.bank.service;

import com.bank.model.Loan;
import com.bank.repository.LoanRepository;
import com.bank.event.EventPublisher;

public class LoanService {
    private final LoanRepository repo;
    private final EventPublisher publisher;

    public LoanService(LoanRepository repo, EventPublisher publisher) {
        this.repo = repo;
        this.publisher = publisher;
    }

    public void approveLoan(String loanId) {
        Loan loan = repo.findById(loanId);
        loan.approve(); // entity enforces its own guard
        repo.save(loan);
        publisher.publish(new LoanApprovedEvent(loanId));
    }
}

What are the common mistakes?

The first mistake is exposing setters for the status field. This lets any caller bypass the guards.

JAVA
// Wrong: public setter bypasses all guards
public void setStatus(LoanStatus status) {
    this.status = status; // anyone can set any status
}

// Correct: no setter, only guarded transition methods
public void approve() { /* guard + transition */ }

The second mistake is putting business rules in the service instead of the entity, which distributes the same guard logic across multiple services.

How does refactoring work between approaches?

Because the service only calls high-level methods like approve(), refactoring the internal state implementation from a guarded enum to the State pattern does not require any change to the service.

JAVA
// Before: guarded enum inside Loan
loan.approve(); // checks status == UNDER_REVIEW, sets APPROVED

// After: State pattern inside Loan
loan.approve(); // delegates to currentState.approve(this)

// Service code is IDENTICAL in both cases
loanService.approveLoan("LOAN001"); // no change

What should a senior engineer know?

Entities owning transitions pairs naturally with persistence: you load the entity, invoke a behaviour, and save it. The entity guarantees it is never in an illegal state. At National Bank of India, a fat loan service once set a status with a setter and disbursed a loan that had never been approved, causing a real financial incident. Pankaj refactored so the loan entity owned guarded transitions, used a composed loan-type value instead of a subclass hierarchy, and kept the service thin. The illegal jump became impossible.

Visual Overview

DIAGRAM
  Loan Lifecycle (guarded enum)
  ─────────────────────────────
  APPLIED ──→ UNDER_REVIEW ──→ APPROVED ──→ DISBURSED ──→ CLOSED
                    │
                    └──→ REJECTED

  Each arrow is a guarded transition method.
  Illegal jumps (e.g., APPLIED → DISBURSED) throw IllegalStateException.
DIAGRAM
  Thin Service vs Rich Entity
  ───────────────────────────
  Controller ──→ LoanService.approveLoan(id)
                      │
                 1. repo.findById(id)
                 2. loan.approve()    ←── entity owns the guard
                 3. repo.save(loan)
                 4. publish event

  The service orchestrates. The entity enforces rules.

Key Takeaway

For modelling state and behaviour cleanly in a low-level design, the first decision is how rich the lifecycle is. If states are simple and the difference between them is mostly which transitions are legal, a guarded enum with transition methods on the entity is the right choice. Each method checks the current state and throws if the move is illegal. If each state carries substantial, genuinely different behaviour, upgrading to the State pattern gives each state its own class and eliminates sprawling conditionals.

For type variants like loan types or vehicle types, composition is preferred over inheritance. Inheritance is only appropriate for a genuine is-a relationship with meaningful shared behaviour. If the variation is about differing data or pluggable behaviour like interest rates and eligibility rules, composition with a type value or strategy object avoids a brittle single-inheritance hierarchy.

The third principle is keeping the service thin while the entity owns its transitions. The anaemic domain model anti-pattern scatters business rules into fat services and lets callers bypass guards with raw setters. The clean design puts transition logic inside the entity so the rules live with the data and cannot be bypassed. The service orchestrates: load, call behaviour, save, publish event.

At National Bank of India, a fat loan service once set a status with a setter and disbursed a loan that had never been approved. Pankaj refactored so the loan entity owned guarded transitions, and the illegal jump became impossible. Because the service only calls high-level methods like approve(), refactoring from a guarded enum to the State pattern later required zero changes to the service.

Remember one sentence: entities own their state transitions with guarded methods, the service stays thin, and you choose guarded enums for simple lifecycles and the State pattern for rich ones.

---

Share
Use ← → arrow keys to navigate