design-patterns
mediator
typescript
javascript
practices
relationships
refactors

This article was inspired by the scientific research Meta-Analysis by Amato and Keith (1991), which examines the impact of parental divorce on children's brains.

Mediator Pattern In TypeScript

The Mediator pattern is less known among developers, but it is incredibly useful in complex cases for reducing dependencies between modules and mitigating coupling. I have used it a few times in my career, and interestingly, we often use such patterns without realizing it.

Mentioned one can dramatically reduce the complexity of our codebase or increase it if not implemented wisely. While entire books could be written on this topic, this small article should highlight the essence: Design patterns should be called upon naturally, not forced..

Today, we'll explore Mediator concept, understand the theory, and implement it in TypeScript to enhance our skill set.

Mediator Definition and Theory

The Mediator is a behavioral design pattern that centralizes complex communication and control logic between objects through a Mediator object, promoting loose coupling and reducing dependencies.

To make this concept more relatable and refer to a real-world example, imagine you are going through a divorce with your spouse (hopefully not). You both dislike each other so much that direct communication is impossible. However, you share responsibilities like a house, children, and other assets.

At this point, your friend recommends calling a Mediator - yes, that’s the name of the pattern and the job. This Mediator will handle all communications and responsibilities between you and your spouse. They convey information in a neutral manner and manage required actions, such as paperwork and financial negotiations, allowing you both to avoid direct interaction.

Let's illustrate the dependencies on a diagram, both before and after involving a Mediator.

The Family Mediator On Diagram The Family Mediator

It's easy to see where this is going. With a Mediator, everything is centralized and scales well. No matter how many people are involved, they don't need to know about each other. The Mediator handles all interactions and keeps them hidden from others. Now, to see how it scales, let's add more people and look at the diagram:

Mediator Scaling Visualized Scaling with the Mediator

Take a look at the shape of the arrows. It's important because it shows that Mediator can hide options from others to communicate backward. For instance, let's say the Husband instance wants to say something to Mediator, which will then delegate it to Lawyer. However, if Lawyer wants to say something to Husband, the Mediator does not allow that.

Communication in the Mediator pattern can be unidirectional or bidirectional, depending on your use case.

Mediator Implementation

Let's stick to the previous example. We're creating an app that allows people to get a faster divorce via a virtual mediation assistant. We'll call our app "divorce.io" (‾◡◝). As mentioned before, we want to establish a way to communicate between different modules that represent different people using a Mediator. This will centralize communication without creating direct relationships between the people involved.

First of all, let's add classes for each person involved.

// Interface that describes a human.
interface Human {
  firstName: string;
  lastName: string;
  say(message: string): void;
}

class Husband implements Human {
  firstName = "Tom";
  lastName = "Potato";

  say(message: string) {
    // Currently nothing...
  }
}

class Wife implements Human {
  firstName = "Jenny";
  lastName = "Potato";

  say(message: string) {
    // Currently nothing...
  }
}

Nothing fancy, our classes allow us to create different people who can say something. We don't have any instances of these classes yet, so let's wait for that. Now, we need to implement a concrete Mediator class that will manage communication between people.

// The author of the propagation command.
interface Who extends Pick<Human, "firstName" | "lastName"> {}

// Interface for any Mediator.
interface Mediator {
  propagate(who: Who, message: string): void;
}

// Utility class with complex logic that prepares documentation.
class DivorcePapers {
  prepare() {
    // Complex process...
  }
}

// Concrete Mediator - in our case, a divorce Mediator.
class DivorceMediator implements Mediator {
  // Utility function to send a response.
  private answer(message: string) {
    console.log(message);
  }

  propagate(who: Who, message: string) {
    // Based on the author, we propagate different logic.
    if (who.firstName === "Tom" && message.includes("hate")) {
      new DivorcePapers().prepare();
      this.answer(
        `Don't worry, ${who.firstName}, the papers will be prepared!`
      );
      return;
    }

    if (who.firstName === "Jenny") {
      this.answer("Tom already asked me for the necessary documents.");
    }
  }
}

The DivorceMediator holds the core logic of the process. It receives communication from each person and responds through the answer method. However, we need to inject the Mediator instance into each person class to enable calls to the propagate method from the Mediator.

  // This code has been added to "Husband" and "Wife" classes.
  constructor(private mediator: Mediator) {}

  say(message: string) {
    this.mediator.propagate(
      {
        firstName: this.firstName,
        lastName: this.lastName
      },
      message
    );
  }

The last step is to create objects of each class and inject the Mediator instance into the Husband and Wife.

// This instance handles everything.
const dMediator = new DivorceMediator();

// The husband does not know about the wife. There is no direct relationship.
const husbando = new Husband(dMediator);
const wajfu = new Wife(dMediator);

