Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Module Boundaries and Dependency Rules

Harmonicon has no Cargo-workspace boundaries between its subsystems (see System Overview for why it’s one library crate) — which means nothing at the compiler level stops any module from importing any other. The structure this chapter describes is enforced by a mix of one automated test, and — for everything the test can’t check — reviewer discipline against a written-down rule. This chapter states the rules, the one place they’re mechanically checked, and a documented exception worth understanding rather than working around.

Rule 1: unrelated things do not share a file

A file is one concern; its name says what that concern is; landing on a file via a grep hit should mean everything in it is relevant to what you were looking for. This is checked mechanically: tests/physical_design.rs::no_file_exceeds_the_line_budget_unless_ allowlisted enforces a ~1000-line budget on non-test code per file (test modules — #[cfg(test)] mod tests { ... } or a sibling tests.rs — are excluded from the count, and files literally named tests.rs are skipped as pure test content with no budget of their own).

The allowlist isn’t an escape hatch that quietly accumulates forever — a second test, allowlist_has_no_stale_entries, fails the build if an allowlisted file has already dropped back under budget, which is what makes the list function as an honest burndown chart of known, intentional debt rather than a ratchet that only ever grows. New code isn’t allowed to add itself to the list preemptively — the rule this enforces is “split before adding to an already-large file,” not “budget permission in advance.”

This rule has real teeth: a 2026-07 pass (docs/physical_design_plan.md) measured gameplay/mod.rs at 2,921 lines mixing plugin wiring, ~30 resource/component/message types, the score-state model, a 250-line scoring system, HUD updates, and 1,250 lines of inline tests (43% of the file) — and split it into the gameplay/ module structure described in The Scoring System and The Gameplay Clock today. The Song Editor’s own snap.rs (see The Song Editor) was split out of state.rs for exactly this reason, as recently as the same session that built the feature living in it — this isn’t a one-time historical cleanup, it’s an ongoing discipline applied as code is written.

Rule 2: folders match modules, and dependencies point downward

A module’s physical location should reflect its level: low-level shared vocabulary at the bottom, features in the middle, app-wiring at the top — and nothing should import upward. System Overview’s package diagram shows the intended shape; this section covers what “pointing the wrong way” actually looked like before it was fixed, as a concrete illustration of the rule rather than an abstract statement of it.

AppState used to live inside menu. Conceptually, an app-wide state machine is vocabulary every feature shares, not a menu concern — but historically it lived in menu/mod.rs, so gameplay (seven files), song_editor, spectrogram, and profile all had to use crate::menu::... to reach it, even though ten of the eleven things they were actually importing from there (AppState, GameplayMode, SelectedSong, ReturnToSongList) had nothing to do with menus at all. Anyone asking “what depends on the menu?” got a misleading answer, and any review of menu code pulled in readers who only ever wanted the state enum. The fix was mechanical once diagnosed: this vocabulary now lives in app.rs at the crate’s top level (see Application States and Modes), which every feature — menu included — depends on downward, and nothing depends on upward.

gameplay::call_response used to import song_editor::playback directly for its synth — two peer features welded sideways, when the synth (audio_system::synth, see The Audio Input Pipeline) is shared audio infrastructure with no real business living inside an editor tool. Moving the synth down to audio_system — vocabulary both gameplay and song_editor can depend on independently — removed the sideways edge entirely, rather than leaving one feature depending on the other’s internals.

The documented exception: composition roots

One place in the codebase looks, at first glance, like it violates “dependencies point downward” — and is worth naming explicitly as a deliberate, understood exception rather than either hiding it or mistaking it for a bug to fix:

gameplay::plugin — the one file responsible for assembling the entire AppState::Playing system schedule, across all three GameplayMode values — imports from jam to register Jam-Session-specific systems into that shared schedule. Read naively, that’s gameplay depending on jam, while Jam Session also describes jam’s own feature code depending on gameplay’s core primitives (GameplayClock, MusicPlayer) — which would be a real circular dependency, and a real problem, if both directions were the same kind of dependency. They aren’t: gameplay::plugin is acting as a composition root — the one place in the codebase whose entire job is wiring separately-developed pieces together — and a composition root being coupled to everything it composes is not the same failure mode as two peer features being coupled to each other’s internals. The rule this exception doesn’t violate: jam’s own feature logic never reaches into gameplay’s feature logic (2D/3D rendering, scoring) — only into the shared low-level vocabulary gameplay::state exists specifically to expose, the same primitives any other feature is free to depend on too.

The practical test for “is this a legitimate composition-root edge, or an actual layering inversion sneaking in”: does the dependency go from assembly/wiring code down into a feature’s own systems/resources (fine — that’s what a composition root does), or does it go from one feature’s own business logic sideways into another feature’s own business logic (the call_response/song_editor::playback case above — not fine, and the kind of thing worth flagging in review the same way the historical AppState-in-menu case would be today).

What isn’t enforced mechanically

The file-size budget is the one rule with a real, running test behind it. Dependency direction itself has no equivalent automated check today — a Cargo workspace with real crate boundaries would get one for free (an illegal use simply wouldn’t compile), which is the main thing a future workspace split, if the project ever grows to warrant one, would buy back over the current single-crate structure. Until then, this chapter — and a reviewer who’s read it — are the mechanism.