Parts of a large codebase rely on module top-level side effects — plugins that self-register on import, a logger configured at import time — so correctness depends on which modules are imported and in what order. As the engineer setting the standard, how do you evaluate that pattern and what would you put in its place?
answer
- the guarantee is real, the contract is invisible
- an import with no bindings still does work
- declare in modules, act in a composition root
- the failure appears far from the cause
- comment or assert the ordering you depend on
basics
~20 sTreat evaluation-order dependence as hidden coupling: the ordering contract lives in import statements no reviewer reads as ordering. Prefer module bodies that only declare things, plus one composition root that constructs and wires everything in an order you can read, test, and change.
solid answer
~50 sThe pattern works, which is why it spreads — but the contract it depends on is invisible. Depth-first evaluation in declaration order is a real guarantee, so the code is correct today; the risk is that nothing states the requirement, so any refactor that reorders imports, splits an entry file, or removes a seemingly unused bare import can break startup with an error far from the cause. My default standard is: module bodies declare, they do not act. Export a `register(app)` or `createClient(config)` and have one composition root call them in an explicit, readable order. Where a side-effect import is genuinely the right tool — polyfills, or a framework that requires it — confine it to the entry module, keep it idempotent, and comment why it must come first. Make failures loud rather than silent: have the consumer assert that initialisation happened instead of quietly reading an unconfigured default.
go deeper
Understand what a bare, binding-free import does: it evaluates that module for its side effects. Never delete one just because nothing appears to use it.
Explain why the ordering works — dependencies first, siblings in declaration order — and why it is fragile: the requirement is recorded only by the position and existence of an import line.
Diagnose the resulting incidents and refactor them: move work out of module bodies into exported initialisers, add a startup assertion so a missing import fails loudly, and fix the test isolation problems the pattern causes.
Set the standard and defend the trade. Say when implicit registration's ergonomics still win, how you would migrate a large codebase incrementally, and what you would put in place so the ordering contract is visible rather than folklore.
## What the pattern is actually leaning on The language guarantee is genuine. A module's dependencies are evaluated before its own body, siblings in the order their `import` declarations appear, each module exactly once. Self-registration works because of it: ```js // plugins/csv.js import { registry } from '../registry.js'; registry.add('csv', handler); // side effect at evaluation time // main.js import './plugins/csv.js'; // "unused" import that is load-bearing import { run } from './app.js'; run(); ``` So the objection is not correctness. It is that the ordering requirement — *this must be evaluated before `run()` reads the registry* — is expressed nowhere except in the position and existence of an import line that reads like a no-op. ## The failure modes to name **Deletion.** An import with no bindings looks unused to a human and to some tooling. Removing it is a plausible cleanup that silently drops a feature. **Reordering and refactoring.** Splitting an entry file, extracting a shared bootstrap module, or converting a module to be loaded on demand all change evaluation order, and none of them look like behavioural changes in review. **Distance between cause and symptom.** The error surfaces as a missing handler or an unconfigured logger, in a module that has no relationship with the one that failed to be imported. **Order-of-import bugs that only appear in one environment.** Because entry points differ between the app, tests, and any tooling that loads modules directly, an ordering that holds in production may not hold in a test harness that imports a module in isolation. **Testing pain.** A module that performs registration when evaluated cannot easily be exercised twice with different setup — it is evaluated once per instance, and its side effect already happened when the test file's imports were linked. ## The standard I would set **Module bodies declare; they do not act.** Top-level code creates functions, classes and constants. Anything that touches shared state, the network, the environment, or a registry goes inside an exported function. **One composition root.** A single module — the entry point — imports the pieces and calls them in an order you can read top to bottom, and change in one place. This turns an implicit graph property into ordinary code, which means it can be tested, logged, and reasoned about. **Registration becomes explicit.** Instead of a plugin self-registering on import, it exports a descriptor and the composition root adds it. The plugin list stops being "whatever got imported" and becomes a value you can inspect, sort, filter per environment, or feed into a test. **Where side-effect imports stay, contain them.** Polyfills and certain framework or instrumentation hooks legitimately must run before anything else. Confine them to the top of the entry module, keep them idempotent, and leave a comment stating the ordering requirement — the comment is the only place the contract can live. **Fail loudly.** A consumer that needs configuration should assert it, not fall back. "Logger used before configure()" thrown at startup costs minutes; a logger silently writing to a default sink costs an incident. ## The counter-arguments, taken seriously Side-effect registration buys real ergonomics: adding a plugin is one import line rather than an edit in a central file, which keeps a growing list from becoming a merge-conflict hotspot. Some ecosystems are built around it and fighting the convention costs more than it saves. And an explicit composition root is more code, and can turn into a monolithic wiring file that everyone touches. So the decision is a trade, and I would state it as one: implicit registration optimises for the convenience of adding an item; explicit wiring optimises for being able to understand and change startup behaviour later. In a small codebase with one entry point, implicit is fine. Once there are multiple entry points, several teams, and tests that load subsets of the graph, the cost of an invisible ordering contract dominates, and I move the wiring into the open. **Migration, if you are inheriting the pattern.** Do not convert everything at once. Add the explicit registration path alongside the implicit one, make new code use it, and make the implicit path noisy — log a warning when a module self-registers — so the remaining sites are enumerable rather than guessed at. Then remove the bare imports one entry point at a time, with the startup assertion in place to catch a miss immediately. ## What to demonstrate in an interview Show that you know the guarantee is real, name precisely why depending on it is fragile in a codebase that changes hands, and give a concrete alternative with its cost. Then say where you would *not* apply the rule. An answer that says "top-level side effects are bad" without the trade is a slogan; an answer that names deletion, reordering, and test isolation as the specific failure modes, and accepts the ergonomic loss of explicit wiring, is a position.
- When is a side-effect-only import genuinely the right choice?At the entry point, for things that must be in place before anything else observes the environment — polyfills being the clearest case — and for ecosystems whose conventions are built around it, where fighting them costs more than the ambiguity. In both cases keep the module idempotent, confine the import to the entry file, and state the ordering requirement in a comment, since nothing else records it.
- A module must self-register because a third-party framework requires it. How do you make that safe?Make the body idempotent so a second evaluation cannot double-register, keep the registration the module's only side effect, and have the consumer assert that the registry is populated before it is used so a missing import fails loudly at startup. Pin the ordering requirement in a comment at the import site, because that line is the whole contract.
- How does evaluation-order coupling show up in tests specifically?A test file's own imports are linked and evaluated before its body runs, so any registration already happened before the first line of the test — and it happens once, so you cannot re-run it with different setup. Suites that load different subsets of the graph get different startup states, producing tests that pass alone and fail together, or vice versa.
- Doesn't a single composition root become a bottleneck everyone edits?It can, and that is the honest cost of making wiring explicit. The mitigation is to keep it thin — it calls each area's own `register()` rather than knowing details — and to split it per entry point where several exist. You are trading a merge-conflict hotspot for the ability to read startup order in one place, which is usually the better trade once more than one team ships into the codebase.
saying these in an interview costs you the question
- Says import order is undefined so the code is already broken
- Treats a bare import as dead code to delete
- Claims moving setup to the top of the file fixes ordering
- Asserts top-level side effects are always wrong, with no trade
- Assumes each test file re-runs an imported module's registration