javascriptoopmodelevent-handlingobserver-pattern

With Node EventEmitter, how would one remove an object from a service's list while also un-subscribing from the service's event-dispatching?


I'm studying about the EventEmitter of Node.js. I'm trying to create a Bakery where customers automatically buy bread if they have money when it bakes bread. Still, I also want to create a list of customers that I can add or remove customers, and they only will listen to the event when they're added and will not listen anymore when removed. This is how I've been doing it:

bakery.js

const eventEmitter = require('./event-emitter.js');
const events = require('./events.js');

class Bakery {
  _breads;
  _customers;
  _breadPrice;

  constructor(breadPrice) {
    this._breads = 0;
    this._customers = [];
    this._breadPrice = breadPrice;
  }

  get breadPrice() {
    return this._breadPrice;
  }

  bakeBreads(breadsQuantity) {
    this._breads += breadsQuantity;
    eventEmitter.emit(events.BREAD_BAKED, breadsQuantity);
  }

  addCustomer(customer) {
    // How would i do?
    this._customers.push(customer);

    eventEmitter.on(events.BREAD_BAKED, (breads) => {
      customer.buyBread(breads);
    })

    customer.bakery = this;
  }

  removeCustomer(customer) {
    // How would i do?
  }
}
module.exports = Bakery;

customer.js

class Customer {
  _customerName;
  _maxPrice;
  _moneyAmount;
  _bakery;

  constructor(customerName, maxPrice, moneyAmount, bakery) {
    this._maxPrice = maxPrice;
    this._moneyAmount = moneyAmount;
    this._bakery = bakery;
    this._customerName = customerName;
  }

  set bakery(bakery) {
    this._bakery = bakery;
  }

  buyBread() {
    if (this._moneyAmount >= this._bakery.breadPrice) {
      this._moneyAmount -= this._bakery.breadPrice;
      console.log(`Customer ${this._customerName} bought a bread costing ${this._bakery._breadPrice}`);
      return;
    }

    console.log(`${this._customerName} doesn't have enough money to buy a bread`);
  }
}
module.exports = Customer;

main.js

const Bakery = require('./bakery.js');
const Customer = require('./customer.js');

const bakery = new Bakery(2.5)
const johnRich = new Customer('John', 1, 10);
const martinPoor = new Customer('Martin', 0.3, 1);

Do you have any ideas on how to implement it correctly?


