DEV Community

loading...
Cover image for Understanding Design Patterns: Observer

Understanding Design Patterns: Observer

carlillo profile image Carlos Caballero Originally published at carloscaballero.io ・14 min read

There are 23 classic design patterns which are described in the original book Design Patterns: Elements of Reusable Object-Oriented Software. These patterns provide solutions to particular problems often repeated in software development.

In this article, I am going to describe how the Observer Pattern works and when it should be applied.


Observer Pattern: Basic Idea

Wikipedia provides us with the following definition:

The observer pattern is a software design pattern in which an object, named the subject, maintains a list of its dependents, called observers, and notifies them automatically of any state changes, usually by calling one of their methods. — Wikipedia

On the other hand, the definition provided by the original book is the following:

Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. — Design Patterns: Elements of Reusable Object-Oriented Software

On many occasions we need to communicate system objects without coupling them either at code or at communication mechanism level. Should we have a group of objects (observers) that are required to be aware of the state of another object (observable), there are different techniques for carrying out the communication between them. The most popular techniques are:

  1. Busy waiting. A process repeatedly verifies a condition. In our case, it would be an observer constantly checking whether or not the observable's condition has changed. This strategy could be a valid solution in certain cases, but it isn't an adequate solution for our scenario, since it would imply having several processes (observers) consuming resources without performing any operations, causing an exponential performance decrease in the number of existing observers.

  2. Polling. In this case, the query operation is performed with a small window of time between operations. This is an attempt to implement synchronism between processes. However, we can once again appreciate degradation in the system's performance, furthermore, depending on the time set between each query, the information can be so delayed that it might be invalid causing a wastage of resources used by this technique.

The following codes show implementations of the previous techniques:

Busy-Waiting:

while(!condition){
   // Query
   if(isQueryValid) condition = true;
}
Enter fullscreen mode Exit fullscreen mode

Polling:

function refresh() {
    setTimeout(refresh, 5000);
    // Query
}

// initial call, or just call refresh directly
setTimeout(refresh, 5000);
Enter fullscreen mode Exit fullscreen mode

Although it isn't the goal of this post, it's a good idea to understand the two alternative techniques to this design pattern. Therefore, we can say that, in a nutshell, the difference between the active wait and polling techniques is that in the former the query operation is performed all the time, while in the latter there are intervals of time where the operation isn't executed.

Busy-Waiting:

while(resourceIsNotReady()){
  //Do nothing
}
Enter fullscreen mode Exit fullscreen mode

Polling:

while(resourceIsNotReady()){
     Sleep(1000); // 1000 or anytime
 }
Enter fullscreen mode Exit fullscreen mode

The Observer pattern allows us to achieve a more efficient and less coupled code, since it avoids the previously mentioned issue, as well as having other advantages regarding code maintainibility. The UML pattern of this pattern is the following:

UML diagram of the book Design Patterns: Elements of Reusable Object-Oriented Software.

The classes that comprise this pattern are the following:

  • Subject is the interface that every observed class implements. This interface contains the attach and detach methods that allow us to add and remove observers from the class. It also contains a notify method, which is responsible for notifying all of the observers that a change has occurred in the observed. Also, all of the subjects store references of the objects that observe them (observers).

  • Observer is the interface that all of the ConcreteObservers implement. In this interface, the update method is defined, which contains the business logic to be executed by each observer upon receiving the change notification from the Subject.

  • ConcreteSubject is the concrete implementation of the Subject class.
    This class defines the state of the SubjectState application, which must be notified when a change occurs. For this reason, the accessor methods (getState and setState) are usually implemented, since they manipulate the state. This class is also responsible for sending the notification to all of its observers when the state changes.

  • ConcreteObserver is the class that models each of the concrete observers. In this class the update method belonging to the Observer interface is implemented, which is responsible for maintaining its state consistently which is responsible for keeping its state consistent with the subject objects it is observing.

Nowadays there's a family of libraries known as Reactive Extensions or ReactiveX which have made this design pattern popular. The Reactive Extensions make use of two design patterns: 1) Observer 2) Iterator. They also have a group of operators that use functional programming. These are some of the most popular Reactive Exntensions:

In these implementations, there are differences in the naming of classes and methods. The following names are the most extended:

  1. Subscriber corresponds with the class Observer.

  2. ConcreteSubscribers correspond with the classes ConcreteObservers.

  3. The Subject class is maintained. The attach and detach methods are renamed to subscribe and unsubscribe.

  4. The ConcreteSubjects classes are concrete implementations, like BehaviorSubject, ReplaySubject o AsyncSubject.


