Robust, maintainable code starts with one rule: don’t tie your core logic to how its data is stored.
A common mistake is locking a central class to a specific storage method. What starts as a quick localStorage.getItem()
quickly turns into a mess when you want to test, refactor, or adopt a new storage mechanism.
The Problem: A Model
Doing Too Much
At first, my Model
was doing it all. Managing state and worrying about persistence.
It was like Reba, a single mom, juggling too many roles at once. Too much for one class!
Example of tight coupling:
export class Model {
private state: GameState = { /* ... default state ... */ };
constructor() {
// Directly reading from localStorage
this.state.scores.player = parseInt(localStorage.getItem('playerScore') || '0', 10);
// ... many more localStorage.getItem calls
}
Why This Was a Problem
- 👎 Tests weren’t isolated (every test depends on
localStorage
). - ⚡️ Low flexibility (switching to IndexedDB or an API means rewriting the
Model
). - ❌ Single Responsibility violated (business logic + storage concerns combined).
The Solution: Helping Model
Get a Break
Break the coupling by introducing an interface:
export interface IGameStorage {
getScore(participant: Participant): number;
setScore(participant: Participant, value: number): void;
removeScore(participant: Participant): void;
getRoundNumber(): number;
setRoundNumber(value: number): void;
removeRoundNumber(): void;
// …other getters/setters as needed
}
Then implement it:
export class LocalStorageGameStorage implements IGameStorage {
getScore(participant: Participant): number {
return parseInt(localStorage.getItem(`${participant}Score`) || '0', 10);
}
// …
}
The Refactor: Small, Test‑Driven Changes
With a test suite in place, the refactor became a series of manageable steps:
✅ Confirm all existing tests were green.
✅ Replace one localStorage.getItem()
call with gameStorage.getScore()
.
✅ Run the test suite. Failures point to the next area that needs adjustment.
✅ Refine test setup by mocking IGameStorage
where needed.
✅ Repeat until every piece of state was abstracted.
Each test acted like a roadmap, making the refactor predictable and manageable.
The Refactored Model: Storage Abstraction in Action
export class Model {
constructor(gameStorage: IGameStorage = new LocalStorageGameStorage()) {
this.gameStorage = gameStorage;
this.state.scores.player = this.gameStorage.getScore(PARTICIPANTS.PLAYER);
this.state.scores.computer = this.gameStorage.getScore(PARTICIPANTS.COMPUTER);
// ... no more direct persistence calls; Model is now truly persistence-agnostic
}
Result: The Model
no longer knows (or cares) where its data comes from. All storage details are abstracted, making it easy to evolve and test.
What This Shows
✅ Abstraction makes code more flexible and testable.
✅ Tests turn refactoring into a safe, incremental process.
✅ Small changes reduce risk and save time in the long run.
The takeaway: Decoupling doesn’t have to be hard. Sometimes it’s as simple as extracting a few direct localStorage
calls and introducing an interface. The payoff (clarity, testability, maintainability) is worth it.
Remember: Much like Reba, who eventually finds help and balance, your code can too! 🫡
Top comments (0)