husbando.say("I hate her!!!");
// Logs: "Don't worry, Tom, the papers will be prepared!"
// In addition, starts papers preparation process.
wajfu.say("He is ugly!!!");
// Logs: "Tom already asked me for the necessary documents."

The DivorceMediator class manages the core logic of the divorce process. Family members send specific messages that the Mediator interprets, triggering actions such as preparing papers and logging via the answer method. Family members are unaware of who else is involved in the process; everything is hidden from them. They only interact with the injected DivorceMediator instance through their constructors.

The beauty of this setup is that we can create any number of process members while keeping the entire process hidden. New handlers can be easily added to the DivorceMediator class, ensuring scalability. We simply add new code without altering existing contracts or hierarchy.

const son = new Son(dMediator);
const lawyer = new Lawyer(dMediator);
const daughter = new Daughter(dMediator); // ...etc.

Here is the full code:

// Interface that describes a human.
interface Human {
  firstName: string;
  lastName: string;
  say(message: string): void;
}

// The data author of the propagation command.
interface Who extends Pick<Human, "firstName" | "lastName"> {}

// Interface for any Mediator.
interface Mediator {
  propagate(who: Who, message: string): void;
}

class Husband implements Human {
  firstName = "Tom";
  lastName = "Potato";

  // Mediator is injected via constructor.
  constructor(private mediator: Mediator) {}

  // Method calls the propagate method from the Mediator.
  say(message: string) {
    this.mediator.propagate(
      {
        firstName: this.firstName,
        lastName: this.lastName
      },
      message
    );
  }
}

class Wife implements Human {
  firstName = "Jenny";
  lastName = "Potato";

  constructor(private mediator: Mediator) {}

  say(message: string) {
    this.mediator.propagate(
      {
        firstName: this.firstName,
        lastName: this.lastName
      },
      message
    );
  }
}

// Utility class with complex logic that prepares documentation.
class DivorcePapers {
  prepare() {
    // Complex process...
  }
}

// Concrete Mediator - in our case, a divorce Mediator.
class DivorceMediator implements Mediator {
  // Utility function to send a response.
  private answer(message: string) {
    console.log(message);
  }

  propagate(who: Who, message: string) {
    // Based on the author, we propagate different logic.
    if (who.firstName === "Tom" && message.includes("hate")) {
      new DivorcePapers().prepare();
      this.answer(
        `Don't worry, ${who.firstName}, the papers will be prepared!`
      );
      return;
    }

    if (who.firstName === "Jenny") {
      this.answer("Tom already asked me for the necessary documents.");
    }
  }
}

// This instance handles everything.
const dMediator = new DivorceMediator();

// The husband does not know about the wife. There is no direct relationship.
const husband = new Husband(dMediator);
const wife = new Wife(dMediator);

husband.say("I hate her!!!");
// Logs: "Don't worry, Tom, the papers will be prepared!"
wife.say("He is ugly!!!");
// Logs: "Tom already asked me for the necessary documents."

Notifications Management with Mediator

To understand it better, let's implement a notifications management. We'll have both system and user notifications. User notifications will be sent to all users on the same channel, except the author. System notifications will be sent to every user. The centralized logic will handle different types of notifications.

// Shape of a notification object.
interface Notification {
  id: string;
  createdAt: string;
  content: string;
}

// Contract between Mediator and Consumer.
interface NotificationsChannel {
  id: string;
  type: 'users' | 'system';
  send(content: string): void;
  receive(content: string): void;
}

// General Mediator interface.
interface Mediator {
  propagate(payload: NotificationsChannel, content: string): void;
  register(channel: NotificationsChannel): void;
  length(): number;
}

// Concrete notification implementation for system notifications.
class SystemNotificationsChannel implements NotificationsChannel {
  public type = 'system' as const;
  public id: string;

  // Mediator is injected via constructor, and upon creation,
  // the instance is registered using "register".
  constructor(private mediator: Mediator) {
    this.mediator.register(this);
    this.id = this.type + this.mediator.length();
  }

  // Sends message - internal behavior is unknown to this class.
  send(content: string) {
    this.mediator.propagate(this, content);
  }

  // Receives message and logs it.
  receive(content: string) {
    console.log(`SystemNotificationsChannel log: ` + content);
  }
}

// Concrete notification implementation for user notifications.
class UsersNotificationsChannel implements NotificationsChannel {
  public type = 'users' as const;
  public id: string;

  constructor(private mediator: Mediator) {
    this.mediator.register(this);
    this.id = this.type + this.mediator.length();
  }

  send(content: string) {
    this.mediator.propagate(this, content);
  }

  receive(content: string) {
    console.log(`UsersNotificationsChannel${this.id} log: ` + content);
  }
}

// Mediator implementation handling all registered channels.
class NotificationsMediator implements Mediator {
  // Holds all registered channels.
  private channels: NotificationsChannel[] = [];

