The design that looks trivial until you write it. Playback has more states than people expect, and shuffle plus repeat interact in ways that turn a naive implementation into tangled boolean logic. That interaction is what the interview is about.
Requirements
- Browse and search songs, albums, artists, playlists.
- Play, pause, skip, seek, previous; a queue with shuffle and repeat.
- Create and share playlists; follow artists.
- Offline downloads for premium users.
- Recommendations and personalised mixes.
The domain model
Song id, title, duration, artistIds, albumId, audioUrls{quality → url}
Album id, title, artistId, releaseDate, songIds (ordered)
Artist id, name, genres, followers
Playlist id, owner, name, visibility, ordered entries, collaborators
User id, subscription tier, library, preferences
Queue the CURRENT playback list — distinct from any playlist
Player playback state machineThe distinction that must be explicit: **a playlist is stored content; the queue is transient playback state. Playing a playlistcopies* it into the queue. If they are the same object, then skipping a track or adding "play next" mutates the saved playlist — a real bug in naive designs, and one interviewers probe for.
The playback state machine
IDLE → LOADING → PLAYING ⇄ PAUSED
│ │ │
│ │ └──→ BUFFERING ──→ PLAYING
│ └──→ ENDED → (next track)
└──→ ERRORBUFFERING is the state people omit, and it is the one that matters: the player is trying
to play but has no data. It is not paused — playback resumes automatically — so the UI and
the logic must treat it distinctly.
class Player {
private PlaybackState state = IDLE;
private Song current;
private Duration position;
private final Queue queue;
private final List<PlaybackListener> listeners;
void play() { transition(PLAYING); }
void pause() { transition(PAUSED); }
void onTrackEnded() {
scrobble(current, position); // record the play BEFORE moving on
queue.next().ifPresentOrElse(this::load, () -> transition(IDLE));
}
}Shuffle and repeat as strategies
The core of the problem. Encoded as booleans, "shuffle on, repeat one, press next" becomes a nest of conditionals. Make each an object:
interface PlayOrderStrategy {
Optional<Song> next(QueueState q);
Optional<Song> previous(QueueState q);
}
class SequentialOrder implements PlayOrderStrategy { … }
class ShuffleOrder implements PlayOrderStrategy {
private final List<Integer> permutation; // precomputed, not random-per-next
// …
}Shuffle is a permutation, not a random pick each time. Choosing randomly on every
next can repeat a song immediately and makes previous impossible to implement — you
cannot go back to a track that was never recorded as coming before. Generate a shuffled
order once (Fisher-Yates) and walk it; previous is then just walking backwards.
Repeat composes on top rather than being a second flag:
enum RepeatMode { OFF, REPEAT_ALL, REPEAT_ONE }REPEAT_ONE short-circuits next to return the current song; REPEAT_ALL wraps the index
at the end — and, if shuffling, reshuffles for the new pass so the second listen is not
identical. Keeping order and repeat as separate concerns is what stops the combinatorial
explosion.
Playlists
playlist_entries playlist_id, position, song_id, added_by, added_atA song may appear twice in a playlist, so (playlist_id, song_id) cannot be the key —
position is part of the identity. That also makes reordering a real operation: renumbering
every row on each drag is O(n), so use fractional or gapped positions (100, 200, 300;
insert at 150) and renumber only occasionally.
Collaborative playlists need concurrent-edit handling, which is the same problem as collaborative editing in miniature — though last-writer-wins per entry is usually acceptable here.
Streaming and offline
Audio is served as segmented, adaptive-quality streams, like video but far smaller. The client prefetches the next track while the current one plays, so a skip is instant — the same prefetch idea as TikTok.
Downloads for offline use are DRM-protected files with a licence that expires, requiring a
periodic online check. The model needs a DownloadState per song per device, and a cache
eviction policy when storage runs out.
Scrobbling
Play events drive royalties and recommendations, so the rules must be explicit: a play counts after a threshold (Spotify uses 30 seconds), a skip before it does not, and events are batched and queued rather than sent per track — and buffered while offline. Getting the "what counts as a play" rule stated is the mark of someone who has thought about the domain.
Patterns worth naming
- State — the player.
- Strategy — play order, audio quality selection, recommendations.
- Observer — UI, scrobbler and cache all reacting to playback events.
- Command — playback controls, which makes them queueable and remotely invokable
(the basis of "control playback on another device").
- Composite — treating songs, albums and playlists uniformly as playable sources.
Edge cases to handle
- A song removed from the catalogue while in someone's playlist or queue → tombstone it,
keep the entry, mark it unavailable.
- Network drops mid-song → BUFFERING, then fall back to a lower quality.
- Regional licensing — a track available in one country and not another.
- Playback on multiple devices — a session must transfer, not duplicate.
- Adding to a queue that is currently shuffling.
Extensions to be ready for
- Crossfade and gapless playback — needs the next track decoded early.
- Collaborative sessions — several people adding to one live queue.
- Podcasts — resume position per episode, variable speed.
- Radio / autoplay — an endless queue generated by recommendations.
- Lyrics sync — timestamped lines streamed alongside audio.
Track this problem on the System Design sheet.
Frequently asked
How should shuffle be implemented in a music player?
As a precomputed permutation of the queue, not a random choice on each next. Choosing randomly per skip can replay a song immediately and makes previous impossible, because there is no recorded order to walk back through. Generate a shuffled order once with Fisher-Yates, walk it forwards and backwards, and reshuffle when repeat-all wraps to a new pass.
What is the difference between a playlist and a play queue?
A playlist is stored, shareable content; the queue is transient playback state. Playing a playlist copies its songs into the queue, so skipping tracks or adding play-next entries modifies only the queue. If the two are the same object, ordinary playback actions silently mutate the user's saved playlist.
Why does the player need a BUFFERING state?
Because it is behaviourally different from both playing and paused: the player is trying to play but has no data, and it will resume on its own once data arrives. Collapsing it into PAUSED means the UI shows a paused player that unexpectedly restarts, and the logic cannot distinguish a user-initiated pause from a network stall.