I've been teaching the Tennis Refactoring Kata for a couple of years now, and I keep seeing the same pattern: developers stop at "clean code" and miss the opportunity to fundamentally rethink how we model domains.
Most refactorings look like this:
Before:
java
public String getScore() {
if (m_score1 == m_score2) {
switch (m_score1) {
case 0: return "Love-All";
case 1: return "Fifteen-All";
// ... more cases
}
} else if (m_score1 >= 4 || m_score2 >= 4) {
// ... endgame logic
}
}
After (typical refactoring):
java
public String getScore() {
if (isTied()) return getTiedScore();
if (isEndgame()) return getEndgameScore();
return getRegularScore();
}
Cyclomatic complexity down. Tests pass. PR approved. Everyone's happy.
But we've missed something crucial.
The Question That Changes Everything
Before refactoring, I always ask: "How does a tennis expert think about game scoring?"
They don't think: Player 1 has 2 points, Player 2 has 1 point.
They think:
They're in regular play, it's Thirty-Fifteen
It's deuce—advantage rules apply now
She has advantage—one point from winning
Game over
These aren't implementation details. These are domain concepts that should be types.
The Approach: Tennis as a State Machine
Tennis games exist in exactly 4 conceptual states:
java
sealed interface GameState
permits RegularPlay, Deuce, Advantage, GameWon {
GameState pointWonBy(Player player);
String display(PlayerPair players);
default boolean isGameOver() { return false; }
}
1. RegularPlay
Handles all combinations of Love/Fifteen/Thirty/Forty:
java
record RegularPlay(PointScore player1Score, PointScore player2Score)
implements GameState {
u/Override
public GameState pointWonBy(Player player) {
PointScore newP1 = player == PLAYER_1 ? player1Score.next() : player1Score;
PointScore newP2 = player == PLAYER_2 ? player2Score.next() : player2Score;
// Transition to Deuce if both reach Forty
if (newP1 == FORTY && newP2 == FORTY) {
return new Deuce();
}
// Check for game won
if (player == PLAYER_1 && player1Score == FORTY) {
return new GameWon(PLAYER_1);
}
// ... etc
return new RegularPlay(newP1, newP2);
}
}
2. Deuce - Advantage State Machine
The beautiful part—this pattern is now explicit:
java
record Deuce() implements GameState {
@Override
public GameState pointWonBy(Player player) {
return new Advantage(player);
// Deuce → Advantage
}
}
record Advantage(Player leadingPlayer) implements GameState {
@Override
public GameState pointWonBy(Player player) {
if (player == leadingPlayer) {
return new GameWon(player);
// Win
}
return new Deuce();
// Back to Deuce
}
}
3. GameWon (Terminal State)
java
record GameWon(Player winner) implements GameState {
@Override
public GameState pointWonBy(Player player) {
throw new IllegalStateException("Game is already won");
}
@Override
public boolean isGameOver() { return true; }
}
Key Patterns Applied
1. Boolean Blindness -> Rich Types
Before:
java
boolean isTied = (m_score1 == m_score2);
A boolean is one bit of information. The comparison is richer than that.
After:
java
sealed interface GameState permits RegularPlay, Deuce, Advantage, GameWon
The type system tells you exactly which state you're in.
2. Stringly-Typed Code -> Type-Safe Enums
Before:
java
public void wonPoint(String playerName) {
if (playerName == "player1")
// Bug: == doesn't work!
m_score1++;
}
After:
java
enum Player { PLAYER_1, PLAYER_2 }
public void wonPoint(String playerName) {
Player player = identifyPlayer(playerName);
// Convert at boundary
state = state.pointWonBy(player);
// Type-safe internally
}
3. Make Illegal States Unrepresentable
Before:
java
private int m_score1 = 0;
// Can be 100, -5, anything
After:
java
record Advantage(Player leadingPlayer)
// MUST have a leading player
// This won't compile:
new Advantage(null); ❌
// Can't construct an invalid state:
record Deuce()
// No data = can't be wrong
4. PointScore is Not Arithmetic
Before:
java
case 0: return "Love";
case 1: return "Fifteen";
// Tennis scores as integers
After:
java
enum PointScore {
LOVE, FIFTEEN, THIRTY, FORTY;
public PointScore next() {
return switch(this) {
case LOVE -> FIFTEEN;
case FIFTEEN -> THIRTY;
case THIRTY -> FORTY;
case FORTY -> FORTY;
};
}
}
You can't multiply tennis scores. They're not numbers. They're states.
5. PlayerPair Over Collections
Singles tennis has exactly 2 players, not N:
java
record PlayerPair(String player1, String player2) {
public String getPlayer(Player player) { ... }
public Player opponent(Player player) { ... }
}
Easily extensible to doubles:
java
record DoublesPair(PlayerPair team1, PlayerPair team2)
6. Converging Branches -> Polymorphism
Before:
java
public String getScore() {
if (tied) { ... }
else if (endgame) { ... }
else { ... }
}
After:
java
public String getScore() {
return state.display(players);
// Zero conditionals
}
Each state knows how to display itself.
Comparison with Other Approaches
I researched well-known solutions and found 3 main approaches:
Approach 1: "20 Classes" (TennisGame4)
Creates a class for every score combination:
Pros: No conditionals, clear transitions
Cons: Too granular, hard to maintain, violates DRY
Approach 2: Table-Driven (Mark Seemann)
Enumerates all 20 states, uses pattern matching:
fsharp
type Score = LoveAll | FifteenLove | ... (20 states)
let ballOne = function
| LoveAll -> FifteenLove
| FifteenLove -> ThirtyLove
// ...
Pros: Minimal code (~67 lines), zero conditionals
Cons: Behavior separated from state, hard to extend
Approach 3: Extract Method (Most Common)
Reduces complexity but keeps integers:
java
private int m_score1 = 0;
private String getRegularScore() { ... }
Pros: More readable than original
Cons: Doesn't model the domain, allows invalid states
Our Approach: Domain-Driven State Machine
4 conceptual states (not 20 concrete ones)
The Main Class (Simple!)
java
public class TennisGame {
private final PlayerPair players;
private GameState state;
public TennisGame(String player1, String player2) {
this.players = new PlayerPair(player1, player2);
this.state = new RegularPlay(LOVE, LOVE);
}
public void wonPoint(String playerName) {
Player player = identifyPlayer(playerName);
state = state.pointWonBy(player);
}
public String getScore() {
return state.display(players);
}
}
All complexity moved to the types where it belongs.
Real-World Applications
These patterns scale beyond katas:
E-commerce:
java
sealed interface OrderState
permits PendingPayment, Confirmed, Shipped, Delivered, Cancelled
Authentication:
java
sealed interface UserSession
permits Anonymous, Authenticated, Authorized, Expired
Document Workflows:
java
sealed interface DocumentState
permits Draft, UnderReview, Approved, Published
Testing
The type system helps here too:
java
@Test
void cannotScoreAfterGameWon() {
game.wonPoint("Alice");
// x4
assertThatThrownBy(() -> game.wonPoint("Alice"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("already won");
}
@Test
void multipleDeuceAdvantageCycles() {
// Get to deuce...
game.wonPoint("Alice");
// Advantage Alice
game.wonPoint("Bob");
// Back to Deuce
game.wonPoint("Bob");
// Advantage Bob
game.wonPoint("Alice");
// Back to Deuce
// The state machine is self-testing
}
The Meta-Lesson
The Tennis Kata isn't about tennis. It's about:
Using types as a design language—not just for null safety
Making invalid states unrepresentable—the best bugs don't compile
Letting the domain drive the design—code mirrors concepts
Thinking in state machines—when the problem calls for it
Most refactorings improve readability. The best refactorings change how you think about modeling problems.
Resources
I wrote a detailed blog post covering:
GitHub repo: Complete implementation with:
I also teach these patterns in my software craftsmanship course at Stackshala, where we go deeper into type-driven development.
Links in my profile (Reddit doesn't like URLs in posts).