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:

  • int for 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

  1. Fowler's solution is a great foundation - Learn his patterns first!
  2. But production code needs more:
    • Type safety (enums, value objects)
    • Immutability and validation
    • Clear domain boundaries
    • MIRO principles
  3. 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
  4. Separation of domains enables:
    • Multiple output formats from one calculation
    • Independent testing
    • Team parallelization
    • Easy extension
  5. 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

Read more