Skip to content

Middleware

When it comes to Node.js web frameworks, the most fascinating design is undoubtedly "middleware." Whether it's Express or Koa (Egg is a framework built on top of Koa), middleware is a major feature. Koa in particular — countless developers adopted it specifically for its "onion model middleware." Let's use the source code to explain the principles of Koa's onion middleware:

javascript
"use strict";

/**
 * Expose compositor.
 */

module.exports = compose;

/**
 * Compose `middleware` returning
 * a fully valid middleware comprised
 * of all those which are passed.
 *
 * @param {Array} middleware
 * @return {Function}
 * @api public
 */

function compose(middleware) {
  if (!Array.isArray(middleware))
    throw new TypeError("Middleware stack must be an array!");
  for (const fn of middleware) {
    if (typeof fn !== "function")
      throw new TypeError("Middleware must be composed of functions!");
  }

  /**
   * @param {Object} context
   * @return {Promise}
   * @api public
   */

  return function(context, next) {
    // last called middleware #
    let index = -1;
    return dispatch(0);
    function dispatch(i) {
      if (i <= index)
        return Promise.reject(new Error("next() called multiple times"));
      index = i;
      let fn = middleware[i];
      if (i === middleware.length) fn = next;
      if (!fn) return Promise.resolve();
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };
}

There's very little code — the core is barely a dozen lines. Let me add comments to explain how it works:

javascript
function compose(middleware) {
  // Throw error if input is not an array
  if (!Array.isArray(middleware)) throw new TypeError("Middleware stack must be an array!")

  // Throw error if any element is not a function
  for (const fn of middleware) {
    if (typeof fn !== "function")
      throw new TypeError("Middleware must be composed of functions!")
  }

  return function(context, next) {
    // index starts at -1
    let index = -1

    // Execute the first middleware by default
    return dispatch(0)

    function dispatch(i) {
      // Throw error if next() is called multiple times
      if (i <= index) return Promise.reject(new Error("next() called multiple times"))

      // Set current middleware index
      index = i
      let fn = middleware[i]
      
      // When i equals the last index of the middleware array, fn is undefined (set to next)
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()

      // This is the core code. It returns a Promise.
      // When execution reaches a middleware without next(), the call stack unwinds,
      // resuming upstream behavior from the last middleware — forming the onion model.
      // If you're not familiar with stacks, brush up on stack data structure concepts.
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

The compose function itself returns a Promise. Through each middleware's await next(), execution flow is controlled. next() is essentially the next middleware function, so code after await next() only executes after the downstream middleware completes. Interestingly, redux-thunk borrows this same design pattern. React developers or anyone interested can check it out.

Conclusion

What matters in frameworks and libraries isn't fancy code — it's design philosophy. Once you understand the design thinking, you can write those impressive-sounding technologies yourself.