Skip to contentVisit my new website at chrisnicholas.dev
Notes

Aspect-Oriented Programming in JavaScript

Aspect-oriented programming (AOP) is a programming paradigm that aims to improve modularity by separating cross-cutting concerns. In very basic terms, this means splitting up a system into different sections, each of which is only responsible for a specific part of the system.

An example

Let's think about an example. If we had an API for adding and removing users for a system, we may also be interested in keeping a log of every API call made. Here we have the API logic in orange

Orange box
and the logging logic in blue
Blue box
:

Before using AOP

If the result of the main logic will never have any effect on the logging, why not split it up into different concerns? We can then have different files that only deal with one specific part of the system, leading to much better maintainability in large codebases:

After using AOP

In AOP, the logger object is an aspect—a feature linked to multiple parts of the system, but not part of the core functionality.

In JavaScript

First we can define a simple logging aspect:

logger.js
const logger = {}

logger.addUser = function (...args) {
  // Do logging
  console.log('LOGGER: AddUser call ', args)
}

export default logger

Then we define the main API logic, using a special addAspects function to initialise the API with the logging aspect:

api.js
import addAspects from './addAspects.js'
import logger from './logger.js'

const api = addAspects(logger)

api.addUser = function ({ name }) {
  // Do API logic (simulating a delay here)
  await new Promise(res => setTimeout(res, 1000))
  console.log(name + ' added')
}

export default api

If we then use the api, we can see that the logger function (the advice in AOP terms) is called alongside the main function, even though we haven't explicitly called it:

index.js
import api from './api.js'

api.addUser({
  name: 'Chris',
  age: 28
})
LOGGER: AddUser call { name: 'Chris', age: 28 }
Chris added

How does that work?

We're making use of JavaScript Proxy and Reflect to build addAspects:

addAspects.js
export default function addAspects (...aspects) {
  const get = (target, prop, receiver) => {
    if (typeof target[prop] !== 'function') {
      return Reflect.get(target, prop, receiver)
    }

    return function (...args) {
      aspects.forEach(aspect => aspect[prop](...args))
      return Reflect.apply(target[prop], target, args)
    }
  }

  return new Proxy({}, { get })
}

This simply watches for API function calls, and runs the correspondingly named advice when this happens. So when addUser is called, logger.addUser will be called too. This function also allows us to add multiple aspects, not just one.

Adding timing

We can further improve addAspects by applying advice in different pointcuts (a location to apply advice). In other words, we can run aspect functions during different stages of execution:

Pointcut stages

We can modify our logger aspect to return functions for different pointcuts:

Extract from logger.js
logger.addUser = function (...args) {
  return {
    before: () => console.log('LOGGER: AddUser begin ', args)
     after: () => console.log('LOGGER: AddUser end   ', args)
  }
}

We can also create a second aspect, this could be for tracking analytics:

analytics.js
const analytics = {}

analytics.addUser = function ({ requestIp }) {
  return {
    during: () => console.log(`ANALYTICS: ${requestIp} adding user`)
  }
}

export default analytics

If we go back to our main API file, change to addAspect(logger, analytics), then run addUser again:

index.js
import api from './api.js'

api.addUser({
  name: 'Chris',
  age: 28,
  requestIp: '192.168.1.0'
})
LOGGER: AddUser begin { name: 'Chris', age: 28 }
ANALYTICS: 192.168.1.0 adding user
Chris added
LOGGER: AddUser end   { name: 'Chris', age: 28 }

Final addAspects

Our addAspects function now looks like this:

addAspects.js
export default function addAspects (...aspects) {
  const get = (target, prop, receiver) => {
    if (typeof target[prop] !== 'function') {
      return Reflect.get(target, prop, receiver)
    }

    return async function (...args) {
      const run = pointcut => aspects.forEach(aspect => aspect[prop]?.(...args)[pointcut]?.())
      run('before')
      const result = Reflect.apply(target[prop], target, args)
      run('during')
      await result
      run('after')
      return result
    }
  }

  return new Proxy({}, { get })
}

We're using the nullish coalescing operator (?.) to check if the aspect has a function for the given advice and pointcut, before running the advice at different stages of execution.

Should I try this?

I think there are specific circumstances where AOP could be quite helpful. Last year I put together Tauque, a no-config bundler (it's 100x quicker than Webpack, thanks to esbuild!), and addAspects would have been super helpful in separating out bundle building and writing to the console.

Tauque bundler
Tauque bundler

Be careful

A major problem with aspect-oriented programming is that it's quite easy to obscure what's going on under the hood. Unless a programmer has knowledge of the implementation of the aspects, it could be difficult to find the source of problems and debug. Use with moderation!

Sign up to my newsletter
Receive notifications when new articles are published.
Your email will only be used for sending article updates.