Menu
👩‍💻Dev.to #architecture·February 25, 2026

Achieving Isomorphic JavaScript with Dependency Injection at the Module Level

This article explores how early binding of dependencies via static imports undermines JavaScript's isomorphism, making modules platform-specific. It proposes using Dependency Injection (DI) at the module level to explicitly declare dependencies, allowing a composition root to provide concrete implementations based on the runtime environment (browser, Node.js, edge). This architectural pattern centralizes platform decisions and improves testability.

Read original on Dev.to #architecture

The Challenge of JavaScript Isomorphism

JavaScript's ability to run in both browser and server environments theoretically enables truly isomorphic applications, where the same codebase can operate seamlessly across different platforms. However, the common practice of using static `import` statements for dependencies can inadvertently couple modules to specific runtimes. For instance, importing a `node:fs` module directly within a shared module immediately renders it non-isomorphic, as this dependency cannot be satisfied in a browser environment. This highlights the problem of 'early binding' where platform assumptions are encoded at module load time, limiting flexibility.

Dependency Injection for Platform Agnostic Modules

To counter early binding, the article advocates for an architectural pattern where modules declare their dependencies explicitly rather than importing them directly. This is essentially Dependency Injection (DI) applied at the module level. Instead of `import fs from "node:fs";`, a module might expose a `__deps__` object detailing its requirements (e.g., `fs`, `logger`). Concrete implementations for these dependencies are then provided from an external 'composition root'.

javascript
export const __deps__ = {
  fs: "node:fs",
  logger: "./logger.mjs",
};

export default function makeUserService({ fs, logger }) {
  return {
    readUserJson(path) {
      const raw = fs.readFileSync(path, "utf8");
      logger.log(`Read ${raw.length} bytes`);
      return JSON.parse(raw);
    },
  };
}

The Role of the Composition Root

The composition root acts as the entry point where platform-specific dependencies are assembled and injected into the core application modules. This centralizes platform decisions at the edge of the system, allowing core logic to remain clean, testable, and truly isomorphic. For a Node.js environment, `node:fs` would be injected, while a browser environment might inject a `browser-fs-adapter.mjs`. This separation of concerns significantly enhances architectural flexibility and simplifies unit testing by allowing easy injection of mocks or fakes.

💡

Architectural Benefits

Decoupling modules from specific runtimes via dependency injection improves modularity, testability, and portability. It centralizes environment-specific logic, making it easier to manage and adapt applications for different deployment targets (browser, server, edge functions).

Trade-offs and Use Cases

While beneficial for complex isomorphic applications, this approach introduces some trade-offs. It can reduce static analyzability and tree-shaking precision, and TypeScript integration might require more manual effort. It also demands a disciplined approach to architecture. This pattern is best suited for applications requiring true cross-runtime compatibility (Node.js, browser, edge), where environment decisions need to be centralized, testability is paramount without heavy mocking, and explicit capability boundaries are desired. For simpler or single-runtime applications, the overhead might outweigh the benefits.

JavaScriptIsomorphismDependency InjectionModule DesignCross-RuntimeArchitecture PatternsTestabilityFront-end Architecture

Comments

Loading comments...