crate を workspace に分けるべきとき
分割の引き金はビルド時間と依存の境界で、ディレクトリの見た目ではありません。3 つの条件が揃ったときに分け、揃わなければ 1 回のビルドを複数パッケージの管理と引き換えにするだけです。
workspace を分けるかどうかは、見た目の判断であるべきではありません。ディレクトリが整ってもビルドが速くならず依存がきれいにならないなら、分割は管理コストだけを増やします。
3 つの条件
- 1 か所の変更で全部を再ビルド:CLI の引数解析だけを変えたのに、数百の依存を持つ中核ライブラリを含めて crate 全体をコンパイルし直す。差分ビルドが効かないのは分割の最初の現実的な理由です。
- 依存の集合が大きく違う:片方は tokio のフル runtime、HTTP クライアント、TLS を必要とし、もう片方は純粋な関数のパーサです。同じ crate にあると、後者の利用者が前者の依存を全部抱えます。
- 安定した部分集合を公開する必要がある:中核ライブラリは外部利用者向けのライブラリ、CLI は内部ツール。同じ crate だと、便利さから内部実装が
pubに流れます。
3 つとも成り立たないなら分けません。数十ファイルの crate のビルドは秒単位です。4 パッケージに分けると cargo test は 4 つを回り、バージョンは一緒に上げ、モジュールパスは crate::foo から dep::foo になります。
分け方
ルートの Cargo.toml にはメンバーと共通バージョンだけを置きます。
[workspace]
members = ["crates/core", "crates/protocol", "crates/cli"]
resolver = "2"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
メンバー側は serde.workspace = true と書き、内部依存は path を使います。依存の更新が 1 か所で済むのは、分けて確実に良くなる唯一の点です。
分けたあとに初めて出る 4 つの問題
- 循環依存に美しい解はない:A が B の型を、B が A の型を必要とするときは、共通型のための crate C をもう 1 つ作るしかなく、依存グラフが菱形になります。これが 2 回以上出るなら分割線の引き方が間違っています。
pubが制御できなくなる:crate をまたぐとすべてをpubにする必要があり、pub(crate)の境界が消えます。内部型が公開 API に漏れるのは時間の問題で、#[doc(hidden)]と意識的なモジュール構成でしか抑えられません。- 内部 crate は公開しない:
publish = falseの 1 行でバージョン同期の手間がかなり減ります。公開が要るのは外部利用者向けの crate だけです。 - feature が組み合わせで爆発する:A の optional 依存が B の feature になり、B の feature を C へ転送することになります。転送はできるだけ避け、最上位で明示的に依存させます。
判断基準
使っている基準は地味です。「分けなかったらどうなるか」を 1 文で書けるなら分ける、書けないなら分けない。CLI を変えるたびに 40 秒待つ、は理由になります。分けた方がきれい、は理由になりません。
まずファイルをモジュールに分ける。ビルド時間が保存の頻度を変え始めてから crate を分ける。

コメント
…