Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Low-Level Design (Object-Oriented Design)  Module 8 – Code quality under time pressure

Weak LLD answers: how to spot and fix them

Over-engineering, under-modelling, pattern stuffing, hidden global state and ignored failure paths in LLD answers, measured on three elevator answers.

  • Intermediate
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot
Free core, Pro depth: 8 of 30 minutes free
Free: Five weaknesses, and what each one costs · Three answers to one elevator prompt · Five questions to ask of your own answer · Practise: cars that do not stop at every floor. With a pass: 3 in-depth parts and 3 interview answers, about 22 minutes.

What you will learn

  • Recognise over-engineering, under-modelling and pattern stuffing in an LLD answer
  • Explain how each weakness hurts correctness, the next change or the tests
  • Measure what a follow-up requirement costs an answer
  • Find hidden global state and ignored failure paths in a design
  • Rewrite an over-engineered answer into a balanced one in safe steps
  • Evaluate an answer against a five-criterion rubric

Before you start

What you’ll learn · Before you start

What you will learn

  • Recognise over-engineering, under-modelling and pattern stuffing in an LLD answer
  • Explain how each weakness hurts correctness, the next change or the tests
  • Measure what a follow-up requirement costs an answer
  • Find hidden global state and ignored failure paths in a design
  • Rewrite an over-engineered answer into a balanced one in safe steps
  • Evaluate an answer against a five-criterion rubric

Before you start

On this page

A weak low-level design (LLD) answer usually runs. It fails when the interviewer asks for one more feature: the change spreads through the code, breaks a test that passed, or cannot be made without starting again. Five weaknesses cause most of that: over-engineering, under-modelling, pattern stuffing, hidden global state and ignored failure paths. Each hurts correctness, the next change or the tests, and each is fixed by making the answer clearer, often smaller. This lesson names the five, then measures three answers to one elevator prompt, over-engineered, under-modelled and balanced, against the same follow-up. The balanced answer absorbed it in 13 lines of code; the under-modelled one changed five of its seven functions.

Five weaknesses, and what each one costs

  • Over-engineering is structure the prompt never asked for: an interface with one implementation, a factory for a plain object, a layer that only passes calls on. Every follow-up must then travel through each layer. Google’s engineering-practices guide calls it code made more generic than it needs to be.
  • Under-modelling is too little structure: one class with dictionaries of dictionaries, states as strings, and input parsing mixed with the rules. The data cannot say what it means, so a new idea changes the code wherever the data is read.
  • Pattern stuffing is naming design patterns for their own sake, such as a Singleton “because a building has one elevator system”. A pattern earns its place only by solving a problem the prompt states.
  • Hidden global state is a singleton or a module-level mutable object that any code can reach. A test leaves state behind, and whether the next test passes depends on the order in which they run.
  • Ignored failure paths means only the happy path was designed. A call from a floor that does not exist, a button pressed twice or a payment that fails after a seat was held leaves a state nobody chose.

A balanced answer gives each idea of the prompt a type and each rule one home, adds a seam only where a stated change will land, refuses impossible requests with a reason, and builds its objects where a test can build them too. Three refactorings from Martin Fowler’s catalogue shrink an over-engineered answer. Collapse Hierarchy merges an interface into its only implementation. Inline Class folds a class that does too little into its user. Remove Middle Man lets callers skip a class that only forwards.

Three answers to one elevator prompt

The prompt: floors 0 to 9 and three cars, A, B and C. People on a floor press up or down (a hall call); people inside a car press a floor (a car call). Each tick, a car moves one floor towards its next stop or stops there. All three answers read commands such as CALL 5 UP, PRESS A 8, TICK 3 and STATUS, and pass the same three base scenarios.

The follow-up is the same for all three: CANCEL 6 UP means that the person who made that hall call took the stairs, but a passenger inside the car who pressed 6 must still get out at 6. compare.py runs the base scenarios on each answer and on its follow-up version, and the follow-up scenarios on the follow-up versions, loading each answer afresh for every scenario:

Three answers, before and after the CANCEL follow-up

answers/compare.py

"""Runs the three answers on the same scenarios, before and after the CANCEL follow-up, and measures what the
follow-up changed in each. Each scenario loads its answer afresh, as if it were a run of its own."""

import ast
import difflib
import importlib
from pathlib import Path

import scenarios

HERE = Path(__file__).resolve().parent
ANSWERS = {"A": "answer_a", "B": "answer_b", "C": "answer_c"}


def passed(module_name, scenario_set):
    count = 0
    for commands, expected in scenario_set.values():
        module = importlib.reload(importlib.import_module(module_name))
        count += module.run(commands) == expected
    return f"{count}/{len(scenario_set)}"


def code_lines(module_name):
    """The lines of a file that are code: no blank lines, comments or docstrings."""
    text = (HERE / f"{module_name}.py").read_text()
    docstrings = set()
    for node in ast.walk(ast.parse(text)):
        if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef)) and ast.get_docstring(node):
            docstrings.update(range(node.body[0].lineno, node.body[0].end_lineno + 1))
    return [line for number, line in enumerate(text.splitlines(), 1)
            if line.strip() and not line.strip().startswith("#") and number not in docstrings]


def changed_lines(before, after):
    """Code lines added or removed between two versions."""
    diff = difflib.unified_diff(code_lines(before), code_lines(after), lineterm="", n=0)
    return sum(1 for line in diff if line[:1] in "+-" and not line.startswith(("+++", "---")))


def functions(module_name):
    """Every function and method, by its qualified name, with its code."""
    text = (HERE / f"{module_name}.py").read_text()
    found = {}

    def visit(node, prefix):
        for child in ast.iter_child_nodes(node):
            if isinstance(child, ast.ClassDef):
                visit(child, f"{prefix}{child.name}.")
            elif isinstance(child, ast.FunctionDef):
                found[prefix + child.name] = ast.get_source_segment(text, child)

    visit(ast.parse(text), "")
    return found


def classes(module_name):
    tree = ast.parse((HERE / f"{module_name}.py").read_text())
    return sum(isinstance(node, ast.ClassDef) for node in ast.walk(tree))


def row(label, values):
    print(f"  {label:<28}" + "".join(f"{value:>8}" for value in values))


def report():
    print("A over-engineered, B under-modelled, C balanced")
    print()
    print(f"{'As written':<30}" + "".join(f"{key:>8}" for key in ANSWERS))
    row("classes", [classes(m) for m in ANSWERS.values()])
    row("lines of code", [len(code_lines(m)) for m in ANSWERS.values()])
    row("base scenarios passed", [passed(m, scenarios.BASE) for m in ANSWERS.values()])

    changes = {}
    for key, before in ANSWERS.items():
        after = before + "_cancel"
        old, new = functions(before), functions(after)
        changes[key] = {
            "base": passed(after, scenarios.BASE),
            "follow-up": passed(after, scenarios.FOLLOW_UP),
            "lines": changed_lines(before, after),
            "changed": sorted(name for name in old if name in new and new[name] != old[name]),
            "new": sorted(name for name in new if name not in old),
            "existing": len(old),
        }

    print()
    print(f"{'After the CANCEL follow-up':<30}" + "".join(f"{key:>8}" for key in ANSWERS))
    row("base scenarios passed", [c["base"] for c in changes.values()])
    row("follow-up scenarios passed", [c["follow-up"] for c in changes.values()])
    row("code lines added or removed", [c["lines"] for c in changes.values()])
    row("existing functions changed", [f"{len(c['changed'])}/{c['existing']}" for c in changes.values()])
    row("new functions", [len(c["new"]) for c in changes.values()])
    row("classes touched", [len({name.rpartition(".")[0] for name in c["changed"] + c["new"] if "." in name})
                            for c in changes.values()])

    for key, c in changes.items():
        print()
        print(f"What the follow-up changed in {key}")
        for name in sorted(c["changed"] + c["new"]):
            print(f"  {name:<34}{'new' if name in c['new'] else 'changed'}")


if __name__ == "__main__":
    report()

Output

A over-engineered, B under-modelled, C balanced

As written                           A       B       C
  classes                           25       1       5
  lines of code                    237      74     110
  base scenarios passed            3/3     3/3     3/3

