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’s subsystems are separate crates in a Cargo workspace (see System Overview for the layering), which means the most important rule here needs no policing at all: a crate may depend only on ones below it, peers may not depend on each other, and Cargo cannot express a cycle — an import pointing the wrong way is a compile error, not a review comment.

That covers direction between crates. Two things it doesn’t cover get their own automated tests: how big a single file may grow, and cycles between modules inside one crate, which Rust still permits. This chapter states the rules, where each is 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

Code’s physical location reflects its level: low-level shared vocabulary at the bottom, features in the middle, wiring at the top — and nothing imports upward. System Overview’s layer diagram shows the shape. The crate split makes this the compiler’s problem, but the judgment call it encodes is still yours to make: which crate does this new thing belong in? Two shapes of wrong answer are worth recognizing, because both compiled fine when the whole game was one crate.

Shared vocabulary hiding inside a feature. An app-wide state machine is vocabulary every feature needs, not a menu concern — but AppState and friends once lived in menu, so gameplay, the editor, the spectrogram and the profile all reached into menu for something that had nothing to do with menus. Anyone asking “what depends on the menu?” got a misleading answer. That vocabulary is now harmonicon-app (see Application States and Modes), which every feature — harmonicon-menu included — sits above.

Two peers welded sideways. Call-and-response once imported the Song Editor’s playback module directly for its synth, when the synth is shared audio infrastructure with no business living inside an editor tool. It now lives in harmonicon-core, which both depend on independently. Today harmonicon-gameplay and harmonicon-editor are peers and that import wouldn’t build — but the diagnosis is what generalizes: when two features want the same thing, the thing goes down, not sideways.

The same reasoning drove the lessons split. The manifest schema, prerequisite graph and progress judgment are data and rules, so they live in harmonicon-song; the skill tree and reader are UI, so they live in harmonicon-lessons above the menu crate (see The Lessons Engine). Pushed together into one crate, the pure layout algorithm would have had an engine in its test dependency tree for no reason.

The composition root

Something has to depend on everything, or nothing would ever be wired together. That job belongs to exactly one place: src/lib.rs’s run(), in the root package, which sits above every library crate and adds each feature’s plugin to one App.

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 — so the rule is not “nothing may depend on everything”, it’s “only assembly code may, and assembly code may contain nothing else”. src/lib.rs is assembly only: plugin registration, the DefaultPlugins configuration, and the handful of startup systems that belong to no feature. The moment real logic lands there, the exception stops being one.

harmonicon-jam and harmonicon-editor both depend on harmonicon-gameplay for the shared primitives it exposes — GameplayClock, MusicPlayer, the AppState::Playing schedule — and neither can reach the other. When Jam Session needs an ordering guarantee against a gameplay system, it takes it through a published SystemSet, not a system name.

The practical test for “is this a legitimate composition-root edge, or a layering inversion sneaking in”: is the code doing the depending pure wiring (fine — that’s what a composition root does), or is it one feature’s own business logic reaching into another’s? The second case no longer compiles between crates, but it very much still compiles between modules of one crate, which is the shape to watch for now.

Rule 3: no module cycles inside a crate either

Cargo rules out a cycle between crates. It has nothing to say about one within a crate — Rust is perfectly happy for a.rs to name b while b.rs names a — so the workspace split, valuable as it is, buys nothing here. tests/physical_design.rs::no_module_dependency_ cycles closes that gap: it walks every .rs file under src/ and every crates/*/src/, builds the graph of crate::-qualified references between top-level modules, and fails on any cycle. Each edge remembers one witness line, so a failure names the use to go delete rather than just announcing that a cycle exists somewhere.

This one has no allowlist. The file-size budget is a burndown chart because a large file is debt to be paid down on a schedule; a module cycle is a design error with no “pay it later” story, and every one found while introducing the check was fixed rather than recorded.

Two ordering rules that follow from the split

Cross-crate ordering goes through a SystemSet, never a system name. .after(some_private_fn) forces the owning crate to make the system and every one of its parameter types public, which turns an implementation detail into permanent API for the sake of one ordering edge. dialogs::combobox::ComboboxEscapeSet and gameplay::plugin::MusicVolumeSet exist for exactly this: publish an ordering point, keep the implementation private.

A new crate must forward the dev/trace_tracy features to its own bevy dependency ("harmonicon-x/dev"). Miss it and Cargo’s feature unification breaks: the build ends up with two differently-configured Bevy builds, which fails in ways that look nothing like the cause.