Introduction to the Factory Pattern in Express.js Middleware

Building scalable web applications with Express.js often requires middleware that adapts to different environments, user roles, or business rules. The factory pattern offers a structured way to generate middleware functions dynamically based on configuration parameters, allowing developers to write reusable, testable, and modular code. Instead of hardcoding middleware behavior, you define a factory function that returns a middleware tailored to specific needs. This approach reduces duplication and keeps your codebase clean as your application grows.

In this article, you will learn the core principles behind the factory pattern, explore best practices for implementing it in Express.js, and see practical examples that you can adopt in your own projects. By the end, you will have a clear understanding of how to design middleware that is both flexible and maintainable.

Understanding the Factory Pattern in Express.js

The factory pattern is a creational design pattern where a function (the factory) produces objects or components based on input parameters. In the context of Express.js middleware, a factory function accepts configuration and returns a middleware function that uses that configuration. This pattern is particularly useful when you need multiple variants of the same middleware logic, such as logging with different verbosity levels, authentication checks for different user roles, or validation rules that change per route.

Express.js middleware follows a simple signature: (req, res, next) => {}. A factory wraps the creation of this middleware, enabling the injection of settings, dependencies, or environment-specific options. The result is a more modular architecture where middleware behavior is defined by configuration rather than duplicated code.

For example, instead of writing separate logging middleware for development and production, you can create a single factory that produces either a verbose logger or a minimal one. This reduces errors and makes your code easier to maintain.

Core Advantages of Factory-Based Middleware

Reusability Across Projects

Factory-generated middleware can be packaged and reused in multiple projects. By exposing configuration options, you make the middleware adaptable without altering its internal logic. This is especially valuable for teams that maintain several Express.js applications.

Configuration-Driven Behavior

With the factory pattern, you can centralize configuration for middleware behavior. For instance, you might define a factory that accepts an options object, and then use environment variables or a configuration file to modify how the middleware operates in different environments. This reduces the risk of hardcoded values scattered across your codebase.

Improved Testability

Because factory functions are pure (they return a new middleware based on input without side effects), they are straightforward to unit test. You can test that the factory returns the correct middleware shape, and separately test the middleware logic. This separation of concerns leads to more reliable tests and faster debugging.

Easier Maintenance and Extensibility

When middleware logic changes, you update the factory in one place. Adding new features often involves extending the factory’s configuration options rather than rewriting middleware. This keeps your codebase flexible as requirements evolve.

Best Practices for Implementing Factory-Based Middleware

1. Keep Factory Functions Pure

A pure function depends only on its input and produces no side effects. For factory functions that generate middleware, this means the output middleware should be determined solely by the configuration passed in. Avoid accessing global state, reading files, or making network calls inside the factory. Any side effects should be contained within the returned middleware function itself.

Pure factories are easier to test and reason about. They also make it safe to call the factory multiple times with the same configuration, knowing you will get identical middleware behavior each time.

// Pure factory example
function createRateLimiter({ maxRequests, windowMs }) {
  return function rateLimitMiddleware(req, res, next) {
    // rate limiting logic using maxRequests and windowMs
    next();
  };
}

2. Use a Single Configuration Object

Passing a configuration object instead of multiple parameters keeps the factory interface clean and extensible. When you need to add new options later, you simply add a property to the configuration object without changing the function signature. This also improves readability at the call site.

// Good: single config object
function createAuthMiddleware({ role, tokenSecret, excludePaths }) {
  return function authMiddleware(req, res, next) {
    // authentication and authorization logic
    next();
  };
}

// Usage
const adminAuth = createAuthMiddleware({
  role: 'admin',
  tokenSecret: process.env.JWT_SECRET,
  excludePaths: ['/login', '/health']
});

Using a configuration object also makes it easier to merge defaults with user-provided values using Object.assign or the spread operator.

3. Encapsulate Middleware Logic

Each middleware factory should focus on a single responsibility. If you find yourself adding multiple unrelated behaviors to the same middleware, consider breaking it into separate factories that can be composed. Encapsulated logic is easier to debug, test, and reuse. For example, separate authentication from logging, even if they are frequently used together.

4. Leverage Closures for Private State

Factories can use closures to maintain state that persists across requests for that specific middleware instance. This is useful for rate limiting, request counting, or caching. The state is private to the middleware instance and not accessible from outside, which prevents accidental interference.

function createRequestCounter() {
  let count = 0;
  return function counterMiddleware(req, res, next) {
    count++;
    console.log(`Requests handled: ${count}`);
    next();
  };
}

const counter = createRequestCounter();
app.use(counter);

This pattern keeps state encapsulated and thread-safe for Node.js single-threaded event loop.

5. Include Error Handling in Factory Design

Design your middleware factories to handle errors gracefully. You can return middleware that catches errors and passes them to Express error handling via next(err). Consider adding configuration options for custom error messages or error response formats.

function createErrorHandler({ logErrors, customMessage }) {
  return function errorHandler(err, req, res, next) {
    if (logErrors) {
      console.error(err);
    }
    res.status(err.status || 500).json({
      message: customMessage || 'Internal Server Error'
    });
  };
}

