This page is part of a static HTML representation of TriTarget.org at https://tritarget.org

The Delegate Decorator pattern

Sukima13th April 2023 at 4:53pm

Every now and then I run across a strange situation where I need to make something look like another thing. The Object Oriented approach is the Decorator pattern. But in JavaScript we have even more advantages because we have the Proxy.

It took some time to find enough use cases to warrant an understanding. But I think I found a situation where this kind of pattern shines. For example, say we have a large company with many departments. One department took on the task and responsibility to create a system that is planned to replace one of many system we have currently. The old system uses two data services which results in two different models. The new system, however, is designed to have one data service which has the same information but combined into one model. You are tasked to convert the old system to the new system but you don’t have access to change the data service instead stuck having to shim the old data into something the new system can use and in reverse.

There are many ways to approach this vague set of requirements but let us assume that all options have been explored and the approach left is to use the old data service (two objects) with the new (one model) system.

To make this task a little more concrete here is a contrived example of the two systems.

Legacy System

GET /v1/movies/:id/base-info

{
  "id": "bada55",
  "name": "The Matrix (1999)"
}

GET /v1/movies/:id/extra-info

{
  "id": "bada55",
  "genre": "Sci-Fi",
}

JSON representations of the legacy system.

New System

GET /v2/movies/:id

{
  "id": "bada55",
  "title": "The Matrix",
  "year": 1999,
  "genre": "Sci-Fi"
}

JSON representation of the new system.

And remember we don’t have the new system yet. The only part we can CRUD data with is the v1 endpoints. But our new system assumes that the new endpoint exists and that the data looks like that. Not only do we need to combine two objects but there is also some translations between the two.

The basic overview of our requirements. Our task is to implement the magic.

The Magic

Before we dive deeper we need to understand the basics of a decorator and how we can use a Proxy to make the situation transparent. For that example take a break from above and think of a system were we are translating values between two data objects.

{
  "id": "bada55",
  "name": "Alice Fancy"
}
{
  "id": "bada55",
  "firstName": "Alice",
  "lastName: "Fancy"
}

JSON representations of the two example data systems. Name become first and last name.

Start off with a data class for the original data { name }:

Example legacy model
class LegacyModel {
  constructor({ id, name }) {
    this.id = id;
    this.name = name;
  }
  save() {
    return fetch(
      `/api/users/${this.id}`,
      { method: 'PUT', body: JSON.stringify(this) }
    );
  }
  static async fetch(id) {
    let res = await fetch(`/api/users/${id}`);
    let data = await res.json();
    return new LegacyModel(data);
  }
}

// And the legacy system would have used it like this:
console.log(
  '%s: Hello %s, how are you?',
  model.id,
  model.name,
);

With the modern system our data class might look like:

Example modern model (if it were implemented)
class ModernModel {
  constructor({ id, firstName, lastName }) {
    this.id = id;
    this.firstName = firstName;
    this.lastName = lastName;
  }
  save() {
    throw new Error(`API hasn't been implemented yet!`);
  }
  static async fetch(id) {
    throw new Error(`API hasn't been implemented yet!`);
  }
}

// And the new system will use it like this:
console.log(
  '%s: Hello %s, with a last name of %s.',
  model.id,
  model.firstName,
  model.lastName,
);

A decorator to merge this might look like this:

Example decorator
class Decorator {
  #model;
  constructor(legacyModel) {
    this.#model = legacyModel;
  }
  get firstName() {
    return this.#model.name.split(' ')[0];
  }
  get lastName() {
    return this.#model.name.split(' ')[1];
  }
  static wrap(legacyModel) {
    let decorator = new Decorator(legacyModel);
    return new Proxy(legacyModel, {
      get(_, prop) {
        let target = Reflect.has(decorator, prop)
          ? decorator
          : legacyModel;
        let value = Reflect.get(target, prop);
        return typeof value === 'function'
          ? value.bind(target)
          : value;
      }
    });
  }
}

// And the new system will use it like this:
let legacyModel = await LegacyModel.fetch('bada55');
let model = Decorator.wrap(legacyModel);
console.log(
  '%s: Hello %s, with a last name of %s.',
  model.id,
  model.firstName,
  model.lastName,
);

There is a lot going on in that wrap method. I’ll try my best to break it down a bit.

  1. We make a decorator and give it the model so it can send messages to the model as needed
  2. We use the original model as the subject for a new Proxy so that the default handlers will go to it instead of the decorator
  3. In the proxy’s get handler we will get the subject and a property that was asked for
  4. We use the Reflect.has to tell if the decorator has those properties, if so use the decorator, and if not use the original model. This is why we made the decorator’s #model property private so there is no name collision with the original model (if the original model has a model property)
  5. We use Reflect.get to actually send the message to the appropriate target
  6. If the result is a function we will need to bind that function to the appropriate target otherwise the dynamic nature of JavaScript would have the this printing to the decorator and not the target

What this affords us is complete control over the message sending when the consumer needs to know nothing about the specifics of the implementation below and only has to assume that the implementation conforms to the new model duck-type.

