← Writing

Module boundaries should follow change

People usually decide to split a module when it gets too big.

That instinct is often correct. It is just not enough.

A file can be large and coherent. It can also be small and confused. The real question is not size. The real question is change.

What changes together? What changes for different reasons? That is where a useful boundary comes from.

Here is a common kind of bad module:

// scene.ts

export function addChild(parent: SceneNode, child: SceneNode): SceneNode {
  /* ... */
}

export function removeChild(parent: SceneNode, childId: string): SceneNode {
  /* ... */
}

export function reparentNode(
  root: SceneNode,
  nodeId: string,
  newParentId: string
): SceneNode {
  /* ... */
}

export function rebuildSpatialIndex(root: SceneNode): SpatialIndex {
  /* ... */
}

export function queryIntersecting(
  index: SpatialIndex,
  bounds: Rect
): readonly string[] {
  /* ... */
}

export function queryPoint(
  index: SpatialIndex,
  point: Vec2
): readonly string[] {
  /* ... */
}

At first glance this file looks fine. Everything is “about the scene.”

That is exactly the trap.

Scene-graph structure and spatial indexing do not change for the same reasons. Hierarchy code changes when editing rules, parenting behavior, or node relationships change. Spatial indexing changes when performance work happens or new query shapes appear.

The names share a noun. The maintenance pressure does not.

The first step is to stop asking “what is this about?” and start asking “what changes together?” That gives you a split like this:

// scene-graph.ts

export function addChild(parent: SceneNode, child: SceneNode): SceneNode {
  /* ... */
}

export function removeChild(parent: SceneNode, childId: string): SceneNode {
  /* ... */
}

export function reparentNode(
  root: SceneNode,
  nodeId: string,
  newParentId: string
): SceneNode {
  /* ... */
}
// spatial-index.ts

export function buildSpatialIndex(
  nodes: readonly SceneNode[]
): SpatialIndex {
  /* ... */
}

export function queryRect(
  index: SpatialIndex,
  rect: Rect
): readonly string[] {
  /* ... */
}

export function queryPoint(
  index: SpatialIndex,
  point: Vec2
): readonly string[] {
  /* ... */
}

This is better for a simple reason: the modules now have different reasons to change.

That sounds abstract until you hit real work.

Suppose somebody needs to optimize hit testing. They can now work in spatial-index.ts without wading through tree-editing rules. Suppose somebody changes the rules for reparenting groups. They can now stay in scene-graph.ts without accidentally tangling indexing concerns into the edit path.

The benefit is not just smaller files. It is a smaller blast radius.

This is also where circular dependencies become useful as a warning sign. If splitting a module causes the two new modules to import each other immediately, the boundary probably is not real yet. The code still changes together, and all you did was spread one concept across two files.

A good split usually has a cleaner dependency story after it lands, not a messier one.

There is another trap worth naming. People often split modules by nouns instead of behaviors. “Selection,” “viewport,” “nodes,” “history,” “scene.” Those categories feel natural because they match product concepts. But product concepts are not always change axes. A selection.ts file that contains hit testing, bounds computation, keyboard behavior, and rendering hints may still be several modules wearing one label.

The practical test is still the best one:

When requirement A changes, what else changes with it?

If the answer is “these same files, every time,” they probably belong together.

If the answer is “completely different parts of this module depending on the task,” the module is lying about its cohesion.

This is why the right boundary often feels less poetic than the wrong one. “Scene graph” and “spatial index” are not glamorous names. They are maintenance names. They point at what actually moves.

That is what a good module should do.

A module is not a category label. It is a unit of change.

View on Bearblog