Object composition in JavaScript

You may have heard the famous quote from the popular book "Design Patterns: Elements of Reusable Object-Oriented Software", written by the "Gang of Four" in 1994: "Favor object composition over class inheritance". What does it really mean and how can I implement this in JavaScript?

Let's imagine you're creating an online book store. People can sign up to become customers and purchase books.

Seems easy enough! Let's go ahead and create the classes and relationships we need.

book.js

class Book {
  constructor(title, price) {
    this.title = title
    this.price = price
  }

  getPrice() {
    return this.price
  }
}

user.js

class User {
  constructor(username) {
    this.username = username
    this.balance = 100
  }

  login() {
    console.log('Logging in!')
  }
}

customer.js

class Customer extends User {
  constructor(...args) {
    super(...args)
  }
  
  getPaymentDetails() {
    return {
      cardNumber: 'xxxx-xxxx-xxxx-xxxx',
      cvc: 123,
    }
  }

  purchase(book) {
    this.balance -= book.getPrice()
  }
}

Users get an initial $100 to spend in the store, and we allow them to purchase books. Seems like our book store is ready for production!

Note: There are of course other components in a real online book store, but I have omitted what I don't find relevant to the point I'm trying to make.

1 month later, your boss comes along and tells you that we have some very important customers who should be able to do everything regular customers can do, except they get a 20% discount on every purchase!

Fair enough, we'll just create another class:

class VIPCustomer extends Customer {
  constructor() {
    super()
    this.discount = 20
  }

  purchase(book) {
    this.balance -= book.getPrice(this.discount)
  }
}

Update the Book class to support discounts:

class Book {
  constructor(title, price) {
    this.title = title
    this.price = price
  }

  getPrice(discount) {
    return discount
      ? this.price - (this.price / 100) * discount
      : this.price
  }
}

Great! Our VIP-customers get their well-deserved discounts and everyone is happy.

Another month passes by and the business is really thriving, so your boss has a surprise for all of the book store employees; from now on they'll get the same discounts as the VIP-customers!

We could model this new feature in a couple of ways.

1. Just let employees be VIPCustomers
2. Create a new class, Employee, that will subtract the 20% discount from purchases - like the VIPCustomer
3. Create a new class, Employee, move the discount logic into a new class from which Employee and VIPCustomer inherit from.

#1 Would technically work for now, but feels a bit awkward. Plus, whatever features VIP-customers potentially would get in the future would automatically count for employees as well.

#2 Seems like a cleaner approach, but the downside is that we would have to keep the discount logic in sync between the Employee class and the VIPCustomer class. What if we increase the discount for VIP-customers but forget to increase it for employees?

#3 looks like a good option. We get the clean separation between an Employee and a VIPCustomer AND we get to reuse the logic for calculating discounts. Let's give this a shot.

First, we extract the functionality of making discounted purchases into its own class:

discountedUser.js

class DiscountedUser extends User {
  constructor(...args) {
    super(...args)
    this.discount = 20
  }

  purchase(book) {
    this.balance -= book.getPrice(this.discount)
  }
}

Then we create a class to represent an employee:

class Employee extends DiscountedUser {

}

And finally, remove the discount logic from VIPCustomer and make it inherit from DiscountedUser instead:

class VIPCustomer extends DiscountedUser {

}

Okay, so now both employees and VIP-customers get discounts, but we've made a breaking change. Our VIPCustomer no longer extends the Customer class, and we therefore no longer have access to the payment details. Also, the Employee class needs access to payment details as well, but since JavaScript doesn't support multiple inheritance, we can't make VIPCustomer and Employee extend both DiscountedUser and Customer.

There are probably other ways we could solve this particular problem, but you see where this is going. The more functionality we add, the deeper our inheritance tree grows and the more brittle our code becomes.

What if we could get the benefits of classical inheritance (code reuse), but without the downsides (fragile relationships) ?

Model things based on what they do, rather than what they are

In our initial implementation we've modeled our app according to what things are. User, Customer, Employee etc.

What if we instead architected our app around the functionality they provide?

If we break down the functionality each of our classes provide, it's basically this:

  • Logging in to the book store
  • Purchase a book
  • Purchase a discounted book
  • Get payment info