Demo

The tale of two models

We need to define a class that will know how to delegate between two models. Presuming we have the two legacy models already defined in this example the decorator:

Delegating Decorator example
class Decorator {
  #baseInfo;
  #extraInfo;
  #changes = new DirtyTracker();
  constructor(baseInfo, extraInfo) {
    this.#baseInfo = baseInfo;
    this.#extraInfo = extraInfo;
  }
  get title() {
    return this.#titleParts()[0];
  }
  set title(title) {
    return this.#setName({ title });
  }
  get year() {
    return Number(this.#titleParts()[1]);
  }
  set year(year) {
    return this.#setName({ year });
  }
  async save() {
    await Promise.all(this.#saveChanges());
    this.#changes.reset();
  }
  #titleParts() {
    let [, ...parts] =
      /^(.+)\s+\((\d{4})\)$/.exec(this.#baseInfo.name);
    return parts;
  }
  #setName({ title = this.title, year = this.year }) {
    this.#changes.base();
    return this.#baseInfo.name = `${title} (${year})`;
  }
  *#saveChanges() {
    let { isDirty } = this.#changes;
    if (isDirty.base) yield this.#baseInfo.save();
    if (isDirty.extra) yield this.#extraInfo.save();
  }
  static wrap(baseInfo, extraInfo) {
    let decorator = new Decorator(baseInfo, extraInfo);
    return new Proxy(baseInfo, {
      get(_, prop) {
        const maybe = (target) =>
          Reflect.has(target, prop) ? target : null;
        let target = maybe(decorator)
          ?? maybe(extraInfo)
          ?? baseInfo;
        let value = Reflect.get(target, prop);
        return typeof value === 'function'
          ? value.bind(target)
          : value;
      },
      set(_, prop, value) {
        const maybe = (target) =>
          Reflect.has(target, prop) ? target : null;
        let target = maybe(decorator)
          ?? maybe(extraInfo)
          ?? baseInfo;
        if (target === baseInfo)
          decorator.#changes.base();
        if (target === extraInfo)
          decorator.#changes.extra();
        return Reflect.set(target, prop, value);
      },
    });
  }
}

Demo

And there is a lot going on there. But I will attempt to walk you through it.

Like the original decorator example we have our getters to mimic the new model from the legacy model. We’ve chained our requests to come from the first decorator or model to satisfy the requst in the order decorator → first model → second model.

Doing the reverse adds the complication of tracking which model had a change. This is because we want to avoid sending unnecessary save requests. We do that with some dirty tracking. I wrote a utility for that.

By the way, if you are interested in the DirtyTracker class here you go, Just keep in mind that dirty tracking could be done any which way. Maybe each model tracks their own dirty state and we could just sned the save message to both without worry. But in this case as we’ve already building a contrived system I opted to put it in the decorator for fun.

Dirty tracking class
class DirtyTracker {
  static BASE = 1 << 0;
  static EXTRA = 1 << 1;
  changes = 0;
  base() {
    this.changes |= DirtyTracker.BASE;
  }
  extra() {
    this.changes |= DirtyTracker.EXTRA;
  }
  reset() {
    this.changes = 0;
  }
  get isDirty() {
    return {
      base: this.changes & DirtyTracker.BASE,
      extra: this.changes & DirtyTracker.EXTRA,
    };
  }
}

I'll pick out a few highlights.

class Decorator {
  …
  async save() {
    await Promise.all(this.#saveChanges());
    this.#changes.reset();
  }
  *#saveChanges() {
    let { isDirty } = this.#changes;
    if (isDirty.base) yield this.#baseInfo.save();
    if (isDirty.extra) yield this.#extraInfo.save();
  }
  …
}

A concise and linear way to grab a set of promises and Promise.all over them

class Decorator {
  …
  static wrap(baseInfo, extraInfo) {
    let decorator = new Decorator(baseInfo, extraInfo);
    return new Proxy(baseInfo, {
      get(_, prop) { … },
      set(_, prop, value) {
        const maybe = (target) =>
          Reflect.has(target, prop) ? target : null;
        let target = maybe(decorator)
          ?? maybe(extraInfo)
          ?? baseInfo;
        if (target === baseInfo)
          decorator.#changes.base();
        if (target === extraInfo)
          decorator.#changes.extra();
        return Reflect.set(target, prop, value);
      },
    });
  }
}

Again grabbing the likely target and setting the value. Also tracking dirty state for the appropriate target

Always return a boolean from a Proxy set handler or things go weird. Refelect.set() will return the appropriate value. When in doubt return true

It is worth experimenting with Proxy to get a feel for how it works. Though the syntax and the caveats are strange and you need to be aware of them the result is a very clean developer experience when using it. The ability to transparently act like something while under the hood having full control to do what you need to do is powerful. Not every situation allows you to redesign or rewrite things to be correct or better. Some time you need to have an intermediary representation and Proxies can facilitate that transition so you can incrementally update as you go.

Discuss this article