DEV Community

Cover image for Mediator Design Pattern: the Typescript way
adairo
adairo

Posted on

Mediator Design Pattern: the Typescript way

When studying design patterns, it is easy to see that different languages provide different tools to implement this patterns. I think that Typescript is somewhat special, its type systems allows us to do some really interesting stuff and enforcing the shape and protocols within our applications. In this blog we are going to explore how the Mediator design pattern can help us to solve a specific problem related with a computer arquitecture problem - Implementing a data bus.

A little bit of background

If you don't know how a cpu, memory or bus work, don't worry, we are not going deep in these topics. Nevertheless, I'll provide you with a very simple introduction to these elements in order to understand our motivation on using the mediator pattern.

The way a simple computer works can be explained as the following. The (main) memory, sometimes called RAM, is a device that can storage data. We can see the memory as a big array, each memory location has an index and can hold a value. These values can be any data but more importantly, they can be instructions that our CPU can execute to accomplish some tasks.

In the other hand we have a CPU - the brain of our computer system. Its primary task is to fetch an instruction from memory and then executing it, one by one, until the program is finished. But, how can we communicate the cpu and memory? That is when our BUS come in to play. The task of the bus is to serve as a mean of communication between the data that travels from the cpu to memory and viceversa. When using a bus, we can connect other devices to the bus, allowing more complex communication schemas. The devices connected to the bus don't need to worry about interacting with other components, they just need to communicate with the bus and the bus will take care of it.

The mediator pattern

The mediator design pattern is often described as a mechanism to restrict the communication between several objects while forcing them to communicate only through a mediator.

The participants of this design patterns are:

  • A Colleague class
    • Serves as a base class for the colleagues that want to communicate with each other. This class must hold a reference to its mediator
  • Concrete Colleagues
    • Inherits from the Colleague class and implements its own logic. Their operations involve communicating with other colleagues
  • A Mediator interface
    • It defines the protocol used by the colleagues to send messages to the Mediator.
  • Concrete Mediator
    • Implements the Mediator Interface and hold references of the colleagues it manages.

Here is a simple class diagram that represents this configuration:

Image description

Implementation

Now, we are going to use the principles behind the mediator pattern to implement a data bus. In this case, the Colleague classes will be the CPU and memory, while the bus will play the role of the mediator.

Our CPU class looks like the following

class CPU {
  private nextInstructionAddress: number = 0;
  private result: number = 0;

  fetch() {
    // - Read the next instruction from memory
    // - Increment the nextInstructionAddress
  }

  decode(instruction: number) {
    // Decode the instruction 
    // in order to determine operation type
  }

