Every Rust tutorial teaches modules. Almost none teach the decision that actually matters in a grown codebase: when does a piece of code need to be a crate (its own manifest, its own dependency list, its own compile boundary) instead of another mod file? The wrong default in both directions is common. Tiny workspaces fragment into crates that share every dependency and differ only by name. Big applications grow a src/ with forty modules where a net module can import a db module on a whim, and the architecture is a set of rules nobody checks.
Valhalla’s workspace offers a worked answer, because its twenty-five crates were split along deliberately drawn seams, and because its own history shows where the line actually runs.
the question tutorials skip
A module and a crate differ in exactly one thing that matters: a crate has its own dependency graph. Everything else (namespaces, visibility, test placement) a module can do. So the real question is never “is this code big enough for its own crate?” It is “what should this code be unable to see?”
Inside vhalla-session, the invitation format and the spent-nonce ledger are modules (invitation.rs, spent_nonce.rs). They share the crate’s no_std world, its Ed25519 dependency, and its failure conventions. They are one trust domain: code that can verify an invitation is the same code that must check the nonce set. Splitting them into crates would buy nothing: every dependency either one needs, the other legitimately needs too.
Now look at vhalla-social versus vhalla-social-store. The first is no_std + alloc: signed records, causal views, quota accounting, all pure. The second is cfg(unix): exclusive file locks, atomic rename, directory fsync. A caller that wants to verify a social record (say, a WASM replica in a browser) must not be forced to also compile a Unix filesystem adapter, and the pure crate must never grow a std::fs call “for caching.” That is a crate boundary: a different dependency set, a different platform contract, a different failure domain.
five reasons that justify a crate
Reading the workspace, a pattern emerges. A module graduates to a crate when at least one of these is true:
- It needs a different dependency closure.
vhalla-corecompiles with#![no_std]: no filesystem, no sockets, no clock, no entropy. That guarantee is only real because the crate’s manifest cannot reachstdat all. Amod coreinside astdcrate would beno_std-compatible by convention and breakable by any future edit. - It needs a different platform gate.
vhalla-rooms-nodesplitscontext/cert(portable, so a WASM consumer can verify certificates) fromcodec/signing/unix(engine wiring behind#[cfg(unix)]). The rooms app’s replica machinery is unix-only; its projection types are platform-neutral. The gate lives at the crate or module level depending on how much is shared, but the decision is about who may compile this code. - It needs to forbid a whole class of import.
vhalla-transportowns anEndpointtrait that carriesFrame, an opaque bounded byte vector it cannot parse. There is no decoding dependency in the crate at all, so “the transport peeked at the payload” is not a bug to catch in review; it cannot be written. - It owns a separate artifact or entry point.
vhalla-cliis thevhallabinary.vhalla-steel-threadis the demonstration binary and end-to-end test surface. Binaries are crates by construction, but note that they sit at the top of the dependency order and are allowed to see everything below them. - It protects an invariant with privacy.
vhalla-policy’sAuthorizedEffecthas private fields, noClone, and no constructor outside the crate. A module can do this too, but only a crate boundary guarantees that no other module in a shared crate can reach in. When the value is a move-only capability, you want the wall to be load-bearing, not advisory.
the boundary in action
The sharpest example is vhalla-identity. It is a Unix crate: it opens directories with mode 0o700, writes a 72-byte record (VHID0001 magic, a 32-byte seed, a SHA-256 digest) at mode 0o600, holds an exclusive lock, and publishes the identity file by hard-linking a fully synced temporary over the final name. Inside it, the Identity type has no seed getter, no Clone, no Debug, and no serializer. You can ask it to sign_envelope or issue_invitation, and the private key never leaves.
Could that be a module inside vhalla-cli? Structurally yes. But the crate boundary is what lets the codebase say: only this manifest may touch the custody files. The CLI can call identity.initiate_session(...); it cannot read identity.key, because key is a private field of a type in a different crate, and there is no feature flag that changes that. The wall is the point.
The same reasoning produced the desktop/ directory, which is not a crate in the workspace at all. It is a separate workspace. The menu-bar companion needs Tauri, and Tauri’s build dependencies must never enter the protocol crates’ Linux or WASM compile checks. When a dependency would contaminate the graph, even members = [...] is too close. The boundary moved up one more level.
when a module is right
None of this means more crates is better. vhalla-rooms-consensus is 1,250 lines in one lib.rs with a fixture module behind a feature gate: the fixture shares every dependency and exists only for tests and engine-level integration tests, so a feature is the right boundary, not a crate. vhalla-discovery splits query, snapshot, and state into modules inside one no_std crate; they share the same bounds (MAX_DOCUMENTS, POLICY_VERSION) and the same closed Error enum, and there is no caller who could want one without the others.
There is also a subtler failure the split avoids: feature unification. The design plan calls it out by name: a crate that exports both a presentation API and, behind a feature, native stores is “a weak compiler boundary,” because Cargo’s feature unification means any dependent that enables the feature turns it on for every dependent in the build. A private field is not private when a feature flag re-exposes the module it lives in. When the thing you are protecting is “this code may not exist in that build,” a feature gate is not enough. The code has to be in a crate that is not in the dependency graph at all.
The test is always the same: imagine the import you want to make impossible. If it is impossible by manifest (the dependency is not declared), you need a crate. If it is impossible by visibility (the field is private, the constructor is sealed), a module is enough. If it is impossible only by convention (“we don’t do that”), you have neither, and the convention will erode.
a decision procedure
Ask these in order:
- Does it need a dependency the rest of the code must not have? Crate. (
no_stdcores, the unix stores, the Tauri workspace.) - Does a caller exist who needs this code but must not get the rest? Crate. (A WASM certificate verifier needs
cert, notunix.) - Does it hold secrets or capabilities whose constructors must be sealed? Crate if the enclosing crate is large or multi-author; module if the enclosing crate is already a sealed unit.
- Is it a different artifact (binary, test surface, prototype)? Crate, or excluded directory.
- Otherwise? Module. Do not pay for a manifest to express what
pub(crate)already does.
The mistake is treating the split as taxonomy, grouping code by what it is. The workspace groups by what it may touch. vhalla-wire and vhalla-crypto are both “parsing and verification,” but one authenticates structure and the other authenticates signatures, and the second must never be reachable from the first. Platonik makes the same point from the other direction: its entire workspace is two crates, platonik-core (the engine, pure) and platonik-cli (the shell), because a deterministic simulation has one trust boundary, not five. The count is not the discipline; the forbidden imports are. Name them, and the boundary follows.
sources
- vhalla:
Cargo.tomlworkspace members,desktop/as a separate workspace crates/vhalla-rooms-node/src/lib.rs: the portable/cfg(unix)split for certificate consumerscrates/vhalla-identity/src/unix.rs: key custody behind a sealedIdentitytypecrates/vhalla-transport/src/lib.rs: theEndpointtrait that cannot parse its framescrates/vhalla-social/src/lib.rsandcrates/vhalla-social-store/src/lib.rs: the pure/persistent pair- valhalla-security-first-design.md: the dependency-direction contract and the feature-unification warning
- platonik: the two-crate contrast case (
crates/platonik-core,crates/platonik-cli)