Music Streaming (Spotify)
Iterator for playlist traversal with pluggable playback strategies (shuffle, repeat, sequential). Observer pushes now-playing updates to all connected UI components without the player knowing who is listening.
Key Abstractions
Facade coordinating user actions, playlist management, and playback
Immutable metadata (title, artist, album, duration) plus a reference to the audio source
Ordered collection of songs with an owner, add/remove, and iterator creation
Listener with a subscription tier, owned playlists, and listening history
Playback controller managing current song, play/pause/skip state, and observer notifications
Defines how the next song is picked: sequential, shuffle, or repeat
Suggests songs based on listening history and what the user has not heard yet
Class Diagram
The Key Insight
A music player looks simple on the surface. Play a song, then play the next one. But the moment you add shuffle, repeat, and repeat-one modes, the traversal logic gets tangled fast if it lives inside the player. The player ends up tracking which mode is active, maintaining shuffle history so "previous" works, and handling edge cases for each mode in a growing switch statement.
Pull that traversal out into an iterator. The player just calls next(). It does not know or care whether the next song comes from a sequential scan, a shuffled list, or a looping single track. Changing playback mode means swapping the iterator. The play loop stays untouched.
Combine this with the observer pattern for now-playing updates and you get clean separation. The player controls state. Iterators control order. Observers control what happens when the song changes.
Requirements
Functional
- Users can create, modify, and delete playlists
- Songs can be played, paused, and skipped
- Support sequential, shuffle, and repeat playback modes, switchable at runtime
- Notify UI components when the current song changes or playback state changes
- Search songs by title or artist
- Recommend songs the user has not listened to yet
Non-Functional
- Playback mode changes should not require modifying the Player class
- Adding a new playback mode means adding a new Strategy and Iterator class, nothing else
- Observer notifications should not block playback control
- Song metadata lookups should be O(1) by ID
Design Decisions
Why Iterator for playlist traversal?
The naive approach is a switch statement inside the player: "if sequential, increment index; if shuffle, pick random; if repeat, wrap around." Three modes, three branches. Now add repeat-one. Four branches. Add a priority queue mode for smart playlists and you have five. Each branch carries its own edge cases around boundary conditions. The iterator pattern gives each mode its own class with clear, testable behavior. The player calls hasNext() and next(). New modes are new classes. Zero changes to the player.
Why Strategy wrapping the Iterator?
Strategy creates the appropriate iterator for a given song list. This separates the decision of "which traversal mode" from the traversal mechanics. The player holds a strategy reference, and when you switch modes, you swap the strategy and reload. The player never touches iterator construction logic directly.
Why Observer for now-playing updates?
Multiple components need to react when the song changes: the main UI, the lock screen widget, the social activity feed, the scrobbling service. Without observer, the player needs explicit references to all of them. Adding a new consumer means modifying the player. With observer, consumers register themselves and the player broadcasts to whoever is listening. Zero coupling between the player and its consumers.
Why is Playlist a first-class entity?
A playlist is more than a List<Song>. It has an owner, a name, and eventually sharing permissions and collaborative editing. Treating it as a class lets you add features without restructuring. It also encapsulates the song list so external code cannot corrupt ordering or bypass validation rules.
Interview Follow-ups
- "How would you handle offline playback?" Add a
DownloadManagerthat caches audio files locally. Songs get alocalPathfield alongsideaudioRef. The player checks local cache first before attempting to stream. - "How would you build shuffle so 'previous' works?" The
ShuffleIteratormaintains a history stack. Callingprevious()pops from the stack. Callingnext()pushes to it. This gives deterministic back-and-forth navigation through the shuffled order. - "How would you add a queue (play next / play later)?" Introduce a
PlaybackQueuethat sits between the player and the playlist iterator. Manually queued songs take priority over iterator output. When the queue empties, fall back to the iterator. - "How would you implement collaborative playlists?" Add an
EditPermissionenum (OWNER, EDITOR, VIEWER) and a permissions map on Playlist. Editors can add and remove songs. Use observer to notify collaborators of changes in real time.
45-Minute LLD Playbook
Each phase is what the interviewer expects you to do and say. Concrete steps, not topic hints. Diagrams are what you would sketch on the board.
- 15 min
Clarify scope and lock the process
GoalPin down what playback means in this design, what notification consumers we need, and whether offline and queue are in scope. End by asking the interviewer how to budget the next 40 minutes.
Do & Say- ASK·1Open with: Are we designing the playback engine plus playlist model, or the whole streaming stack including the audio CDN? I will scope to playback control, playlist traversal, and notification fanout. Audio streaming, encoding, and the CDN are out.
- SAY·2Lock playback modes in scope: Sequential, shuffle, and repeat. Repeat-one and a manual queue are v2. Shuffle-with-history is also v2 unless you call it out.
- SAY·3Lock the observer consumers: Desktop UI gets onSongChanged for the now-playing strip. Lock screen widget gets the same event. Scrobbling service like Last.fm gets it too. I will write one PlayerObserver interface and let three consumers register.
- SAY·4Pin Song as immutable: Song holds id, title, artist, album, duration, and an audio_ref string. The audio bytes are not inside the Song. That is what lets the same Song be shared across playlists without defensive copying.
- ASK·5Ask the process question: Do you want me to sketch the Player and the iterator hierarchy on the board first, or jump into code with the PlaylistIterator interface and let the relationships fall out of the structure? Either works, just want to budget the next 40 minutes correctly.
Interviewer is grading: You park audio streaming, the CDN, and repeat-one explicitly as v2 rather than letting them creep in. You name Song immutability before being asked. You ask the diagram-vs-code question.
- 25-10 min
Sketch the iterator hierarchy and observer wiring
GoalLock the PlaylistIterator and PlaybackStrategy contracts before any code. Name where each pattern lives. Draw the hierarchy only if requested.
Do & Say- WRITE·1Write the PlaylistIterator interface: has_next() -> bool, next() -> Song, current() -> Optional[Song]. Three methods. Every playback mode implements this same contract.
- SAY·2Name the three concrete iterators: SequentialIterator walks the list in order. ShuffleIterator shuffles a copy on construction and walks that. RepeatIterator wraps the index modulo len. Say: has_next never returns False on RepeatIterator. That is what makes the play loop never end.
- WRITE·3Write the PlaybackStrategy interface: create_iterator(songs) -> PlaylistIterator. One method. Each strategy returns the matching iterator. Say: Strategy decouples the choice of mode from the iterator construction. The Player holds a strategy reference, not an iterator class.
- WRITE·4Write the Player state: current_song, is_playing, iterator, strategy, observers list. load_playlist asks the strategy for an iterator and stores it. set_strategy is the runtime mode swap. play and skip pull from the iterator and notify observers.
- WRITE·5Write the PlayerObserver interface: on_song_changed(song), on_playback_state_changed(is_playing). Two methods. NowPlayingDisplay, lock screen, and scrobbling service all implement this.
- SAY·6If a diagram was requested, draw: Player at the top with arrows to PlaylistIterator, PlaybackStrategy, and a list of PlayerObserver. Below the iterator, the three concrete iterators. Below the strategy, the three concrete strategies. Point at each cluster and name the pattern.
- SAY·7If no diagram, verbalize: Iterator hides traversal order, strategy picks which iterator to build, observer fans out song-changed events to whoever is listening.
Interviewer is grading: Method signatures are crisp and complete before code. You explicitly call out that RepeatIterator never returns False on has_next, which is the whole point of that mode. You name strategy and iterator as separate concerns, not the same thing.
- 325 min
Code in this sequence (bottom-up)
GoalBuild foundation first. Talk while you type. End with a mental walk-through of one strategy swap and one observer fanout.
Do & Say- SAY·1Start with the Song dataclass, frozen. Six fields: id, title, artist, album, duration_secs, audio_ref. Say: Frozen means safe to share across playlists. Audio_ref is a pointer to wherever the audio actually lives. Bytes are not in this object. (~2 min)
- SAY·2Then the PlaylistIterator interface and the three concrete iterators in order. SequentialIterator first, simplest. ShuffleIterator with random.shuffle on construction. RepeatIterator with index modulo len. Say while coding RepeatIterator: has_next returns True as long as there are songs at all. Skip just wraps around. (~5 min)
- SAY·3Then PlaybackStrategy and the three concrete strategies. Each create_iterator returns the matching iterator. Three trivial classes but they exist so the Player can hold a strategy reference instead of branching on a mode enum. (~3 min)
- SAY·4Then PlayerObserver interface and NowPlayingDisplay. Two methods on the interface, NowPlayingDisplay prints them. Say: In production this would dispatch over WebSocket or a message bus. Same observer signature, different transport. (~2 min)
- SAY·5Then Playlist. id, name, owner_id, songs list. add_song, remove_song, get_songs returns a defensive copy. iterator(strategy) delegates to strategy.create_iterator. Say: Playlist is a first-class entity, not a bare list. That is what lets us add collaborative editing or sharing later without restructuring. (~3 min)
- SAY·6Then Player. Six fields: current_song, is_playing, iterator, strategy, observers. set_strategy swaps at runtime. load_playlist asks strategy for the iterator. play, skip, pause notify observers. Say: Observers fire inline. In production, dispatch to a worker thread so a slow consumer cannot block playback. (~6 min)
- SAY·7Then User as a dataclass: id, name, subscription, playlists, history. Plus RecommendationEngine with a recommend method that filters out already-heard songs. Keep it simple. (~2 min)
- SAY·8Then MusicService as the facade. Maps for users, songs, playlists. play_playlist sets the strategy, loads the playlist, and starts playing. Search is a substring filter. Say: Facade so clients have one entry point. Internal classes stay testable independently. (~3 min)
- SAY·9Mental walk-through. Playlist with 4 songs, NowPlayingDisplay observer attached. SequentialStrategy: first plays, observer fires. Skip twice, songs two and three. Switch to ShuffleStrategy: new iterator, random order. Switch to RepeatStrategy, skip 4 times, looped back to first. Self-test before declaring done. (~1 min)
Interviewer is grading: Code compiles as you type. You name Song immutability and the audio_ref pattern unprompted. You implement strategy and iterator as separate classes, not collapsed together. You volunteer the worker-thread argument for observer dispatch.
- 45 min
Trade-offs, extensions, and wrap-up
GoalName two trade-offs you defend, volunteer one extension, close with a one-sentence summary.
Do & Say- SAY·1Trade-off one, iterator over flat list with mode switching: List<Song> plus enum switch works for two modes. By mode four it is unmaintainable: shuffle history, repeat wraparound, queue priority all in one method. Iterator gives each mode its own testable class. Repeat-one is a new iterator, not a fifth branch.
- SAY·2Trade-off two, sync observer dispatch versus async: Inline notify is simpler but a slow scrobbling service blocks skip. Async dispatch via a worker thread or message bus protects the play control loop. The cost is observer ordering becomes non-deterministic, which matters if the lock screen and the now-playing strip race.
- SAY·3Extension to volunteer, manual queue: Add a PlaybackQueue between Player and PlaylistIterator. Manually queued songs take priority, fall back to the iterator when empty. The Player asks the queue for next(), the queue asks the iterator if it has nothing.
- WATCH·4Be ready for shuffle-history: ShuffleIterator maintains a history stack. previous() pops the stack, next() pushes. Deterministic back-and-forth navigation through the shuffled order.
- SAY·5Close with one sentence: Iterator for traversal order, strategy for picking which iterator to build, observer for fanning out now-playing events. Adding a mode is a new iterator and a new strategy. Adding a consumer is a new observer.
Interviewer is grading: You defend iterator over a mode-switch with a concrete failure mode (shuffle history breaking the play loop). You volunteer queue or shuffle-history unprompted.
Code Implementation
from enum import Enum
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import random
from typing import Optional
class SubscriptionTier(Enum):
FREE = "free"
PREMIUM = "premium"
@dataclass(frozen=True)
class Song:
"""Immutable. Shared across playlists without defensive copying."""
id: str
title: str
artist: str
album: str
duration_secs: int
audio_ref: str
def __str__(self) -> str:
return f"{self.title} - {self.artist} ({self.duration_secs}s)"
# --- Iterator hierarchy ---
class PlaylistIterator(ABC):
@abstractmethod
def has_next(self) -> bool: ...
@abstractmethod
def next(self) -> Song: ...
@abstractmethod
def current(self) -> Optional[Song]: ...
class SequentialIterator(PlaylistIterator):
def __init__(self, songs: list[Song]):
self._songs = list(songs)
self._index = -1
def has_next(self) -> bool:
return self._index + 1 < len(self._songs)
def next(self) -> Song:
if not self.has_next():
raise StopIteration("No more songs")
self._index += 1
return self._songs[self._index]
def current(self) -> Optional[Song]:
if 0 <= self._index < len(self._songs):
return self._songs[self._index]
return None
class ShuffleIterator(PlaylistIterator):
def __init__(self, songs: list[Song]):
self._songs = list(songs)
random.shuffle(self._songs)
self._index = -1
def has_next(self) -> bool:
return self._index + 1 < len(self._songs)
def next(self) -> Song:
if not self.has_next():
raise StopIteration("No more songs")
self._index += 1
return self._songs[self._index]
def current(self) -> Optional[Song]:
if 0 <= self._index < len(self._songs):
return self._songs[self._index]
return None
class RepeatIterator(PlaylistIterator):
def __init__(self, songs: list[Song]):
self._songs = list(songs)
self._index = -1
def has_next(self) -> bool:
return len(self._songs) > 0
def next(self) -> Song:
if not self._songs:
raise StopIteration("Empty playlist")
self._index = (self._index + 1) % len(self._songs)
return self._songs[self._index]
def current(self) -> Optional[Song]:
if self._songs and self._index >= 0:
return self._songs[self._index % len(self._songs)]
return None
# --- Strategy hierarchy ---
class PlaybackStrategy(ABC):
@abstractmethod
def create_iterator(self, songs: list[Song]) -> PlaylistIterator: ...
class SequentialStrategy(PlaybackStrategy):
def create_iterator(self, songs: list[Song]) -> PlaylistIterator:
return SequentialIterator(songs)
class ShuffleStrategy(PlaybackStrategy):
def create_iterator(self, songs: list[Song]) -> PlaylistIterator:
return ShuffleIterator(songs)
class RepeatStrategy(PlaybackStrategy):
def create_iterator(self, songs: list[Song]) -> PlaylistIterator:
return RepeatIterator(songs)
# --- Observer hierarchy ---
class PlayerObserver(ABC):
@abstractmethod
def on_song_changed(self, song: Song) -> None: ...
@abstractmethod
def on_playback_state_changed(self, is_playing: bool) -> None: ...
class NowPlayingDisplay(PlayerObserver):
def __init__(self, name: str):
self._name = name
def on_song_changed(self, song: Song) -> None:
print(f" [{self._name}] Now playing: {song}")
def on_playback_state_changed(self, is_playing: bool) -> None:
status = "Playing" if is_playing else "Paused"
print(f" [{self._name}] Status: {status}")
# --- Core domain ---
class Playlist:
def __init__(self, playlist_id: str, name: str, owner_id: str):
self.id = playlist_id
self.name = name
self.owner_id = owner_id
self._songs: list[Song] = []
def add_song(self, song: Song) -> None:
self._songs.append(song)
def remove_song(self, song_id: str) -> None:
self._songs = [s for s in self._songs if s.id != song_id]
def get_songs(self) -> list[Song]:
return list(self._songs)
def iterator(self, strategy: PlaybackStrategy) -> PlaylistIterator:
return strategy.create_iterator(self._songs)
def __len__(self) -> int:
return len(self._songs)
def __str__(self) -> str:
return f"Playlist('{self.name}', {len(self._songs)} songs)"
class Player:
def __init__(self):
self._current_song: Optional[Song] = None
self._is_playing: bool = False
self._iterator: Optional[PlaylistIterator] = None
self._strategy: PlaybackStrategy = SequentialStrategy()
self._observers: list[PlayerObserver] = []
def add_observer(self, observer: PlayerObserver) -> None:
self._observers.append(observer)
def remove_observer(self, observer: PlayerObserver) -> None:
self._observers.remove(observer)
def _notify_song_changed(self) -> None:
if self._current_song:
for obs in self._observers:
obs.on_song_changed(self._current_song)
def _notify_state_changed(self) -> None:
for obs in self._observers:
obs.on_playback_state_changed(self._is_playing)
def set_strategy(self, strategy: PlaybackStrategy) -> None:
self._strategy = strategy
def load_playlist(self, playlist: Playlist) -> None:
self._iterator = playlist.iterator(self._strategy)
self._current_song = None
self._is_playing = False
def play(self) -> None:
if self._current_song is None and self._iterator and self._iterator.has_next():
self._current_song = self._iterator.next()
self._notify_song_changed()
self._is_playing = True
self._notify_state_changed()
def pause(self) -> None:
self._is_playing = False
self._notify_state_changed()
def skip(self) -> Optional[Song]:
if self._iterator and self._iterator.has_next():
self._current_song = self._iterator.next()
self._is_playing = True
self._notify_song_changed()
return self._current_song
self._is_playing = False
self._notify_state_changed()
return None
@property
def current_song(self) -> Optional[Song]:
return self._current_song
@property
def is_playing(self) -> bool:
return self._is_playing
@dataclass
class User:
id: str
name: str
subscription: SubscriptionTier
playlists: list[Playlist] = field(default_factory=list)
history: list[Song] = field(default_factory=list)
def create_playlist(self, playlist_id: str, name: str) -> Playlist:
pl = Playlist(playlist_id, name, self.id)
self.playlists.append(pl)
return pl
def record_listen(self, song: Song) -> None:
self.history.append(song)
class RecommendationEngine:
def recommend(self, user: User, all_songs: list[Song], limit: int = 3) -> list[Song]:
listened_ids = {s.id for s in user.history}
unheard = [s for s in all_songs if s.id not in listened_ids]
return unheard[:limit]
class MusicService:
"""Facade. All client interactions go through here."""
def __init__(self):
self._users: dict[str, User] = {}
self._songs: dict[str, Song] = {}
self._playlists: dict[str, Playlist] = {}
self._player = Player()
self._recommender = RecommendationEngine()
@property
def player(self) -> Player:
return self._player
def add_song(self, song: Song) -> None:
self._songs[song.id] = song
def register_user(self, user: User) -> None:
self._users[user.id] = user
def create_playlist(self, user_id: str, playlist_id: str, name: str) -> Playlist:
user = self._users[user_id]
pl = user.create_playlist(playlist_id, name)
self._playlists[playlist_id] = pl
return pl
def play_playlist(self, playlist_id: str, strategy: PlaybackStrategy) -> None:
playlist = self._playlists[playlist_id]
self._player.set_strategy(strategy)
self._player.load_playlist(playlist)
self._player.play()
def search(self, query: str) -> list[Song]:
q = query.lower()
return [
s for s in self._songs.values()
if q in s.title.lower() or q in s.artist.lower()
]
def get_recommendations(self, user_id: str, limit: int = 3) -> list[Song]:
user = self._users[user_id]
return self._recommender.recommend(user, list(self._songs.values()), limit)
if __name__ == "__main__":
service = MusicService()
# Build a small catalog
songs = [
Song("s1", "Bohemian Rhapsody", "Queen", "A Night at the Opera", 354, "audio://s1"),
Song("s2", "Hotel California", "Eagles", "Hotel California", 391, "audio://s2"),
Song("s3", "Stairway to Heaven", "Led Zeppelin", "Led Zeppelin IV", 482, "audio://s3"),
Song("s4", "Imagine", "John Lennon", "Imagine", 183, "audio://s4"),
Song("s5", "Smells Like Teen Spirit", "Nirvana", "Nevermind", 301, "audio://s5"),
]
for song in songs:
service.add_song(song)
# Register user and create playlist
user = User("u1", "Alice", SubscriptionTier.PREMIUM)
service.register_user(user)
playlist = service.create_playlist("u1", "pl1", "Classic Rock Hits")
for song in songs[:4]:
playlist.add_song(song)
print(f"Created: {playlist}")
print()
# Attach UI observer
display = NowPlayingDisplay("Desktop UI")
service.player.add_observer(display)
# Sequential playback
print("=== Sequential Playback ===")
service.play_playlist("pl1", SequentialStrategy())
service.player.skip()
service.player.skip()
service.player.pause()
print()
# Switch to shuffle
print("=== Shuffle Playback ===")
service.play_playlist("pl1", ShuffleStrategy())
service.player.skip()
print()
# Search
print("=== Search for 'queen' ===")
results = service.search("queen")
for r in results:
print(f" Found: {r}")
print()
# Recommendations after listening to some songs
user.record_listen(songs[0])
user.record_listen(songs[1])
print("=== Recommendations (after listening to 2 songs) ===")
recs = service.get_recommendations("u1", limit=3)
for r in recs:
print(f" Recommended: {r}")
# Repeat mode: never runs out of songs
print()
print("=== Repeat Playback (loops forever) ===")
service.play_playlist("pl1", RepeatStrategy())
service.player.skip()
service.player.skip()
service.player.skip()
service.player.skip()
print(f"\nPlayer still going: {service.player.is_playing}")
print("All assertions passed.")Interview Grading by Level
What an interviewer at each level expects to see in your answer. Use this to calibrate, not to perform.
Junior Engineer (L3)
Builds a Player that plays songs in order and supports shuffle as a mode flag, but iterator and strategy stay vague.
- Names Iterator, Strategy, and Observer when asked.
- Writes a Player class with play, pause, skip.
- Stores songs in a Playlist with add and remove.
- Handles shuffle when prompted, even if as a boolean flag.
- Recognizes that the UI needs notifications when the song changes.
- Implements shuffle as an if/else inside the Player rather than a separate ShuffleIterator.
- Stores audio bytes inside the Song object rather than a reference.
- Calls notification methods directly on the UI instead of through an observer interface.
- Forgets that repeat mode means has_next never returns False.
- Treats Playlist as a bare list of song IDs with no encapsulation.
Mid-Level Engineer (L4)
Drives the design with iterator, strategy, and observer as three named patterns. Song is immutable. Shuffle and repeat have their own iterator classes.
- Writes the PlaylistIterator interface before any concrete iterator class.
- Implements RepeatIterator with index modulo length and explains why has_next stays True.
- Commits to Song immutability unprompted with audio_ref as a string, not bytes.
- Separates PlaybackStrategy from PlaylistIterator and explains the difference.
- Implements observer fanout with one method per event type, not a generic callback.
- Walks through a sequential play then a strategy swap to shuffle as a self-check.
- Does not volunteer async observer dispatch until asked.
- Misses the shuffle-history requirement for previous() until prompted.
- Treats the manual queue as a list, not a separate component sitting between Player and iterator.
Senior Engineer (L5+)
Volunteers async observer dispatch, frames each pattern around the failure it prevents, and names the immutability and audio-reference invariants explicitly.
- Volunteers async observer dispatch with a worker thread or message bus, and acknowledges the ordering trade-off.
- Names Song immutability as the reason it can be shared across playlists and across threads without defensive copies.
- Frames iterator over flat list around the failure of mode four (repeat-one breaks the play loop) rather than reciting the textbook.
- Proposes the manual queue as a separate component between Player and iterator, with priority semantics named.
- Suggests shuffle-with-history as a stack-based extension and walks through previous() semantics.
- Names a DownloadManager for offline mode with localPath alongside audio_ref.
- Closes with a one-sentence summary that names iterator, strategy, and observer with the role each plays, in under 20 seconds.
Common Mistakes
- ✗Baking shuffle logic directly into the Player. Adding repeat-one later means rewriting the play loop.
- ✗Storing full audio data inside the Song object. Songs should reference audio, not contain it.
- ✗Skipping an iterator and using raw index math for playlist traversal. Shuffle and repeat edge cases break immediately.
- ✗Making Playlist a plain list with no encapsulation. You lose the ability to enforce ordering rules or notify on changes.
Key Points
- ✓Iterator pattern decouples playlist traversal from the playlist data structure. The player calls next() and does not care about order logic.
- ✓Strategy pattern makes playback modes (shuffle, repeat, sequential) swappable at runtime without touching the Player class
- ✓Observer pattern pushes now-playing updates to UI, social feeds, and scrobbling services simultaneously
- ✓Playlist is a first-class entity with its own identity and encapsulation, not just a bare list of song IDs
- ✓Song is immutable. It holds a reference to the audio, not the bytes. Safe to share across playlists without copying.