  execute(opCode: number, operand1: number, operand2: number) {
    // run the code associated with the decoded instruction
    switch(opCode) {
      case 0:
        this.result = operand1 + operand2;
        break;
      case 1:
        this.result = operand1 / operand2;
        break;
      case 2:
         // Write the value of operand1 in the memory 
         // location given by the value (address) of operand2
        break;
      // ... rest of operations
      default: {
        throw new Error("Invalid operation")
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We can see that in the fetch() method we need to read from memory to grab the instruction to execute. In the operation with opCode = 2, we also need to write a value on memory.

Let's see the interface of the Memory class:

class Memory {
  private memory: number[];

  constructor() {
    this.memory = [];
  }

  read(address: number): number {
    return this.memory[address]
  }

  write(value: number, address: number) {
    this.memory[address] = value;
  }
}
Enter fullscreen mode Exit fullscreen mode

Great, now it's time to connect these classes to work together.

The first step is defining the interface of the Bus class, this interface usually contains a single method that will be called to notify the colleagues for the ocurrence of some event, e.g. The cpu wants to read from memory.

interface Bus {
  notify(message: string) void;
}
Enter fullscreen mode Exit fullscreen mode

Next, we will define the base class for the colleagues classes (cpu and memory). In this base class, a reference of the bus (mediator) is held to achieve communication.

class BusConnector {
  private bus: Bus | undefined;

  connect(bus: Bus) {
    this.bus = bus;
  }

  signal(message: string) {
    if (!this.bus) {
      throw new Error("bus disconnected");
    }

    this.bus.notify(message);
  }
}
Enter fullscreen mode Exit fullscreen mode
  • The connect() method is used to establish the connection between the device and the bus.
  • The signal() method allows the device to communicate with the bus.

Now let's go back to the CPU and Memory classes and make them extend from the BusConnector class (inheritance).

class CPU extends BusConnector {
  // class definition
}

class Memory extends BusConnector {
  // class definition
}
Enter fullscreen mode Exit fullscreen mode

The implementation of the DataBus is very straightforward, just keep in mind we need to implement the Bus interface and hold references of the devices connected to the bus.

class DataBus implements Bus {
  private cpu: CPU;
  private memory: Memory;

  constructor(cpu: CPU, memory: Memory) {
    this.cpu = cpu;
    this.memory = memory;

    // setup the connections
    this.cpu.connect(this);
    this.memory.connect(this);
  }

  notify(message: string): void {
    switch (message) {
      case "memory-read": {
        // read from memory and return the value
        break;
      }
      case "memory-write": {
        // write to memory
        break;
      }
      default: {
        throw new Error("Invalid message")
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two main problems arise from the previous example:

  • a string message is not enough to specify how the read or write operation should be carried out.
  • there is not a clear way of passing the result to the CPU.

This is the perfect time to introduce the Typescript way and leverage some of its basics constructs that will help us to improve the communication between devices.

Let's write the types of the supported messages!

type ReadMessage = {
  type: "memory-read", 
  address: number, 
  response: (value: number) => void
}

type WriteMessage = {
  type: "memory-write",
  value: number, 
  address: number, 
  response: (wasOk: boolean) => void
}

type CPUMemoryMessages = ReadMessage | WriteMessage
Enter fullscreen mode Exit fullscreen mode

Each type defines the properties needed to accurately describe the operation to be done. The type property will be used as a union discriminator, while the response function enables a channel to call back the sender, e.g. Pass the value read from memory to the CPU.

In order to use these typed messages, we have two approaches:

  • Use the CPUMemoryMessages directly in our classes/interfaces
  • Make our classes and interfaces accept a parameter type (a.k.a generic)

We are sticking to the second option to foster reusability.

interface Bus<Messages> {
  notify(message: Messages) void;
}

class BusConnector<Messages> {
  private bus: Bus<Messages> | undefined;

  connect(bus: Bus<Messages>) {
    this.bus = bus;
  }

  signal(message: Messages) {
    if (!this.bus) {
      throw new Error("bus disconnected");
    }

    this.bus.notify(message);
  }
}
Enter fullscreen mode Exit fullscreen mode

The CPU, Memory and DataBus classes now implements the previous interfaces but using the concrete type we are interested in - The CPUMemoryMessages type.

class Memory extends BusConnector<CpuMemoryMessages> {
  // class definition unmodified
}

class CPU extends BusConnector<CpuMemoryMessages> {
  // class definition unmodified
}

class DataBus implements Bus<CPUMemoryMessages> {
  // class initialization ...

  notify(message: CPUMemoryMessages): void {
    switch (message.type) {
      case "memory-read": {
        const { address, response } = message;
        const value = this.memory.read(address);
        response(value);
        break;
      }
      case "memory-write": {
        const { address, value, response } = message;
        try {
          this.memory.write(value, address);
          response(true); // writing operation success
        } catch (e) {
          response(false); // there was an error
        }
        break;
      }
      default: {
        throw new Error("Invalid message");
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Inside each case of the switch statement, Typescript is able to narrow the type of the current message. For example, in the "memory-write" case, Typescript knows that the message contains a value to be written, and a response function that accepts a boolean as well.

Using the DataBus, the CPU class can manipulate the memory, without being coupled with a specific implementation or API of the Memory class. All it needs is to send a message through the DataBus.

class CPU extends BusConnector<CPUMemoryMessages> {
  private nextInstructionAddress: number = 0;
  private result: number = 0;

  fetch() {
    let instruction: number;

    this.signal({
      type: "memory-read",
      address: this.nextInstructionAddress,
      response: (value) => (instruction = value),
    });

    // - Increment the nextInstructionAddress
    this.nextInstructionAddress++;
  }

  decode(instruction: number) {
    // Decode the instruction
    // to determine operation type (opCode) and operands
  }

  execute(opCode: number, operand1: number, operand2: number) {
    // run the code associated with the decoded instruction
    switch (opCode) {
      case 0:
        this.result = operand1 + operand2;
        break;
      case 1:
        this.result = operand1 / operand2;
        break;
      case 2:
        this.signal({
          type: "memory-write",
          value: operand1,
          address: operand2,
          response: (ok) => console.log(`Write operation success: ${ok}`),
        });
        break;
      // ... rest of operations
      default: {
        throw new Error("Invalid operation");
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

To run our little system, we need to create an instance of the DataBus and connect our devices to it. Here is an example of the usage:

const cpu = new CPU();
const memory = new Memory();
const bus = new DataBus(cpu, memory)

cpu.fetch(); // read the instruction at address 0;
cpu.fetch(); // read the instruction at address 1;
cpu.execute(2, 4, 10); // write the value 4 on address 10
Enter fullscreen mode Exit fullscreen mode

You can find the complete code example in the repository for this series.

I hope you have found this post useful, don't hesitate to let me know how you would improve this example. I am planning to write more posts about some design patterns that I have used to solve real problems in my projects, so I'll see you soon!

Top comments (0)