Rohit Nandi

Full Stack Engineer

Building Your Own Express-like Middleware System
Building Your Own Express-like Middleware System (Published on 10th May 2025)

Building Your Own Express-like Middleware System

Middleware is one of the most powerful concepts in Express.js, allowing you to chain functions together to handle requests in a modular way. It's a design pattern that enables separation of concerns, making your code more maintainable and reusable. In this post, I'll walk through how to build your own simplified middleware system that mimics Express.js middleware functionality.

Understanding Middleware

At its core, middleware is just a series of functions that have access to the request object, and a mechanism to pass control to the next middleware in the chain. In Express, middleware functions take three parameters: req, res, and next. For our simplified version, we'll focus on just the request and the ability to move to the next function.

The TypeScript Interface

Let's start by defining our types:

type Request = object
type NextFunc = (error?: any) => void
type MiddlewareFunc = (req: Request, next: NextFunc) => void
type ErrorHandler = (error: Error, req: Request, next: NextFunc) => void

The Request is just a generic object that middleware functions can modify. NextFunc is a function that can optionally take an error parameter to signal that something went wrong. We have two types of functions: regular middleware functions (MiddlewareFunc) and error handlers (ErrorHandler).

Implementing the Middleware Class

Now, let's implement our Middleware class:

class Middleware {
  private middlewares: Array<MiddlewareFunc | ErrorHandler> = [];
  
  use(func: MiddlewareFunc | ErrorHandler) {
    this.middlewares.push(func);
    return this;
  }
  
  start(req: Request) {
    let index = 0;
    
    const next: NextFunc = (error?: any) => {
      // If there's an error, find the next error handler
      if (error) {
        while (index < this.middlewares.length) {
          const handler = this.middlewares[index++];
          if (handler.length === 3) { // Error handlers have 3 parameters
            try {
              (handler as ErrorHandler)(error, req, next);
            } catch (e) {
              next(e);
            }
            return;
          }
        }
        // If no error handler is found, throw the error
        console.error("Unhandled middleware error:", error);
        return;
      }
      
      // No error, proceed to next middleware
      if (index < this.middlewares.length) {
        const middleware = this.middlewares[index++];
        if (middleware.length === 2) { // Regular middleware has 2 parameters
          try {
            (middleware as MiddlewareFunc)(req, next);
          } catch (e) {
            next(e);
          }
        } else {
          // Skip error handlers during normal flow
          next();
        }
      }
    };
    
    next();
  }
}

How It Works

Let's break down how this implementation works:

  1. Storage: We maintain an array of middleware functions and error handlers.

  2. Registration: The use() method adds a new function to our chain.

  3. Execution: The start() method kicks off the processing with an initial request object.

  4. The next() Function: This is the heart of the middleware system.

    • It checks if there's an error. If so, it looks for the next error handler.
    • If there's no error, it calls the next regular middleware.
    • If a middleware throws an exception, it catches it and passes it to the next error handler.
  5. Error Handling: We differentiate between regular middleware and error handlers by checking the number of parameters the function expects (handler.length).

Example Usage

Let's look at a basic example:

const middleware = new Middleware();

middleware.use((req, next) => {
  req.a = 1;
  console.log("Middleware 1");
  next();
});

middleware.use((req, next) => {
  req.b = 2;
  console.log("Middleware 2");
  next();
});

middleware.use((req, next) => {
  console.log("Final middleware:", req);
});

middleware.start({});
// Output:
// Middleware 1
// Middleware 2
// Final middleware: {a: 1, b: 2}

Error Handling Example

Now, let's see how error handling works:

const middleware = new Middleware();

middleware.use((req, next) => {
  req.a = 1;
  console.log("Middleware 1");
  next(new Error("Something went wrong"));
});

middleware.use((req, next) => {
  req.b = 2;
  console.log("Middleware 2"); // This won't execute
  next();
});

middleware.use((error, req, next) => {
  console.log("Error handler:", error.message);
  console.log("Request state:", req);
  // We could either stop here or call next() to continue
  // If we call next() without args, it will resume normal flow
  // If we call next(error), it will look for the next error handler
});

middleware.start({});
// Output:
// Middleware 1
// Error handler: Something went wrong
// Request state: {a: 1}

Handling Asynchronous Operations

One of the powerful aspects of middleware is handling asynchronous operations. Our implementation supports this naturally:

middleware.use(async (req, next) => {
  req.data = await fetchSomeData();
  next();
});

However, there's a catch: if an async function throws an error, our try-catch won't catch it unless we either use await or handle the promise rejection. For a more robust solution, you could enhance the middleware system to handle Promises returned by middleware functions.

Limitations and Improvements

This simplified middleware system captures the essence of Express middleware, but it has limitations:

  1. No Response Object: Our system only passes a request object, while Express passes both request and response.

  2. Limited Error Propagation: Express has a more sophisticated error propagation system.

  3. No Path-Based Middleware: Express allows you to specify middleware for specific paths.

To address these limitations, you could extend the system to include a response object, improve error handling, and add path matching functionality.

Conclusion

Building your own middleware system provides valuable insights into how Express.js works under the hood. While our implementation is simplified, it demonstrates the core concepts: function chaining, error handling, and control flow management.

The middleware pattern is incredibly versatile and can be applied in many contexts beyond web servers. Whether you're building a data processing pipeline, a validation system, or a plugin architecture, understanding middleware can help you create more modular and maintainable code.