8.13. Refactoring Book. Replacing Type Codes (Class • Subclasses • State/Strategy) and Replacing Subclass with Fields
Type codes (magic numbers, strings, or flags that encode “what kind of thing this is”) creep into production systems because they’re quick and easy… at first. Over time they fuel brittle switch
es, scattered duplication, and hard-to-change behavior. The refactorings in this family replace those codes with types that the compiler understands and your teammates can reason about.
This guide modernizes the classic patterns with Java 17+ features (enums, records, sealed classes, switch expressions) and shows when to use each technique in practice. For background and canonical definitions, see Fowler’s catalog and notes about how the 2nd edition reorganized a few entries (e.g., Replace Type Code with State/Strategy effectively funnels into Replace Type Code with Subclasses). (refactoring.com, martinfowler.com)
When to apply which refactoring (quick mental model)
Replace Type Code with Class — The code is primarily data (identity/validation/formatting), not branching behavior. Replace primitive codes with a richer value type (enum or class/record). Great against “primitive obsession.” (ptgmedia.pearsoncmg.com)
Replace Type Code with Subclasses — The code drives polymorphic behavior and you want compile-time coverage (often a closed set). Replace the flag with a class hierarchy. Modern Java: sealed classes. (refactoring.com, Oracle Docs, openjdk.org)
Replace Type Code with State/Strategy — You need to change behavior at runtime or plug in different algorithms without exploding the hierarchy. Use State for lifecycle-dependent behavior; Strategy for algorithm families. (refactoring.guru)
Replace Subclass with Fields — You already have subclasses, but they differ only by constant data. Collapse them into fields on a single type. (Fowler also calls this Remove Subclass.) (refactoring.com)
Below are six real-world Java examples, each with before (problematic), why this hurts, after (refactoring), and why this solution—using modern Java 17+ idioms where it helps (records, sealed classes, switch expressions). (Oracle Docs, openjdk.org)
1) Replace Type Code with Class — Subscription plans in a SaaS app
Context. A user account has a plan: FREE
, PRO
, ENTERPRISE
. It’s used for gating features and quotas.
Before (problematic)
class Account {
private final String plan; // "FREE" | "PRO" | "ENTERPRISE"
private final int projectsLimit;
Account(String plan) {
this.plan = plan;
this.projectsLimit = plan.equals("PRO") ? 50 :
plan.equals("ENTERPRISE") ? 500 : 3;
}
boolean canUseSso() {
return plan.equals("ENTERPRISE");
}
String planLabel() {
return plan.toLowerCase();
}
}
Why this hurts in practice
No type safety. Any typo like
"PROS"
compiles and leaks into data.Scattered rules. Limits/labels/flags appear wherever the string pops up, inviting duplication and drift.
No discoverability. What are the legal values? You only “know” by convention.
After (refactoring)
Use an enum that centralizes invariants and behavior.
enum Plan {
FREE(3, false, "free"),
PRO(50, false, "pro"),
ENTERPRISE(500, true, "enterprise");
private final int projectsLimit;
private final boolean sso;
private final String label;
Plan(int projectsLimit, boolean sso, String label) {
this.projectsLimit = projectsLimit;
this.sso = sso;
this.label = label;
}
public int projectsLimit() { return projectsLimit; }
public boolean canUseSso() { return sso; }
public String label() { return label; }
}
final class Account {
private final Plan plan;
Account(Plan plan) { this.plan = plan; }
boolean canUseSso() { return plan.canUseSso(); }
int projectsLimit() { return plan.projectsLimit(); }
String planLabel() { return plan.label(); }
}
Why this solution
Type safety and exhaustiveness. Only valid plans exist; new plans require explicit declaration.
One source of truth. All plan rules live in
Plan
.Modern Java-friendly.
switch
expressions and pattern matching work great with enums. (openjdk.org, Oracle Docs)
2) Replace Type Code with Class — Handling money with Currency
Context. An e-commerce service passes currency codes as strings.
Before (problematic)
record Money(BigDecimal amount, String currencyCode) {
Money add(Money other) {
if (!currencyCode.equals(other.currencyCode)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(amount.add(other.amount), currencyCode);
}
}
Why this hurts in practice
Validation horror.
"usd"
vs"USD"
vs"US D"
all compile until they blow up at runtime.Formatting drift. Display logic re-implements ISO rules repeatedly.
After (refactoring)
Use the platform’s java.util.Currency
class to represent the code.
import java.util.Currency;
import java.text.NumberFormat;
record Money(BigDecimal amount, Currency currency) {
Money add(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("Currency mismatch");
return new Money(amount.add(other.amount), currency);
}
String format(Locale locale) {
var fmt = NumberFormat.getCurrencyInstance(locale);
fmt.setCurrency(currency);
return fmt.format(amount);
}
static Money of(BigDecimal amount, String iso4217) {
return new Money(amount, Currency.getInstance(iso4217));
}
}
Why this solution
Correct by construction.
Currency.getInstance
accepts only valid ISO-4217 codes; you get canonical symbols/formatting “for free.” (Oracle Docs)Less duplication. One, well-tested source of currency truth across the JDK. (Oracle Docs)
3) Replace Type Code with Subclasses — Notification channels
Context. A service sends notifications over email, SMS, or push, selected by a channel code.
Before (problematic)
final class NotificationService {
void send(String channel, String to, String subject, String body) {
switch (channel) {
case "EMAIL" -> sendEmail(to, subject, body);
case "SMS" -> sendSms(to, body);
case "PUSH" -> sendPush(to, subject, body);
default -> throw new IllegalArgumentException("Unknown channel");
}
}
// email/sms/push implementation details...
}
Why this hurts in practice
Growing switch. Every new channel modifies this method (Open/Closed violation).
Parameter drift. Each channel wants different data (e.g., SMS has no subject).
Testing pain. You can’t isolate behaviors; you need the big service to test each branch.
After (refactoring)
Use subclasses (a sealed hierarchy) to model channel-specific behavior.
sealed interface NotificationChannel permits Email, Sms, Push {
void send(String to, String subject, String body);
}
final class Email implements NotificationChannel {
@Override public void send(String to, String subject, String body) { /* SMTP */ }
}
final class Sms implements NotificationChannel {
@Override public void send(String to, String subject, String body) { /* SMS API ignores subject */ }
}
final class Push implements NotificationChannel {
@Override public void send(String to, String subject, String body) { /* FCM/APNs */ }
}
final class NotificationService {
private final Map<String, NotificationChannel> channels = Map.of(
"EMAIL", new Email(), "SMS", new Sms(), "PUSH", new Push()
);
void send(String channelCode, String to, String subject, String body) {
var channel = channels.get(channelCode);
if (channel == null) throw new IllegalArgumentException("Unknown channel");
channel.send(to, subject, body);
}
}
Why this solution
Closed for modification. New channels = new class;
NotificationService
stays stable.Compiler help. A sealed interface guarantees a known, finite set of implementations—great for exhaustive pattern matching and API control. (Oracle Docs, openjdk.org)
4) Replace Type Code with Subclasses — Banking accounts and overdraft rules
Context. A core banking module has accountType
that controls fees/overdrafts.
Before (problematic)
final class Account {
private final String accountType; // "CHECKING" | "SAVINGS"
boolean canOverdraft() {
return "CHECKING".equals(accountType);
}
BigDecimal feeFor(BigDecimal amount) {
switch (accountType) {
case "CHECKING" -> { return amount.multiply(new BigDecimal("0.01")); }
case "SAVINGS" -> { return BigDecimal.ZERO; }
default -> throw new IllegalStateException("Unknown");
}
}
}
Why this hurts in practice
Mixed concerns. Business rules are tangled in a generic class.
Extension risk. Adding “Student” or “Premium” accounts forces risky edits across multiple methods.
After (refactoring)
Promote account types to a sealed class hierarchy.
sealed abstract class Account permits CheckingAccount, SavingsAccount {
abstract boolean canOverdraft();
abstract BigDecimal feeFor(BigDecimal amount);
}
final class CheckingAccount extends Account {
@Override boolean canOverdraft() { return true; }
@Override BigDecimal feeFor(BigDecimal amount) { return amount.multiply(new BigDecimal("0.01")); }
}
final class SavingsAccount extends Account {
@Override boolean canOverdraft() { return false; }
@Override BigDecimal feeFor(BigDecimal amount) { return BigDecimal.ZERO; }
}
Why this solution
Polymorphism over conditionals. Eliminates fragile switches and localizes policy where it belongs.
Sealed classes balance extensibility with control (only permitted types can extend), aligning with Java 17’s language model. (Oracle Docs)
5) Replace Type Code with State (or Strategy) — Document workflow
Context. A content platform tracks article status (DRAFT
, IN_REVIEW
, PUBLISHED
) with a field and big if
trees.
Before (problematic)
final class Document {
private String status = "DRAFT";
void submitForReview() {
if ("DRAFT".equals(status)) status = "IN_REVIEW";
else throw new IllegalStateException("Only DRAFT can be submitted");
}
void publish() {
if ("IN_REVIEW".equals(status)) status = "PUBLISHED";
else throw new IllegalStateException("Only IN_REVIEW can be published");
}
boolean canEditBody() {
return "DRAFT".equals(status) || "IN_REVIEW".equals(status);
}
}
Why this hurts in practice
State explosion. Every new state or rule multiplies conditionals throughout the class.
Bug-prone transitions. Invalid transitions are easy to introduce.
After (refactoring)
Model states explicitly and delegate behavior to the current state.
sealed interface DocState permits Draft, InReview, Published {
DocState submitForReview();
DocState publish();
boolean canEditBody();
}
final class Draft implements DocState {
public DocState submitForReview() { return new InReview(); }
public DocState publish() { throw new IllegalStateException("Review first"); }
public boolean canEditBody() { return true; }
}
final class InReview implements DocState {
public DocState submitForReview() { return this; } // idempotent
public DocState publish() { return new Published(); }
public boolean canEditBody() { return true; }
}
final class Published implements DocState {
public DocState submitForReview() { throw new IllegalStateException("Already live"); }
public DocState publish() { return this; }
public boolean canEditBody() { return false; }
}
final class Document {
private DocState state = new Draft();
void submitForReview() { state = state.submitForReview(); }
void publish() { state = state.publish(); }
boolean canEditBody() { return state.canEditBody(); }
}
Why this solution
Encapsulated transitions. Legal moves live next to state-specific rules; no giant
if
web.Runtime flexibility. State objects can change behavior dynamically (vs. static subclass choice). See also Strategy vs. State distinctions. (refactoring.guru, Wikipedia)
6) Replace Subclass with Fields — HTTP error responses
Context. An API library had subclasses per HTTP error with only constant data.
Before (problematic)
abstract class HttpError {
abstract int code();
abstract String phrase();
}
final class NotFound extends HttpError {
int code() { return 404; }
String phrase() { return "Not Found"; }
}
final class Unauthorized extends HttpError {
int code() { return 401; }
String phrase() { return "Unauthorized"; }
}
final class Forbidden extends HttpError {
int code() { return 403; }
String phrase() { return "Forbidden"; }
}
Why this hurts in practice
No behavior differences. These subclasses add ceremony, not value.
API noise. Users import multiple types just to get constants.
Hard to extend. New errors require new classes plus wiring.
After (refactoring)
Collapse to a single data carrier. A record
is perfect.
public record HttpError(int code, String phrase) {
public static final HttpError NOT_FOUND = new HttpError(404, "Not Found");
public static final HttpError UNAUTHORIZED = new HttpError(401, "Unauthorized");
public static final HttpError FORBIDDEN = new HttpError(403, "Forbidden");
}
Why this solution
Less hierarchy, same power. Call sites use one simple type; constants model the fixed data.
Modern Java records reduce boilerplate for immutable data. (Oracle Docs)
This is exactly what Replace Subclass with Fields recommends when subclasses differ only by constant returns. (refactoring.com)
Practical guidance: choosing wisely
Start by asking: Is the code about identity/validation or behavior?
Identity/validation → Class (enum/record).
Behavior → Subclasses or Strategy/State.
Prefer sealed hierarchies for closed sets; they give you compiler checks and clearer APIs. (Oracle Docs)
Prefer Strategy when you swap algorithms (e.g., tax calculators per region or promotion engines); prefer Statewhen behavior changes as an object moves through a lifecycle. (refactoring.guru)
If subclasses only return constants, collapse them into a single type with fields. (refactoring.com)
Conclusion
Replacing type codes pays off immediately: type safety, locality of change, and simpler tests. Use enums/records to model domain values; reach for sealed subclasses to express behavioral variety; choose State/Strategy when behavior must be pluggable or evolve at runtime; and remove subclasses that add no real behavior. These moves align with modern Java features (records, sealed classes, switch expressions) and with decades of refactoring practice, reducing conditional complexity while making your intent explicit. (Oracle Docs, openjdk.org, refactoring.com)