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
superand 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
- Replace Hard-Coded Notifications with Observer in the Refactoring to Patterns catalog
- Refactoring to Patterns book