Skip to content

Fractal Express Pattern

Use full Express instances — not Routers — as the building block for every module. Each module creates, configures, and exports its own express() app. Parent modules compose these apps with server.use(), creating a self-similar architecture at every level.

When to Use

  • Structuring an Express server with multiple concerns (logging, security, auth, API, UI)
  • Building independently testable server modules
  • Composing servers from packages in a monorepo
  • Any time you reach for express.Router() — consider express() instead

The Pattern

Every module follows the same three-line structure: create, export, configure.

javascript
import express from "express";

const server = express();
export default server;

// configure this module's middleware and routes
server.use(/* ... */);

The parent composes these modules:

javascript
import express from "express";
import securityMiddleware from "./security.js";
import loggingMiddleware from "./logging.js";
import authMiddleware from "./auth.js";
import sessionEndpoint from "./session.js";
import rpcEndpoint from "./rpc.js";
import uiServer from "@myapp/ui";

const server = express();
export default server;

server.use(securityMiddleware);
server.use(loggingMiddleware);
server.use(authMiddleware);
server.use("/session", sessionEndpoint);
server.use("/rpc", rpcEndpoint);
server.use(uiServer);

Each import is a full Express app. The parent doesn't know or care about each module's internals.

Logging module

A logging module wraps morgan in its own Express instance:

javascript
// server/logging.js
import express from "express";
import morgan from "morgan";

const server = express();
export default server;

server.use(morgan("tiny"));

Security module

A security module wraps helmet the same way:

javascript
// server/security.js
import express from "express";
import helmet from "helmet";

const server = express();
export default server;

server.use(helmet());

RPC service module

An RPC module composes a service with its models:

javascript
// server/rpc.js
import express from "express";
import { rpcService } from "rpc-light/server.js";
import accountModel from "../models/account.js";
import userModel from "../models/user.js";

const service = express();
export default service;

service.use(rpcService({
  account: accountModel,
  user: userModel,
}));

Key Insight

Express apps are valid middleware. Calling express() returns a function with the (req, res, next) signature, so it works anywhere a middleware function works. This means server.use(anotherExpressApp) is a first-class operation — not a hack.

This creates a fractal architecture: apps composed of apps, all the way down. Each level is structurally identical.

Nested composition

A module can itself compose further Express instances, creating sub-sub-applications at any depth:

javascript
// ui/index.js
import express from "express";
import compression from "compression";

const server = express();
export default server;

server.use(compression());
server.use(servePublicDir());
server.use(servePagesDir());

function servePublicDir() {
  return express.static(
    `${import.meta.dirname}/public/`,
  );
}

function servePagesDir() {
  const handler = express();
  handler.use(awaitDevBuild());
  handler.use(serveDistDir());
  return handler;
}

The servePagesDir function creates yet another Express instance — a sub-sub-application.

Settings Inheritance

Express tracks configuration through app.set() — things like trust proxy, view engine, case sensitive routing, and strict routing. Whether a child module inherits those settings depends on whether it was created with express() or Router().

Router() inherits the parent's settings. The Router reads settings by walking up the chain to the root app. Any app.set() call on the parent is immediately visible inside every child Router — including ones you didn't write.

express() is independent. A new Express instance starts with its own default settings and never reads the parent's. Changes to the parent don't affect it.

When inheritance is desirable

A small number of settings are genuinely global in nature, and inheritance saves you from re-declaring them everywhere:

  • trust proxy — this security-sensitive setting controls how Express reads X-Forwarded-For and related headers. If your app sits behind a load balancer, every sub-router that touches req.ip or req.protocol needs the same value. With Router(), setting it once on the root app propagates automatically.

  • view engine / views — if multiple routers render templates using the same engine and directory, inheritance means one declaration on the root app is enough.

When a module is entirely internal — a route group that will always live inside this one app — Router's inherited settings are convenient and cause no problems.

When inheritance causes problems

Inheritance becomes a liability the moment a module is meant to be reusable, independently testable, or shared across parent apps:

  • Testing in isolation. If a Router module relies on trust proxy being set by its parent, your unit test must reconstruct the parent's full configuration just to test the child. An express() module is self-contained: configure it once in the module, test it directly.

  • Monorepo packages. A Router published as a package silently depends on its consumer's settings. Two apps with different trust proxy values will get different behavior from the same Router. An express() module controls its own settings and behaves consistently regardless of where it's mounted.

  • Unexpected propagation. Changing a setting on the root app affects every child Router in the tree. This is the "invisible coupling" that makes large apps hard to reason about — a one-line change at the top silently alters behavior many files away.

Practical rule

Use Router() for internal route grouping where shared settings are a feature, not a liability — typically leaf nodes in a single app that you'll never extract or test in isolation.

Use express() everywhere else: independently testable modules, shared packages, and any module where you want settings to be explicit and local rather than implicit and inherited.

Trade-offs

ApproachProsCons
express()Isolated, testable, portableSlight overhead; settings not inherited
Router()Lighter; inherits global settingsCoupled to parent; hard to test alone
FunctionsSimplestNo routing

Prefer express() over Router(). An Express instance inherits no settings from its parent, so each module is fully isolated. A Router inherits the parent app's settings, which creates invisible coupling. The overhead of a full Express instance is negligible.