After the CANCEL follow-up           A       B       C
  base scenarios passed            3/3     3/3     3/3
  follow-up scenarios passed       3/3     3/3     3/3
  code lines added or removed       22      37      13
  existing functions changed      1/56     5/7    1/13
  new functions                      5       1       2
  classes touched                    6       1       2

What the follow-up changed in A
  CancelCommand.execute             new
  CommandFactory.__init__           changed
  Elevator.remove_request           new
  ElevatorController.cancel         new
  ElevatorService.cancel_hall_call  new
  IElevator.remove_request          new

What the follow-up changed in B
  Elevators.__init__                changed
  Elevators.call                    changed
  Elevators.cancel                  new
  Elevators.handle                  changed
  Elevators.status                  changed
  Elevators.tick                    changed

What the follow-up changed in C
  Car.drop                          new
  ElevatorBank.cancel               new
  run                               changed

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 compare.py

answers/scenarios.py

"""The scenarios every answer runs: command lines, each followed by the lines (marked >) it must print."""


def parse(transcript):
    """Turns a transcript into (commands, expected output lines)."""
    commands, expected = [], []
    for line in transcript.strip().splitlines():
        line = line.strip()
        if line.startswith(">"):
            expected.append(line[1:].strip())
        else:
            commands.append(line)
    return commands, expected


BASE = {
    "one call, then a car call": parse("""
        CALL 5 UP
        > call 5 UP -> A
        TICK 4
        STATUS
        > A at 4, stops [5]
        > B at 0, idle
        > C at 0, idle
        TICK
        > tick 5: A stops at 5
        PRESS A 8
        > press 8 in A
        TICK 3
        > tick 8: A stops at 8
        STATUS
        > A at 8, idle
        > B at 0, idle
        > C at 0, idle
    """),
    "the direction rule picks the car": parse("""
        PRESS A 9
        > press 9 in A
        TICK 2
        CALL 6 UP
        > call 6 UP -> A
        CALL 4 DOWN
        > call 4 DOWN -> B
        CALL 1 UP
        > call 1 UP -> B
        TICK 3
        > tick 3: B stops at 1
        STATUS
        > A at 5, stops [6, 9]
        > B at 3, stops [4]
        > C at 0, idle
        TICK 4
        > tick 6: A stops at 6
        > tick 6: B stops at 4
        > tick 9: A stops at 9
        STATUS
        > A at 9, idle
        > B at 4, idle
        > C at 0, idle
    """),
    "a car turns round": parse("""
        PRESS C 6
        > press 6 in C
        TICK 3
        PRESS C 1
        > press 1 in C
        CALL 2 DOWN
        > call 2 DOWN -> A
        TICK 3
        > tick 5: A stops at 2
        > tick 6: C stops at 6
        STATUS
        > A at 2, idle
        > B at 0, idle
        > C at 6, stops [1]
        TICK 5
        > tick 11: C stops at 1
        CALL 2 UP
        > call 2 UP -> A
        TICK
        > tick 12: A stops at 2
        STATUS
        > A at 2, idle
        > B at 0, idle
        > C at 1, idle
    """),
}

# The follow-up: "CANCEL 6 UP: the person who made that hall call took the stairs. Remove the call from the car
# it was given to. A passenger inside that car who pressed the same floor must still get out there."
FOLLOW_UP = {
    "cancel the only call": parse("""
        CALL 7 DOWN
        > call 7 DOWN -> A
        TICK 3
        CANCEL 7 DOWN
        > cancel 7 DOWN (was A)
        TICK 2
        STATUS
        > A at 3, idle
        > B at 0, idle
        > C at 0, idle
    """),
    "a passenger still gets out": parse("""
        PRESS B 6
        > press 6 in B
        TICK 2
        CALL 6 UP
        > call 6 UP -> B
        CANCEL 6 UP
        > cancel 6 UP (was B)
        TICK 4
        > tick 6: B stops at 6
        STATUS
        > A at 0, idle
        > B at 6, idle
        > C at 0, idle
    """),
    "a call that is not there": parse("""
        CANCEL 3 UP
        > no call 3 UP to cancel
        CALL 3 UP
        > call 3 UP -> A
        TICK 3
        > tick 3: A stops at 3
        CANCEL 3 UP
        > no call 3 UP to cancel
        STATUS
        > A at 3, idle
        > B at 0, idle
        > C at 0, idle
    """),
}

A · answers/answer_a.py

"""Answer A, over-engineered: an interface for each class, a factory for each object, layers that pass calls
on, a class for each command word and a singleton for the system. It works."""

from abc import ABC, abstractmethod
from enum import Enum


class Direction(Enum):
    UP = "up"
    DOWN = "down"


class ElevatorException(Exception):
    pass


class InvalidRequestException(ElevatorException):
    pass


class IRequest(ABC):
    @abstractmethod
    def get_floor(self): ...


class HallRequest(IRequest):
    def __init__(self, floor, direction):
        self._floor = floor
        self._direction = direction

    def get_floor(self):
        return self._floor

    def get_direction(self):
        return self._direction


class CarRequest(IRequest):
    def __init__(self, floor):
        self._floor = floor

    def get_floor(self):
        return self._floor


class RequestFactory:
    def create_hall_request(self, floor, direction):
        return HallRequest(floor, direction)

    def create_car_request(self, floor):
        return CarRequest(floor)


class RequestValidator:
    def __init__(self, floors):
        self._floors = floors

    def validate_floor(self, floor):
        if not 0 <= floor < self._floors:
            raise InvalidRequestException(f"no floor {floor}")

    def validate_hall_request(self, request):
        floor, direction = request.get_floor(), request.get_direction()
        self.validate_floor(floor)
        if (floor, direction) in ((0, Direction.DOWN), (self._floors - 1, Direction.UP)):
            raise InvalidRequestException(f"no {direction.name} call from floor {floor}")


class IElevator(ABC):
    @abstractmethod
    def get_name(self): ...

    @abstractmethod
    def get_floor(self): ...

    @abstractmethod
    def get_direction(self): ...

    @abstractmethod
    def add_request(self, request): ...

    @abstractmethod
    def get_requests(self): ...

    @abstractmethod
    def step(self): ...


class Elevator(IElevator):
    def __init__(self, name):
        self._name = name
        self._floor = 0
        self._direction = None
        self._requests = []

    def get_name(self):
        return self._name

    def get_floor(self):
        return self._floor

    def get_direction(self):
        return self._direction

    def add_request(self, request):
        self._requests.append(request)

    def get_requests(self):
        return list(self._requests)

    def step(self):
        stops = {request.get_floor() for request in self._requests}
        if not stops:
            self._direction = None
            return None
        if self._floor not in stops:
            if self._direction is Direction.UP and max(stops) < self._floor:
                self._direction = None
            if self._direction is Direction.DOWN and min(stops) > self._floor:
                self._direction = None
            if self._direction is None:
                nearest = min(stops, key=lambda stop: (abs(stop - self._floor), stop))
                self._direction = Direction.UP if nearest > self._floor else Direction.DOWN
            self._floor += 1 if self._direction is Direction.UP else -1
            if self._floor not in stops:
                return None
        self._requests = [request for request in self._requests if request.get_floor() != self._floor]
        if not self._requests:
            self._direction = None
        return f"{self._name} stops at {self._floor}"


class ElevatorFactory:
    def create_elevator(self, name):
        return Elevator(name)


class IDispatchStrategy(ABC):
    @abstractmethod
    def select_elevator(self, elevators, request): ...


class NearestElevatorStrategy(IDispatchStrategy):
    def __init__(self, floors):
        self._floors = floors

    def select_elevator(self, elevators, request):
        return min(elevators, key=lambda elevator: (self._cost(elevator, request), elevator.get_name()))

    def _cost(self, elevator, request):
        distance = abs(elevator.get_floor() - request.get_floor())
        moving, wanted = elevator.get_direction(), request.get_direction()
        if moving is None:
            return distance
        here, there = elevator.get_floor(), request.get_floor()
        if moving is Direction.UP and wanted is Direction.UP and here <= there:
            return distance
        if moving is Direction.DOWN and wanted is Direction.DOWN and here >= there:
            return distance
        return distance + 2 * self._floors


