Skip to content

50 — LLD: Design Spotify / Music Player

Understanding the Problem

Model the domain of a music streaming app. You have User, Song, Artist, Album, Playlist, Library, and Player. The player supports play, pause, next, previous, seek, shuffle, and repeat. Any subscribed UI — web, mobile, desktop — gets a live push when the now-playing song changes.

What is the Music Player problem?

It is three patterns in one: State (player states), Observer (now-playing subscribers), and Strategy (playback order). The trick is keeping the Player small and letting each axis of behaviour live in its own class.


Requirements

Clarifying Questions

You: Playback modes?

Interviewer: Sequential, Shuffle, Repeat One.

Three concrete strategies.

You: What happens at the end of the queue in each mode?

Interviewer: Sequential stops. Shuffle picks a random next until manually stopped. Repeat One stays on the current song.

Strategy returns null to signal end-of-queue.

You: Multiple UIs subscribe to the same player?

Interviewer: Yes, think web + mobile showing the same session.

Observer pattern at the Player level.

You: Collaborative playlists?

Interviewer: Not for v1, but design should allow it.

Leave a seam on Playlist for future CRDT layer.

You: Offline mode?

Interviewer: Out of scope.

Final Requirements

  1. Core entities: Song, Artist, Album, Playlist, User, Library, Player.
  2. Player.play / pause / next / previous / seek.
  3. Pluggable playback strategy: Sequential, Shuffle, Repeat One.
  4. Observer interface for now-playing subscribers.
  5. Player states: Stopped, Playing, Paused.

Out of scope: audio decoding, DRM, offline caching, social features, recommendations.


Core Entities and Relationships

EntityResponsibility
SongMetadata: id, title, durationMs, artistId, albumId.
Artist / AlbumCatalogue metadata.
PlaylistOrdered list of song IDs + owner.
LibraryPer-user catalogue and playlists.
PlayerPlayback engine. Queue, index, state, strategy.
PlaybackStrategyNext/previous decision.
PlayerObserverPush target for now-playing changes.

Why songs as IDs in playlists? Because the same song appears in many playlists; keeping IDs avoids duplication and makes updates point to one canonical Song.

Why Player holds state, strategy, and observers? Because those three axes vary independently. The cleanest separation is one class per axis.

Design is iterative — start with a single sequential strategy and one observer, then generalise.


Class Design

Player

State:

  • queue: string[] — song IDs
  • index: int
  • positionMs: int
  • stateName: "STOPPED" | "PLAYING" | "PAUSED"
  • observers: Set<PlayerObserver>
  • strategy: PlaybackStrategy

Methods:

  • loadPlaylist(p)
  • play(), pause()
  • next(), previous()
  • seek(ms)
  • setStrategy(s)
  • subscribe(observer)

PlaybackStrategy (interface)

  • next(current, songs) -> index | null
  • previous(current, songs) -> index | null

Design principles at play:

  • Strategy Pattern — playback order.
  • State Pattern — Stopped/Playing/Paused enforce what operations are legal.
  • Observer Pattern — now-playing pub/sub.
  • Single Responsibility — song, playlist, library, player each do one thing.

Implementation

Core Logic: Next / Previous

Bad approach: Player.next contains if-else for every mode.

  • Adding Repeat-All requires editing Player. Breaks Open/Closed.

Good approach: strategy returns index | null. null means "end of queue, stop."

Great approach: shuffle strategy keeps a history stack to support a meaningful previous. Random without history would lose the back button.

Verification

Queue = [s1, s2, s3, s4], SequentialPlayback, index = 0.

  1. play() → state PLAYING, observers receive onNowPlaying(s1, 0).
  2. next()strategy.next(0, queue) = 1. state stays PLAYING, position 0, observers receive onNowPlaying(s2, 0).
  3. pause() → state PAUSED; observers notified.
  4. seek(30000) → position 30000; observers notified.
  5. setStrategy(new ShufflePlayback()). next() → random index, say 3. History = [1]. Observers notified.
  6. previous() → pop history → 1. Back on s2.
  7. Switch to Repeat One. next() returns same index. Observers notified each time.

Thread Safety

  • A single lock on Player suffices — a music player has a single session per user device.
  • Observer callbacks should run outside the lock. Slow observers (network) must not block next/play.
  • If multiple devices share a session (Spotify Connect), state is centralised server-side and each device's player is an observer.

Complete Code Implementation

java
import java.util.*;
import java.util.concurrent.CopyOnWriteArraySet;

record Song(String id, String title, long durationMs, String artistId, String albumId) {}
record Playlist(String id, String name, List<String> songIds, String ownerId) {}

interface PlaybackStrategy {
    Integer next(int current, List<String> songs);
    Integer previous(int current, List<String> songs);
}

class SequentialPlayback implements PlaybackStrategy {
    public Integer next(int c, List<String> s) { return c + 1 < s.size() ? c + 1 : null; }
    public Integer previous(int c, List<String> s) { return c - 1 >= 0 ? c - 1 : null; }
}

