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 #architectureJavaScript'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.
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'.
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 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).
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.