class IElevatorObserver(ABC):
    @abstractmethod
    def notify(self, message): ...


class ConsoleElevatorObserver(IElevatorObserver):
    def __init__(self):
        self.messages = []

    def notify(self, message):
        self.messages.append(message)


class ElevatorRepository:
    def __init__(self):
        self._elevators = {}

    def save(self, elevator):
        self._elevators[elevator.get_name()] = elevator

    def find_by_name(self, name):
        if name not in self._elevators:
            raise InvalidRequestException(f"no car {name}")
        return self._elevators[name]

    def find_all(self):
        return [self._elevators[name] for name in sorted(self._elevators)]


class ElevatorService:
    def __init__(self, repository, strategy, request_factory, validator, observer):
        self._repository = repository
        self._strategy = strategy
        self._request_factory = request_factory
        self._validator = validator
        self._observer = observer
        self._ticks = 0

    def handle_hall_call(self, floor, direction):
        request = self._request_factory.create_hall_request(floor, direction)
        self._validator.validate_hall_request(request)
        label = f"{floor} {direction.name}"
        for elevator in self._repository.find_all():
            for waiting in elevator.get_requests():
                if isinstance(waiting, HallRequest) and waiting.get_floor() == floor \
                        and waiting.get_direction() is direction:
                    self._observer.notify(f"call {label} -> {elevator.get_name()} (already waiting)")
                    return
        elevator = self._strategy.select_elevator(self._repository.find_all(), request)
        elevator.add_request(request)
        self._observer.notify(f"call {label} -> {elevator.get_name()}")

    def handle_car_call(self, name, floor):
        elevator = self._repository.find_by_name(name)
        self._validator.validate_floor(floor)
        elevator.add_request(self._request_factory.create_car_request(floor))
        self._observer.notify(f"press {floor} in {name}")

    def advance(self, count):
        for _ in range(count):
            self._ticks += 1
            for elevator in self._repository.find_all():
                event = elevator.step()
                if event:
                    self._observer.notify(f"tick {self._ticks}: {event}")

    def report_status(self):
        for elevator in self._repository.find_all():
            stops = sorted({request.get_floor() for request in elevator.get_requests()})
            where = f"{elevator.get_name()} at {elevator.get_floor()}"
            self._observer.notify(f"{where}, stops {stops}" if stops else f"{where}, idle")


class ElevatorController:
    def __init__(self, service):
        self._service = service

    def call(self, floor, direction):
        self._service.handle_hall_call(floor, direction)

    def press(self, name, floor):
        self._service.handle_car_call(name, floor)

    def tick(self, count):
        self._service.advance(count)

    def status(self):
        self._service.report_status()


class ICommand(ABC):
    @abstractmethod
    def execute(self, controller, args): ...


class CallCommand(ICommand):
    def execute(self, controller, args):
        controller.call(int(args[0]), Direction[args[1]])


class PressCommand(ICommand):
    def execute(self, controller, args):
        controller.press(args[0], int(args[1]))


class TickCommand(ICommand):
    def execute(self, controller, args):
        controller.tick(int(args[0]) if args else 1)


class StatusCommand(ICommand):
    def execute(self, controller, args):
        controller.status()


class CommandFactory:
    def __init__(self):
        self._commands = {
            "CALL": CallCommand(),
            "PRESS": PressCommand(),
            "TICK": TickCommand(),
            "STATUS": StatusCommand(),
        }

    def get_command(self, word):
        return self._commands.get(word)


class ElevatorSystem:
    """A building has one elevator system, so this is a singleton."""

    _instance = None

    def __init__(self):
        self.observer = ConsoleElevatorObserver()
        repository = ElevatorRepository()
        factory = ElevatorFactory()
        for name in "ABC":
            repository.save(factory.create_elevator(name))
        strategy = NearestElevatorStrategy(10)
        service = ElevatorService(repository, strategy, RequestFactory(), RequestValidator(10), self.observer)
        self.controller = ElevatorController(service)
        self.commands = CommandFactory()

    @classmethod
    def get_instance(cls):
        if cls._instance is None:
            cls._instance = ElevatorSystem()
        return cls._instance


def run(lines):
    system = ElevatorSystem.get_instance()
    for line in lines:
        word, *args = line.split()
        command = system.commands.get_command(word)
        if command is None:
            system.observer.notify(f"unknown command {word}")
            continue
        try:
            command.execute(system.controller, args)
        except InvalidRequestException as error:
            system.observer.notify(f"refused: {error}")
    out = list(system.observer.messages)
    system.observer.messages.clear()
    return out

A + CANCEL · answers/answer_a_cancel.py

"""Answer A after the CANCEL follow-up: a new method in the interface, the elevator, the service and the
controller, and a new command class with its registration."""

from abc import ABC, abstractmethod
from enum import Enum


class Direction(Enum):
    UP = "up"
    DOWN = "down"


class ElevatorException(Exception):
    pass


class InvalidRequestException(ElevatorException):
    pass


class IRequest(ABC):
    @abstractmethod
    def get_floor(self): ...


class HallRequest(IRequest):
    def __init__(self, floor, direction):
        self._floor = floor
        self._direction = direction

    def get_floor(self):
        return self._floor

    def get_direction(self):
        return self._direction


class CarRequest(IRequest):
    def __init__(self, floor):
        self._floor = floor

    def get_floor(self):
        return self._floor


class RequestFactory:
    def create_hall_request(self, floor, direction):
        return HallRequest(floor, direction)

    def create_car_request(self, floor):
        return CarRequest(floor)


class RequestValidator:
    def __init__(self, floors):
        self._floors = floors

    def validate_floor(self, floor):
        if not 0 <= floor < self._floors:
            raise InvalidRequestException(f"no floor {floor}")

    def validate_hall_request(self, request):
        floor, direction = request.get_floor(), request.get_direction()
        self.validate_floor(floor)
        if (floor, direction) in ((0, Direction.DOWN), (self._floors - 1, Direction.UP)):
            raise InvalidRequestException(f"no {direction.name} call from floor {floor}")


class IElevator(ABC):
    @abstractmethod
    def get_name(self): ...

    @abstractmethod
    def get_floor(self): ...

    @abstractmethod
    def get_direction(self): ...

    @abstractmethod
    def add_request(self, request): ...

    @abstractmethod
    def get_requests(self): ...

    @abstractmethod
    def remove_request(self, request): ...

    @abstractmethod
    def step(self): ...


class Elevator(IElevator):
    def __init__(self, name):
        self._name = name
        self._floor = 0
        self._direction = None
        self._requests = []

    def get_name(self):
        return self._name

    def get_floor(self):
        return self._floor

    def get_direction(self):
        return self._direction

    def add_request(self, request):
        self._requests.append(request)

    def get_requests(self):
        return list(self._requests)

    def remove_request(self, request):
        self._requests.remove(request)
        if not self._requests:
            self._direction = None

    def step(self):
        stops = {request.get_floor() for request in self._requests}
        if not stops:
            self._direction = None
            return None
        if self._floor not in stops:
            if self._direction is Direction.UP and max(stops) < self._floor:
                self._direction = None
            if self._direction is Direction.DOWN and min(stops) > self._floor:
                self._direction = None
            if self._direction is None:
                nearest = min(stops, key=lambda stop: (abs(stop - self._floor), stop))
                self._direction = Direction.UP if nearest > self._floor else Direction.DOWN
            self._floor += 1 if self._direction is Direction.UP else -1
            if self._floor not in stops:
                return None
        self._requests = [request for request in self._requests if request.get_floor() != self._floor]
        if not self._requests:
            self._direction = None
        return f"{self._name} stops at {self._floor}"


class ElevatorFactory:
    def create_elevator(self, name):
        return Elevator(name)


class IDispatchStrategy(ABC):
    @abstractmethod
    def select_elevator(self, elevators, request): ...


