Low-level design · Libraries and infrastructure
Multi-device controller
- Reported once
- SDE
- Command
- Factory
- 1 source
One candidate write-up. Treat it as a single data point.
Design an application that controls several kinds of device, for example a fan and a television. Some commands apply to every device and some belong to one device type. The brief is loose, so part of the task is deciding which commands exist and whether the set of devices is known up front.
Restated in our own words, not a quote from Uber or any page. The answer below is written for this site.
Clarify first
Pin the scope down before you name a single class.
The brief is vague, so pin it down out loud:
- Which commands are generic (power on, power off) and which are specific (fan speed, tv channel)?
- Is the set of device types fixed, or will new ones be added later?
- Are devices registered at runtime, or known when the program starts?
- What should happen when a command is sent to a device that is off, or to a device that does not support it?
- Is undo needed?
Assumed scope. The report names only shared and device-specific commands and four required functions that it does not list. Undo and broadcast are this answer's additions. In: registering devices, running a named command with arguments against a device, running a shared command on all devices, validation errors returned as results, and undo of the last successful command. Out: real hardware, networking, scheduling and persistence.
Entities and relationships
Who owns what, and who talks to whom.
- Device is the base class: an id, a type and an on flag. It can take a snapshot of its own state and restore one.
- Fan and Tv extend Device with their own state (speed and oscillation; channel, volume and mute).
- Command is the base for every action. It holds a device and arguments, and offers
executeandundo. Subclasses such as PowerOn, SetSpeed and SetChannel implement onlyrun. - CommandRegistry maps a command name to a factory function, either shared across all types or registered for one type. It acts as the factory.
- DeviceController has many devices and a history stack, uses the registry to create commands, and runs them.
The design
The class map, the main flow and the patterns that earn their place.
- A client names a device, a command and its arguments.
- The controller asks the registry for a command, looking at the device type first and the shared set second.
- The registry returns a new command object bound to that device.
- The controller runs it. Errors come back as a result object, not an exception.
- The command saves a snapshot of the device, validates, then changes the state.
- Only a successful command is pushed on the history, so undo reverses the last real change.
Open on the whiteboardAll 2 diagrams on one tidy board, with a step-by-step walkthrough.
The core idea is that a request such as "set the fan to speed 3" becomes an object. That object knows how to do the action and how to reverse it, which is the Command pattern. The controller then never needs to know what a fan or a television can do.
Shared and specific commands. The registry keeps two maps. One holds shared commands such as powerOn, available for every device. The other holds commands per device type. Lookup tries the type map first and then the shared map, so a type can also override a shared command. An unknown name fails with a clear error instead of a crash. The registry is a small factory: given a device, a name and arguments, it returns the right command object.
Execution. execute(deviceId, name, ...args) finds the device, asks the registry for a command, runs it and pushes it on the history only if it succeeded. Failures such as an unknown device, a device that is off or a value out of range come back as { ok: false, error }. This is deliberate: a bad command is a normal input, not an exceptional program state.
Undo. The base Command.execute takes a snapshot of the device before it calls run. Undo restores the snapshot. Subclasses validate first and mutate last, so a failed command leaves no partial change and never reaches the history. Because undo is a stack, restoring a whole snapshot is correct: nothing newer is still in effect.
Extension without edits. Adding a lamp means one Device subclass, one Command subclass and one register call. The controller, the registry and the existing commands do not change, which is the point of the design. A shared command applied to all devices is a loop over the same execute.
Broadcast. broadcast runs one command on every device and reports success per device. The command for each device is its own history entry, so one undo reverts only the last device in the loop. Grouping them needs a macro command, which is listed as an extension.
Trade-off. Command classes per action are verbose for tiny actions. A table of functions would be shorter, but then undo and validation have no obvious home. In a real system, commands that talk to hardware are asynchronous and need timeouts, and undo may be impossible, so only some commands would be undoable.
The code
Runnable JavaScript, with the checks it passes.
class Device {
constructor(id, type) {
this.id = id;
this.type = type;
this.on = false;
}
snapshot() {
const { id, type, ...state } = this;
return state;
}
restore(state) {
Object.assign(this, state);
}
}
class Fan extends Device {
constructor(id) {
super(id, "fan");
this.speed = 1;
this.oscillating = false;
}
}
class Tv extends Device {
constructor(id) {
super(id, "tv");
this.channel = 1;
this.volume = 10;
this.muted = false;
}
}
class Command {
constructor(device, args) {
this.device = device;
this.args = args;
this.before = null;
}
execute() {
this.before = this.device.snapshot();
this.run();
return this.device.snapshot();
}
undo() {
this.device.restore(this.before);
}
requireOn() {
if (!this.device.on) throw new Error(this.device.id + " is off");
}
requireRange(value, low, high) {
if (!Number.isInteger(value) || value < low || value > high) throw new Error("value out of range");
}
}
class PowerOn extends Command {
run() {
this.device.on = true;
}
}
class PowerOff extends Command {
run() {
this.device.on = false;
}
}
class SetSpeed extends Command {
run() {
this.requireOn();
this.requireRange(this.args[0], 1, 5);
this.device.speed = this.args[0];
}
}
class Oscillate extends Command {
run() {
this.requireOn();
this.device.oscillating = Boolean(this.args[0]);
}
}
class SetChannel extends Command {
run() {
this.requireOn();
this.requireRange(this.args[0], 1, 999);
this.device.channel = this.args[0];
}
}
class AdjustVolume extends Command {
run() {
this.requireOn();
this.requireRange(this.args[0], -100, 100);
this.device.volume = Math.min(100, Math.max(0, this.device.volume + this.args[0]));
this.device.muted = false;
}
}
class Mute extends Command {
run() {
this.requireOn();
this.device.muted = true;
}
}
class CommandRegistry {
constructor() {
this.shared = new Map();
this.byType = new Map();
}
registerShared(name, make) {
this.shared.set(name, make);
}
register(type, name, make) {
if (!this.byType.has(type)) this.byType.set(type, new Map());
this.byType.get(type).set(name, make);
}
create(device, name, args) {
const make = (this.byType.get(device.type) || new Map()).get(name) || this.shared.get(name);
if (!make) throw new Error("unknown command " + name + " for " + device.type);
return make(device, args);
}
namesFor(type) {
return [...new Set([...this.shared.keys(), ...(this.byType.get(type) || new Map()).keys()])].sort();
}
}
class DeviceController {
constructor(registry) {
this.registry = registry;
this.devices = new Map();
this.history = [];
}
add(device) {
if (this.devices.has(device.id)) return false;
this.devices.set(device.id, device);
return true;
}
execute(deviceId, name, ...args) {
const device = this.devices.get(deviceId);
if (!device) return { ok: false, error: "unknown device " + deviceId };
try {
const command = this.registry.create(device, name, args);
const state = command.execute();
this.history.push(command);
return { ok: true, state };
} catch (error) {
return { ok: false, error: error.message };
}
}
broadcast(name, ...args) {
const results = {};
for (const id of this.devices.keys()) results[id] = this.execute(id, name, ...args).ok;
return results;
}
undo() {
const command = this.history.pop();
if (!command) return false;
command.undo();
return true;
}
}
function createController() {
const registry = new CommandRegistry();
registry.registerShared("powerOn", (d, a) => new PowerOn(d, a));
registry.registerShared("powerOff", (d, a) => new PowerOff(d, a));
registry.register("fan", "setSpeed", (d, a) => new SetSpeed(d, a));
registry.register("fan", "oscillate", (d, a) => new Oscillate(d, a));
registry.register("tv", "setChannel", (d, a) => new SetChannel(d, a));
registry.register("tv", "adjustVolume", (d, a) => new AdjustVolume(d, a));
registry.register("tv", "mute", (d, a) => new Mute(d, a));
return new DeviceController(registry);
}Runs here in your browser, in a sandboxed worker. Nothing is sent anywhere.
Try your own input
Write JavaScript that calls the code above and prints with console.log. It runs after the solution.
Extend it
The change requests an interviewer throws next.
- How would you add a macro command such as movie night that runs several commands on different devices and undoes them together?
- How would you make commands asynchronous with a timeout, since a real device answers over a network?
- How would you add a new device type whose commands need a capability check, for example a fan without oscillation?
- How would you record a command log so the controller can rebuild device state after a restart?
- How would you let two users control the same device without one silently undoing the other?
Where candidates slip
The mistakes this problem punishes.
What tends to go wrong:
- Jumping to code without asking which commands exist and whether devices are fixed. With a vague brief, the clarifying questions are part of what is being judged.
- A controller with a long switch on device type and command name, so every new device edits it.
- Putting every command on the base Device class, so a fan has a
setChannelmethod that makes no sense. - Throwing an exception for ordinary bad input, or worse, failing silently when the device is off.
- Undo that restores only some fields, or that records a command that failed.
- Letting a failed command change state before it fails the validation.
Have a go before you read.
Interviews reward the sketch you make cold. Clarify out loud, draw it, then compare with the answer.
Now make it stick
Where this was reported
Public pages only. Companies do not confirm these, and they rotate their questions, so treat each link as one person's account or one list's tag.