1

When One Display Breaks the Whole System

The Scenario

Learning objective: After this step you will be able to identify the symptoms of tightly coupled code and explain why direct dependencies create maintenance headaches.

You work at a weather company. Your WeatherStation collects temperature, humidity, and pressure data from sensors, and updates three displays:

Display What it shows
CurrentDisplay Current temperature and humidity
StatisticsDisplay Average, min, and max temperature
ForecastDisplay Weather forecast based on pressure

The Architecture Problem

Look at the UML Diagram tab (bottom-right). Notice how WeatherStation has direct arrows to every single display. That means the station hard-codes every display it talks to:

WeatherStation +current: CurrentDisplay +stats: StatisticsDisplay +forecast: ForecastDisplay +new_measurement(temp, humidity, pressure) CurrentDisplay +show(temp, humidity, pressure) StatisticsDisplay -_temps: list +show(temp, humidity, pressure) ForecastDisplay -_last_pressure: float +show(temp, humidity, pressure) creates and calls creates and calls creates and calls Every display is a\ndirect dependency.\nAdding one = editing this class.

The code works. Ship it, right? Not so fast. Your manager just dropped a new requirement:

“Add a Heat Index Display that shows the computed heat index based on temperature and humidity.”

Your Task

Open weather_station.py and:

  1. Create a HeatIndexDisplay class with a show(self, temp, humidity, pressure) method. Use this simplified formula:
    heat_index = temp - 0.55 * (1 - humidity/100) * (temp - 58)
    

    Print: [Heat] Heat Index: {value:.1f}°F

  2. Integrate it into WeatherStation so it gets called whenever new data arrives.

  3. Run the code and verify all four displays update.

What to Notice

Pay attention to how many places you had to change just to add one new display. Count them. Then check the UML Diagram tab — watch the new dependency arrow appear. That observation is the entire point of this exercise.

Errors Are Expected

If something breaks, good — that is the lesson. Tightly coupled code resists change. Read the error messages and fix them.

Starter files
weather_station.py
class CurrentDisplay:
    """Shows current temperature and humidity."""

    def show(self, temp: float, humidity: float, pressure: float) -> None:
        print(f"[Current]  {temp}°F, {humidity}% humidity")


class StatisticsDisplay:
    """Shows average, min, max temperature."""

    def __init__(self) -> None:
        self._temps: list[float] = []

    def show(self, temp: float, humidity: float, pressure: float) -> None:
        self._temps.append(temp)
        avg = sum(self._temps) / len(self._temps)
        lo, hi = min(self._temps), max(self._temps)
        print(f"[Stats]    Avg={avg:.1f}°F  Min={lo}°F  Max={hi}°F")


class ForecastDisplay:
    """Shows forecast based on pressure changes."""

    def __init__(self) -> None:
        self._last_pressure: float = 0.0

    def show(self, temp: float, humidity: float, pressure: float) -> None:
        if pressure > self._last_pressure:
            outlook = "Improving weather!"
        elif pressure < self._last_pressure:
            outlook = "Cooler, rainy weather"
        else:
            outlook = "More of the same"
        self._last_pressure = pressure
        print(f"[Forecast] {outlook}")


class WeatherStation:
    """Collects weather data and updates all displays.

    NOTICE: This class directly creates and calls every display.
    To add a new display, you must modify THIS class."""

    def __init__(self) -> None:
        self.current: CurrentDisplay = CurrentDisplay()
        self.stats: StatisticsDisplay = StatisticsDisplay()
        self.forecast: ForecastDisplay = ForecastDisplay()

    def new_measurement(self, temp: float, humidity: float, pressure: float) -> None:
        """Called when sensors report new weather data."""
        print(f"\n--- Measurement: {temp}°F, {humidity}%, {pressure} hPa ---")
        self.current.show(temp, humidity, pressure)
        self.stats.show(temp, humidity, pressure)
        self.forecast.show(temp, humidity, pressure)


# --- Demo ---
if __name__ == "__main__":
    station = WeatherStation()
    station.new_measurement(80, 65, 1013.25)
    station.new_measurement(82, 70, 1012.50)
    station.new_measurement(78, 90, 1015.00)
2

Design Patterns: A Shared Vocabulary

What Are Design Patterns?

Learning objective: After this step you will be able to define what a design pattern is and describe the Observer pattern using a real-world analogy and its UML structure.

