When a codebase grows past the point where one binary crate and a src/ directory can hold it, the reflex most of us were trained with is to split along library lines and give every crate its own repository, its own version number, its own release process, and its own CI pipeline, and the pitch always sounds tidy until the first time one logical change has to land as four pull requests in four repos merged in dependency order while CI matrices multiply in the background. I spent enough of the 2000s doing that dance with CPAN-style per-module distributions and PEAR packages to have a standing allergy to it, so when Cargo offered the opposite bet, one repository per application with every crate that application needs living inside it, both Grexa and Arte-Ogre took it, and the interesting part is not the bet itself but the specific mechanics that make it stay pleasant a year and many thousands of lines later.
The layout is the boring part, on purpose
Grexa, the Qt 6 / Kirigami file-content search workbench, is a six-member workspace, one GUI application under apps/ and five library and binary crates under crates/, and Arte-Ogre, the GPU-accelerated image editor, is seven crates under crates/, so together the two workspaces hold a little over ninety thousand lines of Rust without either repo feeling like it needs a map. The root Cargo.toml in each is the entire coordination story:
[workspace]
members = [
"apps/grexa-gui",
"crates/grexa-ai",
"crates/grexa-cli",
"crates/grexa-containers",
"crates/grexa-core",
"crates/grexa-i18n",
]
resolver = "3"
[workspace.package]
version = "1.11.2"
edition = "2024"
license = "GPL-3.0-only"
repository = "https://github.com/visorcraft/grexa"
[workspace.dependencies]
anyhow = "1.0"
clap = { version = "4.6", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Nothing in that file is clever, and that is the point, because the workspace root is the one place where the questions that used to require a wiki page get answered: which crates exist, what version the application is at, what edition and license everything shares, and which versions of the shared dependencies the whole tree is pinned to.
One version, one license, one pin
The first decision that pays for itself is inheriting package metadata from the workspace root instead of repeating it, so every member crate carries lines like version.workspace = true, edition.workspace = true, and license.workspace = true in its own Cargo.toml, which means a release is one edit in one file rather than a find-and-replace across six manifests that someone will get half wrong, and the license can never quietly drift between the GUI and the core because there is literally only one place it is written. The same trick applied to [workspace.dependencies] kills the second classic multi-crate disease, the one where every member pins its own version of a shared dependency and Cargo’s semver unification quietly papers over the compatible drift until a major bump splits the graph into two copies of the same crate and a type crossing a crate boundary stops compiling; here the shared crates are declared once at the root and each member opts in with clap.workspace = true, so version drift between members is not discouraged by policy, it is structurally impossible without editing the root, which is exactly the kind of impossible I want my tooling to provide.
Path dependencies are the architecture document
The second decision is that the layering between crates is expressed as path dependencies in the manifests themselves, not as a diagram in a markdown file that goes stale the week it is drawn. In Grexa the CLI crate depends on grexa-core and grexa-containers by path, the GUI shell depends on grexa-core, grexa-ai, grexa-containers, and grexa-i18n by path, and grexa-core depends on neither of them, so the dependency direction is the architecture: the engine knows nothing about presentation, the CLI and the GUI are siblings rather than wrapper and wrapped, and anyone who tries to import widget code from the core gets a compile error instead of a code-review conversation. Arte-Ogre pushes the same idea further, with the ogre binary crate depending only on ogre-ui and ogre-ui fanning out to ogre-core, ogre-gpu, ogre-io, ogre-vector, and ogre-plugins, which means the seven crates form a real layer cake you can read out of cargo tree whenever you suspect someone has sawed through a floor.
[dependencies]
eframe = { workspace = true, features = ["wgpu"] }
egui-wgpu = { workspace = true }
wgpu = { workspace = true }
ogre-ui = { path = "../ogre-ui" }
[features]
default = ["ml"]
ml = ["ogre-ui/ml"]
That manifest also shows the third mechanic, feature flags threaded through the layers, because Arte-Ogre’s AI matte refinement pulls an entire ONNX runtime, and shipping that to every user whether they want it or not would be rude, so the ml feature is on by default in the shipped binary, forwards down through ogre-ui/ml into ogre-io/ml, and a --no-default-features build drops the runtime entirely; the flag lives on the binary crate but the actual conditional compilation happens two hops down in ogre-io, and the workspace is what makes that kind of cross-crate feature plumbing legible rather than spooky.
The honest tradeoffs, and when to actually split
None of this is free, and the costs are worth naming because they are the ones that will bite you, not the imaginary ones: a workspace rebuild recompiles more than a narrow change strictly requires until you learn to scope cargo check -p, the single CI pipeline has to be designed so the expensive GUI jobs do not gate the fast core tests, the git log mixes engine commits with packaging commits, and cargo publish ordering gymnastics apply the day you ever want the crates on crates.io, which is why both of these repos mark everything publish = false and treat the workspace as an application boundary rather than a distribution mechanism. The split criterion we actually follow is visible in Grexa’s own GUI manifest, which depends on grexa-db not by path but by git tag, tag = "v1.10.0", because grexa-db is a standalone product with consumers outside Grexa and it earned its own repository the only way a crate should: something that is not this application needed to depend on it. Until that is true, a crate stays in the workspace where its refactors land atomically with the callers they break, and that is the whole rule.
The modern framing would call this a monorepo, but the honest comparison is older than the buzzword: it is the well-run single tree we used to keep in Subversion, one checkout, one revision number, one place where a change is either whole or absent, except that Cargo finally treats the workspace as a first-class unit instead of an accident of directory layout, and the crate boundaries give you the encapsulation the old single-tree projects had to enforce with convention and hope. One repo per application, one workspace per repo, one root manifest that answers the coordination questions, and the multi-repo choreography becomes a thing you read about in old blog posts rather than a thing you do on a Tuesday.