class NearestElevatorStrategy(IDispatchStrategy):
    def __init__(self, floors):
        self._floors = floors

    def select_elevator(self, elevators, request):
        return min(elevators, key=lambda elevator: (self._cost(elevator, request), elevator.get_name()))

    def _cost(self, elevator, request):
        distance = abs(elevator.get_floor() - request.get_floor())
        moving, wanted = elevator.get_direction(), request.get_direction()
        if moving is None:
            return distance
        here, there = elevator.get_floor(), request.get_floor()
        if moving is Direction.UP and wanted is Direction.UP and here <= there:
            return distance
        if moving is Direction.DOWN and wanted is Direction.DOWN and here >= there:
            return distance
        return distance + 2 * self._floors


class IElevatorObserver(ABC):
    @abstractmethod
    def notify(self, message): ...


class ConsoleElevatorObserver(IElevatorObserver):
    def __init__(self):
        self.messages = []

    def notify(self, message):
        self.messages.append(message)


class ElevatorRepository:
    def __init__(self):
        self._elevators = {}

    def save(self, elevator):
        self._elevators[elevator.get_name()] = elevator

    def find_by_name(self, name):
        if name not in self._elevators:
            raise InvalidRequestException(f"no car {name}")
        return self._elevators[name]

    def find_all(self):
        return [self._elevators[name] for name in sorted(self._elevators)]


class ElevatorService:
    def __init__(self, repository, strategy, request_factory, validator, observer):
        self._repository = repository
        self._strategy = strategy
        self._request_factory = request_factory
        self._validator = validator
        self._observer = observer
        self._ticks = 0

    def handle_hall_call(self, floor, direction):
        request = self._request_factory.create_hall_request(floor, direction)
        self._validator.validate_hall_request(request)
        label = f"{floor} {direction.name}"
        for elevator in self._repository.find_all():
            for waiting in elevator.get_requests():
                if isinstance(waiting, HallRequest) and waiting.get_floor() == floor \
                        and waiting.get_direction() is direction:
                    self._observer.notify(f"call {label} -> {elevator.get_name()} (already waiting)")
                    return
        elevator = self._strategy.select_elevator(self._repository.find_all(), request)
        elevator.add_request(request)
        self._observer.notify(f"call {label} -> {elevator.get_name()}")

    def cancel_hall_call(self, floor, direction):
        label = f"{floor} {direction.name}"
        for elevator in self._repository.find_all():
            for waiting in elevator.get_requests():
                if isinstance(waiting, HallRequest) and waiting.get_floor() == floor \
                        and waiting.get_direction() is direction:
                    elevator.remove_request(waiting)
                    self._observer.notify(f"cancel {label} (was {elevator.get_name()})")
                    return
        self._observer.notify(f"no call {label} to cancel")

    def handle_car_call(self, name, floor):
        elevator = self._repository.find_by_name(name)
        self._validator.validate_floor(floor)
        elevator.add_request(self._request_factory.create_car_request(floor))
        self._observer.notify(f"press {floor} in {name}")

    def advance(self, count):
        for _ in range(count):
            self._ticks += 1
            for elevator in self._repository.find_all():
                event = elevator.step()
                if event:
                    self._observer.notify(f"tick {self._ticks}: {event}")

    def report_status(self):
        for elevator in self._repository.find_all():
            stops = sorted({request.get_floor() for request in elevator.get_requests()})
            where = f"{elevator.get_name()} at {elevator.get_floor()}"
            self._observer.notify(f"{where}, stops {stops}" if stops else f"{where}, idle")


class ElevatorController:
    def __init__(self, service):
        self._service = service

    def call(self, floor, direction):
        self._service.handle_hall_call(floor, direction)

    def press(self, name, floor):
        self._service.handle_car_call(name, floor)

    def cancel(self, floor, direction):
        self._service.cancel_hall_call(floor, direction)

    def tick(self, count):
        self._service.advance(count)

    def status(self):
        self._service.report_status()


class ICommand(ABC):
    @abstractmethod
    def execute(self, controller, args): ...


class CallCommand(ICommand):
    def execute(self, controller, args):
        controller.call(int(args[0]), Direction[args[1]])


class PressCommand(ICommand):
    def execute(self, controller, args):
        controller.press(args[0], int(args[1]))


class TickCommand(ICommand):
    def execute(self, controller, args):
        controller.tick(int(args[0]) if args else 1)


class StatusCommand(ICommand):
    def execute(self, controller, args):
        controller.status()


class CancelCommand(ICommand):
    def execute(self, controller, args):
        controller.cancel(int(args[0]), Direction[args[1]])


class CommandFactory:
    def __init__(self):
        self._commands = {
            "CALL": CallCommand(),
            "PRESS": PressCommand(),
            "TICK": TickCommand(),
            "STATUS": StatusCommand(),
            "CANCEL": CancelCommand(),
        }

    def get_command(self, word):
        return self._commands.get(word)


class ElevatorSystem:
    """A building has one elevator system, so this is a singleton."""

    _instance = None

    def __init__(self):
        self.observer = ConsoleElevatorObserver()
        repository = ElevatorRepository()
        factory = ElevatorFactory()
        for name in "ABC":
            repository.save(factory.create_elevator(name))
        strategy = NearestElevatorStrategy(10)
        service = ElevatorService(repository, strategy, RequestFactory(), RequestValidator(10), self.observer)
        self.controller = ElevatorController(service)
        self.commands = CommandFactory()

    @classmethod
    def get_instance(cls):
        if cls._instance is None:
            cls._instance = ElevatorSystem()
        return cls._instance


def run(lines):
    system = ElevatorSystem.get_instance()
    for line in lines:
        word, *args = line.split()
        command = system.commands.get_command(word)
        if command is None:
            system.observer.notify(f"unknown command {word}")
            continue
        try:
            command.execute(system.controller, args)
        except InvalidRequestException as error:
            system.observer.notify(f"refused: {error}")
    out = list(system.observer.messages)
    system.observer.messages.clear()
    return out

B · answers/answer_b.py

"""Answer B, under-modelled: one class keeps each car as a dictionary, its direction as a string and its
stops as plain floor numbers, and each method reads its own command words."""


class Elevators:
    def __init__(self):
        self.cars = {}
        for name in "ABC":
            self.cars[name] = {"floor": 0, "dir": "IDLE", "stops": set()}
        self.t = 0

    def handle(self, line):
        p = line.split()
        if p[0] == "CALL":
            return [self.call(p)]
        if p[0] == "PRESS":
            return [self.press(p)]
        if p[0] == "TICK":
            return self.tick(p)
        if p[0] == "STATUS":
            return self.status()
        return [f"unknown command {p[0]}"]

    def call(self, p):
        f = int(p[1])
        best = None
        for name in sorted(self.cars):
            c = self.cars[name]
            cost = abs(c["floor"] - f)
            if c["dir"] == "UP" and (p[2] != "UP" or c["floor"] > f):
                cost += 20
            if c["dir"] == "DOWN" and (p[2] != "DOWN" or c["floor"] < f):
                cost += 20
            if best is None or cost < best[0]:
                best = (cost, name)
        self.cars[best[1]]["stops"].add(f)
        return f"call {f} {p[2]} -> {best[1]}"

    def press(self, p):
        self.cars[p[1]]["stops"].add(int(p[2]))
        return f"press {p[2]} in {p[1]}"

    def tick(self, p):
        out = []
        for _ in range(int(p[1]) if len(p) > 1 else 1):
            self.t += 1
            for name in sorted(self.cars):
                c = self.cars[name]
                if not c["stops"]:
                    c["dir"] = "IDLE"
                    continue
                if c["floor"] not in c["stops"]:
                    if c["dir"] == "UP" and max(c["stops"]) < c["floor"]:
                        c["dir"] = "IDLE"
                    if c["dir"] == "DOWN" and min(c["stops"]) > c["floor"]:
                        c["dir"] = "IDLE"
                    if c["dir"] == "IDLE":
                        n = min(c["stops"], key=lambda s: (abs(s - c["floor"]), s))
                        c["dir"] = "UP" if n > c["floor"] else "DOWN"
                    c["floor"] += 1 if c["dir"] == "UP" else -1
                    if c["floor"] not in c["stops"]:
                        continue
                c["stops"].discard(c["floor"])
                if not c["stops"]:
                    c["dir"] = "IDLE"
                out.append(f"tick {self.t}: {name} stops at {c['floor']}")
        return out

    def status(self):
        out = []
        for name in sorted(self.cars):
            c = self.cars[name]
            if c["stops"]:
                out.append(f"{name} at {c['floor']}, stops {sorted(c['stops'])}")
            else:
                out.append(f"{name} at {c['floor']}, idle")
        return out


