I was recently programming with Claude Code when we encountered a familiar smell: subclasses of domain objects that existed only to handle separate concerns, like database persistence.

Together with Claude, we fixed the design flaw with Replace Hard-Coded Notifications with Observer—a refactoring from Refactoring to Patterns.

While Refactoring to Patterns is now over 22 years old, it’s as relevant as ever, even in the era of agentic software development.

The Smell

Our code has a domain class named StationActivity that tracks player activity during a game.

Two subclasses of StationActivity had been created solely to persist game data to Firebase.

Here’s one of those subclasses:

class PlayerStationActivity extends StationActivity {
    constructor(
        unresponsiveThresholdMs: number,
        private repository: PlayerRepository,
        private playerId: string
    ) {
        super(unresponsiveThresholdMs);
    }

    flip(batch: Batch): void {
        super.flip(batch);
        const lastFlip = batch.lastFlip;
        if (lastFlip && lastFlip.playerId === this.playerId) {
            this.repository.saveFlip(
                game.eventId!,
                game.id,
                round.roundNumber,
                station.index,
                batch.id,
                { dieIndex, newValue, timestamp, playerId },
                batch.startTime
            );
        }
    }
}

There was also a HostStationActivity that did nearly the same thing for bot players.

So we had two subclasses, both hard-coded to persist data to a repository, both passing seven parameters manually.

The subclasses weren’t expressing behavioral variation—they were just injecting data and doing it from domain classes.

The Refactoring

The fix had three parts:

1. Make the domain emit events instead of calling repositories directly:

// In game.updateDie()
this.emit('flip', { batch, flip: batch.lastFlip! });

2. Let the domain object graph provide context:

Instead of passing seven parameters, add a path getter to the batch that derives location from the object graph:

get path(): BatchPath {
    return {
        eventId: this.station!.round!.game!.eventId!,
        gameId: this.station!.round!.game!.id,
        roundNumber: this.station!.round!.roundNumber,
        stationIndex: this.station!.index,
        batchId: this.id
    };
}

3. Subscribe at the application layer:

// Player app: persist own flips
game.on('flip', ({ batch, flip }) => {
    if (flip.playerId === this.session.playerId) {
        repository.saveFlip(batch.path, flip, batch.startTime);
    }
});

// Host app: persist bot flips
game.on('flip', ({ batch, flip }) => {
    const player = batch.station?.player;
    if (player?.type === 'bot') {
        repository.saveFlip(batch.path, flip, batch.startTime);
    }
});

Why This Is Better

Aspect Subclass Approach Observer Approach
Domain purity Domain knows about repositories Domain just emits events
Parameter passing 7 params manually threaded Data derived from object graph
Visibility Side effects hidden in subclasses Side effects explicit at app layer
Duplication Two subclasses doing same thing One unified saveFlip() method
Testability Must mock repository in domain tests Domain tests need no mocks

The Observer pattern keeps the domain pure. The Game class says “a flip happened” and moves on. It doesn’t know or care who’s listening or what they do with that information.

The Pattern in Action

This is the same pattern you use with DOM event listeners:

// DOM
button.addEventListener('click', (event) => {
    saveToDatabase(event.target.value);
});

// Domain
game.on('flip', ({ batch, flip }) => {
    repository.saveFlip(batch.path, flip, batch.startTime);
});

The domain object is the event emitter. Application code subscribes with callbacks. When something happens, all subscribers get notified.

This event-driven approach to persistence fits naturally in the JavaScript ecosystem, where EventEmitter patterns are idiomatic—from Node.js core to frontend frameworks. This decades-old refactoring isn’t just still applicable, it’s idiomatic.


Sidebar: How Does Claude Refactor Code?

When I mentioned refactoring “with Claude,” you might wonder how an AI actually edits code. Does it use an abstract syntax tree like an IDE?

No. Claude Code uses text-based editing—reading files as plain text, searching with grep, and making edits via string replacement. There’s no AST, no symbol resolution, no type checker.

Instead, Claude relies on pattern recognition from training data. When it sees a subclass that only calls super and adds a side effect, it recognizes the smell—not by parsing node types, but because it’s seen millions of similar patterns.

This is both powerful (works with any language, can make judgment calls) and limited (might miss a reference, no compilation guarantees). It’s closer to how a human reads and edits code than how an IDE refactors it.

The decades-old patterns still guide the refactoring. The tools just read the code differently now.


When to Apply This Refactoring

Look for these signs:

  • Subclasses that only add side effects (persistence, logging, analytics)
  • Multiple subclasses doing nearly identical things to different targets
  • Domain classes with repository dependencies they shouldn’t have

When you spot these, consider Replace Hard-Coded Notifications with Observer. Your domain stays focused on business logic, and side effects move to the application layer where they belong.

Further Reading