Beyond Extract Method: What Elite Developers See in the Tennis Kata
The Tennis Refactoring Kata has been solved thousands of times. Most solutions look the same: extract a few methods, add some constants, maybe introduce an enum for scores. The tests pass, the code looks cleaner, and everyone moves on.
But something profound is being missed.
The Tennis Kata isn't about making messy code readable. It's a masterclass in domain-driven design, type-driven development, and making illegal states unrepresentable. It's about writing code that not only works but teaches.
After studying dozens of solutions and refactoring this kata with hundreds of developers, I want to show you what separates good refactorings from transformative ones.
The Original Crime Scene
Here's what we're typically handed:
public class TennisGame1 implements TennisGame {
private int m_score1 = 0;
private int m_score2 = 0;
private String player1Name;
private String player2Name;
public void wonPoint(String playerName) {
if (playerName == "player1") // Bug: string comparison with ==
m_score1 += 1;
else
m_score2 += 1;
}
public String getScore() {
if (m_score1 == m_score2) {
switch (m_score1) {
case 0: return "Love-All";
case 1: return "Fifteen-All";
case 2: return "Thirty-All";
default: return "Deuce";
}
} else if (m_score1 >= 4 || m_score2 >= 4) {
int minusResult = m_score1 - m_score2;
if (minusResult == 1) return "Advantage player1";
else if (minusResult == -1) return "Advantage player2";
else if (minusResult >= 2) return "Win for player1";
else return "Win for player2";
} else {
// ... more nested conditional horror
}
}
}
Most developers see: cyclomatic complexity, magic numbers, stringly-typed code, poor naming.
Elite developers see: a domain model screaming to get out.
The Question That Changes Everything
Before touching the keyboard, 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, and it's Thirty-Fifteen
- It's deuce now—advantage rules apply
- She has advantage—one point from winning
- Game over
These aren't implementation details. These are domain concepts that should be first-class citizens in your code.
Principle #1: Boolean Blindness—When True/False Hides the Story
The Common Refactoring:
boolean isTied = (m_score1 == m_score2);
if (isTied) {
// handle tied scores
}
You've given the boolean a name. That's progress. But you've also thrown away semantic information.
The comparison m_score1 == m_score2 produces a single bit. But the relationship between scores is richer than true/false:
- Are they tied?
- If not tied, who's ahead?
- By how much?
The boolean forces you to recompute context.
The Better Solution:
Don't use booleans when richer types can preserve meaning:
enum Player { PLAYER_1, PLAYER_2 }
sealed interface GameState permits RegularPlay, Deuce, Advantage, GameWon {
GameState pointWonBy(Player player);
String display(PlayerPair players);
}
Now your code speaks in domain concepts: "The game is in Deuce state" not "this boolean is true."
Why This Matters: Booleans require mental translation. Types communicate intent directly. When you return new Deuce(), every developer instantly knows the game state.
Principle #2: Stringly-Typed Code—The Billion-Dollar Mistake
The original code uses strings for player identity:
public void wonPoint(String playerName) {
if (playerName == "player1") // Broken: == doesn't work for strings
Even "fixing" this misses the point:
if (playerName.equals("player1")) // Still fragile
The Problem: Strings are infinitely flexible, which means they're infinitely wrong. Your code accepts:
wonPoint("playe1")- typo compileswonPoint("Player1")- wrong case compileswonPoint(null)- null compileswonPoint("Charlie")- non-existent player compiles
The Better Solution:
Use types to make errors impossible:
enum Player { PLAYER_1, PLAYER_2 }
record PlayerPair(String player1, String player2) {
public String getPlayer(Player player) {
return player == Player.PLAYER_1 ? player1 : player2;
}
public Player opponent(Player player) {
return player == Player.PLAYER_1 ? Player.PLAYER_2 : Player.PLAYER_1;
}
}
public void wonPoint(String playerName) {
Player player = identifyPlayer(playerName); // Convert at boundary
state = state.pointWonBy(player); // Type-safe internally
}
Why This Matters:
wonPoint(Player.PLAYER_1)is the only valid call- IDE autocompletes correctly
- Refactoring tools work perfectly
- Entire categories of bugs vanish at compile time
Notice the PlayerPair record: Singles tennis has exactly 2 players. Not zero. Not three. Not N. A pair encodes this constraint in the type system. Want doubles? Extend the pair:
record DoublesPair(PlayerPair team1, PlayerPair team2)
No Map overhead. No arbitrary collection. Just the right abstraction.
Principle #3: Design-Reflective Code—Mirroring the Domain
The Common Refactoring:
Most developers keep using integers and convert them to strings:
private int m_score1 = 0; // 0, 1, 2, 3 map to Love, 15, 30, 40
private String translateScore(int score) {
switch(score) {
case 0: return "Love";
case 1: return "Fifteen";
case 2: return "Thirty";
case 3: return "Forty";
}
}
The Problem: Tennis doesn't work with integers. The progression
Love - Fifteen - Thirty - Forty isn't arithmetic.
You can't multiply tennis scores. You can't take their average. They're states, not numbers.
The Better Solution:
Model the domain accurately:
enum PointScore {
LOVE("Love"),
FIFTEEN("Fifteen"),
THIRTY("Thirty"),
FORTY("Forty");
private final String display;
PointScore(String display) {
this.display = display;
}
public String display() { return display; }
public PointScore next() {
return switch(this) {
case LOVE -> FIFTEEN;
case FIFTEEN -> THIRTY;
case THIRTY -> FORTY;
case FORTY -> FORTY; // Can't go beyond forty in regular play
};
}
}
Now your code reads: score.next() instead of score++. Which one better communicates tennis scoring?
Why This Matters: When code mirrors domain concepts, domain experts can verify its correctness. Show this PointScore enum to a tennis player and they'll immediately confirm: Yes, that's exactly how scoring works.
Principle #4: MIRO—Make Illegal States Unrepresentable
The Subtle Bug Everyone Misses:
private int m_score1 = 0;
private int m_score2 = 0;
This representation allows millions of invalid states:
m_score1 = 100- Impossible in tennism_score2 = -5- Nonsensicalm_score1 = 40, m_score2 = 40- This is "Deuce", not "Forty-All"
Your type system can't help you. You're relying on runtime logic to maintain invariants.
The Better Solution:
Use the type system to enforce domain rules:
sealed interface GameState
permits RegularPlay, Deuce, Advantage, GameWon {
}
record RegularPlay(PointScore player1Score, PointScore player2Score)
implements GameState {
// Only Love, Fifteen, Thirty, Forty are valid
// If both reach Forty, it transitions to Deuce
}
record Deuce() implements GameState {
// No data needed—deuce is deuce
// Cannot construct an invalid deuce state
}
record Advantage(Player leadingPlayer) implements GameState {
// MUST have a leading player
// Cannot construct Advantage without specifying who
}
record GameWon(Player winner) implements GameState {
// MUST have a winner
// Game over is final
}
Why This Matters:
Try to construct an invalid state:
new Advantage(null); // Compilation error
new RegularPlay(PointScore.LOVE, PointScore.LOVE, PointScore.FIFTEEN); // Won't compile
The best bugs are the ones that don't compile.
The sealed interface says: Tennis games exist in exactly these four states, and no others. This is documentation that the compiler enforces.
Principle #5: State Machines Should Be Explicit
The Hidden Pattern:
Tennis scoring has a beautiful state machine buried in the code:
Deuce → Advantage(Player A) → Game Won (Player A wins)
↓
Deuce (if Player B wins)
In the original code, this pattern is invisible—buried in integer comparisons.
The Better Solution:
Make the state machine explicit:
record Deuce() implements GameState {
@Override
public GameState pointWonBy(Player player) {
// From deuce, any point leads to advantage
return new Advantage(player);
}
@Override
public String display(PlayerPair players) {
return "Deuce";
}
}
record Advantage(Player leadingPlayer) implements GameState {
@Override
public GameState pointWonBy(Player player) {
if (player == leadingPlayer) {
// Leading player wins → Game over
return new GameWon(player);
} else {
// Opponent wins → Back to deuce
return new Deuce();
}
}
@Override
public String display(PlayerPair players) {
return "Advantage " + players.getPlayer(leadingPlayer);
}
}
Why This Matters:
The Deuce↔Advantage state machine is now self-documenting. You can verify it by reading the code. More importantly, you can extend it:
Adding tiebreak rules? Add one state:
sealed interface GameState
permits RegularPlay, Deuce, Advantage, GameWon, Tiebreak
The existing states don't change. That's the power of proper abstraction.
Principle #6: Converging Branches—From Trees to Sequences
The Original Problem:
The original code is a branching tree:
if (tied) {
switch(score) { ... }
} else if (endgame) {
if (diff == 1) { ... }
else if (diff == -1) { ... }
...
} else {
for (int i...) { ... }
}
Every path requires separate mental simulation. Cognitive load compounds.
The Better Solution:
Use polymorphism to eliminate branching:
public class TennisGame {
private GameState state;
public String getScore() {
return state.display(players); // One line. Zero branches.
}
public void wonPoint(Player player) {
state = state.pointWonBy(player); // State handles transitions
}
}
Each state type implements its own display and transitions:
// RegularPlay knows how to display regular scores
record RegularPlay(...) {
public String display(PlayerPair players) {
if (player1Score == player2Score) {
return player1Score.display() + "-All";
}
return player1Score.display() + "-" + player2Score.display();
}
}
// Deuce knows how to display deuce
record Deuce() {
public String display(PlayerPair players) {
return "Deuce";
}
}
Why This Matters:
The main code path has zero conditionals. Complexity is distributed to where it belongs—in each state's implementation. This is polymorphism used correctly: not for code reuse, but for eliminating branching logic.
Principle #7: Representational Independence
The Common Mistake:
public int getPlayer1Score() { return m_score1; }
public int getPlayer2Score() { return m_score2; }
By exposing internal representation, you've locked yourself in. Clients now depend on scores being integers. Want to change to a state machine? Breaking change for everyone.
The Better Solution:
public interface TennisGame {
String getScore(); // Only expose what clients need
void wonPoint(Player player);
boolean isGameOver();
}
// Implementation is hidden
class TennisGameImpl implements TennisGame {
private GameState state; // Could be anything
// Could swap to event sourcing, doesn't matter
}
Why This Matters:
Your interface is stable. Implementation can evolve radically:
- State machine → Event sourcing
- Records → Classes
- Memory → Database
Clients don't break. This is Representational Independence—the secret to long-term maintainability.
Principle #8: Causal Dependencies—What Really Depends on What?
The Hidden Problem:
In the original code, display logic depends on score comparisons:
if (m_score1 == m_score2) {
// display tied score
} else if (m_score1 >= 4 || m_score2 >= 4) {
// display endgame score
}
The causal chain is inverted: we're checking consequences rather than modeling causes.
The Better Solution:
Make state the cause, display the effect:
public String getScore() {
return state.display(players); // State knows how to display itself
}
public void wonPoint(Player player) {
state = state.pointWonBy(player); // State knows how to transition
}
Why This Matters:
The dependency graph flows in one direction:
- Points won - State changes - Display updates
No circular dependencies. No hidden coupling. Just a clear causal chain that anyone can follow.
The Complete Solution
Here's what it all looks like together:
// Main API
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);
}
}
// States
sealed interface GameState
permits RegularPlay, Deuce, Advantage, GameWon {
GameState pointWonBy(Player player);
String display(PlayerPair players);
}
record RegularPlay(PointScore p1Score, PointScore p2Score) implements GameState { ... }
record Deuce() implements GameState { ... }
record Advantage(Player leader) implements GameState { ... }
record GameWon(Player winner) implements GameState { ... }
That's it. The main class is trivial. All complexity lives in the types where it belongs.
Full implementation available on GitHub →
Comparing with Well-Known Solutions
Now let's see how this compares to other approaches the community has developed.
Solution 1: The "20 Classes" Approach
Some developers create a class for every possible score combination:
class LoveAll implements IScore {
public IScore Player1Scored() { return new FifteenLove(); }
public IScore Player2Scored() { return new LoveFifteen(); }
}
class FifteenLove implements IScore { ... }
class LoveFifteen implements IScore { ... }
// ... 17 more classes
What It Gets Right:
- Makes states explicit
- No conditionals in main code
- Each state knows its transitions
What It Misses:
- 20+ classes for concepts that could be grouped
- Violates DRY (lots of similar implementations)
- Hard to maintain and extend
- Doesn't recognize that "Fifteen-Love" and "Thirty-Love" are variations of the same concept (RegularPlay)
Our Advantage: We recognize that tennis has 4 conceptual states, not 20 concrete ones. RegularPlay captures all the Love/15/30/40 combinations with data, not separate types.
Solution 2: Table-Driven State Machine (Mark Seemann)
Mark Seemann's approach enumerates all 20 states and uses pattern matching:
type Score =
| LoveAll | FifteenLove | LoveFifteen | ThirtyLove
| FifteenAll | LoveThirty | FortyLove
// ... 13 more states
let ballOne = function
| LoveAll -> FifteenLove
| FifteenLove -> ThirtyLove
| Deuce -> AdvantagePlayerOne
// ... complete pattern matching
What It Gets Right:
- Minimal code (67 lines total!)
- Zero conditionals
- Complete state enumeration
- Exhaustive pattern matching catches errors
What It Misses:
- Behavior separated from state
- Not extensible (adding tiebreak = rewrite everything)
- Doesn't capture domain concepts (which states are conceptually related?)
- Two separate functions for player 1 vs player 2 (duplication)
Our Advantage: We combine types and data. States aren't just labels—they carry information. Advantage(player) knows who has advantage. Our states also encapsulate their own behavior, making extension natural.
Solution 3: Extract Method (Most Common)
Most refactorings extract methods and reduce nesting:
public String getScore() {
if (scores.areTied()) {
return getTiedScore();
} else if (scores.isEndgame()) {
return getEndgameScore();
} else {
return getRegularScore();
}
}
What It Gets Right:
- More readable than the original
- Lower cyclomatic complexity
- Named methods communicate intent
What It Misses:
- Still using integers for scores
- Still has conditional branching
- Doesn't model the domain
- Doesn't make illegal states unrepresentable
- Not extensible
Our Advantage: We don't just organize the mess—we eliminate it by modeling the domain properly. When your types match reality, complexity disappears.
Key Metrics Comparison
| Approach | Types | Lines | Domain Clarity | Extensibility | Type Safety |
|---|---|---|---|---|---|
| 20 Classes | 20+ | ~400 | Low | Hard | Medium |
| Table-Driven | 1 | ~67 | Medium | Hard | High |
| Extract Method | 1-2 | ~150 | Low | Medium | Low |
| Our Solution | 4+2 | ~150 | High | Easy | Highest |
The Real Test: Can a Domain Expert Verify It?
Show the original code to a tennis expert: "I have no idea if this is correct."
Show the 20-classes solution: "Why are there so many classes?"
Show the table-driven solution: "This is just a lookup table..."
Show our solution:
sealed interface GameState permits RegularPlay, Deuce, Advantage, GameWon
"Yes! Those are exactly the phases of a tennis game. And you've got the deuce-advantage loop right here..."
That's the difference between code that works and code that teaches.
What Most Developers Miss: The Meta-Lesson
The Tennis Kata isn't testing your ability to extract methods or reduce complexity.
It's testing whether you can:
- See the domain model hidden in messy code
- Use types as a design language to encode domain rules
- Make invalid states unrepresentable through careful type design
- Separate what from how through proper abstraction
- Think in state machines when the problem calls for it
- Balance pragmatism and purity (4 states vs 20 classes)
These skills separate developers who clean code from developers who transform it.
Beyond the Kata: Real-World Applications
These principles scale far beyond tennis scoring:
E-commerce Order States:
sealed interface OrderState
permits PendingPayment, Confirmed, Shipped, Delivered, Cancelled
Authentication Flows:
sealed interface UserSession
permits Anonymous, Authenticated, Authorized, Expired
Document Workflows:
sealed interface DocumentState
permits Draft, UnderReview, Approved, Published, Archived
Game Development:
sealed interface GamePhase
permits Menu, Playing, Paused, GameOver
Every time you see states hiding in booleans and integers, you have an opportunity to apply these patterns.
The Path Forward: Master These Patterns
The techniques in this article—Boolean Blindness, Stringly-Typed Code, MIRO, Design-Reflective Code, State Machines, Converging Branches, Representational Independence—aren't just refactoring tricks.
They're a way of thinking about code.
Most developers learn to write code that works. Elite developers learn to write code that:
- Teaches the domain to future readers
- Prevents entire categories of bugs through types
- Adapts easily to new requirements
- Documents itself through structure
These skills aren't taught in most courses or bootcamps. They're discovered through years of painful refactoring sessions, reading advanced papers, and studying languages like Haskell, F#, and Rust.
But they don't have to take years.
Learn the Patterns That Change Everything
I teach these advanced complexity management techniques in my course "Writing Clean, Debt-Free Code: Advanced Patterns for Professional Developers".
In one comprehensive module, you'll learn:
Boolean Blindness – When and how to replace booleans with richer types
Stringly-Typed Code – Techniques for eliminating string-based programming
Method-Call Protocols – Encoding valid call sequences in the type system
Design-Reflective Code – Making code mirror domain concepts
Representational Independence – Building modules that hide implementation details
MIRO State Modeling – Making illegal states unrepresentable
Causal Dependencies – Understanding and managing what depends on what
Converging Branches – Eliminating conditionals through polymorphism
Plus: Real-world case studies, refactoring exercises, and patterns that apply to your production code immediately.
This isn't theory. These are battle-tested patterns from decades of building maintainable systems.
Stop Writing Technical Debt
Every day you write code without these patterns, you're accumulating technical debt that will cost you 10x more to fix later.
Learn to write clean, debt-free code from the start.
→ Enroll in the Software Craftsmanship Course
Start Your Transformation Today
The Tennis Kata is your training ground. The real game is your production codebase.
What will you build when you know how to model domains properly? When your types prevent bugs before they happen? When your code teaches as much as it works?
The choice is yours.
Clone the repo. Study the patterns. Transform your code.
→ View Full Implementation on GitHub
→ Join the Software Craftsmanship Course
Want more content like this? Subscribe to the blog for weekly insights on advanced software design.