  // Registers a new channel.
  register(channel: NotificationsChannel) {
    this.channels.push(channel);
  }

  // Propagates a message, handling differently based on the channel type.
  propagate(payload: NotificationsChannel, content: Notification['content']) {
    if (payload.type === 'users') {
      this.channels.forEach((channel) => {
        if (channel.type === payload.type && channel.id !== payload.id) {
          channel.receive(content);
        }
      });
    } else {
      this.channels.forEach((channel) => {
        if (channel.type === 'users') {
          channel.receive(content);
        }
      });
    }
  }

  // Returns the number of registered channels.
  length(): number {
    return this.channels.length;
  }
}

// Usage example
const mediator = new NotificationsMediator();

const userChannel1 = new UsersNotificationsChannel(mediator);
const userChannel2 = new UsersNotificationsChannel(mediator);
const systemChannel1 = new SystemNotificationsChannel(mediator);

userChannel1.send(`Hi all`);
userChannel2.send(`Hi bro`);
systemChannel1.send(`Not allowed notification use detected. Both banned`);

// The result is logged as:
// UsersNotificationsChannelusers2 log: Hi all
// UsersNotificationsChannelusers1 log: Hi bro
// UsersNotificationsChannelusers1 log: Not allowed notification use detected. Both banned
// UsersNotificationsChannelusers2 log: Not allowed notification use detected. Both banned

The key point is bidirectional communication between the Mediator and Consumer, where each can call the other's public methods. This is demonstrated in the propagate method, which invokes channel.receive, and in the channels implementation, where mediator.register is called.

When a UsersNotificationsChannel is created with new UsersNotificationsChannel(mediator), it invokes register, storing the instance in the channels array within NotificationsMediator. During propagate, the array is iterated to call receive and share information with other instances, ensuring the sender is excluded.

You could also implement this using the Observable pattern, which is valid. There are multiple approaches to achieve this, with the Mediator pattern being a scalable choice if implemented carefully.

The NotificationsMediator facilitates bidirectional communication, allowing both the module and its consumers to invoke methods and access public properties. In contrast, the earlier example with the family was unidirectional.

Too Big Mediators - God Classes Issue

A God Class has too many responsibilities and knows too much about other parts of the system, leading to tightly coupled and hard-to-maintain code.

The Mediator pattern reduces coupling, but it can get complicated if misused. Incorrect implementation often leads to nightmare.

class PaymentMediator {
  propagate() {
    // Too much logic here...
  }
}

To avoid this, we may use the Strategy pattern to delegate tasks:

// Strategy base.
class PaymentStrategy {
  pay(amount) {
    throw new Error("This method should be overridden!");
  }
}

// Concrete strategies.
class CreditCardPayment extends PaymentStrategy {
  pay(amount) {
    console.log(`Paid ${amount} using Credit Card.`);
  }
}

class PayPalPayment extends PaymentStrategy {
  pay(amount) {
    console.log(`Paid ${amount} using PayPal.`);
  }
}

class PaymentMediator {
  propagate(amount) {
    // Delegate work to strategy classes.
    if (condition) {
      new CreditCardPayment().pay(amount);
    } else {
      new PayPalPayment().pay(amount);
    }
  }
}

Other Use Cases Ideas

  1. Message Brokers: Delegate certain information about system events to different microservices.
  2. Chat: Delegate messages and interaction highlights to different users based on conditions.
  3. Managing Distributed System Logic: There may be one bus (Mediator) that maintains the overarching process between smaller subsystems within a larger system.
  4. State Manager for Frontend.
  5. Divorce App (~ ̄▽ ̄)~.

Summary

Now you see how the Mediator pattern solves common coupling and dependency management problems. Instead of an everyone-to-everyone relationship, we've created a one-to-many relationship. Everything is centralized. The Mediator can be great for many situations, but it should never be forced. You should first identify the problem, as we did with the complex relationships between family members.

The Mediator saves a lot of time related to maintaining and rewriting complex relationships. Additionally, it often allows developers to add new code without changing existing code, which is the best possible outcome. Each code change introduces risk, so minimizing changes to existing code is beneficial.

It's important to avoid creating overly large Mediators, as they can become hard to maintain. Key points to remember from this article:

  1. The Mediator is a behavioral design pattern.
  2. It reduces coupling and simplifies dependency management.
  3. It transforms relationships from many-to-many to one-to-many.
  4. Instances of Mediator should be injected into other classes/modules, not initialized by them.
  5. The implementation of any design pattern should be considered carefully and should occur naturally, rather than being forced.
  6. The God Class problem may occur (you need to be careful).
Author avatar
About Authorpraca_praca

👋 Hi there! My name is Adrian, and I've been programming for almost 7 years 💻. I love TDD, monorepo, AI, design patterns, architectural patterns, and all aspects related to creating modern and scalable solutions 🧠.