Solution

  • A possible solution to the OP's problem comes with two main design changes which again cause some more changes within the models and implementations as well.

    Thus, the changes to the Customer class are less invasive than the ones to the Bakery class.

    customer.js

    class Customer {
    
      _customerName;
      _maxPrice;
      _moneyAmount;
      _handlePurchaseBread;
    
      constructor(customerName, maxPrice, moneyAmount, handlePurchaseBread) {
      
        this._customerName = customerName;
    
        this._maxPrice = maxPrice;
        this._moneyAmount = moneyAmount;
    
        this._handlePurchaseBread = (typeof handlePurchaseBread === 'function')
          // - either a customer instance gets provided its own function which
          //   handles specifically how much bread a customer is going to buy.
          //    - in this case the just created customer instance needs to be
          //      explicitly bound to the provided handler.
          && handlePurchaseBread.bind(this)
          // - or one does fall back to a hanlder where each customer instance
          //   does purchase exactly one bread each time the handler gets invoked.
          //    - in this case the customer's correct `this` handling gets achieved
          //      via the lexical binding of the utilized arrow function expression.
          || (({ bakery, quantity }) => this.buyBread(bakery, 1));
      }
    
      // - the arguments signature of `buyBread` needs to be changed
      //   in order to allow a customer to purchase bread from more
      //   than just a single bakery.
      // - In addition the quantity of to be purchased pieces of bread
      //   has to be taken into consideration as well.
      buyBread(bakery, quantity = 1) {
        if (this._moneyAmount >= bakery.breadPrice) {
    
          this._moneyAmount -= bakery.breadPrice;
          
          console.log(
            `Customer ${ this._customerName } bought a bread costing ${ bakery.breadPrice }.`
          );
          return;
        }
        console.log(`${ this._customerName } doesn't have enough money to buy a bread.`);
      }
    }
    module.exports = Customer;
    

    The changes a bakery needs to undergo are as follows ...

    With implementing point 2) it comes to the light that a customer should be capable of buying bread from whichever bakery.

    It is the direct result of the OP's design of adding/removing customers to a bakery's customer list and dispatching "bread-baked" events to every registered customer. Thus there is no value in assigning a single bakery instance to a customer object. On the other hand it should be clear that the dispatched data has to provide not only the amount of freshly baked pieces of bread but also the bakery which was in charge of baking it all ...

    Which directly causes the change of the arguments-signature of a customer's buyBread method. Its first argument has to be a bakery reference and its second argument has to be the amount of to be purchased pieces of bread with a default of 1.

    Furthermore, there are changes which target the privacy and protection of fields. The OP uses the underscore for the annotation of pseudo-private fields. On the other hand the OP uses get methods for accessing such already public fields like with a bakery's _breadPrice versus breadPrice. In this case, and some others too, it is totally fine to make use of true private properties

    In addition and most importantly, the OP seems to use a single EventEmitter instance for every to be created Bakery instance. But a correctly targeted event/data-dispatching relies on the loose relationship in between a bakery instance and all of its registered customers. Thus, every bakery object needs its own EventEmitter instance.

    bakery.js

    const EventEmitter = require('node:events');
    const events = require('./events.js');
    
    class Bakery {
      _breads;
    
      // - since a `breadPrice` getter already does exist for the
      //   former `_breadPrice`, make it a true private field.
      #breadPrice;
    
      // - likewise for customers; but here due to storing
      //   customer references within a map which via the
      //   getter can be accessed as an array of customer objects.
      #customers;
      
      #eventEmitter;
    
      constructor(breadPrice) {
        this._breads = 0;
    
        this.#breadPrice; = breadPrice;
    
        // store customers within a `Map` instance.
        this.#customers = new Map;
    
        // every bakery object needs its own `EventEmitter` instance.
        this.#eventEmitter = new EventEmitter;
      }
    
      get breadPrice() {
        // public, but with write protecting.
        return this.#breadPrice;
      }
      get customers() {
        // public, but with write protecting and presented as array.
        return [...this.#customers.values()];
      }
    
      // - change the wording of `breadsQuantity` to `quantity`
      // - within the context of newly baked breads its clear
      //   what `quantity` refers to.
      // - and consider changing the wording of `this._breads`
      //   to e.g. `this._breadCount`.
      bakeBreads(quantity) {
        this._breads += quantity;
    
        // - emitting just the newly baked quantity does not help.
        // - one in addition needs the reference of the bakery
        //   which did the baking job.
        this.#eventEmitter.emit(events.BREAD_BAKED, { bakery: this, quantity });
      }
    
      addCustomer(customer) {
        const { _customerName: name } = customer;
    
        // - identify new customers by name.
        if (!this.#customers.has(name)) {
    
          // - store a new customer under its name based key.
          this.#customers.set(name, customer);
    
          // - register a customer's handler of how to purchase bread.
          this.#eventEmitter.on(events.BREAD_BAKED, customer._handlePurchaseBread);
        }
      }
      removeCustomer(customer) {
        const { _customerName: name } = customer;
    
        // - identify the to be removed customer by name.
        if (this.#customers.has(name)) {
    
          // - delete a new customer via its name based key.
          this.#customers.delete(name);
    
          // - un-register a customer's handler of how to purchase bread.
          this.#eventEmitter.off(events.BREAD_BAKED, customer._handlePurchaseBread);
        }
      }
    }
    module.exports = Bakery;
    

    Edit

    Within a next code iteration the modeling approach regarding buying and selling really needs to be improved.

    A possible solution was to implement a bakery's sellBread method which gets passed a customer-reference and the amount of pieces of to be purchased bread. This method would be in control of the entire validation of whether a buyer/seller transaction is/was successful. A customer likewise would features a buyBread method which gets passed a bakery-reference and the already mentioned amount of pieces of to be purchased bread. This method would just forward to the passed bakery-reference's sellBread method, but would return a transaction result which either is successful or comes with a reason of why a transaction did fail.

    The next provided example code implements a Bakery class which in addition extends EventTarget in order to demonstrate how one does utilize addEVentListener/removeEventListener and dispatchEvent directly at Bakery instances.

    On top there are other code improvements like verifying whether the items passed to addCustomers/removeCustomers are valid customer-objects.

    // main.js
    
    // const Bakery = require('./bakery.js');
    // const { Customer } = require('./customer.js');
    
    const klugesherz =
      new Bakery({ name: 'Pâtisserie Klugesherz', breadPrice: 1.5 });
    const hanss =
      new Bakery({ name: 'Boulangerie Patisserie Hanss', breadPrice: 2.7 });
    
    const johnRich = new Customer({
      name: 'John Rich',
      maxPrice: 5,
      moneyTotal: 20,
      handlePurchaseBread: function ({ currentTarget: bakery }) {
        this.buyBread(bakery, 3);
      },
    });
    const martinPoor = new Customer({
      name: 'Martin Poor',
      maxPrice: 3,
      moneyTotal: 10,
      handlePurchaseBread: function ({ currentTarget: bakery }) {
        const quantity = (
          ((bakery.name === 'Boulangerie Patisserie Hanss') && 1) ||
          ((bakery.name === 'Pâtisserie Klugesherz') && 2) || 0
        );
        this.buyBread(bakery, quantity);
      },
    });
    
    klugesherz.addCustomers(johnRich, martinPoor);
    hanss.addCustomers(johnRich, martinPoor);
    
    console.log({
      bakeries: {
        klugesherz: klugesherz.valueOf(),
        hanss: hanss.valueOf(),
      },
      customers: {
        johnRich: johnRich.valueOf(),
        martinPoor: martinPoor.valueOf(),
      },
    });
    
    console.log('\n+++ klugesherz.bakeBread(4) +++');
    klugesherz.bakeBread(4);
    
    console.log('\n+++ hanss.bakeBread(5) +++');
    hanss.bakeBread(5);
    
    console.log('\n+++ klugesherz.bakeBread(4) +++');
    klugesherz.bakeBread(4);
    
    console.log('\n+++ hanss.bakeBread(5) +++');
    hanss.bakeBread(5);
    
    console.log('\n... remove John Rich from the customer list of Patisserie Hanss.\n\n');
    hanss.removeCustomers(johnRich);
    
    console.log('\n+++ klugesherz.bakeBread(4) +++');
    klugesherz.bakeBread(4);
    
    console.log('\n+++ hanss.bakeBread(5) +++');
    hanss.bakeBread(5);
    
    console.log('\n... remove Martin Poor from the customer list of Patisserie Hanss.');
    hanss.removeCustomers(martinPoor);
    
    console.log('... remove John Rich and Martin Poor from the customer list of Pâtisserie Klugesherz.\n\n');
    klugesherz.removeCustomers(johnRich, martinPoor);
    
    console.log('+++ klugesherz.bakeBread(4) +++');
    klugesherz.bakeBread(4);
    
    console.log('+++ hanss.bakeBread(5) +++');
    hanss.bakeBread(5);
    .as-console-wrapper { min-height: 100%!important; top: 0; }
    <script>
    // customer.js
    
    class Customer {
      #name;
      #maxPrice;
      #moneyTotal;
      #handlePurchaseBread;
    
      constructor({ name, maxPrice, moneyTotal, handlePurchaseBread }) {
        this.#name = name;
        this.#maxPrice = maxPrice;
        this.#moneyTotal = moneyTotal;
        this.#handlePurchaseBread = (typeof handlePurchaseBread === 'function')
          && handlePurchaseBread.bind(this)
          || (({ currentTarget: bakery }) => { this.buyBread(bakery, 1); });
      }
      get name() {
        return this.#name;
      }
      get maxPrice() {
        return this.#maxPrice;
      }
      get moneyTotal() {
        return this.#moneyTotal;
      }
      get handlePurchaseBread() {
        return this.#handlePurchaseBread;
      }
    
      buyBread(bakery, quantity = 1) {
        const { approved, reason } = bakery.sellBread(this, quantity);
    
        if (approved === true) {
    
          this.#moneyTotal = this.moneyTotal - (bakery.breadPrice * quantity);
    
          console.log(
            `Customer ${ this.name } bought ${ quantity } piece/s of bread for a total of ${ (bakery.breadPrice * quantity).toFixed(2) } at ${ bakery.name }.`
          );
        } else if (typeof reason === 'string') {
    
          console.log('Buying a bread did fail, due to ...', reason);
        }
      }
    
      valueOf() {
        const { name, maxPrice, moneyTotal, handlePurchaseBread } = this;
        return { name, maxPrice, moneyTotal, handlePurchaseBread };
      }
    }
    
    function isCustomer(value) {
      return ((value instanceof Customer) || (
        Object
          .keys(value.valueOf())
          .sort()
          .join('_') === 'handlePurchaseBread_maxPrice_moneyTotal_name'
      ));
    }
    
    // module.exports = { Customer, isCustomer };
    </script>
    
    <script>
    // bakery.js
    
    // const { isCustomer } = require('./customer.js');
    
    class Bakery extends EventTarget {
      #name;
      #breadPrice;
      #breadCount;
      #moneyTotal;
      #customers;
    
      constructor({ name, breadPrice }) {
        super();
    
        this.#name = name;
        this.#breadPrice = breadPrice;
        this.#breadCount = 0;
        this.#moneyTotal = 0;
        this.#customers = new Map;
      }
    
      get name() {
        return this.#name;
      }
      get breadPrice() {
        return this.#breadPrice;
      }
      get breadCount() {
        return this.#breadCount;
      }
      get moneyTotal() {
        return this.#moneyTotal;
      }
      get customers() {
        return [...this.#customers.values()];
      }
    
      addCustomers(...customers) {
        customers
          .flat()
          .filter(isCustomer)
          .forEach(customer => {
            const { name } = customer;
            
            if (!this.#customers.has(name)) {
              this.#customers.set(name, customer);
    
              this.addEventListener('bread-baked', customer.handlePurchaseBread);
            }
          });
      }
      removeCustomers(...customers) {
        customers
          .flat()
          .filter(isCustomer)
          .forEach(customer => {
            const { name } = customer;
            
            if (this.#customers.has(name)) {
              this.#customers.delete(name);
    
              this.removeEventListener('bread-baked', customer.handlePurchaseBread);
            }
          });
      }
    
      bakeBread(quantity = 10) {
        this.#breadCount = this.#breadCount + quantity;
    
        this.dispatchEvent(
          new CustomEvent('bread-baked', { detail: { quantity } })
        );
      }
    
      sellBread(customer, quantity = 1) {
        const transaction = { approved: false };
    
        if (quantity >= 1) {
          if (this.breadCount >= quantity) {
    
            const isWithinPriceLimit = this.breadPrice <= customer.maxPrice;
            const canEffortPurchase = (this.breadPrice * quantity) <= customer.moneyTotal;
    
            if (isWithinPriceLimit) {
              if (canEffortPurchase) {
    
                this.#breadCount = this.breadCount - quantity;
    
                transaction.approved = true;
              } else {
                transaction.reason =
                  `Customer ${ customer.name } doesn't have enough money for buying a bread at ${ this.name }.`;
              }
            } else {
              transaction.reason =
                `Customer ${ customer.name } does have a price limit which just did exceed at ${ this.name }.`;
            }
          } else {
            transaction.reason =
              `The ${ this.name } bakery is too low on bread stock in order to fulfill ${ customer.name }'s order.`;
          }
        } else {
          transaction.reason =
            `Customer ${ customer.name } did not provide a valid quantity for purchasing bread at ${ this.name }.`;
        }
        return transaction;
      }
    
      valueOf() {
        const { name, breadPrice, breadCount, moneyTotal, customers } = this;
        return {
          name, breadPrice, breadCount, moneyTotal,
          customers: customers.map(customer => customer.valueOf()),
        };
      }
    }
    
    // module.exports = Bakery;
    </script>