Replacing Type Code With State/Strategy
When the Type-Code Affects Behavior And It Should Change at Runtime
This post is part of a series:
Replacing Type Code With State/Strategy (this post)
Previously, we talked about potential issues switch-case can cause, and what to do when the type affects only data, or behavior, too.
Now that we are switch-case ninjas, we can go even further and see how to tackle when the type code and the behavior change dynamically at runtime.
The Problem
We’ll dive into the realm of DnD. Or something similar.
In our game, we’ll have four rooms. One is the start, the others contain different things: a dragon, treasure, and fireproof armor. We can move between specific rooms:
If we don’t move, we should act depending on the room: pick up the armor, attack the dragon, or open the chest.
Our initial code skeleton is the following:
class Game {
enum Room {
START, DRAGON, CHEST, ARMOR
}
enum GameStatus {
IN_PROGRESS, ENDED
}
enum Action {
MOVE, ACT
}
enum MovementDirection {
NORTH, SOUTH, EAST, WEST
}
Room room = Room.START;
GameStatus gameStatus = GameStatus.IN_PROGRESS;
boolean playerFireproof = false;
boolean dragonLives = true;
void play() {
while (gameStatus == GameStatus.IN_PROGRESS) {
System.out.println(currentRoomDescription());
Action action = nextUserAction();
if (action == Action.ACT) {
act();
} else {
move(movementDirection());
}
}
}
Action nextUserAction() {
// implementation skipped for easier understanding
}
MovementDirection movementDirection() {
// implementation skipped for easier understanding
}
String currentRoomDescription() {
// TODO
}
void act() {
// TODO
}
void move(MovementDirection direction) {
// TODO
}
}Three things depend on the current room:
The room’s description
If we move in a direction, in which room do we end up
The action we can perform
For simplicity, we won’t show any error messages when the user tries to do anything invalid. For example, when they try to move in a direction where there aren’t any doors.
Let’s see a naive implementation:
class Game {
// rest of the code
String currentRoomDescription() {
switch (room) {
case Room.START:
return "You are in an empty room. You see a door to the North and to the East.";
case Room.DRAGON:
if (dragonLives) {
return "You see a dragon. You can attack it if you want. Behind it there is a door to the East. You can go back South.";
}
return "The dragon is dead. You see a door to the South and to the East.";
case Room.CHEST:
return "There is a chest in the middle of the room. You can open it. You can also go back to the West.";
case Room.ARMOR:
if (playerFireproof) {
return "You are in an empty room. You see a door to the West.";
}
return "There is a fireproof armor in the middle of the room. You can pick it up. You can also go back to the West.";
default:
throw new IllegalStateException();
}
}
void act() {
switch (room) {
case Room.START:
// do nothing - no action is available in the start room
return;
case Room.DRAGON:
if (!dragonLives) {
// we can't do anything if the dragon is dead
return;
}
if (playerFireproof) {
// the player is fireproof so they can kill the dragon
dragonLives = false;
return;
}
gameStatus = GameStatus.ENDED;
System.out.println("The dragon burned you alive. You're dead. Game over.");
return;
case Room.CHEST:
gameStatus = GameStatus.ENDED;
System.out.println("You got the chest. Congratulations, you won!");
return;
case Room.ARMOR:
// player picks up the armor
playerFireproof = true;
return;
default:
throw new IllegalStateException();
}
}
void move(MovementDirection direction) {
switch (room) {
case Room.START:
switch (direction) {
case MovementDirection.NORTH:
room = Room.DRAGON;
return;
case MovementDirection.EAST:
room = Room.ARMOR;
return;
}
break;
case Room.DRAGON:
switch (direction) {
case MovementDirection.SOUTH:
room = Room.START;
return;
case MovementDirection.EAST:
if (!dragonLives) {
room = Room.CHEST;
}
return;
}
break;
case Room.CHEST:
if (direction == MovementDirection.WEST) {
room = Room.DRAGON;
}
break;
case Room.ARMOR:
if (direction == MovementDirection.WEST) {
room = Room.START;
}
break;
default:
throw new IllegalStateException();
}
}
}Yikes, that’s super ugly. We only have four rooms and the code already got out of hand. (And not only because now we have a god object.) We don’t want to imagine how unmaintainable it will be for dozens or hundreds of rooms.
Improving the Code
Fortunately, we already learned that we can create a class hierarchy to deal with different behaviors. The only problem is that we didn’t have to deal with changing states before.
The solution is easy. Keep the room-independent things in the game class and extract the rest.
Before we do that, we need to refactor a few things:
It’s worth extracting the
MovementDirectionenum because it’s not that tightly coupled to theGameclassThe
move()method should return the next room since it doesn’t have access to theGame.roomfield anymoreSimilarly, it’s worth extracting the
GameStatusenum and makeact()return the new statusWe should create the
Playerclass to manage the player’s stateWe pass the
Playerto theact()method to use/alter the player’s state
After all the steps above, the code looks like the following:
enum MovementDirection {
NORTH, SOUTH, EAST, WEST
}
enum GameStatus {
IN_PROGRESS, ENDED
}
class Player {
private boolean fireproof = false;
void pickUpFireproofArmor() {
fireproof = true;
}
boolean isFireproof() {
return fireproof;
}
}
class Game {
enum Room {
START, DRAGON, CHEST, ARMOR
}
enum Action {
MOVE, ACT
}
Room room = Room.START;
Player player = new Player();
GameStatus gameStatus = GameStatus.IN_PROGRESS;
void play() {
while (gameStatus == GameStatus.IN_PROGRESS) {
System.out.println(currentRoomDescription());
Action action = nextUserAction();
if (action == Action.ACT) {
gameStatus = act(player);
} else {
room = move(movementDirection());
}
}
}
Action nextUserAction() {
// implementation skipped for easier understanding
}
MovementDirection movementDirection() {
// implementation skipped for easier understanding
}
String currentRoomDescription() {
// implementation skipped for easier understanding
}
GameStatus act(Player player) {
// implementation skipped for easier understanding
}
Room move(MovementDirection direction) {
// implementation skipped for easier understanding
}
}The next step is to move the three room-dependent methods to a new interface:
interface Room {
String description();
GameStatus act(Player player);
Room move(MovementDirection direction);
}Now it's time to move forward and implement the four rooms:
interface Room {
Room START = new StartRoom();
Room DRAGON = new DragonRoom();
Room ARMOR = new ArmorRoom();
Room CHEST = new ChestRoom();
String description();
GameStatus act(Player player);
Room move(MovementDirection direction);
}
class StartRoom implements Room {
@Override
String description() {
return "You are in an empty room. You see a door to the North and to the East.";
}
@Override
GameStatus act(Player player) {
// nothing to do
return GameStatus.IN_PROGRESS;
}
@Override
Room move(MovementDirection direction) {
switch (direction) {
case MovementDirection.NORTH:
return Room.DRAGON;
case MovementDirection.EAST:
return Room.ARMOR;
}
return this;
}
}
class DragonRoom implements Room {
boolean dragonLives = true;
@Override
String description() {
if (dragonLives) {
return "You see a dragon. You can attack it if you want. Behind it there is a door to the East. You can go back South.";
}
return "The dragon is dead. You see a door to the South and to the East.";
}
@Override
GameStatus act(Player player) {
if (!dragonLives) {
// we can't do anything if the dragon is dead
return GameStatus.IN_PROGRESS;
}
if (player.isFireproof()) {
// the player is fireproof so they can kill the dragon
dragonLives = false;
return GameStatus.IN_PROGRESS;
}
System.out.println("The dragon burned you alive. You're dead. Game over.");
return GameStatus.ENDED;
}
@Override
Room move(MovementDirection direction) {
switch (direction) {
case MovementDirection.SOUTH:
return Room.START;
case MovementDirection.EAST:
if (!dragonLives) {
return Room.CHEST;
}
break;
}
return this;
}
}
class ChestRoom implements Room {
@Override
String description() {
return "There is a chest in the middle of the room. You can open it. You can also go back to the West.";
}
@Override
GameStatus act(Player player) {
System.out.println("You got the chest. Congratulations, you won!");
return GameStatus.ENDED;
}
@Override
Room move(MovementDirection direction) {
if (direction == MovementDirection.WEST) {
return Room.DRAGON;
}
return this;
}
}
class ArmorRoom implements Room {
boolean armorPickedUp = false;
@Override
String description() {
if (armorPickedUp) {
return "You are in an empty room. You see a door to the West.";
}
return "There is a fireproof armor in the middle of the room. You can pick it up. You can also go back to the West.";
}
@Override
GameStatus act(Player player) {
armorPickedUp = true;
player.pickUpFireproofArmor();
return GameStatus.IN_PROGRESS;
}
@Override
Room move(MovementDirection direction) {
if (direction == MovementDirection.WEST) {
return Room.START;
}
return this;
}
}Note that we introduced constants in the Room interface so every room has a single instance. This is useful because each room class manages its state independently1. It improves cohesion and better follows the single-responsibility principle (SRP).
But there’s still something unsatisfying. Look closely at DragonRoom:
description()branches ondragonLivesact()branches ondragonLivesmove()branches ondragonLives
Three methods, all dispatching on the same internal flag. That’s a smaller version of the same smell we started with: behavior varying based on a boolean type code, with branching scattered across methods. ArmorRoom has the exact same pattern with armorPickedUp.
The flag is hiding something. dragonLives = true and dragonLives = false aren’t just two states of the same room. They’re two different kinds of rooms. They share a physical location but almost nothing else: different descriptions, different valid actions, different valid moves. When the flag flips, everything about the room changes.
That’s exactly the symptom that says “this wants to be a new variant”.
Going Further: Stateless Rooms, Explicit World
Let’s split the flagged rooms. DragonRoom becomes two classes: DragonRoom (the dragon is alive) and DefeatedDragonRoom (the dragon is dead). ArmorRoom becomes ArmorRoom and EmptyArmorRoom. We also push the pattern further in a second way: instead of having act() return GameStatus and tracking a separate gameStatus field on Game, act() returns the next Room. Killing the dragon? DragonRoom.act() returns the defeated variant. Picking up the armor? ArmorRoom.act() returns the empty variant. Even the game ending becomes a variant transition via a small TerminalRoom class that answers true to isTerminal()2.
But there’s a subtle problem we have to solve first.
If the rooms are stateless, where does the fact “the dragon is dead” live? Not in DragonRoom - that’s what we just got rid of. If the player walks from the defeated dragon room back to the start and comes north again, StartRoom.move() needs to know whether to route to the live dragon or to the defeated one. Stateless rooms have no memory, so the memory has to live somewhere else.
The cleanest home is an explicit World object. World holds facts about the environment that have to persist across room transitions: the dragon has been slain, the armor has been taken. Player keeps the things that belong to the player - like isFireproof(), which tells us whether they’re wearing the armor. The two are separate concerns on purpose: picking up the armor means the player wears it and the armor is gone from the room. Two facts, two objects3.
The rooms themselves stay stateless. They ask World where to route the player on a move, and they tell World when an action changes something persistent4.
class World {
private boolean dragonSlain = false;
private boolean armorTaken = false;
void slayDragon() {
dragonSlain = true;
}
void takeArmor() {
armorTaken = true;
}
Room dragonRoom() {
if (dragonSlain) {
return Room.DEFEATED_DRAGON;
}
return Room.DRAGON;
}
Room armorRoom() {
if (armorTaken) {
return Room.EMPTY_ARMOR;
}
return Room.ARMOR;
}
}
class Game {
Room room = Room.START;
Player player = new Player();
World world = new World();
// other code skipped for easier understanding
void play() {
while (!room.isTerminal()) { // we are checking the room instead of a global game state
System.out.println(room.description());
Action action = nextUserAction();
if (action == Action.ACT) {
room = room.act(player, world);
} else {
room = room.move(movementDirection(), world); // the room changes, not the game's state
}
}
System.out.println(room.description()); // we have to print the closing message here
}
}
interface Room {
Room START = new StartRoom();
Room DRAGON = new DragonRoom();
Room DEFEATED_DRAGON = new DefeatedDragonRoom();
Room ARMOR = new ArmorRoom();
Room EMPTY_ARMOR = new EmptyArmorRoom();
Room CHEST = new ChestRoom();
Room VICTORY = new TerminalRoom("You got the chest. Congratulations, you won!");
Room BURNED_ALIVE = new TerminalRoom("The dragon burned you alive. You're dead. Game over.");
String description();
Room act(Player player, World world);
Room move(MovementDirection direction, World world);
default boolean isTerminal() {
return false;
}
}
class StartRoom implements Room {
@Override
String description() {
return "You are in an empty room. You see a door to the North and to the East.";
}
@Override
Room act(Player player, World world) {
// nothing to do
return this;
}
@Override
Room move(MovementDirection direction, World world) {
switch (direction) {
case MovementDirection.NORTH:
return world.dragonRoom();
case MovementDirection.EAST:
return world.armorRoom();
}
return this;
}
}
class DragonRoom implements Room {
@Override
String description() {
return "You see a dragon. You can attack it if you want. Behind it there is a door to the East. You can go back South.";
}
@Override
Room act(Player player, World world) {
if (player.isFireproof()) {
// the player is fireproof so they can kill the dragon
world.slayDragon();
return Room.DEFEATED_DRAGON;
}
return Room.BURNED_ALIVE;
}
@Override
Room move(MovementDirection direction, World world) {
if (direction == MovementDirection.SOUTH) {
return Room.START;
}
return this;
}
}
class DefeatedDragonRoom implements Room {
@Override
String description() {
return "The dragon is dead. You see a door to the South and to the East.";
}
@Override
Room act(Player player, World world) {
// nothing to do
return this;
}
@Override
Room move(MovementDirection direction, World world) {
switch (direction) {
case MovementDirection.SOUTH:
return Room.START;
case MovementDirection.EAST:
return Room.CHEST;
}
return this;
}
}
class ArmorRoom implements Room {
@Override
String description() {
return "There is a fireproof armor in the middle of the room. You can pick it up. You can also go back to the West.";
}
@Override
Room act(Player player, World world) {
player.pickUpFireproofArmor();
world.takeArmor();
return Room.EMPTY_ARMOR;
}
@Override
Room move(MovementDirection direction, World world) {
if (direction == MovementDirection.WEST) {
return Room.START;
}
return this;
}
}
class EmptyArmorRoom implements Room {
@Override
String description() {
return "You are in an empty room. You see a door to the West.";
}
@Override
Room act(Player player, World world) {
// nothing to do
return this;
}
@Override
Room move(MovementDirection direction, World world) {
if (direction == MovementDirection.WEST) {
return Room.START;
}
return this;
}
}
class ChestRoom implements Room {
@Override
String description() {
return "There is a chest in the middle of the room. You can open it. You can also go back to the West.";
}
@Override
Room act(Player player, World world) {
return Room.VICTORY;
}
@Override
Room move(MovementDirection direction, World world) {
if (direction == MovementDirection.WEST) {
return Room.DEFEATED_DRAGON;
}
return this;
}
}
class TerminalRoom implements Room {
private final String message;
TerminalRoom(String message) {
this.message = message;
}
@Override
String description() {
return message;
}
@Override
Room act(Player player, World world) {
return this;
}
@Override
Room move(MovementDirection direction, World world) {
return this;
}
@Override
boolean isTerminal() {
return true;
}
}Every room class is now stateless: it owns no mutable fields. (So we don’t even have to store instances in constants, we could instantiate them every time we need them. It’s a design choice.) Every runtime decision that looks like “what’s the current state of this room?” is answered by asking World. Every runtime decision that looks like “what can the player do?” is answered by asking Player. The rooms themselves are pure behavior.
This is what “the type changes at runtime” actually means. When the player kills the dragon, the current room reference flips from DragonRoom to DefeatedDragonRoom. Two different classes, with different descriptions, different valid moves, different actions. The transition is driven by act() returning the next variant and Game reassigning room to it. Picking up the armor does the same thing. Even the game ending is a variant transition: the Room.VICTORY and Room.BURNED_ALIVE singletons are both TerminalRoom instances that answer true to isTerminal().
Notice the asymmetry in the Room interface: act() takes both Player and World, but move() only takes World. That’s because navigation in this game depends only on world facts - “where does north lead from the start room?” is a question about the world, not about the player5.
Also note that every class we have now is much simpler than before6.
In the previous post in the series, we already saw the advantages of introducing subclasses. Therefore, we’ll talk about what’s new in this implementation.
Introducing State/Strategy
Compared to our pet example in the previous post, we shared behavior and state among the classes. For example:
Game status
Flow of the game
Reading user input
Adding the common behavior to a superclass could have been a solution. From an architectural perspective, that would have been violating the SOLID principles; therefore, a suboptimal choice.
Not to mention that managing the game status in multiple places is challenging.
It was a wiser decision to keep the common parts in the Game class and extract the changing parts to the Room interface and its implementations.
This refactoring is called “replace type code with state/strategy”. The state/strategy refers to the State and Strategy design patterns.
Their intent is exactly the same as we implemented above:
Separate the common parts from the changing parts
Introduce a class hierarchy for the changing parts
Make them interchangeable at runtime
Their class diagrams (and code representations) are the same. The differentiator is only philosophical:
If the different implementations do different things, then it’s the State pattern. For example, different rooms have different behaviors.
If they do the same things, but differently, then it’s the Strategy. For example, when we save an image to different file formats, the result is the same: an image file on the disk, but the algorithms that converted the image to a binary representation are different.
Further Patterns
There are many other patterns we could use to replace conditional statements with polymorphism. Each of them has specific conditions under which they work best.
A non-comprehensive list of such patterns:
It’s worth knowing them because they are very powerful - if we use them wisely.
Do you struggle with any of those? I might write a post about them. Drop a comment to express your interest.
TLDR - State/Strategy for runtime-changing behavior
Symptom: a
switch-casewhere the current variant itself changes at runtime - not a flag inside a variant.Fix: each variant is its own stateless class. Persistent world facts live on
World, player facts onPlayer.Trick: transition methods return the next variant:
room = room.act(player, world)orroom = room.move(direction, world). Even game-over is a variant viaTerminalRoomandisTerminal().If a variant needs to become another variant mid-flight, you’re in State/Strategy territory.
Try this week: find one class in your codebase where a boolean flag flips the meaning of three or more methods. That’s a State/Strategy candidate.
Conclusion
If we feel that conditional statements are hard to maintain, we have many alternatives. In this series, we saw reasons beyond maintainability. We also understood a few specific refactoring techniques and their limitations.
But the most important thing is to use these techniques wisely. If a pattern is overused, it can decrease maintainability like any other technique. We should always remember that there isn’t a single best solution, a one-size-fits-all technique. Because, in engineering, the best answer to most questions is: “it depends”.
The series in one decision frame
Four posts, one question: what is the type code actually doing? Match the answer to the technique.
Think of the middle three rows as a ladder - each step adds one requirement, and each new requirement buys you one more tool. Stop one step too early and you’ll feel the
switchcreeping back in (see the innerswitchin Part 3’s enum). Climb one step too high and you’ll pay for flexibility you don’t need.The outer two rows are the reasons not to climb: the first because the code won’t live long enough to earn the refactoring, the last because the shape of the problem belongs to a different tool entirely.
The real skill isn’t knowing the patterns - it’s knowing which row you’re standing on.
And it’s also harmful because testability suffers. We basically introduced a weird singleton. Luckily, we’ll get rid of it soon.
Room.VICTORY and Room.BURNED_ALIVE could be separate classes instead of two instances of a shared TerminalRoom. Using a single class works here because both terminal states behave identically - they only differ in the text they display before the game ends. If one of them had unique behavior (a score screen, a restart prompt, a replay option), splitting them into distinct classes would be the right move. Reach for shared code when the behavior is genuinely shared, not to save typing.
The Player and World classes below aren’t what a strict DDD design would produce. Immutable value objects, richer types (sealed hierarchies or enums instead of booleans), explicit invariants, and commands/events would all be legitimate improvements. The goal here is to show the refactoring moves clearly, so I’ve kept these classes small and mutable on purpose. Treat them as placeholders for whatever shape fits your actual domain.
It might feel odd that World holds state while the rooms don’t. An equally defensible model would let each room own its state (as we did in Step 1) - the tradeoffs aren’t clear-cut. For this example, keeping rooms stateless and putting environmental facts in World is cleaner because of the “walk away and come back” problem: if the dragon room tracks whether its own dragon is alive, StartRoom has no way to ask about that without coupling to specific room types. Worth remembering what George E. P. Box said: “all models are wrong, but some are useful.”
move() only takes World because navigation in this game depends on world facts, not player attributes. In a game with player-gated doors (keyed locks, stamina thresholds, level requirements) move() would need Player too. The asymmetric signature reflects the honest dependency, not laziness.
Yes, we still have a few switch-case statements in the Room.move() implementations, but that should be your homework to remove those.


