Structure JavaScript modules around clear responsibilities
Three fictional tasks, four module roles and an actually failing import cycle make dependency decisions concrete.

A JavaScript project does not become easier to understand simply because one long file turns into many short files. The useful questions concern responsibility and dependency. A selection rule should not quietly search for a form. A renderer should not independently fetch another set of records. The entry module can connect those parts without containing every decision itself. Those boundaries make it possible to explain what a later change actually affects.
This guide develops a small task overview containing three explicitly fictional records. Selecting a category changes the displayed tasks and their planned minute total. The numbers are teaching data rather than measured work times. The complete implementation was executed with Node.js and Edge. The important result is not merely that imports work. The example checks whether the business rule runs without a browser, whether rendering preserves literal text and whether a missing enhancement leaves the original information available.
Start with an actual change request
Imagine that a static overview now needs a way to show planning tasks only. In an existing implementation, the records, total calculation, element queries and event listener might all appear together. That arrangement can produce a perfectly functional first version. The difficulty appears when the same selection is needed for another view. A test may then have to construct an entire page merely to check which records belong to a category.
The split should therefore follow a concrete change question. Which parts change when a category is added? Which change when a list becomes a table? Which change only when the interface uses another language? A useful boundary keeps those decisions distinguishable. It does not pursue a maximum number of files. This example needs a data module, a selection function, a rendering adapter and an entry module. Another abstraction would need another real requirement to justify its existence.
Describe the public contract before dividing files
The selection function receives a task list and an allowed category. It returns a new bounded result containing the matching tasks and their planned minute total. It does not change the supplied list. It knows nothing about a select control, a URL, storage or a language preference. These sentences already form a useful contract. A caller can decide whether the function fits its task without reading every implementation detail.
The contract also defines input limits. Our teaching model accepts at most one hundred tasks. Each task has exactly an identifier, title, minute value and category. Identifiers are positive safe integers and cannot repeat within the list. Minutes range from zero to 480. A title contains at least one non-whitespace character and at most eighty Unicode code points. These are deliberate model choices. They are neither general project planning rules nor a complete validator for arbitrary external data. Other tasks need their own explicitly stated rules.
Four responsibilities and three directed connections
| File | Responsibility | Deliberately absent dependency |
|---|---|---|
| catalog.mjs | Provide three fixed teaching records | No rendering or event registration |
| selection.mjs | Validate input and select matching tasks | No document or browser state |
| view.mjs | Render a supplied result as a list | No selection policy or data loading |
| main-en.mjs | Connect the components to the English page | No duplicate calculation of the same rule |
The entry imports the other three modules. The three leaf modules do not import one another in this example. That is not a requirement for every larger application. Here the simple direction makes ownership visible. The rule can run independently. The view can be checked with a prepared result. Only the entry needs to know where this particular page places its control and overview.