class ShufflePlayback implements PlaybackStrategy {
    private final Deque<Integer> history = new ArrayDeque<>();
    private final Random rng = new Random();
    public Integer next(int c, List<String> s) {
        if (s.isEmpty()) return null;
        history.push(c);
        return rng.nextInt(s.size());
    }
    public Integer previous(int c, List<String> s) { return history.isEmpty() ? null : history.pop(); }
}

class RepeatOnePlayback implements PlaybackStrategy {
    public Integer next(int c, List<String> s) { return c; }
    public Integer previous(int c, List<String> s) { return c; }
}

interface PlayerObserver { void onNowPlaying(String songId, long positionMs, String state); }

public class Player {
    private final List<String> queue = new ArrayList<>();
    private int index = 0;
    private long positionMs = 0;
    private String stateName = "STOPPED";
    private final Set<PlayerObserver> observers = new CopyOnWriteArraySet<>();
    private PlaybackStrategy strategy = new SequentialPlayback();
    private final Object lock = new Object();

    public void subscribe(PlayerObserver o) { observers.add(o); }
    public void setStrategy(PlaybackStrategy s) { synchronized (lock) { this.strategy = s; } }

    public void loadPlaylist(Playlist p) {
        synchronized (lock) {
            queue.clear(); queue.addAll(p.songIds());
            index = 0; positionMs = 0; stateName = "STOPPED";
        }
        emit();
    }
    public void play() {
        synchronized (lock) { if (queue.isEmpty()) return; stateName = "PLAYING"; }
        emit();
    }
    public void pause() {
        synchronized (lock) { if (!stateName.equals("PLAYING")) return; stateName = "PAUSED"; }
        emit();
    }
    public void next() {
        synchronized (lock) {
            Integer n = strategy.next(index, queue);
            if (n == null) { stateName = "STOPPED"; positionMs = 0; }
            else { index = n; positionMs = 0; }
        }
        emit();
    }
    public void previous() {
        synchronized (lock) {
            Integer p = strategy.previous(index, queue);
            if (p != null) index = p;
            positionMs = 0;
        }
        emit();
    }
    public void seek(long ms) {
        synchronized (lock) { positionMs = Math.max(0, ms); }
        emit();
    }

    private void emit() {
        String songId; long pos; String st;
        synchronized (lock) {
            songId = queue.isEmpty() ? null : queue.get(index);
            pos = positionMs; st = stateName;
        }
        for (PlayerObserver o : observers) {
            try { o.onNowPlaying(songId, pos, st); } catch (Exception ignored) {}
        }
    }
}
cpp
#include <memory>
#include <mutex>
#include <optional>
#include <random>
#include <string>
#include <unordered_set>
#include <vector>

struct Song { std::string id, title, artistId, albumId; long long durationMs; };
struct Playlist { std::string id, name, ownerId; std::vector<std::string> songIds; };

struct PlaybackStrategy {
    virtual std::optional<int> next(int c, const std::vector<std::string>& s) = 0;
    virtual std::optional<int> previous(int c, const std::vector<std::string>& s) = 0;
};

class SequentialPlayback : public PlaybackStrategy {
public:
    std::optional<int> next(int c, const std::vector<std::string>& s) override { return c + 1 < (int)s.size() ? std::optional<int>{c + 1} : std::nullopt; }
    std::optional<int> previous(int c, const std::vector<std::string>&) override { return c > 0 ? std::optional<int>{c - 1} : std::nullopt; }
};

class ShufflePlayback : public PlaybackStrategy {
    std::vector<int> history;
    std::mt19937 rng{std::random_device{}()};
public:
    std::optional<int> next(int c, const std::vector<std::string>& s) override {
        if (s.empty()) return std::nullopt;
        history.push_back(c);
        return std::uniform_int_distribution<int>(0, (int)s.size() - 1)(rng);
    }
    std::optional<int> previous(int, const std::vector<std::string>&) override {
        if (history.empty()) return std::nullopt;
        int v = history.back(); history.pop_back(); return v;
    }
};

struct PlayerObserver { virtual void onNowPlaying(const std::string& songId, long long pos, const std::string& state) = 0; };

class Player {
public:
    void subscribe(PlayerObserver* o) { std::lock_guard<std::mutex> g(obsMu); observers.insert(o); }
    void setStrategy(std::shared_ptr<PlaybackStrategy> s) { std::lock_guard<std::mutex> g(mu); strategy = std::move(s); }

