Skip to content
Express.js ex patterns 4 min read

The Factory Pattern

A surprising number of Express problems trace back to one habit: building the app at the top level of a module and exporting it ready-made. That single shared instance is hard to configure differently per environment and nearly impossible to spin up cleanly inside a test. The factory pattern flips this around — instead of exporting a finished object, you export a function that builds and returns a freshly configured instance. The same idea applies to apps, routers, and services, and it is the foundation for testable, multi-config Express code.

Why a factory instead of a singleton

When a module runs const app = express() at load time and exports app, every importer shares the same instance. The configuration is frozen at import, so you cannot easily create a second app with a different database or logger, and tests inherit whatever side effects ran during import. A factory defers all of that: nothing is constructed until you call the function, and each call yields an independent instance you can configure however you like.

AspectExported singletonFactory function
When constructedAt import timeWhen you call it
Configurable per callNoYes (pass options)
Multiple instancesNoYes
Test isolationShared state leaksFresh instance per test
Dependency injectionHardNatural (pass deps in)

An app factory

The core move is to wrap app setup in a createApp(deps) function that accepts its dependencies and configuration as arguments. It wires middleware and routes, then returns the app without calling listen — starting the server is a separate concern left to the caller.

// app.js
const express = require("express");

function createApp({ db, logger = console } = {}) {
  const app = express();

  app.use(express.json());
  app.use((req, res, next) => {
    logger.info(`${req.method} ${req.url}`);
    next();
  });

  // Routes receive their dependencies explicitly
  app.use("/users", createUserRouter({ db }));

  app.use((err, req, res, next) => {
    logger.error(err.message);
    res.status(err.status || 500).json({ error: err.message });
  });

  return app;
}

module.exports = { createApp };

The server entry point stays tiny. It resolves the real dependencies, calls the factory, and only then binds a port.

// server.js
const { createApp } = require("./app");
const { connectDb } = require("./db");

async function main() {
  const db = await connectDb(process.env.DATABASE_URL);
  const app = createApp({ db });
  app.listen(3000, () => console.log("Listening on http://localhost:3000"));
}

main();

Keep app.listen out of the factory. A factory that returns an un-started app can be handed directly to Supertest, mounted under another app, or started on any port — a factory that calls listen itself can do none of those.

Router and service factories

The same pattern scales down to routers and services. A router factory takes its collaborators as arguments and returns a configured express.Router(), so the router never reaches for a global database handle.

// routes/user.router.js
const express = require("express");

function createUserRouter({ db }) {
  const router = express.Router();
  const users = createUserService({ db });

  router.get("/:id", async (req, res, next) => {
    try {
      const user = await users.findById(req.params.id);
      res.json(user);
    } catch (err) {
      next(err);
    }
  });

  return router;
}

module.exports = { createUserRouter };

A service factory closes over its dependencies and returns an object of methods. Because the db is captured in the closure, the rest of the code calls users.findById(id) without knowing where the data lives.

// services/user.service.js
function createUserService({ db }) {
  return {
    async findById(id) {
      const user = await db.users.find(id);
      if (!user) {
        const err = new Error("User not found");
        err.status = 404;
        throw err;
      }
      return user;
    },
  };
}

module.exports = { createUserService };

Multiple configurations from one factory

Because the factory accepts options, the same code produces differently configured instances. This is how you run a production app and an integration test side by side, or mount two API versions with different settings in one process.

const prodApp = createApp({ db: realDb, logger: pino() });
const testApp = createApp({ db: fakeDb }); // logger defaults to console

Effortless test setup

The biggest payoff is testing. A factory hands you a real app wired to fake dependencies, with no shared state between test files. Pass the un-started app straight to Supertest:

// app.test.js
const request = require("supertest");
const { createApp } = require("./app");

test("GET /users/1 returns the user", async () => {
  const fakeDb = { users: { find: async (id) => ({ id, name: "Ada" }) } };
  const app = createApp({ db: fakeDb });

  const res = await request(app).get("/users/1");

  expect(res.status).toBe(200);
  expect(res.body).toEqual({ id: "1", name: "Ada" });
});

Output:

PASS  ./app.test.js
  ✓ GET /users/1 returns the user (28 ms)

On Express 5, async handlers that reject are forwarded to your error middleware automatically. On Express 4 you still need the explicit try/catchnext(err) (or express-async-errors) shown above.

Best Practices

  • Export a createApp/createX function, not a pre-built instance, so configuration happens at call time.
  • Accept dependencies and options as arguments and provide sensible defaults via destructuring.
  • Never call app.listen inside the factory — return the un-started app and start it in your entry point.
  • Use closures in router and service factories to inject collaborators instead of reaching for module-level globals.
  • Build a fresh instance per test with fake dependencies to keep tests isolated and fast.
  • Keep factories pure: construct and wire, but avoid running side effects like opening connections inside them.
Last updated June 14, 2026
Was this helpful?