Keep hidden startup out of the data module
The data module exports three tasks. Both the list and each individual record are frozen. This protects the shallow teaching structure against accidental changes by another module. Freezing only the outer list would not automatically stop a caller from editing a contained object. Nested data would require a further explicit decision about the intended protection. Our records contain primitive field values only, which keeps the decision easy to inspect.
Importing this module creates no elements and registers no listeners. That is a checked design choice for this component. An import can generally execute initialization code, which is precisely why a data definition should not unexpectedly start an interface. If future requirements introduce server data, an explicitly invoked loading component would make that work more visible than a hidden request performed merely by importing a definition. The application flow would then decide when loading is needed.
The rule copies records instead of changing shared objects
The selection function first checks the category and list shape. It then validates each record and copies it into a new object. Only after that step does it select the requested category. Consequently the function also validates records that will not be displayed in the chosen category. This is intentional: the contract rejects a malformed input list as a whole. Skipping damaged records would require a different contract, including an observable report of what was excluded.
The result contains a new frozen array and frozen records. A caller therefore cannot casually change the original teaching data or a result returned to another caller. This property makes two selections easier to compare. It does not create an authorization boundary. Confidential records must not be delivered to a browser merely because a later selection function hides them. Deciding which records a person may receive belongs in an appropriately trusted part of the application.
Pass an existing result to the renderer
The rendering adapter receives a root element, a previously calculated selection and short language labels. It searches for the list and status only inside that root. It therefore avoids relying on a global identifier for every output element. Another section could use the same adapter with a different root if the entry connects the appropriate elements. The adapter still does not decide which tasks belong to a category.
New list items are created through the owning document's DOM methods. Titles and minutes are assigned as text. In the actual browser check, a task title resembling an image tag appears literally and creates no image element. That specific property is demonstrated. It does not establish protection for every possible output context. If the same text later becomes part of a URL, stylesheet or HTML string, the rules for that context need separate examination. The small adapter avoids those additional contexts.
Use the entry as the visible place of composition
The entry imports the three named exports and locates the root and category control. A short update function calls the business rule and then the renderer. It runs once during startup and again when the category changes. Event registration therefore belongs to the part that actually understands this page flow. Merely importing the rendering file does not install a listener. This distinction prevents confusing accidental startup in otherwise reusable components.
The category control initially sits in a hidden container. That container becomes visible only after the first render succeeds. Without JavaScript, the three existing static tasks remain readable. When the rendering module is deliberately made unavailable in a test, the entry cannot start and the same static overview remains. The hidden filter then promises no unavailable enhancement. This is the bounded failure handled here. The example contains no general recovery mechanism, telemetry service or persistent preference.
Run the complete files together
Place the following files in one directory and serve them over HTTP. The HTML file loads the entry as a module. Relative imports include the actual filenames and extensions. The German and English executions share the same catalog, selection function and renderer. Only interface labels and the short language object in each entry differ. German task titles deliberately remain identical teaching data so that the language comparison does not accidentally compare two different catalogs.
catalog.mjs
export const tasks = Object.freeze([
Object.freeze({ id: 17, title: 'Planung', minutes: 45, category: 'planning' }),
Object.freeze({ id: 8, title: 'Datenabgleich', minutes: 15, category: 'technical' }),
Object.freeze({ id: 22, title: 'Nachweis', minutes: 30, category: 'planning' })
]);
selection.mjs
const categories = new Set(['all', 'planning', 'technical']);
const fields = ['category', 'id', 'minutes', 'title'];
export function selectTasks(records, category) {
if (!categories.has(category) || !Array.isArray(records) || records.length > 100) {
throw new TypeError('Invalid selection input');
}
const seen = new Set();
const copy = records.map(record => {
if (!record || typeof record !== 'object' || Array.isArray(record) ||
Object.keys(record).sort().join('|') !== fields.join('|') ||
!Number.isSafeInteger(record.id) || record.id <= 0 || seen.has(record.id) ||
typeof record.title !== 'string' || !record.title.trim() ||
[...record.title].length > 80 || !Number.isSafeInteger(record.minutes) ||
record.minutes < 0 || record.minutes > 480 ||
!['planning', 'technical'].includes(record.category)) {
throw new TypeError('Invalid task record');
}
seen.add(record.id);
return Object.freeze({ id: record.id, title: record.title,
minutes: record.minutes, category: record.category });
});
const items = Object.freeze(copy.filter(record =>
category === 'all' || record.category === category));
return Object.freeze({ items,
totalMinutes: items.reduce((sum, record) => sum + record.minutes, 0) });
}
view.mjs
export function renderPlan(root, selection, labels) {
const list = root.querySelector('ul');
const summary = root.querySelector('[role="status"]');
if (!list || !summary) throw new TypeError('Missing view elements');
const document = root.ownerDocument;
const rows = selection.items.map(item => {
const row = document.createElement('li');
row.dataset.id = String(item.id);
row.textContent = item.title + ': ' + item.minutes + ' ' + labels.minutes;
return row;
});
list.replaceChildren(...rows);
summary.textContent = selection.items.length + ' ' + labels.tasks +
' · ' + selection.totalMinutes + ' ' + labels.minutes;
}
main-en.mjs
import { tasks } from './catalog.mjs';
import { selectTasks } from './selection.mjs';
import { renderPlan } from './view.mjs';
const root = document.querySelector('#plan');
const filter = document.querySelector('#category');
const labels = {"minutes":"minutes","tasks":"tasks"};
function update() {
renderPlan(root, selectTasks(tasks, filter.value), labels);
}
filter.addEventListener('change', update);
update();
filter.parentElement.hidden = false;
example-en.html
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Tasks by category</title><link rel="stylesheet" href="./example.css"><script type="module" src="./main-en.mjs"></script></head><body><main><h1>Tasks by category</h1><p>Three fictional tasks with planned minutes. These are not measured work times.</p><div hidden><label for="category">Category</label> <select id="category"><option value="all">All tasks</option><option value="planning">Planning</option><option value="technical">Technical</option></select></div><section id="plan" aria-label="Task overview"><ul><li data-id="17">Planung: 45 minutes</li><li data-id="8">Datenabgleich: 15 minutes</li><li data-id="22">Nachweis: 30 minutes</li></ul><p role="status">3 tasks · 90 minutes</p></section><noscript><p>The complete task overview is shown above.</p></noscript></main></body></html>
example.css
[hidden]{display:none!important}:root{font-family:Arial,sans-serif;color:#192329;background:#fffaf3;color-scheme:light dark}body{max-width:46rem;margin:2rem auto;padding:0 1rem;line-height:1.6}select{font:inherit;padding:.4rem;max-width:100%}:focus-visible{outline:3px solid #b80024;outline-offset:4px}li{padding:.4rem 0}section{border:1px solid #77838b;padding:1rem;border-radius:.6rem}[role=status]{font-weight:bold}@media(prefers-color-scheme:dark){:root{color:#fffaf3;background:#192329}:focus-visible{outline-color:#ff7794}}
Named exports make the consumed contract discoverable
The exports in this design are explicitly called tasks, selectTasks and renderPlan. Reading the entry reveals which capability comes from which file. Named exports are a practical choice for this example, not a prohibition on other module forms. A default export can also be useful. What matters is that its meaning remains clear at the call site and that changes to the public contract happen intentionally. An uninformative helper filename cannot replace that explanation.
Aggregation files deserve the same attention. A central file that forwards everything from every feature can obscure actual dependencies. A small public interface can instead offer a stable entry into a coherent feature. In this example an aggregation layer would add work without a demonstrated benefit. The direct imports already reveal the whole small design. Only a larger actual usage pattern would justify another public boundary.
An imported binding does not make every object immutable
A consumer cannot simply reassign an imported binding. That does not mean an exported object is automatically immutable. Another module may still change properties when the object permits mutation. The separate decision to freeze our teaching records therefore matters. Looking only at the import declaration can hide the presence of shared mutable state.
An application with changing state would benefit from an explicitly named owner. That owner could perform changes through defined functions and give other components suitable snapshots. This is a possible extension rather than something silently implemented here. The present example has no global state container and no event system for business changes. The selected view is a derivation from three unchanged tasks. Its small requirement supports a small state solution.
Understand a dependency cycle through an executed counterexample
Two additional modules were created for a separate counterexample. The first imports a value from the second and immediately calculates its own exported value. The second imports the first value and also tries to calculate immediately. The actual Node execution produces a reference error because a required binding has not yet been initialized. This counterexample is stored separately from the functioning task overview.
The experiment does not show that every JavaScript import cycle is forbidden. Some cyclic relationships can execute when values are accessed at an appropriate later point. For a small business application, a cycle is nevertheless a useful reason to inspect responsibility. Often the shared rule belongs in a third independent component. Adding timers would make execution harder to explain and would not, without a further requirement, resolve the business design problem.
Check observable properties rather than a folder diagram alone
| Area | Actually checked property | Scope limit |
|---|---|---|
| Business rule | Four catalog sizes, three categories each, order, total and unchanged input | Our bounded task model |
| Invalid inputs | Duplicate identifiers, extra fields, invalid values and unknown categories are rejected | No general inspection of arbitrary executable objects |
| Browser rendering | Selected rows, sum, retained focus and literal HTML-like title output | One actual DOM output context |
| Failure behavior | Three static tasks remain without JavaScript or when the adapter is unavailable | No automatic repair or reload feature |
The run executed 231 assertions. The browser portion contains sixteen cases across both languages. Normal views were inspected at widths of 320, 390 and 1440 pixels in light and dark schemes. Further cases cover disabled JavaScript and a deliberately generated module failure. These counts describe work actually performed. They are not a quality rating and do not establish behavior in every browser or assistive technology.
Develop a change along its actual boundary
Adding a category affects the business function's allowed values, appropriate data and the category control. Changing list appearance affects the renderer and stylesheet. Changing interface language affects the labels and entry. The actual small implementation makes those relationships visible. It does not promise that every future request changes exactly one file. Some business changes legitimately cross several parts of an application.
A review should therefore ask whether each changed file has a clear reason. If the view suddenly calculates authorization or the catalog opens a dialog, responsibility has probably shifted. If the rule is copied merely to support another language, labels may have been confused with business content. These observations are more specific than a general request for clean architecture. They also help a small team review changes without introducing another framework.
Separate module design from delivery strategy
Multiple modules do not automatically mean that optional code loads only when needed. The static entry in this example needs its three dependencies during startup. A genuinely later request requires an explicit loading boundary connected to a suitable flow. File separation also establishes neither a particular transfer size nor a loading duration. Such claims need their own measurements under stated conditions.
Publication must keep compatible file versions available. An already open page may still contain an older entry. Removing its dependencies or changing public export names without a transition can create a runtime error despite well-organized source files. Module boundaries help identify the contract. Versioning, cache policy and rollback remain additional operational decisions that deserve inspection alongside the development-directory behavior.
Know the limits before adapting the teaching model
The function accepts ordinary records shaped like those created by our example. It is not a defense against arbitrary executable JavaScript objects. A foreign object could contain special property accessors, for example. An untrusted source therefore needs an earlier decision about how it is read and converted into a bounded data format. A neat module interface does not perform that work or make it unnecessary.
A completely frozen result also cannot judge whether a plan is realistic. The planning total of 75 minutes correctly matches two fictional entries. Whether those durations are appropriate for a real commission was not investigated. Small teaching examples should not quietly promote convenient numbers into professional estimates. The demonstrated benefit is reproducible selection with unchanged input. A real estimate requires its own task and evidence.
The separation can support a precise change description. Rather than listing new files, a description can identify the changed decision and its evidence. An additional technical task might come with checks that existing planning tasks remain unchanged. A rendering change might show that the same calculated results reach a different view. Such descriptions focus a review more effectively than a generic claim that code was improved.
Boundaries should remain proportional to their job. Two functions that always change together and implement one small contract can sensibly share a file. A renderer used once does not automatically need an extensible plugin mechanism. Another boundary helps when it exposes a real independence. This example deliberately uses normal functions and native module imports. Its reasoning does not depend on a package directory or an additional build configuration.
A checklist for understandable module boundaries
- State the concrete change question before splitting files.
- Describe each public function's input, result and failure behavior.
- Keep business rules executable without a document or browser state.
- Supply a previously calculated result to the renderer.
- Keep event registration and startup decisions in an identifiable entry.
- Avoid shared mutation or give it an explicit owner.
- Record actual import relationships and investigate unexpected cycles.
- Keep static information available when enhancement cannot start.
- Distinguish executed checks from universal quality claims.
- Publish compatible file versions with a controlled rollback path.
A useful module structure ultimately shows who makes the decision, who presents the result and who connects them. In this task example the same files make that statement checkable both in a browser and outside it. That traceability is the value of the split. File count alone does not explain it.
Sources and technical scope
The following official sources were actually opened on 9 November 2026. They explain language and runtime behavior. The task design and results come from our own executed example.