def run(lines):
    e = Elevators()
    out = []
    for line in lines:
        out.extend(e.handle(line))
    return out

B + CANCEL · answers/answer_b_cancel.py

"""Answer B after the CANCEL follow-up: hall calls had to move out of "stops" into a set of their own, so
every method that reads the stops changed."""


class Elevators:
    def __init__(self):
        self.cars = {}
        for name in "ABC":
            self.cars[name] = {"floor": 0, "dir": "IDLE", "stops": set(), "calls": set()}
        self.t = 0

    def handle(self, line):
        p = line.split()
        if p[0] == "CALL":
            return [self.call(p)]
        if p[0] == "PRESS":
            return [self.press(p)]
        if p[0] == "TICK":
            return self.tick(p)
        if p[0] == "STATUS":
            return self.status()
        if p[0] == "CANCEL":
            return [self.cancel(p)]
        return [f"unknown command {p[0]}"]

    def call(self, p):
        f = int(p[1])
        best = None
        for name in sorted(self.cars):
            c = self.cars[name]
            cost = abs(c["floor"] - f)
            if c["dir"] == "UP" and (p[2] != "UP" or c["floor"] > f):
                cost += 20
            if c["dir"] == "DOWN" and (p[2] != "DOWN" or c["floor"] < f):
                cost += 20
            if best is None or cost < best[0]:
                best = (cost, name)
        self.cars[best[1]]["calls"].add((f, p[2]))
        return f"call {f} {p[2]} -> {best[1]}"

    def press(self, p):
        self.cars[p[1]]["stops"].add(int(p[2]))
        return f"press {p[2]} in {p[1]}"

    def cancel(self, p):
        f = int(p[1])
        for name in sorted(self.cars):
            c = self.cars[name]
            if (f, p[2]) in c["calls"]:
                c["calls"].discard((f, p[2]))
                if not c["stops"] and not c["calls"]:
                    c["dir"] = "IDLE"
                return f"cancel {f} {p[2]} (was {name})"
        return f"no call {f} {p[2]} to cancel"

    def tick(self, p):
        out = []
        for _ in range(int(p[1]) if len(p) > 1 else 1):
            self.t += 1
            for name in sorted(self.cars):
                c = self.cars[name]
                todo = c["stops"] | {f for f, d in c["calls"]}
                if not todo:
                    c["dir"] = "IDLE"
                    continue
                if c["floor"] not in todo:
                    if c["dir"] == "UP" and max(todo) < c["floor"]:
                        c["dir"] = "IDLE"
                    if c["dir"] == "DOWN" and min(todo) > c["floor"]:
                        c["dir"] = "IDLE"
                    if c["dir"] == "IDLE":
                        n = min(todo, key=lambda s: (abs(s - c["floor"]), s))
                        c["dir"] = "UP" if n > c["floor"] else "DOWN"
                    c["floor"] += 1 if c["dir"] == "UP" else -1
                    if c["floor"] not in todo:
                        continue
                c["stops"].discard(c["floor"])
                c["calls"] = {(f, d) for f, d in c["calls"] if f != c["floor"]}
                if not c["stops"] and not c["calls"]:
                    c["dir"] = "IDLE"
                out.append(f"tick {self.t}: {name} stops at {c['floor']}")
        return out

    def status(self):
        out = []
        for name in sorted(self.cars):
            c = self.cars[name]
            todo = c["stops"] | {f for f, d in c["calls"]}
            if todo:
                out.append(f"{name} at {c['floor']}, stops {sorted(todo)}")
            else:
                out.append(f"{name} at {c['floor']}, idle")
        return out


def run(lines):
    e = Elevators()
    out = []
    for line in lines:
        out.extend(e.handle(line))
    return out

C · answers/answer_c.py

"""Answer C, balanced: a type for each thing the prompt names, one home for each rule, refusals for requests
the building cannot serve, and nothing the prompt does not ask for."""

from dataclasses import dataclass, field
from enum import Enum


class Direction(Enum):
    UP = "up"
    DOWN = "down"


class Refused(Exception):
    """A request the building cannot serve; run() prints the reason."""


@dataclass(frozen=True)
class HallCall:
    floor: int
    direction: Direction

    def __str__(self):
        return f"{self.floor} {self.direction.name}"


@dataclass
class Car:
    name: str
    floor: int = 0
    moving: Direction | None = None
    car_calls: set[int] = field(default_factory=set)
    hall_calls: set[HallCall] = field(default_factory=set)

    def stops(self):
        return self.car_calls | {call.floor for call in self.hall_calls}

    def heads_for(self, call):
        """Moving the way the call wants to go, with the call's floor still ahead."""
        if self.moving is Direction.UP:
            return call.direction is Direction.UP and self.floor <= call.floor
        if self.moving is Direction.DOWN:
            return call.direction is Direction.DOWN and self.floor >= call.floor
        return False

    def step(self):
        """One tick: serve this floor, or move one floor towards the next stop and serve that floor."""
        stops = self.stops()
        if not stops:
            self.moving = None
            return None
        if self.floor not in stops:
            if self.moving is Direction.UP and max(stops) < self.floor:
                self.moving = None
            if self.moving is Direction.DOWN and min(stops) > self.floor:
                self.moving = None
            if self.moving is None:
                nearest = min(stops, key=lambda stop: (abs(stop - self.floor), stop))
                self.moving = Direction.UP if nearest > self.floor else Direction.DOWN
            self.floor += 1 if self.moving is Direction.UP else -1
            if self.floor not in stops:
                return None
        self.car_calls.discard(self.floor)
        self.hall_calls = {call for call in self.hall_calls if call.floor != self.floor}
        if not self.stops():
            self.moving = None
        return f"{self.name} stops at {self.floor}"


def cost(car, call, floors):
    """The dispatch rule: the distance, plus twice the building's height if the car must turn round first."""
    distance = abs(car.floor - call.floor)
    if car.moving is None or car.heads_for(call):
        return distance
    return distance + 2 * floors


class ElevatorBank:
    def __init__(self, floors=10, names="ABC"):
        self.floors = floors
        self.cars = {name: Car(name) for name in names}
        self.ticks = 0

    def call(self, floor, direction):
        call = HallCall(self._floor(floor), direction)
        if (floor, direction) in ((0, Direction.DOWN), (self.floors - 1, Direction.UP)):
            raise Refused(f"no {direction.name} call from floor {floor}")
        for car in self.cars.values():
            if call in car.hall_calls:
                return f"call {call} -> {car.name} (already waiting)"
        car = min(self.cars.values(), key=lambda car: (cost(car, call, self.floors), car.name))
        car.hall_calls.add(call)
        return f"call {call} -> {car.name}"

    def press(self, name, floor):
        self._car(name).car_calls.add(self._floor(floor))
        return f"press {floor} in {name}"

    def tick(self, count=1):
        events = []
        for _ in range(count):
            self.ticks += 1
            for car in self.cars.values():
                event = car.step()
                if event:
                    events.append(f"tick {self.ticks}: {event}")
        return events

    def status(self):
        return [f"{car.name} at {car.floor}, " + (f"stops {sorted(car.stops())}" if car.stops() else "idle")
                for car in self.cars.values()]

    def _floor(self, floor):
        if not 0 <= floor < self.floors:
            raise Refused(f"no floor {floor}")
        return floor

    def _car(self, name):
        if name not in self.cars:
            raise Refused(f"no car {name}")
        return self.cars[name]


def run(lines):
    """The boundary: turns command lines into calls and collects what the bank prints."""
    bank = ElevatorBank()
    out = []
    for line in lines:
        word, *args = line.split()
        try:
            if word == "CALL":
                out.append(bank.call(int(args[0]), Direction[args[1]]))
            elif word == "PRESS":
                out.append(bank.press(args[0], int(args[1])))
            elif word == "TICK":
                out.extend(bank.tick(int(args[0]) if args else 1))
            elif word == "STATUS":
                out.extend(bank.status())
            else:
                out.append(f"unknown command {word}")
        except Refused as reason:
            out.append(f"refused: {reason}")
    return out

