Build Spotify, Music Player App | System Design
A comprehensive low-level design (LLD) architectural guide for building a robust, extensible, and enterprise-grade Music Player Application (such as Spotify) using SOLID design principles, clean architecture, and standard design patterns.
1. Why Do We Need This? (Problem Statement & Status Quo)
Low-Level Design (LLD) interviews often test your ability to translate abstract business requirements into clean, scalable, decoupled, and maintainable object-oriented classes.
- The Status Quo Trap: Developers often dump all business logic, playback algorithms, and device integration into bloated classes, resulting in tightly coupled code that shatters when a new audio output device or playback mode is introduced.
- The Scalable Solution: By applying design patterns like Strategy, Adapter, Facade, Factory, and Singleton, we isolate concerns into distinct components, ensuring that modifying or extending the app (e.g., adding a new JBL speaker or a shuffle-mode algorithm) requires zero changes to existing core logic.
2. Core Mental Model & Real-Life Analogy
Real-Life Analogy: The Hi-Fi Stereo Receiver & Remote Control
Analogy: Think of a modern high-end home stereo system. You do not wire your music streaming service directly into your Bluetooth headphones or wired speakers. Instead, you interact with a Remote Control (Facade). The remote talks to a Central Music Engine, which sends audio streams to an Active Output Device (Adapter) connected via a standardized port.
If you unplug your wired headphones and plug in a Bluetooth speaker, you don't rebuild the stereo; you simply adapt the new hardware interface to fit the standard audio jack pipeline. Furthermore, your music playlist can be played sequentially, shuffled randomly, or managed via a custom user queue using interchangeable playback strategies.
3. Functional & Non-Functional Requirements
Functional Requirements
- Song Playback: Users can play, pause, resume, and track song metadata (Title, Artist, File Path).
- Playlist Management: Users can create playlists, add songs to playlists, and play entire playlists.
- Flexible Playback Algorithms: Playlists must support multiple playback strategies:
- Sequential: Songs play one after another in order.
- Random (Shuffle): Songs play in a randomized order without repetition until the list exhausts.
- Custom Queue: Users can dynamically enqueue specific tracks to play next.
- Extensible: The architecture must allow plugging in new playback algorithms in the future.
- Multi-Output Device Support: The application must effortlessly route audio to various external output hardware (Bluetooth speakers, wired earphones, headphones) and remain open to new hardware integrations.
Non-Functional Requirements
- Maintainability & Clean Architecture: Strict adherence to SOLID principles, single responsibility, and decoupled components.
- Extensibility: Adding new audio devices or playback strategies must require zero modifications to existing engine code.
- Performance: O(1) playlist lookup times via indexing maps rather than linear scans.
4. High-Level System Flow Walkthrough
The following sequence illustrates how an external playback request travels from the client application down to the third-party hardware API:
5. Bottom-Up Architectural Evolution: Core Models & Entities
We construct our system using a Bottom-Up Approach, starting from the smallest isolated models and gradually composing them into larger subsystems.
A. The Song Model
Represents an individual track asset stored in the system.
public class Song {
private String title;
private String artist;
private String filePath;
public Song(String title, String artist, String filePath) {
this.title = title;
this.artist = artist;
this.filePath = filePath;
}
// Getters and Setters
public String getTitle() { return title; }
public String getArtist() { return artist; }
public String getFilePath() { return filePath; }
}
B. The Playlist Model
Manages a collection of songs under a named category. A song can exist independently of any playlist.
import java.util.ArrayList;
import java.util.List;
public class Playlist {
private String name;
private List<Song> songs;
public Playlist(String name) {
this.name = name;
this.songs = new ArrayList<>();
}
public void addSong(Song song) {
songs.add(song);
}
public String getName() { return name; }
public List<Song> getSongs() { return songs; }
}
6. Audio Output & The Adapter Design Pattern
Different hardware manufacturers (JBL, Sony, Apple) provide proprietary third-party APIs (BluetoothSpeakerAPI, WiredSpeakerAPI) with completely different method signatures. To unify them under our application, we use the Adapter Pattern.
Step 1: The Unified Audio Output Interface
public interface IAudioOutputDevice {
void playAudio(Song song);
}
Step 2: Third-Party External APIs (Incompatible Interfaces)
public class BluetoothSpeakerAPI {
public void playSoundViaBluetooth(String audioData) {
System.out.println("Playing audio via Bluetooth Speaker: " + audioData);
}
}
public class WiredSpeakerAPI {
public void playSoundViaJack(String audioData) {
System.out.println("Playing audio via Wired Jack: " + audioData);
}
}
Step 3: Concrete Adapters
Adapters implement IAudioOutputDevice and hold a has-a composition reference to the third-party API, translating our standard playAudio() call into the vendor-specific method.
public class BluetoothSpeakerAdapter implements IAudioOutputDevice {
private BluetoothSpeakerAPI bluetoothAPI;
public BluetoothSpeakerAdapter(BluetoothSpeakerAPI bluetoothAPI) {
this.bluetoothAPI = bluetoothAPI;
}
@Override
public void playAudio(Song song) {
String data = song.getTitle() + " - " + song.getFilePath();
bluetoothAPI.playSoundViaBluetooth(data);
}
}
public class WiredSpeakerAdapter implements IAudioOutputDevice {
private WiredSpeakerAPI wiredAPI;
public WiredSpeakerAdapter(WiredSpeakerAPI wiredAPI) {
this.wiredAPI = wiredAPI;
}
@Override
public void playAudio(Song song) {
String data = song.getTitle() + " - " + song.getFilePath();
wiredAPI.playSoundViaJack(data);
}
}
7. The Audio Engine & Device Factory
A. The Audio Engine
The engine controls playback states (play, pause, resume) and maintains a reference to the active song and output device.
public class AudioEngine {
private Song currentSong;
private boolean isPaused;
public void play(IAudioOutputDevice device, Song song) {
if (song == null) return;
if (isPaused && currentSong != null && currentSong.equals(song)) {
isPaused = false;
System.out.println("Resuming song: " + song.getTitle());
} else {
currentSong = song;
isPaused = false;
System.out.println("Starting song: " + song.getTitle());
}
device.playAudio(song);
}
public void pause() {
if (currentSong != null && !isPaused) {
isPaused = true;
System.out.println("Paused song: " + currentSong.getTitle());
}
}
public Song getCurrentSong() { return currentSong; }
}
B. Device Factory & Device Manager
The Device Factory handles object creation logic based on an enumeration type, keeping creation decoupled from management. The Device Manager acts as a Singleton controller managing active hardware connections.
public enum DeviceType {
BLUETOOTH, WIRED, HEADPHONES
}
public class DeviceFactory {
public IAudioOutputDevice createDevice(DeviceType type) {
switch (type) {
case BLUETOOTH:
return new BluetoothSpeakerAdapter(new BluetoothSpeakerAPI());
case WIRED:
return new WiredSpeakerAdapter(new WiredSpeakerAPI());
default:
throw new IllegalArgumentException("Unknown device type");
}
}
}
public class DeviceManager {
private static DeviceManager instance;
private IAudioOutputDevice currentDevice;
private DeviceFactory deviceFactory;
private DeviceManager() {
this.deviceFactory = new DeviceFactory();
}
public static synchronized DeviceManager getInstance() {
if (instance == null) {
instance = new DeviceManager();
}
return instance;
}
public void connect(DeviceType type) {
this.currentDevice = deviceFactory.createDevice(type);
System.out.println("Connected to device: " + type);
}
public IAudioOutputDevice getDevice() {
if (currentDevice == null) {
throw new IllegalStateException("No output device connected!");
}
return currentDevice;
}
}
8. Playlist Management & Strategy Design Pattern for Playback
To support Sequential, Random, and Custom queue playback without cluttering core logic with conditional (if-else) statements, we apply the Strategy Pattern.
A. The Playback Strategy Interface
public interface IPlayStrategy {
void setPlaylist(Playlist playlist);
Song getNextSong();
Song getPreviousSong();
boolean hasNext();
boolean hasPrevious();
default void addToNextQueue(Song song) {
// Default empty implementation for non-custom strategies (Interface Segregation Tradeoff)
}
}
B. Concrete Playback Strategies
- Sequential Strategy: Iterates forward and backward linearly through the playlist array.
- Random Strategy: Uses internal stacks and shuffled remaining lists to handle unbiased shuffling and history tracking.
- Custom Queue Strategy: Maintains a user-defined priority queue for "Play Next" actions.
public class SequentialPlayStrategy implements IPlayStrategy {
private Playlist playlist;
private int currentIndex = -1;
@Override
public void setPlaylist(Playlist playlist) {
this.playlist = playlist;
this.currentIndex = -1;
}
@Override
public boolean hasNext() {
return playlist != null && currentIndex + 1 < playlist.getSongs().size();
}
@Override
public Song getNextSong() {
if (hasNext()) {
currentIndex++;
return playlist.getSongs().get(currentIndex);
}
return null;
}
@Override
public boolean hasPrevious() {
return playlist != null && currentIndex - 1 >= 0;
}
@Override
public Song getPreviousSong() {
if (hasPrevious()) {
currentIndex--;
return playlist.getSongs().get(currentIndex);
}
return null;
}
}
9. The Facade Design Pattern (Simplifying the Subsystem)
The client application shouldn't manually orchestrate DeviceManager, PlaylistManager, AudioEngine, and IPlayStrategy. The Music Player Facade encapsulates this immense complexity behind a clean, unified API.
public class MusicPlayerFacade {
private static MusicPlayerFacade instance;
private AudioEngine audioEngine;
private DeviceManager deviceManager;
private PlaylistManager playlistManager;
private IPlayStrategy playStrategy;
private Playlist currentPlaylist;
private MusicPlayerFacade() {
this.audioEngine = new AudioEngine();
this.deviceManager = DeviceManager.getInstance();
this.playlistManager = PlaylistManager.getInstance();
}
public static synchronized MusicPlayerFacade getInstance() {
if (instance == null) {
instance = new MusicPlayerFacade();
}
return instance;
}
public void connectDevice(DeviceType type) {
deviceManager.connect(type);
}
public void setStrategy(StrategyType type) {
this.playStrategy = StrategyManager.getInstance().getStrategy(type);
if (currentPlaylist != null) {
playStrategy.setPlaylist(currentPlaylist);
}
}
public void loadPlaylist(String name) {
this.currentPlaylist = playlistManager.getPlaylist(name);
if (playStrategy != null) {
playStrategy.setPlaylist(currentPlaylist);
}
}
public void playAll() {
if (playStrategy == null || currentPlaylist == null) return;
while (playStrategy.hasNext()) {
Song song = playStrategy.getNextSong();
IAudioOutputDevice device = deviceManager.getDevice();
audioEngine.play(device, song);
}
}
public void pauseSong() {
audioEngine.pause();
}
}
10. Design Patterns Summary Table
11. Summary and Key Takeaways
- Bottom-Up Design Mastery: Always start building from atomic data models (
Song,Playlist), scale to hardware adapters and engines, and cap it off with managers and facades. - Adherence to SOLID: Single Responsibility Principle is preserved by separating device creation (Factory), hardware state (Manager), and playback rules (Strategy).
- Extensibility in Action: Introducing a new output hardware (e.g., AirPlay Speaker) or a new playback mode (e.g., Loop Single Track) requires creating a new adapter or strategy class, leaving existing production code untouched (Open-Closed Principle).
- Decoupled Clients: The top-level client application interacts exclusively with the
MusicPlayerFacade, remaining completely agnostic of underlying third-party audio drivers or queue data structures.