Observer Pattern: Communication Strategies

There are two communication strategies between Subjects (observables) and Observers (observadores) in the observer pattern:

  • Pull. In this model, the subject sends the minimum information to the observers and they are responsible for making inquiries to obtain more detail. This model focuses on the fact that the Subject ignores the observers.

  • Push. In this model, the subject sends the greatest amount of information to the observers the information of the change produced, regardless of whether they wanted it or not. In this model, the Subject knows in depth the needs of each of its observers.

Although a priori it may seem that the push communication technique is less reusable due to the fact that the Subject must have knowledge about the observers, this is not always the case. On the other hand, the pull based communication technique can be inefficient because the observers have to figure out what changed without help from the Subject.


Observer Pattern: When To Use

  1. When there is a one-to-many dependency between system objects so that when the object changes state, all dependent objects need to be notified automatically.

  2. You do not want to use busy-waiting and Polling to update observers.

  3. Decouple the dependencies between the Subject objects (Observables) and the Observers (Observers) allowing to respect the Open-Closed Principle.


Observer Pattern: Advantages and Disadvantages

The Observer pattern has a number of advantages that can be summarized in the following points:

  • The code is more maintainable because it is less coupled between the observable classes and their dependencies (the observers).

  • Clean code since the Open-Closed Principle is guaranteed due to the new observers (subscribers) can be introduced without breaking the existing code in the observable (and vice versa).

  • Cleaner code because the Single Responsibility Principle (SRP) is respected since the responsibility of each observer is transferred to its update method instead of having that business logic in the Observable object.

  • Relationships between objects can be established at runtime rather than at compile time.

However, the main drawback of the observer pattern, like most design patterns, is that there is an increase in complexity in the code, and an increase in the number of classes required for the code. Although, this disadvantage is well known when applying design patterns since the price to pay for gaining abstraction in the code.


Observer Pattern Examples

Next, we are going to illustrate two examples of application of the Observer pattern:

  1. Basic structure of the Observer pattern. In this example we are going to translate the theoretical UML diagram into TypeScript code to identify each of the classes involved in the pattern.

  2. An auction system in which there is an object (subject) that emits the change produced (push technique) in the price of a product that is being auctioned to all observers (observer) interested in acquiring that product. Every time the price of the product auction increases because some observer has increased the bid, it is notified to all observers.

The following examples will show the implementation of this pattern using TypeScript. We have chosen TypeScript to carry out this implementation rather than JavaScript — the latter lacks interfaces or abstract classes so the responsibility of implementing both the interface and the abstract class would fall on the developer.


Example 1: Basic structure of the observer pattern

In this first example, we're going to translate the theoretical UML diagram into TypeScript to test the potential of this pattern. This is the diagram to be implemented:

UML diagram of the book Design Patterns: Elements of Reusable Object-Oriented Software.

First, we are going to define the interface (Subject) of our problem. Being an interface, all the methods that must be implemented in all the specific Subject are defined, in our case there is only one ConcreteSubject. The Subject interface defines the three methods necessary to comply with this pattern: attach, detach and notify. The attach and detach methods receive the observer as a parameter that will be added or removed in the Subject data structure.

import { Observer } from "./observer.interface";

export interface Subject {
  attach(observer: Observer): void;
  detach(observer: Observer): void;
  notify(): void;
}
Enter fullscreen mode Exit fullscreen mode

There can be as many ConcreteSubject as we need in our problem. As this problem is the basic scheme of the Observer pattern, we only need a single ConcreteSubject. In this first problem, the state that is observed is the state attribute, which is of type number. On the other hand, all observers are stored in an array called observers. The attach and detach methods check whether or not the observer is previously in the data structure to add or remove it from it. Finally, the notify method is in charge of invoking the update method of all the observers that are observing the Subject.

Objects of the ConcreteSubject class perform some task related to the specific business logic of each problem. In this example, there is a method called operation that is in charge of modifying the state and invoking the notify method.

import { Observer } from "./observer.interface";
import { Subject } from "./subject.interface";

export class ConcreteSubject implements Subject {
  public state: number;
  private observers: Observer[] = [];

  public attach(observer: Observer): void {
    const isAttached = this.observers.includes(observer);
    if (isAttached) {
      return console.log("Subject: Observer has been attached already");
    }

    console.log("Subject: Attached an observer.");
    this.observers.push(observer);
  }

