Cracking The Coding Interview Together – Problem 7.3: Jukebox
We have all seen them in classic diners or modern digital bars: the jukebox. It stands in the corner, a beacon of collective music curation, waiting for a user to slide in a coin, browse a catalog, and queue up their favorite track. But behind the neon lights and the tactile buttons lies a fascinating software engineering challenge.
At first glance, designing a jukebox seems deceptively simple. You might think, "Isn't it just a list of songs and a media player?" But as senior engineers, we know that the devil is always in the details. How do we handle concurrent song requests from multiple input terminals? How do we cleanly decouple the payment processing system from the playback engine? What happens when the power cuts out mid-song? Our goal today is to design a robust, modular, and thread-safe Jukebox system using clean object-oriented principles that can gracefully handle these real-world complexities.
Problem Statement
Our task is to design a musical jukebox using object-oriented principles. To make this a production-grade design, we must satisfy the following operational goals:
- Catalog Management: Maintain a searchable database of songs, artists, and albums.
- Queue Management: Manage a playlist queue where songs are played in a first-in, first-out (FIFO) order.
- Payment Processing: Support a credit-based system where users insert money (coins, cards, or digital tokens) to purchase song credits.
- Playback Control: Model a media player that can play, pause, skip, and stop songs, transitioning smoothly between idle and active states.
- User Roles: Differentiate between standard customers (who browse and queue songs) and administrators (who can add/remove songs, collect cash, and service the machine).
Analyze the Problem Together
Let's break down the core constraints and highlight the "Aha!" conflict of this design. A jukebox is fundamentally a stateful, event-driven hardware-software hybrid. The biggest mistake junior developers make here is creating a massive, monolithic Jukebox class that does everything: handles audio decoding, manages the UI, tracks coins, and holds the song list. This violates the Single Responsibility Principle (SRP) and makes the system incredibly fragile.
If we tightly couple our playback logic to a specific audio player library, we won't be able to easily migrate from playing local MP3s to streaming via a cloud API (like Spotify or Apple Music) in the future. Similarly, if our payment logic is hardcoded into the main loop, adding a new payment method (like Apple Pay or credit cards) will require rewriting the entire core engine.
To avoid these pitfalls, we must decouple our system into distinct, highly cohesive components:
| Component | Responsibility | Design Pattern / Architectural Choice |
|---|---|---|
| Core Domain | Representing Songs, Artists, and Albums. | Rich Domain Model with immutable value objects where appropriate. |
| Playlist Manager | Thread-safe queueing of selected tracks. | Producer-Consumer pattern using thread-safe collections. |
| Payment System | Tracking credits and validating transactions. | State Pattern or Strategy Pattern for payment processors. |
| Audio Engine | Interfacing with physical or digital playback hardware. | Bridge Pattern to decouple playback abstraction from implementation. |
Questions for the Interviewer
Before we write a single line of code, we must clarify the scope. In a real interview, asking these questions demonstrates your senior-level analytical thinking:
- "How is payment structured? Is it a flat rate per song, or do different songs (e.g., longer tracks or premium releases) cost different amounts of credits?"
Assumption for this design: Each song costs a flat rate of 1 credit. The payment system converts physical/digital currency into credits. - "Are we designing for a single physical machine with one screen, or a distributed system where multiple remote tablets/phones can queue songs to a central player?"
Assumption for this design: We will design the core engine to be thread-safe so it can easily support multiple concurrent input terminals feeding into a single playback queue. - "Do we need to implement the actual audio decoding and hardware drivers?"
Assumption for this design: No, we will define a cleanAudioPlayerinterface and mock the hardware interaction, focusing instead on the object-oriented relationships and state transitions.
Think About the Solution
Let's map out our high-level architecture. We want a central coordinator—the Jukebox class—acting as a Facade. This class will expose clean APIs to the outside world while delegating specific tasks to specialized subsystems.
Here is how the components will interact:
- A User searches the
SongSelector(which queries theMusicCatalog) for a song. - The User inserts money into the
PaymentProcessor, which increments their available credits. - The User selects a song. The
Jukeboxverifies they have enough credits, deducts the credit, and adds the song to the thread-safePlaylist. - The
PlaybackEngineruns on a dedicated background thread, continuously pulling songs from thePlaylistand playing them via theAudioPlayer.
We must also handle critical edge cases: What if the queue is empty? The playback thread should block or sleep efficiently without burning CPU cycles. What if a song file is corrupted? The playback engine must catch the exception, log it, and move on to the next song in the queue without crashing the entire machine.
Write Code and Explain
Let's implement a production-ready, thread-safe Jukebox system in Java. We will use modern concurrency utilities to ensure our design can handle real-world multi-threaded environments.
package com.thedevlearnings.jukebox;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
// ==========================================
// 1. Core Domain Models
// ==========================================
class Song {
private final String id;
private final String title;
private final Artist artist;
private final int durationInSeconds;
public Song(String id, String title, Artist artist, int durationInSeconds) {
this.id = id;
this.title = title;
this.artist = artist;
this.durationInSeconds = durationInSeconds;
}
public String getId() { return id; }
public String getTitle() { return title; }
public Artist getArtist() { return artist; }
public int getDurationInSeconds() { return durationInSeconds; }
}
class Artist {
private final String id;
private final String name;
public Artist(String id, String name) {
this.id = id;
this.name = name;
}
public String getId() { return id; }
public String getName() { return name; }
}
// ==========================================
// 2. Thread-Safe Playlist (Queue Manager)
// ==========================================
class Playlist {
// We use a BlockingQueue to handle the Producer-Consumer pattern safely.
private final BlockingQueue<Song> queue;
public Playlist() {
this.queue = new LinkedBlockingQueue<>();
}
public void addSong(Song song) {
queue.offer(song); // Thread-safe addition
}
public Song nextSong() throws InterruptedException {
// This will block the playback thread if the queue is empty,
// preventing CPU spinning.
return queue.take();
}
public List<Song> getQueuedSongs() {
return new ArrayList<>(queue);
}
public void clear() {
queue.clear();
}
}
// ==========================================
// 3. Payment and Credit Management
// ==========================================
class CreditManager {
private final AtomicInteger credits = new AtomicInteger(0);
private final int costPerSong;
public CreditManager(int costPerSong) {
this.costPerSong = costPerSong;
}
public void addCredits(int amount) {
if (amount > 0) {
credits.addAndGet(amount);
}
}
public boolean deductCreditsForSong() {
while (true) {
int current = credits.get();
if (current < costPerSong) {
return false;
}
if (credits.compareAndSet(current, current - costPerSong)) {
return true;
}
}
}
public int getAvailableCredits() {
return credits.get();
}
}
// ==========================================
// 4. Audio Playback Engine
// ==========================================
interface AudioPlayer {
void play(Song song);
void stop();
}
class MockAudioPlayer implements AudioPlayer {
@Override
public void play(Song song) {
System.out.println("[AudioPlayer] Now playing: '" + song.getTitle() + "' by " + song.getArtist().getName());
try {
// Simulate song playing
Thread.sleep(2000);
} catch (InterruptedException e) {
System.out.println("[AudioPlayer] Playback interrupted.");
Thread.currentThread().interrupt();
}
}
@Override
public void stop() {
System.out.println("[AudioPlayer] Playback stopped.");
}
}
// ==========================================
// 5. The Orchestrator (Facade Pattern)
// ==========================================
public class Jukebox implements Runnable {
private final Playlist playlist;
private final CreditManager creditManager;
private final AudioPlayer audioPlayer;
private final Map<String, Song> songCatalog;
private final ReentrantLock stateLock = new ReentrantLock();
private volatile boolean running = true;
private Song currentSong;
public Jukebox(AudioPlayer audioPlayer, int costPerSong) {
this.playlist = new Playlist();
this.creditManager = new CreditManager(costPerSong);
this.audioPlayer = audioPlayer;
this.songCatalog = new ConcurrentHashMap<>();
}
// Catalog Management
public void addSongToCatalog(Song song) {
songCatalog.put(song.getId(), song);
}
public Song getSongFromCatalog(String songId) {
return songCatalog.get(songId);
}
// User Interactions
public void insertPayment(int amount) {
creditManager.addCredits(amount);
System.out.println("[Jukebox] Added " + amount + " credits. Total credits: " + creditManager.getAvailableCredits());
}
public boolean selectSong(String songId) {
Song song = getSongFromCatalog(songId);
if (song == null) {
System.out.println("[Jukebox] Song not found in catalog.");
return false;
}
if (creditManager.deductCreditsForSong()) {
playlist.addSong(song);
System.out.println("[Jukebox] Queued song: " + song.getTitle() + ". Remaining credits: " + creditManager.getAvailableCredits());
return true;
} else {
System.out.println("[Jukebox] Insufficient credits to play: " + song.getTitle());
return false;
}
}
// Playback Loop (Runs on a background thread)
@Override
public void run() {
System.out.println("[Jukebox] Playback engine started.");
while (running) {
try {
// This blocks until a song is available in the playlist queue
Song song = playlist.nextSong();
stateLock.lock();
try {
currentSong = song;
} finally {
stateLock.unlock();
}
audioPlayer.play(song);
stateLock.lock();
try {
currentSong = null;
} finally {
stateLock.unlock();
}
} catch (InterruptedException e) {
System.out.println("[Jukebox] Playback loop interrupted. Shutting down.");
Thread.currentThread().interrupt();
break;
}
}
}
public void shutdown() {
this.running = false;
audioPlayer.stop();
}
public Song getCurrentSong() {
stateLock.lock();
try {
return currentSong;
} finally {
stateLock.unlock();
}
}
}Code Walkthrough
Let's trace how this design solves our core challenges:
- Thread-Safe Queueing: We used a
LinkedBlockingQueueinside ourPlaylistclass. This is a classic implementation of the Producer-Consumer pattern. When the jukebox queue is empty, the playback thread callsplaylist.nextSong(), which internally invokesqueue.take(). This blocks the thread efficiently without consuming CPU cycles until a user selects a song and pushes it to the queue. - Lock-Free Credit Management: Instead of using heavy synchronization blocks for credit updates, we utilized
AtomicIntegerand a Compare-And-Swap (CAS) loop inCreditManager.deductCreditsForSong(). This ensures that even if multiple users are tapping cards or inserting coins simultaneously at different terminals, credit deductions remain perfectly consistent and race-condition free. - State Isolation: The
Jukeboxclass acts as a clean Facade. It coordinates theCreditManager,Playlist, andAudioPlayer, but none of these classes have direct dependencies on each other. If we decide to swap out ourMockAudioPlayerfor a real hardware decoder or a cloud streaming client, we only need to implement theAudioPlayerinterface.
Time & Space Complexity
Let's analyze the algorithmic efficiency of our design's core operations:
- Song Selection & Queueing (
selectSong):- Time Complexity:
O(1). Looking up a song in ourConcurrentHashMapcatalog takes constant time. Deducting credits using atomic operations isO(1), and offering a song to theLinkedBlockingQueueis alsoO(1). - Space Complexity:
O(1)auxiliary space per operation.
- Time Complexity:
- Playback Retrieval (
playlist.nextSong):- Time Complexity:
O(1). Retrieving and removing the head of a linked blocking queue is a constant-time operation. - Space Complexity:
O(1).
- Time Complexity:
- Catalog Storage:
- Space Complexity:
O(N), whereNis the total number of songs in the catalog. This is highly efficient and easily fits within memory for standard jukebox catalogs (typically thousands of songs).
- Space Complexity:
Why This Problem Matters (The Senior Perspective)
In a senior-level interview, writing working code is only half the battle. You must demonstrate that you can think about how this system behaves in a production ecosystem.
Consider these architectural extensions:
- Persistence and Crash Recovery: In a real-world deployment, if the jukebox loses power, we shouldn't lose the user's paid credits or their queued songs. A senior engineer would suggest backing the
CreditManagerandPlaylistwith a lightweight local database (like SQLite) or a transactional log file. Every credit addition, deduction, and queue event would be written to a write-ahead log (WAL) before updating the in-memory state. - Distributed Architecture: If we scale this system to a modern bar where users scan a QR code to queue songs from their mobile phones, our in-memory queues won't suffice. We would transition the
Playlistto a distributed message broker like Redis or RabbitMQ, and theJukeboxplayback engine would act as a consumer subscribing to a centralized queue topic. - The State Pattern for Hardware: A physical jukebox has distinct physical states:
Out of Order,Idle,Accepting Coins,Playing, andMaintenance Mode. Implementing the State Pattern would allow us to cleanly transition between these states, disabling song selection when the machine is out of order or when an administrator is collecting cash.
Final Thoughts
Designing a jukebox is a fantastic exercise because it forces us to move beyond simple data structures and think deeply about concurrency, state management, and clean separation of concerns. By decoupling our payment logic, playback engine, and catalog management, we built a system that is not only robust and thread-safe but also highly adaptable to future business requirements.
Next time you are designing a system, ask yourself: "Am I building a monolith, or are my components isolated enough to withstand a complete platform migration?"
Keep coding, keep designing, and we will see you in the next system design breakdown!
Member discussion