C + CANCEL · answers/answer_c_cancel.py

"""Answer C after the CANCEL follow-up: Car.drop() and ElevatorBank.cancel() are new, and run() reads one
more command word."""

from dataclasses import dataclass, field
from enum import Enum


class Direction(Enum):
    UP = "up"
    DOWN = "down"


class Refused(Exception):
    """A request the building cannot serve; run() prints the reason."""


@dataclass(frozen=True)
class HallCall:
    floor: int
    direction: Direction

    def __str__(self):
        return f"{self.floor} {self.direction.name}"


@dataclass
class Car:
    name: str
    floor: int = 0
    moving: Direction | None = None
    car_calls: set[int] = field(default_factory=set)
    hall_calls: set[HallCall] = field(default_factory=set)

    def stops(self):
        return self.car_calls | {call.floor for call in self.hall_calls}

    def heads_for(self, call):
        """Moving the way the call wants to go, with the call's floor still ahead."""
        if self.moving is Direction.UP:
            return call.direction is Direction.UP and self.floor <= call.floor
        if self.moving is Direction.DOWN:
            return call.direction is Direction.DOWN and self.floor >= call.floor
        return False

    def step(self):
        """One tick: serve this floor, or move one floor towards the next stop and serve that floor."""
        stops = self.stops()
        if not stops:
            self.moving = None
            return None
        if self.floor not in stops:
            if self.moving is Direction.UP and max(stops) < self.floor:
                self.moving = None
            if self.moving is Direction.DOWN and min(stops) > self.floor:
                self.moving = None
            if self.moving is None:
                nearest = min(stops, key=lambda stop: (abs(stop - self.floor), stop))
                self.moving = Direction.UP if nearest > self.floor else Direction.DOWN
            self.floor += 1 if self.moving is Direction.UP else -1
            if self.floor not in stops:
                return None
        self.car_calls.discard(self.floor)
        self.hall_calls = {call for call in self.hall_calls if call.floor != self.floor}
        if not self.stops():
            self.moving = None
        return f"{self.name} stops at {self.floor}"

    def drop(self, call):
        self.hall_calls.discard(call)
        if not self.stops():
            self.moving = None


def cost(car, call, floors):
    """The dispatch rule: the distance, plus twice the building's height if the car must turn round first."""
    distance = abs(car.floor - call.floor)
    if car.moving is None or car.heads_for(call):
        return distance
    return distance + 2 * floors


class ElevatorBank:
    def __init__(self, floors=10, names="ABC"):
        self.floors = floors
        self.cars = {name: Car(name) for name in names}
        self.ticks = 0

    def call(self, floor, direction):
        call = HallCall(self._floor(floor), direction)
        if (floor, direction) in ((0, Direction.DOWN), (self.floors - 1, Direction.UP)):
            raise Refused(f"no {direction.name} call from floor {floor}")
        for car in self.cars.values():
            if call in car.hall_calls:
                return f"call {call} -> {car.name} (already waiting)"
        car = min(self.cars.values(), key=lambda car: (cost(car, call, self.floors), car.name))
        car.hall_calls.add(call)
        return f"call {call} -> {car.name}"

    def press(self, name, floor):
        self._car(name).car_calls.add(self._floor(floor))
        return f"press {floor} in {name}"

    def cancel(self, floor, direction):
        call = HallCall(floor, direction)
        for car in self.cars.values():
            if call in car.hall_calls:
                car.drop(call)
                return f"cancel {call} (was {car.name})"
        return f"no call {call} to cancel"

    def tick(self, count=1):
        events = []
        for _ in range(count):
            self.ticks += 1
            for car in self.cars.values():
                event = car.step()
                if event:
                    events.append(f"tick {self.ticks}: {event}")
        return events

    def status(self):
        return [f"{car.name} at {car.floor}, " + (f"stops {sorted(car.stops())}" if car.stops() else "idle")
                for car in self.cars.values()]

    def _floor(self, floor):
        if not 0 <= floor < self.floors:
            raise Refused(f"no floor {floor}")
        return floor

    def _car(self, name):
        if name not in self.cars:
            raise Refused(f"no car {name}")
        return self.cars[name]


def run(lines):
    """The boundary: turns command lines into calls and collects what the bank prints."""
    bank = ElevatorBank()
    out = []
    for line in lines:
        word, *args = line.split()
        try:
            if word == "CALL":
                out.append(bank.call(int(args[0]), Direction[args[1]]))
            elif word == "PRESS":
                out.append(bank.press(args[0], int(args[1])))
            elif word == "TICK":
                out.extend(bank.tick(int(args[0]) if args else 1))
            elif word == "STATUS":
                out.extend(bank.status())
            elif word == "CANCEL":
                out.append(bank.cancel(int(args[0]), Direction[args[1]]))
            else:
                out.append(f"unknown command {word}")
        except Refused as reason:
            out.append(f"refused: {reason}")
    return out

Every version passes everything it is given, so the difference is the cost of the change. The balanced answer C needed 13 code lines: Car.drop(), ElevatorBank.cancel() and one command word in run(). The under-modelled answer B needed 37 and changed five of its seven functions: its stops were bare floor numbers, so hall calls had to be split from passengers’ car calls wherever the stops were read. The over-engineered answer A changed almost nothing that existed, but the change ran through six classes, four of which only declare, register or pass it on.

The CANCEL follow-up runs through 6 classes of answer A, changes 5 of the 7 functions of answer B, and adds 2 methods to answer C.A, over-engineered22 lines in 6 classesB, under-modelled37 lines, 5 of 7 functionsC, balanced13 lines, 2 new methodsCommandFactorychanged: registers CANCELCancelCommand.executenew: passes the line onElevatorController.cancelnew: passes it onElevatorService.cancel_hall_callnew: finds the callIElevator.remove_requestnew: declares itElevator.remove_requestnew: removes iteach car's dictionarystops: floors, as beforecalls: new, (floor, direction)changed: __init__, handle,call, tick, statusnew: cancelrun()changed: reads CANCELElevatorBank.cancelnew: finds the callCar.dropnew: drops the call

Where the CANCEL follow-up landed in each answer

Text description of the diagram

The diagram has three panels, one above the other, one for each answer. Each shows what the CANCEL follow-up added or changed, in the order a CANCEL line reaches it, and the counts that compare.py measured.

  1. Answer A, over-engineered: 22 code lines in 6 classes. CommandFactory changed, to register CANCEL. It creates the new CancelCommand, whose execute method reads the line and passes it on to the new ElevatorController.cancel, which passes it on to the new ElevatorService.cancel_hall_call, which finds the call. That calls IElevator.remove_request, a new method the interface only declares, implemented by the new Elevator.remove_request, which removes the call.
  2. Answer B, under-modelled: 37 code lines, and 5 of its 7 functions changed. Each car's dictionary keeps its stops as floor numbers only, as before, and gains calls, a new set of floor and direction pairs. The functions that read that data changed: __init__, handle, call, tick and status; cancel is new; press and run did not change.
  3. Answer C, balanced: 13 code lines in 2 classes and run(). run() changed, to read CANCEL, and calls the new ElevatorBank.cancel, which finds the call and calls the new Car.drop, which drops it and leaves the car idle when no stops are left.
A over-engineered, B under-modelled, C balanced; the CANCEL rows count what the follow-up changed
Criterion ABC
Classes 2515
Lines 23774110
CANCEL: lines 223713
CANCEL: functions 663
CANCEL: classes 612

Five questions to ask of your own answer

Ask your answer five questions before the interviewer asks for a change. They are the five criteria of the rubric used to score answers:

  1. Fit. Does every noun and state of the prompt have a type, with nothing the prompt does not need?
  2. One home per rule. Can you point at the one place where each rule lives?
  3. Change. Would the likeliest follow-up land in one or two places?
  4. Failure paths. What happens to a request the system cannot serve?
  5. Tests. Can a test build the system, drive it and throw it away, sharing nothing?

Practise: cars that do not stop at every floor

