When to Split a Monolith: It Is Not About Line Count
TLDR
Splitting a monolith is not about file size or line count. It is about state ownership: when you cannot answer "who initializes this, who owns this state, where do this operation's side effects end," the monolith needs to be split. A 2500-line file can be fine. A 500-line file can be unmanageable if everything shares the same closure.
The Problem Class
Every long-lived frontend codebase has at least one file that grew past the point where a single developer can hold it in working memory. The conventional advice is to split it when it hits some line count threshold, or when a linter rule complains, or "when it feels big."
These triggers are wrong. They produce splits that either happen too early (before natural module boundaries emerge) or too late (after the shared state is so tangled that splitting reveals hidden bugs). The right trigger is a specific architectural signal: state ownership becomes ambiguous.
The Naive Approach
Wait until the file is large, then split by feature or by type. Put all CSS in one file, all JavaScript in another. Or put all the canvas code in one file and all the UI code in another. Draw the line wherever feels natural.
This produces splits that may or may not correspond to actual ownership boundaries. If two features share a piece of state, the split puts the state in one file and one of the features in another. The second feature now imports the state from the first feature's file. The boundary is artificial.
Why It Breaks
Shared state in a monolith is invisible because the entire codebase has access to everything. Arrow functions using $(this) work because this is always the module object and everything is in the module object. Initialization order does not matter because everything runs in a single script tag. Implicit dependencies are fine because there is no module boundary to violate.
The split makes all of this visible simultaneously. Arrow functions break because $(this) now refers to the wrong this. Initialization order errors surface because module A imports module B, which imports module C, which tries to use module A before it is initialized. Implicit dependencies fail because they are no longer in scope.
These are not new bugs. They were latent in the monolith. The split reveals them by imposing the constraints that modules require.
// works in monolith: arrow function, 'this' is the shared module object
$('#traceBtn').on('click', () => {
const mode = $(this).data('mode'); // 'this' is the module, not the button
// happens to work because mode is on the module object too
});
// breaks after split: 'this' in arrow function is the imported module, not the button // the data attribute is on the button, not the module $('#traceBtn').on('click', () => { const mode = $(this).data('mode'); // undefined });
The Better Model
Split when the codebase shows these specific signals, in order of priority:
Signal 1: State ownership is ambiguous. You have a variable and you cannot immediately name which feature owns it and is responsible for its validity. If two features both write to selectedElements, neither owns it. Split by ownership, not by feature.
Signal 2: A change in one area requires understanding a different area. If modifying the wire drawing logic requires reading the layer panel code to understand what state is shared, the boundary between them needs to be explicit.
Signal 3: You are adding the second major feature that shares state with the first. The second feature is the right time to establish the module pattern. The third feature will follow the established pattern. Waiting for the fourth or fifth means the shared state is already deeply tangled.
The split itself follows from ownership. Each module owns its state, exports only the interface that other modules need, and imports only what it uses:
// core/svgEditor.js owns: the SVG element, the selection, the camera
// canvas/canvasEngine.js owns: drawing, hit testing, drag state
// features/layers.js owns: layer panel UI
// features/history.js owns: undo stack
// Each exports a narrow interface export const layerPanel = { rebuild() { / ... / }, getSelectedIds() { / ... / }, };
Ownership boundaries make the split correct. File sizes are irrelevant.
Tradeoffs
Splitting early (when the code is still small) produces module files with few functions and sparse imports. The split feels over-engineered. But early splits establish patterns before the code is complex. Late splits require refactoring intertwined state.
The real cost of a late split: every latent bug in the monolith becomes visible and must be fixed during the split. This is not a reason to avoid splitting. It is a reason to do it before the bugs accumulate.
The One Thing to Watch For
After splitting, search for all jQuery event handlers that use $(this) inside arrow functions. Arrow functions do not bind this, so $(this) inside an arrow function is the outer module's this, not the event target. Convert to regular function declarations for any event handler where $(this) needs to be the clicked element.