The YouTube Analogy

Think about how YouTube notifications work:

  1. A creator (like MrBeast) uploads a new video
  2. All subscribers automatically get notified
  3. The creator does not need to know who the subscribers are or what they will do
  4. Subscribers can subscribe or unsubscribe at any time without the creator changing anything

This is exactly the Observer Design Pattern:

YouTube Observer Pattern Weather Station
Channel (MrBeast) Subject WeatherStation
“Upload” event State change new_measurement()
Subscribers Observers Display objects
Subscribe button attach() Register a display
Unsubscribe detach() Remove a display
Push notification notify() Call update() on all displays

What Is a “Design Pattern”?

A design pattern is a proven, reusable solution to a common software design problem. Design patterns are not code you copy-paste — they are blueprints for structuring your classes and objects to solve specific types of architectural problems.

The most famous catalog comes from the 1994 book Design Patterns by Gamma, Helm, Johnson, and Vlissides — the Gang of Four (GoF). They cataloged 23 patterns in three categories:

Category What It Solves Examples
Creational How objects are created Factory, Singleton, Builder
Structural How objects are composed Adapter, Decorator, Facade
Behavioral How objects communicate Observer, Strategy, State

The Observer is a behavioral pattern — it defines how objects talk to each other in a loosely coupled way.

The Observer Pattern in UML

Here is the formal structure. Compare this to the tightly coupled diagram from Step 1 — notice how Subject no longer points to specific concrete observers:

«abstract» Subject -_observers: list +attach(observer: Observer) +detach(observer: Observer) +notify() «abstract» Observer +update(subject: Subject) ConcreteSubject -_state +get_state() +set_state(value) ConcreteObserverA +update(subject: Subject) ConcreteObserverB +update(subject: Subject) 0..* notifies Only knows the Observer\ninterface — NOT concrete types Dashed arrows = implements.\nAny class can become an observer.

Key UML relationships:

Polling vs. Notification

An alternative to Observer is polling — each display repeatedly asks the station “has anything changed?” This is like refreshing a web page over and over instead of getting push notifications. It works, but wastes resources and introduces delays.

Predict Before You Run

Look at pattern_preview.py. Before running it, predict:

  1. How many times will “Data changed!” print?
  2. Will DashboardA always print before DashboardB?

Run it to check your predictions.

Starter files
pattern_preview.py
# A tiny preview of the Observer pattern in action.
# PREDICT: What will this print? In what order?

class DataSource:
    """A simple Subject that notifies observers."""

    def __init__(self) -> None:
        self._observers: list = []
        self._value: int = 0

    def attach(self, observer) -> None:
        self._observers.append(observer)

    def set_value(self, new_value: int) -> None:
        self._value = new_value
        print(f"Data changed! New value: {self._value}")
        for obs in self._observers:
            obs.update(self._value)


class DashboardA:
    def update(self, value: int) -> None:
        print(f"  Dashboard A sees: {value}")


class DashboardB:
    def update(self, value: int) -> None:
        print(f"  Dashboard B sees: {value * 2} (doubled)")


source = DataSource()
source.attach(DashboardA())
source.attach(DashboardB())

source.set_value(10)
source.set_value(20)
3

Building Observer: Step by Step

Constructing the Observer Pattern

Learning objective: After this step you will be able to implement the Observer pattern from scratch, identify each UML role in your code, and watch the class diagram update live.

The Blueprint

The Observer pattern has four roles. Here is the UML for what you are about to build:

«abstract» Subject -_observers: list +attach(observer: Observer) +detach(observer: Observer) +notify() «abstract» Observer +update(subject: Subject) TemperatureSensor -_temperature: float +temperature: float TemperatureLogger +readings: list +update(subject: Subject) TemperatureAlert +threshold: float +update(subject: Subject) 0..* notifies
Role Class Responsibility
Subject (base) Subject Maintains observer list; provides attach(), detach(), notify()
ConcreteSubject TemperatureSensor Holds temperature state; calls notify() when it changes
Observer (interface) Observer Declares the update() method all observers must implement
ConcreteObserver TemperatureLogger, TemperatureAlert Implement update() to react to changes

Your Task — Faded Implementation

