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.
| Aspect | Exported singleton | Factory function |
|---|---|---|
| When constructed | At import time | When you call it |
| Configurable per call | No | Yes (pass options) |
| Multiple instances | No | Yes |
| Test isolation | Shared state leaks | Fresh instance per test |
| Dependency injection | Hard | Natural (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.listenout 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 callslistenitself 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/catch→next(err)(orexpress-async-errors) shown above.
Best Practices
- Export a
createApp/createXfunction, 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.listeninside 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.