Design a stopwatch class
start, pause, resume, reset and lap with a monotonic clock and an explicit state machine.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Time, TTLs and expiry inside objects — Injecting a clock, expiring entries lazily or with a timer wheel.
- Designing for unit tests — Seams, fakes and injected clocks that make a class testable.
- Value objects and immutability — Objects defined by their value, never changed, safely shared.
01The design, in the six-step framework
1. Requirements and use cases
A user starts the watch, pauses it, resumes it, records laps while it runs, reads the elapsed time at any moment, and resets everything. Each lap shows its split (time since the previous lap) and the total at that moment. Illegal actions, such as pausing a watch that is not running, are rejected clearly rather than silently ignored.
Out of scope: a display loop or UI thread, persistence across app restarts, and countdown mode. The reported interview framed it as "no libraries or platform classes", so the design must not lean on a framework timer.
Constraints: elapsed time must be exact across any number of pause and resume cycles; time paused never counts; the class must be unit-testable without sleeping, which forces the clock to be injected; and the state must be impossible to get wrong, which rules out a pile of booleans.
2. Core entities and relationships
- Stopwatch: the only mutable object. It holds the state, the accumulated time of finished runs, the start instant of the current run, and the laps.
- WatchState: IDLE, RUNNING, PAUSED. The transitions are the whole specification.
- Lap: an immutable record (index, split, total). Once recorded it never changes, so it can be handed to a UI without copying.
- IllegalTransition: the error raised for an action the current state does not permit; it names both, so a log line is self-explanatory.
- clock: any zero-argument callable returning seconds;
time.monotonicin production, a FakeClock in tests.
The stopwatch composes its laps: they are created by it and cleared by reset. The clock is used, not owned, which is the one seam that makes every test deterministic.
3. Interfaces and key methods
class WatchState(Enum):
IDLE = auto(); RUNNING = auto(); PAUSED = auto()
@dataclass(frozen=True)
class Lap:
index: int
split: float # time since the previous lap
total: float # elapsed at the moment of the lap
class Stopwatch:
def __init__(self, clock: Callable[[], float] = time.monotonic) -> None: ...
def start(self) -> None: """IDLE -> RUNNING; raises if already running."""
def pause(self) -> None: """RUNNING -> PAUSED."""
def resume(self) -> None: """PAUSED -> RUNNING."""
def reset(self) -> None: """any -> IDLE, laps cleared."""
def lap(self) -> Lap: """RUNNING only; records and returns a Lap."""
def elapsed(self) -> float: """Total running time, correct in every state."""
def laps(self) -> tuple[Lap, ...]: ...
Invariants: _run_started is not None exactly when the state is RUNNING; elapsed() equals _accumulated plus the current run; laps are strictly increasing in total; reset is allowed from every state and is the only way back to IDLE.
4. Design patterns and the choices behind them
An explicit state machine instead of flags
What varies is which actions are legal. Two booleans, running and paused, allow four combinations of which one is meaningless, and every method has to reason about both. A three-value state enum with a _require guard makes each method a one-line precondition plus its effect. The full State pattern (one class per state) was considered and rejected: with three states and five actions it would triple the code for no change in behaviour; say that out loud rather than reaching for the pattern by reflex (see trade-offs and over-engineering).
Accumulate on pause, measure on read
The tempting design stores start_time and computes now - start_time, then tries to subtract paused intervals. Instead the watch banks time into _accumulated at each pause and keeps only the current run open. Elapsed time is then a single expression that is correct in every state, and pause and resume are O(1) with no list of intervals.
Monotonic clock, injected
Wall-clock time jumps when the device syncs or the user changes the time zone; a stopwatch must use a monotonic source (see time inside objects). Injecting the clock is what allows the demo at the bottom of the code to assert exact numbers; designing for unit tests is the difference between "it looked right" and a proof.
Laps as value objects
A frozen dataclass for Lap means a UI holding the list cannot corrupt the watch, and equality is by value, which makes tests read naturally.
5. Core flow and code
start(): the guard checks IDLE, records_run_started = clock(), moves to RUNNING.- After 2 s the user presses lap:
elapsed()reads 2.0, a Lap(1, split 2.0, total 2.0) is appended and_last_lap_totalbecomes 2.0. pause()at 3.5 s: 3.5 is banked into_accumulated, the open run is closed, state PAUSED.- Ten seconds pass.
elapsed()still returns 3.5 because no run is open. resume()opens a new run; half a second laterelapsed()returns 4.0.reset()clears everything and returns to IDLE; a followingpause()raises IllegalTransition.
import time
from dataclasses import dataclass
from enum import Enum, auto
from typing import Callable
class WatchState(Enum):
IDLE = auto(); RUNNING = auto(); PAUSED = auto()
class IllegalTransition(Exception):
def __init__(self, state: WatchState, action: str) -> None:
super().__init__(f"cannot {action} while {state.name}")
@dataclass(frozen=True)
class Lap:
index: int
split: float
total: float
class Stopwatch:
"""Elapsed time = accumulated time of finished runs + the current run, if any."""
def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
self._clock = clock
self._state = WatchState.IDLE
self._accumulated = 0.0 # seconds from runs that ended in a pause
self._run_started: float | None = None
self._laps: list[Lap] = []
self._last_lap_total = 0.0
@property
def state(self) -> WatchState:
return self._state
def _require(self, action: str, *allowed: WatchState) -> None:
if self._state not in allowed:
raise IllegalTransition(self._state, action)
def start(self) -> None:
self._require("start", WatchState.IDLE)
self._run_started = self._clock()
self._state = WatchState.RUNNING
def pause(self) -> None:
self._require("pause", WatchState.RUNNING)
self._accumulated += self._clock() - self._run_started
self._run_started = None
self._state = WatchState.PAUSED
def resume(self) -> None:
self._require("resume", WatchState.PAUSED)
self._run_started = self._clock()
self._state = WatchState.RUNNING
def reset(self) -> None:
self._state = WatchState.IDLE
self._accumulated = 0.0
self._run_started = None
self._laps.clear()
self._last_lap_total = 0.0
def elapsed(self) -> float:
running = (self._clock() - self._run_started) if self._run_started is not None else 0.0
return self._accumulated + running
def lap(self) -> Lap:
self._require("lap", WatchState.RUNNING)
total = self.elapsed()
lap = Lap(index=len(self._laps) + 1, split=total - self._last_lap_total, total=total)
self._laps.append(lap)
self._last_lap_total = total
return lap
def laps(self) -> tuple[Lap, ...]:
return tuple(self._laps)
class FakeClock:
"""Test double: time moves only when the test says so."""
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
def _demo() -> None:
clock = FakeClock()
sw = Stopwatch(clock)
sw.start(); clock.advance(2.0)
first = sw.lap() # split 2.0, total 2.0
clock.advance(1.5); sw.pause()
clock.advance(10.0) # paused time must not count
sw.resume(); clock.advance(0.5)
assert abs(sw.elapsed() - 4.0) < 1e-9
assert first.split == 2.0 and sw.laps()[0].total == 2.0
6. Extensions, concurrency and what interviewers probe
- A display that ticks: the UI polls
elapsed()on its own timer; the watch never pushes, so it stays free of threads and platform classes, which was the point of the reported question. - Countdown mode: a subclass is the wrong tool (it changes the meaning of elapsed); a second small class sharing the same clock and state enum keeps both honest.
- Thread safety: if two threads can press buttons, wrap each public method in one lock; the state guard already makes a lost race visible as IllegalTransition rather than a corrupted total.
- Precision:
time.monotonicis fine for a UI stopwatch; for sub-millisecond work useperf_counterand say why the two differ. - Test first: the fake-clock sequence in
_demo, then every illegal transition (start twice, pause when idle, lap when paused), thenresetfrom each state. - Common mistake: subtracting
time.time()values, which breaks on clock adjustments, and storing paused intervals in a list, which makes elapsed O(n) and the pause logic fragile.