    void loadPlaylist(const Playlist& p) {
        { std::lock_guard<std::mutex> g(mu); queue = p.songIds; index = 0; positionMs = 0; stateName = "STOPPED"; }
        emit();
    }
    void play() {
        { std::lock_guard<std::mutex> g(mu); if (queue.empty()) return; stateName = "PLAYING"; }
        emit();
    }
    void pause() {
        { std::lock_guard<std::mutex> g(mu); if (stateName != "PLAYING") return; stateName = "PAUSED"; }
        emit();
    }
    void next() {
        { std::lock_guard<std::mutex> g(mu);
          auto n = strategy->next(index, queue);
          if (!n) { stateName = "STOPPED"; positionMs = 0; }
          else { index = *n; positionMs = 0; } }
        emit();
    }
    void previous() {
        { std::lock_guard<std::mutex> g(mu);
          auto p = strategy->previous(index, queue);
          if (p) index = *p;
          positionMs = 0; }
        emit();
    }
    void seek(long long ms) { { std::lock_guard<std::mutex> g(mu); positionMs = std::max(0LL, ms); } emit(); }

private:
    std::vector<std::string> queue;
    int index = 0;
    long long positionMs = 0;
    std::string stateName = "STOPPED";
    std::shared_ptr<PlaybackStrategy> strategy = std::make_shared<SequentialPlayback>();
    std::unordered_set<PlayerObserver*> observers;
    std::mutex mu, obsMu;

    void emit() {
        std::string songId, st; long long pos;
        {
            std::lock_guard<std::mutex> g(mu);
            songId = queue.empty() ? "" : queue[index];
            pos = positionMs; st = stateName;
        }
        std::unordered_set<PlayerObserver*> snap;
        { std::lock_guard<std::mutex> g(obsMu); snap = observers; }
        for (auto* o : snap) try { o->onNowPlaying(songId, pos, st); } catch (...) {}
    }
};
typescript
interface Song { id: string; title: string; durationMs: number; artistId: string; albumId: string; }
interface Playlist { id: string; name: string; songIds: string[]; ownerId: string; }

interface PlaybackStrategy {
  next(current: number, songs: readonly string[]): number | null;
  previous(current: number, songs: readonly string[]): number | null;
}
class SequentialPlayback implements PlaybackStrategy {
  next(c: number, s: readonly string[]) { return c + 1 < s.length ? c + 1 : null; }
  previous(c: number, _s: readonly string[]) { return c - 1 >= 0 ? c - 1 : null; }
}
class ShufflePlayback implements PlaybackStrategy {
  private history: number[] = [];
  next(c: number, s: readonly string[]) {
    if (s.length === 0) return null;
    this.history.push(c);
    return Math.floor(Math.random() * s.length);
  }
  previous(_c: number, _s: readonly string[]) { return this.history.pop() ?? null; }
}
class RepeatOnePlayback implements PlaybackStrategy {
  next(c: number, _s: readonly string[]) { return c; }
  previous(c: number, _s: readonly string[]) { return c; }
}

interface PlayerObserver { onNowPlaying(songId: string | null, positionMs: number): void; }

type PlayerStateName = "STOPPED" | "PLAYING" | "PAUSED";

class Player {
  private queue: string[] = [];
  private index = 0;
  private positionMs = 0;
  private stateName: PlayerStateName = "STOPPED";
  private observers = new Set<PlayerObserver>();
  private strategy: PlaybackStrategy = new SequentialPlayback();

  subscribe(o: PlayerObserver) { this.observers.add(o); }
  setStrategy(s: PlaybackStrategy) { this.strategy = s; }

  loadPlaylist(p: Playlist) {
    this.queue = [...p.songIds];
    this.index = 0;
    this.positionMs = 0;
    this.stateName = "STOPPED";
    this.emit();
  }

  play() {
    if (this.queue.length === 0) return;
    this.stateName = "PLAYING";
    this.emit();
  }
  pause() { if (this.stateName === "PLAYING") { this.stateName = "PAUSED"; this.emit(); } }
  next() {
    const n = this.strategy.next(this.index, this.queue);
    if (n === null) { this.stateName = "STOPPED"; this.positionMs = 0; this.emit(); return; }
    this.index = n; this.positionMs = 0; this.emit();
  }
  previous() {
    const p = this.strategy.previous(this.index, this.queue);
    if (p === null) { this.positionMs = 0; this.emit(); return; }
    this.index = p; this.positionMs = 0; this.emit();
  }
  seek(ms: number) { this.positionMs = Math.max(0, ms); this.emit(); }

  private emit() {
    const songId = this.queue[this.index] ?? null;
    for (const o of this.observers) { try { o.onNowPlaying(songId, this.positionMs); } catch {} }
  }
}

Extensibility

1. "Crossfade"

Decorator on Player.next that overlaps two audio sources. The observer still sees a single now-playing change; the crossfade is below the API surface.

2. "Offline downloads"

Song.source = Remote | Local. Inject a SourceResolver strategy into the audio layer. Player itself does not change.

3. "Collaborative playlists"

Add a CRDT on Playlist.songIds (e.g., RGA or Yjs-style). Each client sends position-based ops; merges commutatively. The Playlist class gains an apply(op) method.


What is Expected at Each Level

LevelExpectations
MidEntities, sequential playback, play/pause/next.
Senior / SMTSStrategy for playback, Observer for now-playing, State for player, clean separation.
StaffMulti-device session continuity, CRDT-backed playlists, performance budget for observer fan-out, recommendations integration.

Frontend interview preparation reference.