Add a follow-up to the balanced answer: floors 7 to 9 are let to one company, and only car C stops there. The sample tests check that nothing else changes, where hall calls go, and the two new refusals.

Exercise · Medium · Python

Cars that do not stop at every floor

elevators.py is the balanced answer to the elevator prompt. A follow-up arrives: floors 7 to 9 are let to one company, and only car C stops there. Add it so that a building without such rules prints exactly what it printed before:

  • ElevatorBank(floors=10, names="ABC", serves=None): serves maps a car's name to the floors it stops at, such as {"A": range(7), "B": range(7)}. A car that is not in serves stops at every floor.
  • Car.serves(floor) answers whether the car stops at that floor.
  • A hall call goes only to a car that stops at its floor, chosen among those cars by the same cost rule. If no car stops there, call() raises Refused("no car stops at 8").
  • press() raises Refused("A does not stop at 8") when the car skips that floor. A floor that does not exist is still refused first, as no floor 12.
  • run(lines, serves=None) passes serves on to the bank.

The sample tests run a building without rules, calls that only one car can take, the two refusals and Car.serves().

Starter code · elevators.py

"""The balanced answer to the elevator prompt. Add the follow-up of the task: some cars do not stop at every
floor."""

from dataclasses import dataclass, field
from enum import Enum


class Direction(Enum):
    UP = "up"
    DOWN = "down"


class Refused(Exception):
    """A request the building cannot serve; run() prints the reason."""


@dataclass(frozen=True)
class HallCall:
    floor: int
    direction: Direction

    def __str__(self):
        return f"{self.floor} {self.direction.name}"


@dataclass
class Car:
    name: str
    floor: int = 0
    moving: Direction | None = None
    car_calls: set[int] = field(default_factory=set)
    hall_calls: set[HallCall] = field(default_factory=set)

    def stops(self):
        return self.car_calls | {call.floor for call in self.hall_calls}

    def heads_for(self, call):
        """Moving the way the call wants to go, with the call's floor still ahead."""
        if self.moving is Direction.UP:
            return call.direction is Direction.UP and self.floor <= call.floor
        if self.moving is Direction.DOWN:
            return call.direction is Direction.DOWN and self.floor >= call.floor
        return False

    def step(self):
        """One tick: serve this floor, or move one floor towards the next stop and serve that floor."""
        stops = self.stops()
        if not stops:
            self.moving = None
            return None
        if self.floor not in stops:
            if self.moving is Direction.UP and max(stops) < self.floor:
                self.moving = None
            if self.moving is Direction.DOWN and min(stops) > self.floor:
                self.moving = None
            if self.moving is None:
                nearest = min(stops, key=lambda stop: (abs(stop - self.floor), stop))
                self.moving = Direction.UP if nearest > self.floor else Direction.DOWN
            self.floor += 1 if self.moving is Direction.UP else -1
            if self.floor not in stops:
                return None
        self.car_calls.discard(self.floor)
        self.hall_calls = {call for call in self.hall_calls if call.floor != self.floor}
        if not self.stops():
            self.moving = None
        return f"{self.name} stops at {self.floor}"


def cost(car, call, floors):
    """The dispatch rule: the distance, plus twice the building's height if the car must turn round first."""
    distance = abs(car.floor - call.floor)
    if car.moving is None or car.heads_for(call):
        return distance
    return distance + 2 * floors


class ElevatorBank:
    def __init__(self, floors=10, names="ABC"):
        self.floors = floors
        self.cars = {name: Car(name) for name in names}
        self.ticks = 0

    def call(self, floor, direction):
        call = HallCall(self._floor(floor), direction)
        if (floor, direction) in ((0, Direction.DOWN), (self.floors - 1, Direction.UP)):
            raise Refused(f"no {direction.name} call from floor {floor}")
        for car in self.cars.values():
            if call in car.hall_calls:
                return f"call {call} -> {car.name} (already waiting)"
        car = min(self.cars.values(), key=lambda car: (cost(car, call, self.floors), car.name))
        car.hall_calls.add(call)
        return f"call {call} -> {car.name}"

    def press(self, name, floor):
        self._car(name).car_calls.add(self._floor(floor))
        return f"press {floor} in {name}"

    def tick(self, count=1):
        events = []
        for _ in range(count):
            self.ticks += 1
            for car in self.cars.values():
                event = car.step()
                if event:
                    events.append(f"tick {self.ticks}: {event}")
        return events

    def status(self):
        return [f"{car.name} at {car.floor}, " + (f"stops {sorted(car.stops())}" if car.stops() else "idle")
                for car in self.cars.values()]

    def _floor(self, floor):
        if not 0 <= floor < self.floors:
            raise Refused(f"no floor {floor}")
        return floor

    def _car(self, name):
        if name not in self.cars:
            raise Refused(f"no car {name}")
        return self.cars[name]


def run(lines):
    """The boundary: turns command lines into calls and collects what the bank prints."""
    bank = ElevatorBank()
    out = []
    for line in lines:
        word, *args = line.split()
        try:
            if word == "CALL":
                out.append(bank.call(int(args[0]), Direction[args[1]]))
            elif word == "PRESS":
                out.append(bank.press(args[0], int(args[1])))
            elif word == "TICK":
                out.extend(bank.tick(int(args[0]) if args else 1))
            elif word == "STATUS":
                out.extend(bank.status())
            else:
                out.append(f"unknown command {word}")
        except Refused as reason:
            out.append(f"refused: {reason}")
    return out
The sample tests · test_elevators.py
from elevators import ElevatorBank, run

UPPER = {"A": range(7), "B": range(7)}  # floors 7 to 9: only car C stops there


def test_a_building_without_restrictions_prints_the_same():
    """without serves, a scenario prints exactly what the balanced answer printed"""
    commands = ["PRESS A 9", "TICK 2", "CALL 6 UP", "CALL 4 DOWN", "CALL 1 UP", "TICK 3", "STATUS", "TICK 4", "STATUS"]
    assert run(commands) == [
        "press 9 in A", "call 6 UP -> A", "call 4 DOWN -> B", "call 1 UP -> B", "tick 3: B stops at 1",
        "A at 5, stops [6, 9]", "B at 3, stops [4]", "C at 0, idle",
        "tick 6: A stops at 6", "tick 6: B stops at 4", "tick 9: A stops at 9",
        "A at 9, idle", "B at 4, idle", "C at 0, idle",
    ]


def test_a_car_on_its_way_down_still_takes_calls_below_it():
    """without serves, car A going down from 8 takes the DOWN call from 4, not the one from 7 behind it"""
    commands = ["PRESS A 8", "TICK 8", "PRESS A 2", "TICK 2", "CALL 4 DOWN", "CALL 7 DOWN"]
    assert run(commands) == ["press 8 in A", "tick 8: A stops at 8", "press 2 in A", "call 4 DOWN -> A", "call 7 DOWN -> B"]


def test_a_hall_call_goes_to_a_car_that_stops_there():
    """a call from floor 8 goes to C, the only car that stops there, though A is first by name"""
    assert run(["CALL 8 DOWN", "TICK 8", "STATUS"], serves=UPPER) == [
        "call 8 DOWN -> C", "tick 8: C stops at 8", "A at 0, idle", "B at 0, idle", "C at 8, idle",
    ]


def test_the_cost_rule_still_chooses_among_those_cars():
    """car A, at floor 6, takes the call from 5 but not the nearer-looking call from 8"""
    assert run(["PRESS A 6", "TICK 6", "CALL 5 DOWN", "CALL 8 DOWN"], serves=UPPER) == [
        "press 6 in A", "tick 6: A stops at 6", "call 5 DOWN -> A", "call 8 DOWN -> C",
    ]


def test_a_press_for_a_floor_the_car_skips_is_refused():
    """pressing 8 inside A is refused, inside C it is a stop"""
    assert run(["PRESS A 8", "PRESS C 8"], serves=UPPER) == ["refused: A does not stop at 8", "press 8 in C"]


def test_a_call_no_car_can_serve_is_refused():
    """when no car stops at floor 8, the call is refused and no car moves"""
    serves = {name: range(7) for name in "ABC"}
    assert run(["CALL 8 DOWN", "TICK 3", "STATUS"], serves=serves) == [
        "refused: no car stops at 8", "A at 0, idle", "B at 0, idle", "C at 0, idle",
    ]