Open observer_basics.py. The Subject and Observer base classes are fully implemented for you. Your job:

  1. Complete TemperatureSensor (TODO 1): The temperature property setter should store the new value AND call self.notify()

  2. Complete TemperatureLogger (TODO 2): Its update() should read subject.temperature, append it to self.readings, and print it

  3. Complete TemperatureAlert (TODO 3): Its update() should check if temperature exceeds the threshold

  4. Complete main() (TODOs 4-7): Create a sensor, attach both observers, then set the temperature to 72, 95, and 68

Watch the UML

As you add classes and methods, check the UML Diagram tab — the class diagram updates live to show your inheritance hierarchy and relationships. Compare it to the target diagram above.

Starter files
observer_basics.py
from abc import ABC, abstractmethod


# ── Sub-goal 1: Subject base class (COMPLETE — study this) ─────

class Subject:
    """Base class for observable objects."""

    def __init__(self) -> None:
        self._observers: list = []

    def attach(self, observer: 'Observer') -> None:
        """Register an observer."""
        if observer not in self._observers:
            self._observers.append(observer)
            print(f"  Attached: {type(observer).__name__}")

    def detach(self, observer: 'Observer') -> None:
        """Unregister an observer."""
        self._observers.remove(observer)
        print(f"  Detached: {type(observer).__name__}")

    def notify(self) -> None:
        """Notify all registered observers."""
        for observer in self._observers:
            observer.update(self)


# ── Sub-goal 2: Observer interface (COMPLETE — study this) ─────

class Observer(ABC):
    """Interface that all observers must implement."""

    @abstractmethod
    def update(self, subject: Subject) -> None:
        """Called by the Subject when state changes."""
        pass


# ── Sub-goal 3: ConcreteSubject — YOUR TURN ───────────────────

class TemperatureSensor(Subject):
    """A sensor that tracks temperature and notifies observers."""

    def __init__(self) -> None:
        super().__init__()
        self._temperature: float = 0.0

    @property
    def temperature(self) -> float:
        return self._temperature

    @temperature.setter
    def temperature(self, value: float) -> None:
        self._temperature = value
        print(f"\nSensor: Temperature set to {value}°F")
        # TODO 1: Notify all observers that the state changed.
        #         (Hint: which inherited method broadcasts changes?)


# ── Sub-goal 4: ConcreteObservers — YOUR TURN ─────────────────

class TemperatureLogger(Observer):
    """Records every temperature reading."""

    def __init__(self) -> None:
        self.readings: list[float] = []

    def update(self, subject: Subject) -> None:
        # TODO 2: Read subject.temperature, append to self.readings,
        #         and print: f"Logger: Recorded {temp}°F"
        pass


class TemperatureAlert(Observer):
    """Alerts when temperature exceeds a threshold."""

    def __init__(self, threshold: float = 90.0) -> None:
        self.threshold: float = threshold

    def update(self, subject: Subject) -> None:
        # TODO 3: Read subject.temperature.
        #   If temp > self.threshold:
        #     print(f"ALERT: Temperature {temp}°F exceeds {self.threshold}°F!")
        #   Else:
        #     print(f"Alert: {temp}°F is within safe range")
        pass


# ── Sub-goal 5: Wire it up — YOUR TURN ────────────────────────

def main() -> None:
    # TODO 4: Create a TemperatureSensor
    # TODO 5: Create a TemperatureLogger and a TemperatureAlert(threshold=90)
    # TODO 6: Attach both observers to the sensor
    # TODO 7: Set sensor.temperature to 72, then 95, then 68
    pass


if __name__ == "__main__":
    main()
4

The Big Refactor: Observer-Powered Weather Station

Applying Observer to the Weather Station

Learning objective: After this step you will be able to refactor tightly coupled code into the Observer pattern, demonstrate dynamic attach/detach, and compare the two architectures using UML diagrams.

Before vs. After

Here is what the architecture looks like after the refactoring. Compare this to Step 1:

«abstract» Subject -_observers: list +attach(observer: Observer) +detach(observer: Observer) +notify() «abstract» Observer +update(subject: Subject) WeatherStation -_temperature: float -_humidity: float -_pressure: float +temperature: float +humidity: float +pressure: float +new_measurement(temp, humidity, pressure) CurrentDisplay +update(station) StatisticsDisplay -_temps: list +update(station) ForecastDisplay -_last_pressure: float +update(station) HeatIndexDisplay +update(station) 0..* notifies ZERO references to\nany specific display!

