Mastering the Observer Design Pattern with Practical Examples

Nikolay Nikolov
ITNEXT
Published in
5 min readMar 29, 2024

--

Photo by Maarten van den Heuvel on Unsplash

The Observer Pattern is like a conductor orchestrating a symphony in software development. It’s a clever way for objects to stay in sync without getting tangled up.

Here’s how it works: you have a main character called the “Subject” who keeps a list of friends called “Observers”. Whenever the Subject changes, it sends out a message to all its Observers, letting them know what’s going on.

This pattern isn’t just about sending messages, though. It’s a game-changer for making software more flexible and easier to manage. Using the Observer Pattern makes adding new observers easy — you can simply plug them in without needing to change the existing code.

Where is it used?

The Observer Pattern is widely used in scenarios where a change in one object triggers a cascading effect across multiple objects or components.

Common use cases include:

  1. User Interface (UI) Development: Updating UI elements in response to changes in underlying data.
  2. Event Handling Systems: Notifying multiple listeners about events or changes.
  3. Distributed Systems: Broadcasting updates to multiple components in a distributed system.
  4. Model-View-Controller (MVC) Architectures: Keeping views synchronized with changes in the model.

Advantages

  • Loose Coupling: The Subject and Observers are decoupled, allowing changes in one without affecting the other.
  • Extensibility: Easily add or remove observers without modifying the subject.
  • Reusability: Reuse existing subject and observer classes in different contexts.

Example

Let’s consider a simple example where we have a weather monitoring system in PHP. Initially, let’s say we have a WeatherStation class responsible for fetching weather data and displaying it:

namespace App\Api;

class WeatherApiClient {
public function fetchTemperature(): int {
// Simulate fetching temperature from an external API
return random_int(0, 40);
}
}
namespace App\Subject;

use App\Api\WeatherApiClient;

class WeatherStation
{
private int $temperature;

public function getTemperature(): int
{
return $this->temperature;
}

public function fetchWeatherData(): void
{
$this->temperature = (new WeatherApiClient())->fetchTemperature();

echo sprintf(
"Weather data fetched: Temperature is %d°C ",
$this->temperature
) . "<br>";
}
}
// The caller
$weatherStation = new WeatherStation();
$weatherStation->fetchWeatherData();

echo sprintf(
"Current temperature: %d°C ",
$weatherStation->getTemperature()
) . "<br>";

This code fetches weather data and displays the temperature. However, if we want to extend this system to perform additional tasks whenever weather data changes (like sending notifications or logging), it would become messy to modify the WeatherStation class directly. This tightly couples different functionalities, making the code hard to maintain and extend.

To clean this up and make it more flexible using the Observer Pattern, we’ll create separate observer classes for different functionalities:

// Observer Interface
namespace App\Interface;

use App\Subject\WeatherStation;

interface WeatherObserverInterface
{
public function update(WeatherStation $station): void;
}
// Concrete Observer: TemperatureDisplay
namespace App\Observer;

use App\Subject\WeatherStation;
use App\Interface\WeatherObserverInterface;

class TemperatureDisplay implements WeatherObserverInterface
{
public function update(WeatherStation $weatherStation): void
{
echo sprintf(
"Current temperature: %d°C ",
$weatherStation->getTemperature()
) . "<br>";
}
}
// Concrete Observer: TemperatureLogger
namespace App\Observer;

use App\Subject\WeatherStation;
use App\Interface\WeatherObserverInterface;

class TemperatureLogger implements WeatherObserverInterface
{
public function update(WeatherStation $weatherStation): void
{
echo sprintf(
"Logging weather data: Temperature is: %d°C",
$weatherStation->getTemperature()
) . "<br>";
}
}
// Subject: WeatherStation
namespace App\Subject;

use App\Api\WeatherApiClient;
use App\Interface\WeatherObserverInterface;

class WeatherStation
{
private int $temperature;
private array $observers = [];

public function attach(WeatherObserverInterface $observer): void
{
$this->observers[] = $observer;
}

public function notifyObservers(): void
{
foreach ($this->observers as $observer) {
$observer->update($this);
}
}

public function fetchWeatherData(): void
{
$this->temperature = (new WeatherApiClient())->fetchTemperature();
echo sprintf(
"Weather data fetched: Temperature is %d°C ",
$this->temperature
) . "<br>";

$this->notifyObservers();
}

public function getTemperature(): int
{
return $this->temperature;
}
}
// The caller
$weatherStation = new WeatherStation();
$weatherStation->attach(new TemperatureDisplay());
$weatherStation->attach(new TemperatureLogger());

$weatherStation->fetchWeatherData();

Now, with the Observer Pattern, the WeatherStation class doesn't need to know anything about the specific functionalities (like displaying temperature or logging). Each Observer (TemperatureDisplay, Logger) is responsible for its own task, and we can easily attach observers without modifying the WeatherStation class. This results in cleaner, decoupled code that's easier to maintain and extend.

Symfony Solution

In a Symfony solution, you can leverage the Dependency Injection component to manage services and their dependencies.

Utilizing the Observer Pattern in Symfony, employing tagged services and interfaces passed through the constructor, fosters decoupling, flexibility, and maintainability by enabling components to react to events independently of each other.

// Observer Interface
namespace App\Interface;

use App\Subject\WeatherStation;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.weather_observer')]
interface WeatherObserverInterface
{
public function update(WeatherStation $weatherStation): void;
}
// Subject: WeatherStation
namespace App\Subject;

use App\Api\WeatherApiClient;
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;

class WeatherStation
{
private int $temperature;

public function __construct(
#[TaggedIterator('app.weather_observer')] private readonly iterable $observers,
private readonly WeatherApiClient $weatherApiClient,
) {
}

public function notifyObservers(): void
{
foreach ($this->observers as $observer) {
$observer->update($this);
}
}

public function fetchWeatherData(): void
{
$this->temperature = $this->weatherApiClient->fetchTemperature();
echo sprintf(
"Weather data fetched: Temperature is %d°C ",
$this->temperature
) . "<br>";

$this->notifyObservers();
}

public function getTemperature(): int
{
return $this->temperature;
}
}
// The caller
namespace App\Caller;

use App\Subject\WeatherStation;

class SomeCaller
{
public function __construct(
private readonly WeatherStation $weatherStation
){
}

public function someMethod(): void
{
$this->weatherStation->fetchWeatherData();
}
}

In this scenario, there’s no need to manually attach Observers to the Subject. When an Observer is created and implements the WeatherObserverInterface, it's automatically injected into the Subject's constructor via Symfony's Dependency Injection system. And this makes the code cleaner and more maintainable.

Additionally, through the attributes [AutoconfigureTag(...)] and [TaggedIterator(...)], you don't need to add anything to the services.yaml file to set up the entire structure.

Symfony provides numerous attributes where you can define all the settings directly in the code. This enhances clarity when reading the code, as opposed to having to check the services.yaml file to understand the magic behind it. Very cool!

Conclusion

In conclusion, the Observer Pattern offers a versatile solution for establishing relationships between objects in software development. By decoupling the subject from its observers, it enables flexible and scalable systems where changes in one component trigger seamless updates across others.

Whether in simple applications or complex frameworks like Symfony, the Observer Pattern remains a valuable tool for building robust, extensible, and maintainable software architectures.

👉 Before you go!

If you enjoyed this article, show your appreciation with a friendly round of 👏 applause, hit that ‘Follow’ button and 🔔 subscribe for more engaging content.

Your support is greatly appreciated!

--

--

Head of Software Development at CONUTI GmbH | 20 years experience | Passionate about clean code, design patterns, and software excellence.