Beyond Fowler's Refactoring: Advanced Domain Modeling for the Theatrical Players Kata
Going further than polymorphism - demonstrating value objects, type-driven design, and MIRO principles
Introduction
Martin Fowler's Theatrical Players example from Chapter 1 of "Refactoring" (2nd Edition) is a masterclass in basic refactoring techniques. He demonstrates:
- Extract Method
- Split Phase
- Replace Conditional with Polymorphism
- Move Method
These are essential patterns every developer should know. But Fowler's solution stops at a foundation level - intentionally, as it's an introductory chapter.
In this post, we'll build on that foundation and show you advanced domain modeling patterns that take your code from "good" to "production-ready." We'll demonstrate:
Type-safe enums instead of strings
Value objects for money and domain concepts
Rich domain models with proper encapsulation
MIRO principles (Make Illegal States Unrepresentable)
Clear separation of event data, business rules, and presentation
TRACK framework application for identifying complexity
Who is this for? Developers who understand basic refactoring and want to learn advanced patterns used in production systems.
The Original Problem
A theater company needs a program to print statements for their customers. Given:
- A customer name
- Performances they attended (play + audience size)
- Play catalog (name → type)
Output a statement showing:
- Each performance with its cost
- Total amount owed
- Volume credits earned
Business Rules:
- Tragedy: $400 base + $10 per attendee over 30
- Comedy: $300 base + $100 if >20 attendees + $5 per extra + $3 per all attendees
- Credits: Max(audience - 30, 0) + bonus for comedies
What Fowler Achieved (And Why It's Good)
Fowler's refactored solution introduces:
1. Extract Method
Long functions → Small, focused functions
2. Split Phase
Separates calculation from formatting:
// Phase 1: Calculate
const statementData = createStatementData(invoice, plays);
// Phase 2: Format
return renderPlainText(statementData);
3. Replace Conditional with Polymorphism
class TragedyCalculator {
amount() { /* tragedy pricing */ }
}
class ComedyCalculator {
amount() { /* comedy pricing */ }
}
These are excellent foundational patterns. But let's see what's still missing...
What Fowler's Solution Still Has
Let me show you the issues that remain, even after Fowler's refactoring:
Problem 1: Stringly-Typed Code
Fowler's code:
class Play {
constructor(name, type) {
this.name = name;
this.type = type; // String "tragedy" or "comedy"
}
}
// Usage
if (play.type === "tragedy") { ... } // Typo-prone!
Problems:
- Typos compile fine:
"tradegy"vs"tragedy" - No IDE autocomplete
- No compiler verification
- Magic strings scattered everywhere
Our improvement:
public enum PlayType {
TRAGEDY("Tragedy"),
COMEDY("Comedy");
private final String displayName;
// ... factory methods, validation
}
// Usage - type-safe!
if (play.getType() == PlayType.TRAGEDY) { ... }
// Exhaustive switching
switch (playType) {
case TRAGEDY: return calculateTragedyPrice();
case COMEDY: return calculateComedyPrice();
// Compiler warns if we add HISTORY and don't handle it!
}
Benefits:
- Compile-time errors for typos
- IDE autocomplete works
- Refactoring-safe
- Self-documenting
- Cannot create invalid types
Problem 2: Primitive Obsession
Fowler's code:
class PerformanceCalculator {
get amount() {
return 40000; // What unit? Cents? Dollars?
}
get volumeCredits() {
return 10; // Could be confused with money!
}
}
// Can accidentally mix them
let total = amount + volumeCredits; // Compiles! Wrong!
Problems:
intfor money loses currency semantics- Can mix money with credits
- Division by 100 scattered everywhere
- No formatting/precision handling
- Can't prevent negative amounts
- No type safety
Our improvement:
// Use battle-tested Joda-Money
class TragedyPricing {
private static final Money BASE_AMOUNT = Money.of(CurrencyUnit.USD, 400);
public Money calculateAmount(Performance perf) {
// Clear currency, proper precision, formatting built-in
}
}
// Custom value object for credits
public final class VolumeCredits {
private final int value;
private VolumeCredits(int value) {
if (value < 0) {
throw new IllegalArgumentException("Credits cannot be negative");
}
this.value = value;
}
public static VolumeCredits of(int amount) {
return new VolumeCredits(amount);
}
public VolumeCredits add(VolumeCredits other) {
return new VolumeCredits(this.value + other.value);
}
}
// Now this won't compile!
Money total = money.plus(credits); // ❌ Compile error!
Benefits:
- Cannot mix money with credits (type error)
- Cannot create negative amounts
- Currency-aware
- Precision handling automatic
- Self-documenting code
Problem 3: Anemic Domain Model
Fowler's code:
class Play {
constructor(name, type) {
this.name = name; // Public, mutable
this.type = type; // Just data, no behavior
}
}
class Performance {
constructor(playID, audience) {
this.playID = playID; // String ID, not object reference
this.audience = audience; // No validation!
}
}
// Can do this - no protection!
play.name = ""; // Empty name allowed
performance.audience = -50; // Negative audience allowed!
Problems:
- Public fields (no encapsulation)
- Mutable (can be changed anywhere)
- No validation
- String IDs instead of object references
- Just data bags, no rich behavior
Our improvement:
public final class Play {
private final String name;
private final PlayType type;
private Play(String name, PlayType type) {
this.name = validateName(name);
this.type = Objects.requireNonNull(type);
}
public static Play of(String name, PlayType type) {
return new Play(name, type);
}
private static String validateName(String name) {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("Play name cannot be empty");
}
return name.trim();
}
// Getters only - no setters!
public String getName() { return name; }
public PlayType getType() { return type; }
// Rich behavior
public boolean isTragedy() { return type.isTragedy(); }
public boolean isComedy() { return type.isComedy(); }
}
public final class Performance {
private final Play play; // Object reference, not string!
private final int audienceSize;
private Performance(Play play, int audienceSize) {
this.play = Objects.requireNonNull(play);
this.audienceSize = validateAudienceSize(audienceSize);
}
private static int validateAudienceSize(int size) {
if (size < 0) {
throw new IllegalArgumentException(
"Audience size cannot be negative. Got: " + size
);
}
return size;
}
// Now this won't compile!
// performance.audienceSize = -50; // No setter!
}
Benefits:
- Immutable (thread-safe, predictable)
- Encapsulated (private fields)
- Validated (illegal states prevented)
- Rich associations (Play object, not string)
- MIRO: Invalid states unrepresentable
Problem 4: Magic Numbers
Fowler's code:
class TragedyCalculator {
get amount() {
let result = 40000; // What is 40000?
if (this.performance.audience > 30) { // Why 30?
result += 1000 * (this.performance.audience - 30); // Why 1000?
}
return result;
}
}
Problems:
- Magic numbers everywhere
- No explanation of business rules
- Hard to understand intent
- Hard to change pricing (find all instances)
Our improvement:
public final class TragedyPricing implements PricingStrategy {
// Named constants make business rules explicit
private static final Money BASE_AMOUNT = Money.of(CurrencyUnit.USD, 400);
private static final int AUDIENCE_THRESHOLD = 30;
private static final Money AMOUNT_PER_EXTRA_ATTENDEE = Money.of(CurrencyUnit.USD, 10);
@Override
public Money calculateAmount(Performance performance) {
Money amount = BASE_AMOUNT;
int audience = performance.getAudienceSize();
if (audience > AUDIENCE_THRESHOLD) {
int extraAttendees = audience - AUDIENCE_THRESHOLD;
Money extraCharge = AMOUNT_PER_EXTRA_ATTENDEE.multipliedBy(extraAttendees);
amount = amount.plus(extraCharge);
}
return amount;
}
}
Benefits:
- Business rules explicit and named
- Self-documenting code
- Easy to modify (change constants)
- Single source of truth
Problem 5: Mixed Domains
Fowler's solution still mixes calculation and formatting:
function statement(invoice, plays) {
const statementData = {
customer: invoice.customer,
performances: invoice.performances.map(enrichPerformance),
};
return renderPlainText(statementData);
function enrichPerformance(aPerformance) {
const calculator = createPerformanceCalculator(/* ... */);
const result = Object.assign({}, aPerformance);
result.play = playFor(aPerformance);
result.amount = calculator.amount; // Calculate here
result.volumeCredits = calculator.volumeCredits;
return result;
}
}
function htmlStatement(invoice, plays) {
// Must DUPLICATE calculation logic!
const statementData = { /* calculate again */ };
return renderHtml(statementData);
}
Problems:
- Adding HTML requires duplicating calculation
- Can't reuse calculations for other formats
- Hard to test calculation without parsing strings
Our improvement - Three Separate Domains:
// DOMAIN 1: EVENT DOMAIN (What happened)
public final class Invoice {
private final String customer;
private final List<Performance> performances;
// Just data, no calculation logic
}
// DOMAIN 2: CALCULATION DOMAIN (Business rules)
public final class StatementCalculator {
public StatementResult calculate(Invoice invoice) {
// Returns typed result, not formatted string
}
}
public final class StatementResult {
private final Money totalAmount;
private final VolumeCredits totalCredits;
private final List<LineItem> lineItems;
// Pure data, no formatting logic
}
// DOMAIN 3: PRESENTATION DOMAIN (Formatting)
public interface StatementFormatter {
String format(StatementResult result);
}
public class PlainTextFormatter implements StatementFormatter { ... }
public class HtmlFormatter implements StatementFormatter { ... }
public class JsonFormatter implements StatementFormatter { ... }
// Usage - calculate ONCE, format MANY ways
StatementResult result = calculator.calculate(invoice);
String text = new PlainTextFormatter().format(result);
String html = new HtmlFormatter().format(result);
String json = new JsonFormatter().format(result);
Benefits:
- Calculate once, format multiple ways
- No calculation duplication
- Easy to test (no string parsing)
- Easy to add new formats
- Clear separation of concerns
Our Architecture: Three Domains
┌─────────────────────────────────────┐
│ EVENT DOMAIN (What happened) │
│ Play, Performance, Invoice │
│ - Immutable records │
│ - No calculation logic │
│ - Validated at construction │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ CALCULATION DOMAIN (Business rules)│
│ PricingStrategy, Calculator │
│ - Pure functions │
│ - Strategy pattern │
│ - Returns StatementResult │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ PRESENTATION DOMAIN (Formatting) │
│ StatementFormatter implementations │
│ - Multiple formatters │
│ - No calculation logic │
│ - Operates on StatementResult │
└─────────────────────────────────────┘
TRACK Framework Analysis
Let's apply the TRACK framework to identify where Fowler's solution can be improved:
1. Method Call Protocol
Problem: Hidden order dependencies
Solution: Type-enforced phases
// Types enforce order - can't format before calculating
StatementResult result = calculator.calculate(invoice); // Must happen first
String text = formatter.format(result); // Can only happen after
2. Ripple Effects
Problem: Changes cascade unexpectedly
Solution: Separated domains
- Change pricing? Only modify strategy classes
- Change formatting? Calculator unchanged
- Add new play type? Add strategy, everything else stays same
3. Accidental Complexity
Problem: Mixed concerns
Solution: Single responsibility
Each class has ONE job:
- Invoice: Hold event data
- Calculator: Orchestrate calculations
- Strategy: Define pricing rules
- Formatter: Format output
4. Causal Dependencies
Problem: Hidden cause-effect
Solution: Explicit relationships
Invoice → Calculator → StatementResult → Formatter → String
Each arrow is explicit in the type system.
5. Knowledge Duplication
Problem: Same logic in multiple places
Solution: Single source of truth
- Each strategy owns its pricing rules (once)
- Calculation happens once, formatting many times
- No scattered magic numbers
Side-by-Side Comparison
| Aspect | Fowler's Solution | Our Advanced Solution |
|---|---|---|
| Play Types | String "tragedy" |
PlayType.TRAGEDY (enum) |
| Money | int 40000 |
Money.of(USD, 400) |
| Credits | int credits |
VolumeCredits.of(25) |
| Validation | Runtime (if any) | Compile-time + construction |
| Mutability | Mutable fields | Immutable objects |
| Associations | String IDs | Object references |
| Magic Numbers | 40000, 1000, 30 |
Named constants |
| Domain Model | Anemic (data bags) | Rich (behavior + validation) |
| Separation | Calculation + formatting mixed | Three separate domains |
| Type Safety | Runtime errors | Compile-time errors |
| Testability | String parsing required | Direct value access |
| Extensibility | Good (polymorphism) | Excellent (+ value objects) |
Code Example: The Complete Flow
// 1. Create domain objects (Event Domain)
Play hamlet = Play.of("Hamlet", PlayType.TRAGEDY);
Play asLike = Play.of("As You Like It", PlayType.COMEDY);
Performance perf1 = Performance.of(hamlet, 55);
Performance perf2 = Performance.of(asLike, 35);
Invoice invoice = Invoice.of("BigCo", List.of(perf1, perf2));
// 2. Calculate (Calculation Domain)
StatementCalculator calculator = new StatementCalculator();
StatementResult result = calculator.calculate(invoice);
// Direct access to typed values - no string parsing!
Money total = result.getTotalAmount(); // USD 1230.00
VolumeCredits credits = result.getTotalCredits(); // 37 credits
// 3. Format (Presentation Domain)
String text = new PlainTextFormatter().format(result);
String html = new HtmlFormatter().format(result);
String json = new JsonFormatter().format(result);
Output (Plain Text):
Statement for BigCo
Hamlet: USD 650.00 (55 seats)
As You Like It: USD 580.00 (35 seats)
Amount owed is USD 1230.00
You earned 37 credits
Testing Benefits
Fowler's approach requires string parsing:
test("statement", () => {
const result = statement(invoice, plays);
expect(result).toContain("Amount owed is $1,730"); // Fragile!
});
Our approach - direct value access:
@Test
void calculatesCorrectTotal() {
StatementResult result = calculator.calculate(invoice);
// Type-safe assertions
assertThat(result.getTotalAmount())
.isEqualTo(Money.of(CurrencyUnit.USD, 1730));
assertThat(result.getTotalCredits())
.isEqualTo(VolumeCredits.of(47));
// No string parsing needed!
}
Benefits:
- No brittle string parsing
- Test calculation independently of formatting
- Formatting changes don't break calculation tests
- Clear, precise assertions
When to Use These Patterns
Use Value Objects When:
- Dealing with money, measurements, or domain quantities
- Need to prevent mixing incompatible types
- Want compile-time safety
- Working on financial, e-commerce, or billing systems
Use Type-Safe Enums When:
- Have a fixed set of valid values
- Currently using strings for types/statuses
- Want IDE autocomplete and refactoring support
- Need exhaustive switching
Use Rich Domain Models When:
- Building business-critical applications
- Domain logic is complex
- Team size > 1 developer
- Long-term maintenance expected
Use Domain Separation When:
- Multiple output formats needed
- Calculation logic is complex
- Want independent testability
- Team works on different concerns
Trade-Offs to Consider
More Code:
- More classes (value objects, strategies, formatters)
- More interfaces
- More structure
But:
- Each class is simpler
- Easier to understand in isolation
- Easier to test
- Easier to change
- Production-ready
When NOT to over-engineer:
- Throwaway scripts
- Proof of concepts
- Very simple domains
- Single developer, short lifespan
When these patterns shine:
- Production systems
- Team environments
- Complex business rules
- Long-term maintenance
- Financial/critical systems
Real-World Applications
These patterns are used in:
E-commerce Platforms:
- Money value objects for prices
- Order aggregates
- Pricing strategies (regular, sale, bulk)
- Multiple output formats (invoice, receipt, API)
Financial Systems:
- Money with multiple currencies
- Trade calculations separate from reporting
- Audit trails (immutable events)
- Type-safe transaction types
Booking Systems:
- Reservation aggregates
- Pricing strategies (seasonal, group, early-bird)
- Multiple confirmation formats
Healthcare:
- Patient records (immutable)
- Treatment calculations
- Multiple report formats (doctor, patient, insurance)
Getting Started
1. Clone the Repository
git clone https://github.com/stackshala/theatrical-players-advanced
cd theatrical-players-advanced
2. Build and Run
mvn clean compile
mvn test
mvn exec:java -Dexec.mainClass="com.stackshala.theatricalplayers.Main"
Key Takeaways
- Fowler's solution is a great foundation - Learn his patterns first!
- But production code needs more:
- Type safety (enums, value objects)
- Immutability and validation
- Clear domain boundaries
- MIRO principles
- Value objects eliminate whole classes of bugs:
- Can't mix money with credits
- Can't create negative amounts
- Can't use invalid play types
- Compiler enforces correctness
- Separation of domains enables:
- Multiple output formats from one calculation
- Independent testing
- Team parallelization
- Easy extension
- These patterns scale:
- Start simple (Fowler's approach)
- Add patterns as complexity grows
- Let pain points guide you
Further Learning
Want to master these patterns?
Visit Stackshala for the complete course on Software Craftsmanship.
Recommended Reading:
- "Refactoring" by Martin Fowler (start here!)
- "Domain-Driven Design" by Eric Evans
- "Implementing Domain-Driven Design" by Vaughn Vernon
- "Clean Architecture" by Robert C. Martin
Conclusion
Fowler's Theatrical Players example teaches essential refactoring techniques. We've shown how to go further with:
Type-safe enums instead of strings
Value objects for domain concepts
Rich, immutable domain models
MIRO principles applied
Clear separation of concerns
Production-ready patterns
These aren't just "nice to have" - they're patterns used in production systems handling millions of dollars in transactions daily.
Start with Fowler's foundation. Then level up with these advanced patterns.
Ready to write better code? Get the complete implementation at GitHub and join us at Stackshala!
Written by the Stackshala team. Questions? Reach out at https://www.stackshala.com