Notice: WeatherStation has zero arrows to any display. It only connects to Observer (abstract) through Subject. Any class that implements Observer can plug in — no changes to the station needed.

The Transformation

Open weather_observer.py. The WeatherStation has been refactored to inherit from Subject. Study it and notice:

Your Task

  1. Complete ForecastDisplay.update() (TODO 1): Read station.pressure, compare to self._last_pressure, print the outlook.

  2. Create HeatIndexDisplay (TODO 2): A brand-new observer.
    • Formula: heat_index = temp - 0.55 * (1 - humidity/100) * (temp - 58)
    • Print: [Heat] Heat Index: {heat_index:.1f}°F
  3. Complete main() (TODOs 3-6):
    • Attach all four displays to the station
    • Run three measurements: (80, 65, 1013.25), (82, 70, 1012.50), (78, 90, 1015.00)
    • Then detach the ForecastDisplay and run one more: (75, 60, 1011.00)
    • Verify: the forecast should NOT appear in the last measurement

The Key Question

How many lines of WeatherStation did you change to add HeatIndexDisplay? Check the UML diagram — the station’s connections do not change.

Starter files
weather_observer.py
from abc import ABC, abstractmethod


# ── Reusable Subject/Observer base classes ─────────────────────

class Subject:
    """Base class for any observable object."""

    def __init__(self) -> None:
        self._observers: list = []

    def attach(self, observer: 'Observer') -> None:
        if observer not in self._observers:
            self._observers.append(observer)

    def detach(self, observer: 'Observer') -> None:
        self._observers.remove(observer)

    def notify(self) -> None:
        for observer in self._observers:
            observer.update(self)


class Observer(ABC):
    """Interface for all observers."""

    @abstractmethod
    def update(self, subject: Subject) -> None:
        pass


# ── The Refactored Weather Station (Subject) ───────────────────

class WeatherStation(Subject):
    """Collects weather data and notifies observers.

    COMPARE TO STEP 1: This class knows NOTHING about displays.
    It only knows it has observers with an update() method."""

    def __init__(self) -> None:
        super().__init__()
        self._temperature: float = 0.0
        self._humidity: float = 0.0
        self._pressure: float = 0.0

    def new_measurement(self, temp: float, humidity: float,
                        pressure: float) -> None:
        print(f"\n--- Measurement: {temp}°F, {humidity}%, "
              f"{pressure} hPa ---")
        self._temperature = temp
        self._humidity = humidity
        self._pressure = pressure
        self.notify()

    @property
    def temperature(self) -> float:
        return self._temperature

    @property
    def humidity(self) -> float:
        return self._humidity

    @property
    def pressure(self) -> float:
        return self._pressure


# ── Concrete Observers (Displays) ──────────────────────────────

class CurrentDisplay(Observer):
    """Shows current conditions."""

    def update(self, station: Subject) -> None:
        print(f"[Current]  {station.temperature}°F, "
              f"{station.humidity}% humidity")


class StatisticsDisplay(Observer):
    """Shows temperature statistics."""

    def __init__(self) -> None:
        self._temps: list[float] = []

    def update(self, station: Subject) -> None:
        self._temps.append(station.temperature)
        avg = sum(self._temps) / len(self._temps)
        lo, hi = min(self._temps), max(self._temps)
        print(f"[Stats]    Avg={avg:.1f}°F  Min={lo}°F  Max={hi}°F")


class ForecastDisplay(Observer):
    """Shows forecast based on pressure changes."""

    def __init__(self) -> None:
        self._last_pressure: float = 0.0

    def update(self, station: Subject) -> None:
        # TODO 1: Read station.pressure. Compare to self._last_pressure.
        #   If pressure > self._last_pressure: outlook = "Improving weather!"
        #   Elif pressure < self._last_pressure: outlook = "Cooler, rainy weather"
        #   Else: outlook = "More of the same"
        #   Update self._last_pressure. Print: f"[Forecast] {outlook}"
        pass


# TODO 2: Create HeatIndexDisplay(Observer) here.
#   update() should compute:
#     heat_index = temp - 0.55 * (1 - humidity/100) * (temp - 58)
#   Print: f"[Heat]     Heat Index: {heat_index:.1f}°F"