  public detach(observer: Observer): void {
    const observerIndex = this.observers.indexOf(observer);
    if (observerIndex === -1) {
      return console.log("Subject: Nonexistent observer");
    }

    this.observers.splice(observerIndex, 1);
    console.log("Subject: Detached an observer");
  }

  public notify(): void {
    console.log("Subject: Notifying observers...");
    for (const observer of this.observers) {
      observer.update(this);
    }
  }

  public operation(): void {
    console.log("Subject: Business Logic.");
    this.state = Math.floor(Math.random() * (10 + 1));

    console.log(`Subject: The state has just changed to: ${this.state}`);
    this.notify();
  }
}
Enter fullscreen mode Exit fullscreen mode

The other piece of this design pattern is the observer. Therefore, let's start by defining the Observer interface which only needs to define the update method which is in charge of executing every time an observer is notified that a change has occurred.

import { Subject } from "./subject.interface";

export interface Observer {
  update(subject: Subject): void;
}
Enter fullscreen mode Exit fullscreen mode

Each class that implements this interface must include its business logic in the update method. In this example two ConcreteObservers have been defined, which will perform actions according to the Subjects state. The following code shows two concrete implementations for two different types of observers: ConcreteObserverA and ConcreteObserverB.

import { ConcreteSubject } from "./concrete-subject";
import { Observer } from "./observer.interface";
import { Subject } from "./subject.interface";