Instead of using classes, we'll simply use plain old functions to create the objects we need -by composing small pieces of functionality.

Let's create one function for each piece of functionality in our app:

const withPurchasing = (state = {}) => {
  let balance = 100
  return {
    ...state,
    purchase: book => {
      balance -= book.getPrice()
    },
  }
}

const withDiscount = (state = {}) => {
  const discount = 20
  return {
    ...state,
    purchase: book => {
      state.balance -= book.getPrice(discount)
    },
  }
}

const withLogin = (state = {}) => {
  return {
    ...state,
    login: () => {
      console.log('Logged in!')
    },
  }
}

const withPaymentInfo = (state = {}) => {
  return {
    ...state,
    getPaymentDetails: () => {
      return {
        cardNumber: 'xxxx-xxxx-xxxx-xxxx',
        cvc: 123,
      }
    },
  }
}

Each of the functions accept a "state" parameter. You can name it whatever you find makes the most sense to you, but it basically represents the object you want to augment. They return a copy of the "state" object you provide, with the additional methods added.

Creating our objects

Great! We have the different features separated into their own functions, now lets create some objects!

Note: If you wonder what the 'pipe()' functions does and looks like, check out this article: https://www.freecodecamp.org/news/pipe-and-compose-in-javascript-5b04004ac937/

const createCustomer = (username, balance) => {
  const customer = { username, balance }
  return pipe(
    withLogin,
    withPurchasing,
    withPaymentInfo
  )(customer)
}

Instead of creating a Customer class and using the new keyword to create customer objects, we have a so-called factory function, a function that creates customer objects for us. We take the inputs (what would be constructor arguments in the class example), username and balance and attach the features that a customer should have, in this case withLogin, withPurchasing, withPaymentInfo

Let's use the createCustomer function to create a customer object and verify that it has the correct properties

const bob = createCustomer('bob99', 100);
console.log(bob);

// output
{
  username: 'bob99',
  balance: 100,
  login: [Function: login],
  purchase: [Function: purchase],
  getPaymentDetails: [Function: getPaymentDetails]
}

If we log our object to the console, we see that it has the login(), purchase() and getPaymentDetails()-methods we expected.

So, how do we create a VIP-customer? Easy!

const createVipCustomer = (username, balance) => {
  const vipCustomer = { username, balance }
  return pipe(
    withLogin,
    withPaymentInfo,
    withPurchasing,
    withDiscount
  )(vipCustomer)
}

The only difference is the added withDiscount feature.

Employees have the same features as VIP-customers for now, so they will be the same - however, it's now really easy to extend the behaviour of both VIP-customers and employees.

const createEmployee = (username, balance) => {
  const employee = { username, balance }
  return pipe(
    withLogin,
    withPaymentInfo,
    withPurchasing,
    withDiscount
  )(employee)
}

Finally, our createBook function:

const createBook = (title, price) => {
  return {
    title,
    price,
  }
}

Adding new features

So your boss comes along again and tells you that all customers should be able to invite other users to the store, and employees should be able to invite users but also ban users. With our new architecture, implementing this is trivial. Let's create two functions that encapsulate inviting users and banning users.

const withUserInviting = (state = {}) => {
  return {
    ...state,
    inviteUser: user => {
      // Send the invitation
    }
  }
}

const withUserBanning = (state = {}) => {
  return {
    ...state,
    banUser: user => {
      // Ban!
    }
  }
}

Then we update the createCustomer() and createEmployee() functions

const createCustomer = (username, balance) => {
  const customer = { username, balance }
  return pipe(
    withLogin,
    withPurchasing,
    withPaymentInfo,
    withUserInviting,
  )(customer)
}

const createEmployee = (username, balance) => {
  const employee = { username, balance }
  return pipe(
    withLogin,
    withPaymentInfo,
    withPurchasing,
    withDiscount,
    withUserInviting,
    withUserBanning,
  )(employee)
}

And we're done!

Summary

Modelling your software according to what things do instead of what they are, gives you the benefits of classical inheritance without the downsides - the ability to reuse properties/behaviour while keeping your code adaptive to changing requirements.