app.use(createErrorHandler({ logErrors: true, customMessage: 'Something went wrong' }));

Practical Examples of Factory-Generated Middleware

Example 1: Configurable Logger Middleware

Building on the basic example, a configurable logger can include timestamps, log levels, and even remote logging endpoints. The factory pattern makes it easy to generate different loggers for different parts of your application.

function createLogger({ level = 'info', timestamp = true, transport = console }) {
  return function loggerMiddleware(req, res, next) {
    const time = timestamp ? new Date().toISOString() : '';
    const message = `[${level}] ${time} ${req.method} ${req.url}`;
    transport.log(message);
    next();
  };
}

const devLogger = createLogger({ level: 'debug', transport: console });
const prodLogger = createLogger({ level: 'error', transport: remoteLoggingService });

Example 2: Role-Based Authentication Middleware

A factory that accepts roles and token validation logic can generate middleware that enforces access control across your routes.

function createAuthGuard({ roles, tokenValidator }) {
  return function authGuard(req, res, next) {
    const token = req.headers.authorization?.split(' ')[1];
    if (!token) {
      return res.status(401).json({ error: 'No token provided' });
    }
    try {
      const decoded = tokenValidator(token);
      if (!roles.includes(decoded.role)) {
        return res.status(403).json({ error: 'Insufficient permissions' });
      }
      req.user = decoded;
      next();
    } catch (err) {
      return res.status(401).json({ error: 'Invalid token' });
    }
  };
}

// Usage with JWT
const requireAdmin = createAuthGuard({
  roles: ['admin'],
  tokenValidator: (token) => jwt.verify(token, process.env.JWT_SECRET)
});

app.use('/admin', requireAdmin, adminRouter);

Example 3: Validation Middleware Factory

Validation logic often varies by route. A factory that accepts a validation schema (using libraries like Joi or Yup) can generate middleware that validates request body, query, or params.

function createValidationMiddleware({ schema, source = 'body' }) {
  return function validationMiddleware(req, res, next) {
    const { error, value } = schema.validate(req[source]);
    if (error) {
      return res.status(400).json({ error: error.details[0].message });
    }
    req[source] = value; // use validated value
    next();
  };
}

// Usage with Joi
const userSchema = Joi.object({
  name: Joi.string().required(),
  email: Joi.string().email().required()
});

app.post('/user', createValidationMiddleware({ schema: userSchema }), userController);

Testing Factory-Generated Middleware

Testing middleware produced by factories is straightforward because the factory is a pure function. You can test the factory itself to ensure it returns a function with the expected signature, and then test the returned middleware with mock request, response, and next objects.

// Example test for the logger factory
const { createLogger } = require('./loggerFactory');

test('createLogger returns a middleware function', () => {
  const middleware = createLogger({ level: 'info' });
  expect(typeof middleware).toBe('function');
  expect(middleware.length).toBe(3); // req, res, next
});

test('logger calls next', () => {
  const middleware = createLogger({ level: 'info' });
  const req = { method: 'GET', url: '/test' };
  const res = {};
  const next = jest.fn();
  middleware(req, res, next);
  expect(next).toHaveBeenCalled();
});

By isolating the middleware logic, you can test edge cases such as missing configuration, invalid options, or error paths without needing a full Express app.

Common Pitfalls to Avoid

Over-Configuring the Factory

While configuration objects are powerful, adding too many options can make the factory hard to use and document. Keep the number of options reasonable and provide sensible defaults. If a factory requires more than five or six options, consider splitting it into multiple factories or using a builder pattern.

Mutating Global State Inside Middleware

Even though the factory is pure, the returned middleware might inadvertently modify global state. This can lead to unpredictable behavior in concurrent requests. Always keep state local to the middleware closure or use request-scoped storage.

Creating Tight Coupling to External Services

If your factory depends on external services (like a database or an API), pass them as dependencies through the configuration object rather than importing them directly. This makes the factory more portable and easier to test with mocks.

Forgetting to Handle Errors in Async Middleware

Express 5 handles async errors automatically, but in Express 4, async middleware that throws must catch errors and pass them to next(). Ensure your factory-generated middleware returns a function that properly handles both synchronous and asynchronous errors.

Conclusion

The factory pattern provides a robust foundation for creating modular, configurable middleware in Express.js. By keeping factories pure, using configuration objects, encapsulating logic, and leveraging closures for private state, you can build middleware that is easy to maintain, test, and reuse across projects. The pattern scales from simple logging to complex authentication and validation, making it a valuable tool in any Node.js developer’s toolbox.

As you design your next Express.js application, consider where factory-based middleware can reduce duplication and improve flexibility. With the best practices outlined here, you will be well-equipped to implement this pattern effectively and avoid common pitfalls. The result is a codebase that adapts to changing requirements without sacrificing clarity or reliability.

For further reading on Express.js middleware patterns, refer to the official Express.js middleware guide. To deepen your understanding of design patterns in Node.js, the Node.js Design Patterns book offers comprehensive coverage.