Publication date:

Software development faces two persistent difficulties: complexity and changing requirements. Decomposition helps with both, but only when the resulting parts are themselves resilient to change. This article looks at the elements of JavaScript code, their interfaces, and the boundaries that protect a change from spreading through an entire application.

Elements and their interfaces

At different levels of detail, JavaScript code contains objects, functions, classes, ES modules, and npm packages. Objects, functions and classes are basic elements; modules and packages combine them.

Bottom-up development builds packages from smaller elements; top-down work travels the other way. In either direction, good decomposition seeks high cohesion inside a component and low coupling between components.

Addressing code

In ES2015+ applications the ES module is the primary building block. In a browser it is loaded through a URL-like hierarchy; in Node.js it is a file within a package in node_modules.

An application-level address therefore has two parts: a module path and an export name. Examples:

import { exportName } from 'https://domain.example/path/to/mod.mjs';

Or use a relative or package address:

import { exportName } from '../../path/to/mod.mjs';
import { exportName } from '@vendor/project/src/path/to/mod.mjs';

Absolute addresses are more fragile than relative or mapped ones. Import maps can decouple consumers from a physical resource location. export default also decouples a consumer from the internal export name, though it limits a module to one default export.

Interfaces at each level

For a function, its name, inputs, and result form the interface. A positional signature is concise:

function producer(x1, x2) { return x1 * x2; }

Destructuring a single object gives a more change-tolerant public boundary:

function producer({ x1, x2 }) {
  return { sum: x1 + x2, product: x1 * x2 };
}
const { sum, product } = producer({ x1: 1, x2: 2 });

Fields can be added without shifting a position. The trade-off is more verbose code and weaker immediate readability, so this pattern is most valuable at public or long-lived boundaries. Classes expose names, public members, and method contracts. An ES module exposes only what it exports.

An npm package is the largest reusable unit. Its package name, version and main/exports entry point are part of the contract. Consumers can technically import internal files unless the package defines export restrictions, so public/private conventions need to be explicit.

Public and private areas

Everything outside an element’s interface is its private implementation. A private nested function, private class field, or non-exported module binding can change without forcing consumers to change:

class SomeClass {
  #privateValue;
  constructor() {
    const internal = {};
    this.useInternal = () => { internal.value = this.#privateValue; };
  }
  setValue(value) { this.#privateValue = value; }
}

More useful behaviour in the private area means a smaller public surface and fewer dependencies to maintain. Packages can declare a primary entry point and re-export intended APIs; they can also adopt folder conventions, such as treating Api/ as public and other modules as internal.

Practical rules

The larger the component, the more important the stability of its name and interface. Thoughtful boundaries do not prevent change; they keep a local change local.