export class ConcreteObserverA implements Observer {
  public update(subject: Subject): void {
    if (subject instanceof ConcreteSubject && subject.state < 3) {
      console.log("ConcreteObserverA: Reacted to the event.");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
import { ConcreteSubject } from "./concrete-subject";
import { Observer } from "./observer.interface";
import { Subject } from "./subject.interface";

export class ConcreteObserverB implements Observer {
  public update(subject: Subject): void {
    if (
      subject instanceof ConcreteSubject &&
      (subject.state === 0 || subject.state >= 2)
    ) {
      console.log("ConcreteObserverB: Reacted to the event.");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we define our Client or Context class, which makes use of this pattern. In the following code the necessary classes to simulate the use of Subject and Observer are implemented:

import { ConcreteObserverA } from "./concrete-observerA";
import { ConcreteObserverB } from "./concrete-observerB";
import { ConcreteSubject } from "./concrete-subject";

const subject = new ConcreteSubject();

const observer1 = new ConcreteObserverA();
subject.attach(observer1);

const observer2 = new ConcreteObserverB();
subject.attach(observer2);

subject.operation();
subject.operation();

subject.detach(observer2);

subject.operation();
Enter fullscreen mode Exit fullscreen mode

Example 2 — Auctions using Observer

In this example we're going to use the Observer pattern to simulate an action house in which a group of auctioneers (Auctioneer) bid for different products (product). The auction is directed by an agent (Agent). All of our auctioneers need to be notified each time one of them increases their bid, so that they can decide whether to continue bidding or to retire.

Like we did in the previous example, let's begin by taking a look at the UML diagram that is going to help us identify each of the parts that this pattern is composed of.

Observer pattern

The product that is being auctioned is the Subject's state, and all of the observers await notifications whenever it changes. Therefore, the product class is comprised of three attributes: price, name and auctioneer (the auctioneer that is assigned the product).

import { Auctioneer } from "./auctioneer.interface";

export class Product {
  public price;
  public name;
  public auctionner: Auctioneer = null;

  constructor(product) {
    this.price = product.price || 10;
    this.name = product.name || "Unknown";
  }
}
Enter fullscreen mode Exit fullscreen mode

The Agent is the interface that defines the methods for managing the group of Auctioneers, and notifying them that the bid on the auctioned product has changed. In this case, the attach and detach methods have been renamed to subscribe and unsubscribe.

import { Auctioneer } from "./auctioneer.interface";

export interface Agent {
  subscribe(auctioneer: Auctioneer): void;
  unsubscribe(auctioneer: Auctioneer): void;
  notify(): void;
}
Enter fullscreen mode Exit fullscreen mode

The concrete implementation of the Agent interface is performed by the ConcreteAgent class. As well as the three methods previously described, which have a very similar behavior to the one presented in the previous example, the bidUp method has been implemented, which, after making some checks on the auctioneer's bid, assigns it as valid and notifies all of the auctioneers of the change.

import { Agent } from "./agent.interface";
import { Auctioneer } from "./auctioneer.interface";
import { Product } from "./product.model";

export class ConcreteAgent implements Agent {
  public product: Product;
  private auctioneers: Auctioneer[] = [];

  public subscribe(auctioneer: Auctioneer): void {
    const isExist = this.auctioneers.includes(auctioneer);
    if (isExist) {
      return console.log("Agent: Auctioneer has been attached already.");
    }

    console.log("Agent: Attached an auctioneer.");
    this.auctioneers.push(auctioneer);
  }

  public unsubscribe(auctioneer: Auctioneer): void {
    const auctioneerIndex = this.auctioneers.indexOf(auctioneer);
    if (auctioneerIndex === -1) {
      return console.log("Agent: Nonexistent auctioneer.");
    }

    this.auctioneers.splice(auctioneerIndex, 1);
    console.log("Agent: Detached an auctioneer.");
  }

  public notify(): void {
    console.log("Agent: Notifying auctioneer...");
    for (const auctioneer of this.auctioneers) {
      auctioneer.update(this);
    }
  }

  public bidUp(auctioneer: Auctioneer, bid: number): void {
    console.log("Agent: I'm doing something important.");
    const isExist = this.auctioneers.includes(auctioneer);
    if (!isExist) {
      return console.log("Agent: Auctioneer there is not in the system.");
    }
    if (this.product.price >= bid) {
      console.log("bid", bid);
      console.log("price", this.product.price);
      return console.log(`Agent: ${auctioneer.name}, your bid is not valid`);
    }
    this.product.price = bid;
    this.product.auctionner = auctioneer;

    console.log(
      `Agent: The new price is ${bid} and the new owner is ${auctioneer.name}`
    );
    this.notify();
  }
}
Enter fullscreen mode Exit fullscreen mode

In this problem there are four different types of Auctioneer defined in the AuctioneerA, AuctioneerB, AuctioneerC and AuctioneerD classes. All of these auctioneers implement the Auctioneer interface, which defines the name, MAX_LIMIT and the update method. The MAX_LIMIT attribute defines the maximum amount that can be bid by each type of Auctioneer.

import { Agent } from "./agent.interface";

export interface Auctioneer {
  name: string;
  MAX_LIMIT: number;
  update(agent: Agent): void;
}
Enter fullscreen mode Exit fullscreen mode

The different types of Auctioneer have been defined, to illustrate that each one will have a different behavior upon receiving the Agents notification in the update method. Nevertheless, all that has been modified in this example is the probability of continuing to bid and the amount they increase their bids by.

import { Agent } from "./agent.interface";
import { Auctioneer } from "./auctioneer.interface";
import { ConcreteAgent } from "./concrete-agent";

export class ConcreteAuctioneerA implements Auctioneer {
  name = "ConcreteAuctioneerA";
  MAX_LIMIT = 100;

  public update(agent: Agent): void {
    if (!(agent instanceof ConcreteAgent)) {
      throw new Error("ERROR: Agent is not a ConcreteAgent");
    }

    if (agent.product.auctionner === this) {
      return console.log(`${this.name}: I'm the owner... I'm waiting`);
    }

    console.log(`${this.name}: I am not the owner... I'm thinking`);
    const bid = Math.round(agent.product.price * 1.1);
    if (bid > this.MAX_LIMIT) {
      return console.log(`${this.name}: The bid is higher than my limit.`);
    }
    agent.bidUp(this, bid);
  }
}
Enter fullscreen mode Exit fullscreen mode
import { Agent } from "./agent.interface";
import { Auctioneer } from "./auctioneer.interface";
import { ConcreteAgent } from "./concrete-agent";

export class ConcreteAuctioneerB implements Auctioneer {
  name = "ConcreteAuctioneerB";
  MAX_LIMIT = 200;

  public update(agent: Agent): void {
    if (!(agent instanceof ConcreteAgent)) {
      throw new Error("ERROR: Agent is not a ConcreteAgent");
    }

    if (agent.product.auctionner === this) {
      return console.log(`${this.name}: I'm the owner... I'm waiting`);
    }

    console.log(`${this.name}: I am not the owner... I'm thinking`);
    const isBid = Math.random() < 0.5;
    if (!isBid) {
      return console.log(`${this.name}: I give up!`);
    }
    const bid = Math.round(agent.product.price * 1.05);
    if (bid > this.MAX_LIMIT) {
      return console.log(`${this.name}: The bid is higher than my limit.`);
    }
    agent.bidUp(this, bid);
  }
}
Enter fullscreen mode Exit fullscreen mode
import { Agent } from "./agent.interface";
import { Auctioneer } from "./auctioneer.interface";
import { ConcreteAgent } from "./concrete-agent";

export class ConcreteAuctioneerC implements Auctioneer {
  name = "ConcreteAuctioneerC";
  MAX_LIMIT = 500;

  public update(agent: Agent): void {
    if (!(agent instanceof ConcreteAgent)) {
      throw new Error("ERROR: Agent is not a ConcreteAgent");
    }

    if (agent.product.auctionner === this) {
      return console.log(`${this.name}: I'm the owner... I'm waiting`);
    }

    console.log(`${this.name}: I am not the owner... I'm thinking`);
    const isBid = Math.random() < 0.2;
    if (!isBid) {
      return console.log(`${this.name}: I give up!`);
    }
    const bid = Math.round(agent.product.price * 1.3);
    if (bid > this.MAX_LIMIT) {
      return console.log(`${this.name}: The bid is higher than my limit.`);
    }
    agent.bidUp(this, bid);
  }
}
Enter fullscreen mode Exit fullscreen mode
import { Agent } from "./agent.interface";
import { Auctioneer } from "./auctioneer.interface";
import { ConcreteAgent } from "./concrete-agent";

export class ConcreteAuctioneerD implements Auctioneer {
  name = "ConcreteAuctioneerD";
  MAX_LIMIT = 1000;

  public update(agent: Agent): void {
    if (!(agent instanceof ConcreteAgent)) {
      throw new Error("ERROR: Agent is not a ConcreteAgent");
    }

    if (agent.product.auctionner === this) {
      return console.log(`${this.name}: I'm the owner... I'm waiting`);
    }

    console.log(`${this.name}: I am not the owner... I'm thinking`);
    const isBid = Math.random() < 0.8;
    if (!isBid) {
      return console.log(`${this.name}: I give up!`);
    }
    const bid = Math.round(agent.product.price * 1.2);
    if (bid > this.MAX_LIMIT) {
      return console.log(`${this.name}: The bid is higher than my limit.`);
    }
    agent.bidUp(this, bid);
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, let's show the Client class, which makes use of the observer pattern. In this example, an auction house is declared, with an Agent and four Auctioneers, where two different products (diamond and gem) are being auctioned. In the first auction, all four auctioneers participate. In the second auction, the D class auctioneer retires leaving the three remaining to participate.

import { ConcreteAgent } from "./concrete-agent";
import { ConcreteAuctioneerA } from "./concrete-auctioneerA";
import { ConcreteAuctioneerB } from "./concrete-auctioneerB";
import { ConcreteAuctioneerC } from "./concrete-auctioneerC";
import { ConcreteAuctioneerD } from "./concrete-auctioneerD";
import { Product } from "./product.model";

const concreteAgent = new ConcreteAgent();

const auctioneerA = new ConcreteAuctioneerA();
const auctioneerB = new ConcreteAuctioneerB();
const auctioneerC = new ConcreteAuctioneerC();
const auctioneerD = new ConcreteAuctioneerD();

concreteAgent.subscribe(auctioneerA);
concreteAgent.subscribe(auctioneerB);
concreteAgent.subscribe(auctioneerC);
concreteAgent.subscribe(auctioneerD);

const diamond = new Product({ name: "Diamond", price: 5 });
concreteAgent.product = diamond;

concreteAgent.bidUp(auctioneerA, 10);

console.log("--------- new Bid-----------");

concreteAgent.unsubscribe(auctioneerD);

const gem = new Product({ name: "Gem", price: 3 });
concreteAgent.product = gem;

concreteAgent.bidUp(auctioneerB, 5);

console.log(`The winner of the bid is 
             Product: ${diamond.name}
             Name: ${diamond.auctionner.name}
             Price: ${diamond.price}`);

console.log(`The winner of the bid is 
             Product: ${gem.name}
             Name: ${gem.auctionner.name}
             Price: ${gem.price}`);
Enter fullscreen mode Exit fullscreen mode

Finally, I've created two npm scripts, through which the code presented in this article can be executed:

npm run example1
npm run example2
Enter fullscreen mode Exit fullscreen mode

GitHub Repo available here.


Conclusion

Observer is a design pattern that allows respecting the Open-Closed Principle since new Subject and Observer can be created without breaking the existing code. In addition, it allows communication between two actors of the system without the need for them to be linked in the knowledge of each other. Finally, the performance degradation that occurs in more elementary techniques such as busy-waiting and polling is overcome.

Finally, the most important thing about this pattern is not the concrete implementation of it, but being able to recognize the problem that this pattern can solve, and when it can be applied. The specific implementation is the least of it, since it will vary depending on the programming language used.

Discussion (0)

Forem Open with the Forem app