def test_floors_that_do_not_exist_are_refused_as_before():
    """floor 12 is still 'no floor 12', not a floor some car skips"""
    assert run(["CALL 12 DOWN", "PRESS A 12"], serves=UPPER) == ["refused: no floor 12", "refused: no floor 12"]


def test_each_car_answers_which_floors_it_serves():
    """Car.serves(floor) is the one place that knows where a car stops"""
    bank = ElevatorBank(serves=UPPER)
    assert [bank.cars["A"].serves(floor) for floor in (0, 6, 7, 9)] == [True, True, False, False]
    assert all(bank.cars["C"].serves(floor) for floor in range(10))
A hint

Give the rule one home. Add a field to Car that holds the floors it stops at, with None for every floor, and a method serves(floor) that reads it. Then call() chooses among the cars for which car.serves(floor) is true, and press() asks the same method once it has checked the car and the floor.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 7 The prompt asks for text-message notices only. Which weakness does this code show?

    Read the code, then choose one answer.

    class INotifier(ABC):
        @abstractmethod
        def send(self, text): ...
    
    class SmsNotifier(INotifier):
        def send(self, text):
            print(f"SMS: {text}")
    
    class NotifierFactory:
        def create(self):
            return SmsNotifier()
    Show the answer to question 1

    Answer: Over-engineering; an interface with one implementation and a factory that always builds the same class

    Neither the interface nor the factory decides anything: there is one notifier, and the factory can only build it. Both add a place that every change must visit. A plain SmsNotifier class is enough until a second kind of notice is asked for.

  2. Question 2 of 7 Which weakness does this part of a cinema answer show?

    Read the code, then choose one answer.

    def handle(self, line):
        p = line.split()
        if p[0] == "HOLD":
            if self.seats[p[1]]["state"] == "FREE":
                self.seats[p[1]] = {"state": "HELD", "by": p[2]}
                return f"held {p[1]}"
    Show the answer to question 2

    Answer: Under-modelling; reading the input, the rules and the data are one method, a seat is a dictionary and its state a string

    The seat has no type of its own, so nothing stops a typo such as "HLED", and the rule "only a free seat can be held" sits inside the code that splits the line. A Seat class with a closed set of states, and a run() that only turns lines into calls, give each idea one home.

  3. Question 3 of 7 An elevator answer makes ElevatorSystem a Singleton "because a building has only one". Nothing in the prompt needs one shared instance. Which weaknesses is that? Choose every one.

    Choose every answer that is right.

    Show the answer to question 3

    Answer:

    • Pattern stuffing
    • Hidden global state

    The pattern is named without a problem that it solves, which is pattern stuffing, and it gives every caller the same object, which is hidden global state. One system per run is fine; run() can build it and pass it on.

  4. Question 4 of 7 Each scenario passes when it runs on its own, but two fail when the whole suite runs, and which two depends on the order. What is the most likely cause?

    Choose one answer.

    Show the answer to question 4

    Answer: State that outlives a test, such as a singleton or a module-level dictionary

    A test that passes alone and fails after another one inherits something from it. Look for what lives longer than a test: singletons, class attributes, module-level collections and caches.

  5. Question 5 of 7 In the report of compare.py, how many of answer B's seven existing functions did the CANCEL follow-up change?

    Type a number.

    Show the answer to question 5

    Answer: 5

    __init__, handle, call, tick and status changed, and cancel is new. Only press and run stayed as they were, because the stops had to be split into car calls and hall calls wherever they were read.

  6. Question 6 of 7 Why did CANCEL take 37 code lines in answer B and 13 in answer C?

    Choose one answer.

    Show the answer to question 6

    Answer: B kept every stop as a bare floor number, so hall calls had to be split from car calls wherever the stops were read

    In C, hall calls and car calls were separate from the start, so removing one hall call needed one new method on the bank and one on the car. B's data could not tell the two apart until it was changed.

  7. Question 7 of 7 Which of these belong in a balanced answer to the elevator prompt? Choose every one.

    Choose every answer that is right.

    Show the answer to question 7

    Answer:

    • A refusal for a call from floor 12 of a building with floors 0 to 9
    • A HallCall value with a floor and a direction
    • A Direction enum with UP and DOWN

    The enum and the value class give the prompt's ideas types, and the refusal answers a failure path. The interface and the factory would add places to change without adding a choice the prompt asks for.

Text Diff Paste answer_c.py and answer_c_cancel.py side by side to see the whole CANCEL change of the balanced answer.

Each weakness reproduced in code that runs Pro: opens with a Pro, Premium or Ultimate pass

Each weakness is easy to see in someone else’s code and easy to miss in your own. This part makes each one fail in front of you: a suite that passes one scenario at a time and fails as a whole, requests that nobody wrote a scenario for, a quick fix that strands a passenger, and one call traced through every layer it crosses.

In this part:

  • Hidden global state: scenarios that pass alone and fail together
  • Requests nobody wrote a scenario for
  • Under-modelling: the quick CANCEL that strands a passenger
  • Over-engineering and pattern stuffing: one call through every layer

3 runnable examples (Python) · about 5 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Read a complete sample first: Low-level design: parking lot (the model)

Shrinking an over-engineered answer in three measured steps Pro: opens with a Pro, Premium or Ultimate pass

An over-engineered answer is fixed by taking structure out, one step at a time, with the scenarios passing after every step. This part removes answer A’s singleton, collapses its one-implementation hierarchies and removes its middle men, and measures what each step took away.

In this part:

  • Three refactorings, measured after each step
  • Step 1: build the system instead of fetching a singleton
  • Step 2: Collapse Hierarchy and Inline Class
  • Step 3: Remove Middle Man
  • Which steps fit in the last fifteen minutes

1 runnable example (Step 1, Step 2, Step 3, shrink.py) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Scoring answers with the rubric, level by level Pro: opens with a Pro, Premium or Ultimate pass

A rubric turns “this feels heavy” into points that you can check and defend. This part gives each of the five criteria a score of 0, 1 or 2, scores the three elevator answers with the evidence for each point, and says what an answer at each level of experience is expected to show.

In this part:

  • The rubric: five criteria, 0 to 2 points each
  • The three elevator answers, scored against the rubric
  • What each level of experience is expected to show
  • Defending a lean answer when the interviewer asks for patterns

about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    What is the difference between over-engineering and under-modelling in a low-level design answer?

    Over-engineering adds structure the prompt does not need: an interface for a class with one implementation, a factory for an object that a constructor could build, a layer that only passes calls on. Under-modelling leaves out structure the prompt does need: one class keeps everything in nested dictionaries, a status or a direction is a free-form string, and nothing says what a value means. Both answers can pass their scenarios. The difference shows when a requirement changes. An over-engineered answer spreads a small change across many classes, each of which must be read to find the one that decides; an under-modelled answer has to change most of its methods, because its data cannot express the new idea. A balanced answer gives each idea of the prompt a type and each rule one home, and adds a seam only where a stated change will land.

  2. Mid to senior

    Your elevator answer has an interface for every class, three factories, a command class for each input word and a Singleton for the system. The interviewer says it feels heavy. What do you remove first, what do you keep, and how do you justify each choice?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    Each test of your elevator answer passes when it runs alone, but two fail when the whole suite runs, and which two depends on the order. What do you look for, and how do you change the design so that it cannot happen again?

    Model answer, Pro. First step and follow-ups

  4. Fresher to senior

    Halfway through a 60-minute round, the interviewer asks what your parking-lot answer does when a ticket is lost, a card payment is declined at the exit, or the same ticket is scanned twice. It handles none of them. Which do you design now, which do you only describe, and how?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A weak LLD answer usually runs; its weakness shows when a follow-up arrives.
  • Five weaknesses recur: over-engineering, under-modelling, pattern stuffing, hidden global state and ignored failure paths.
  • Judge an answer by what a likely follow-up costs it: 13 code lines for the balanced elevator answer, 37 lines and five of seven functions for the under-modelled one.
  • Give each idea a type and each rule one home, add seams only for stated changes, refuse impossible requests, and build objects where tests can build them.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.