def main() -> None:
    station = WeatherStation()

    # TODO 3: Create all four display instances

    # TODO 4: Attach all four to the station

    # TODO 5: Run three measurements:
    #   station.new_measurement(80, 65, 1013.25)
    #   station.new_measurement(82, 70, 1012.50)
    #   station.new_measurement(78, 90, 1015.00)

    # TODO 6: Detach the ForecastDisplay, then run one more:
    #   print("\n>>> Detaching ForecastDisplay...")
    #   station.detach(forecast)
    #   station.new_measurement(75, 60, 1011.00)
    #   (The forecast should NOT appear in this last measurement!)
    pass


if __name__ == "__main__":
    main()
5

Design Judgment: When and When Not

When Observer Is the Right Tool — and When It Is Not

Learning objective: After this step you will be able to evaluate whether the Observer pattern is appropriate for a given scenario, explain the Push vs. Pull trade-off, and recognize the danger of over-engineering.

The Danger of Over-Engineering

Design patterns are powerful, but they are not free. Every pattern adds:

The worst mistake after learning design patterns is applying them everywhere:

“The best engineers focus on solving problems simply, not memorizing existing solutions.”

When Observer Is Worth It

Use Observer when… Skip Observer when…
One state change triggers updates in multiple objects There is only one receiver that will never change
Observers need to be added/removed at runtime The relationship is fixed at design time
Subject and observers should evolve independently Debugging is critical and traceability matters most
You expect new observer types over time A simple direct method call would suffice

Push vs. Pull: A Design Decision

You have used the Pull model (observers call subject.temperature). Here is the alternative:

Subject_Pull +notify() +get_state() «abstract» Observer_Pull +update(subject) Subject_Push +notify() «abstract» Observer_Push +update(data) Observer calls\nsubject.get_state()\nto fetch data Subject sends data\ndirectly as arguments.\nObserver is simpler.
Model How it works Pros Cons
Pull update(subject) — observer queries what it needs Flexible; observers choose their data More coupling to subject API
Push update(temp=80, humidity=65) — subject sends everything Observers are simpler and independent Subject must guess what observers need

Your Task — Scenario Analysis

Open design_judgment.py. For each scenario, decide whether Observer is a good fit (True) or overkill (False). Set the variable and run to check.

Starter files
design_judgment.py
# For each scenario, set the answer to True or False.
# True  = Observer IS a good fit
# False = Observer is OVERKILL or wrong for this case

# --- Scenario 1 ---
# A stock trading app shows live prices on a chart, a portfolio
# summary, a news ticker, and push notifications. New display
# widgets are added every sprint. Users configure their dashboard
# dynamically.
scenario_1 = None  # TODO: True or False?

# --- Scenario 2 ---
# A simple calculator app has one display that shows the result.
# There will never be a second display. The display is created
# once when the app starts and never changes.
scenario_2 = None  # TODO: True or False?

# --- Scenario 3 ---
# A game engine physics system detects collisions. Multiple
# systems react: sound effects, particles, damage, achievements.
# Game modders should add collision handlers without editing
# the physics engine source code.
scenario_3 = None  # TODO: True or False?

# --- Scenario 4 ---
# A script reads a config file once at startup and passes the
# values to three functions. The config never changes during
# execution.
scenario_4 = None  # TODO: True or False?

# --- Scenario 5 ---
# A chat application notifies all connected users when a new
# message arrives. Users join and leave the chat dynamically
# throughout the session.
scenario_5 = None  # TODO: True or False?


# --- Verification (do not edit below) ---
answers = {
    "Scenario 1 (Stock trading dashboard)": (scenario_1, True),
    "Scenario 2 (Simple calculator)": (scenario_2, False),
    "Scenario 3 (Game collision events)": (scenario_3, True),
    "Scenario 4 (One-time config read)": (scenario_4, False),
    "Scenario 5 (Chat notifications)": (scenario_5, True),
}
correct = 0
for name, (student, expected) in answers.items():
    if student is None:
        print(f"  [ ] {name} -- not answered yet")
    elif student == expected:
        print(f"  [+] {name} -- Correct!")
        correct += 1
    else:
        why = "Observer fits here" if expected else "Too simple for Observer"
        print(f"  [X] {name} -- Incorrect. {why}.")
print(f"\nScore: {correct}/{len(answers)}")