Architecture for browser JavaScript – how to split code into modules, draw boundaries between layers, and answer ‘where does this live?’ before the project turns into one unmaintainable file.
Start with features, not file types
A common mistake is organising by technical role only: all utilities in utils.js, all DOM code in dom.js, everything else in main.js. That works for a week. Then every change touches three files and nobody knows who owns what.
Feature folders group code by what the user sees or does. A ‘checkout’ feature might own its UI, its validation rules, and its API calls. Shared code moves to a shared/ or lib/ folder only when two features genuinely need it.
src/
features/
cart/
cart.js // public API for the feature
cart-view.js // DOM rendering
cart-store.js // state + persistence
catalog/
catalog.js
catalog-view.js
shared/
format.js
http.js
main.js
Layers and dependency direction
Think in layers. Data and rules sit at the bottom. UI reads state and sends intentions upward as events or function calls. The DOM should not fetch directly from five different modules – route that through a small API surface.
// cart-store.js - no DOM imports here
export function createCartStore(initialItems = []) {
let items = [...initialItems];
const listeners = new Set();
return {
getItems: () => [...items],
add(item) {
items = [...items, item];
listeners.forEach((fn) => fn(items));
},
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
// cart-view.js - knows about DOM, not HTTP
export function renderCart(root, store) {
const list = root.querySelector('[data-cart-list]');
function paint(items) {
list.replaceChildren(
...items.map((item) => {
const li = document.createElement('li');
li.textContent = item.name;
return li;
})
);
}
store.subscribe(paint);
paint(store.getItems());
}
Dependency rule: inner layers never import outer layers. cart-store.js does not import cart-view.js. The view imports the store. That keeps business logic testable without a browser.
Public APIs and barrel files
Each feature should expose one entry module. Other features import from features/cart/cart.js, not from internal files. That lets you refactor internals without breaking the rest of the app.
// features/cart/cart.js
export { createCartStore } from './cart-store.js';
export { renderCart } from './cart-view.js';
export function initCart(root) {
const store = createCartStore();
renderCart(root, store);
return store;
}
Barrel files that re-export everything in a folder are useful at the feature boundary. Avoid mega-barrels that re-export half the project – they hide dependencies and slow down bundlers.
Side effects at the edges
Keep pure functions in the middle: given the same input, same output, no hidden globals. Side effects – DOM updates, fetch, localStorage – live at the edges. That makes behaviour easier to reason about and debug.
// pure
export function lineTotal(item) {
return item.price * item.quantity;
}
export function cartTotal(items) {
return items.reduce((sum, item) => sum + lineTotal(item), 0);
}
// edge - called from main.js or a thin adapter
export async function saveCart(items) {
localStorage.setItem('cart', JSON.stringify(items));
}
When to split a module
- The file is hard to name in one honest word.
- You scroll past 200 lines hunting for one function.
- Two unrelated features import each other (circular dependency smell).
- Tests would need to mock half the browser to test one calculation.
Splitting too early is also a problem. Two tiny files that always change together should probably be one file until the boundary is obvious.
Configuration and environment
Pass config in at startup instead of scattering magic strings. A single config.js or object built in main.js keeps API base URLs and feature flags in one place.
// main.js
const config = {
apiBase: '/api/v1',
debug: location.search.includes('debug=1'),
};
initCatalog(document.querySelector('#catalog'), config);
initCart(document.querySelector('#cart'), config);
The next tutorial looks at state management without a framework – stores, subscriptions, and when plain objects stop being enough.

