Skip to content

Modules, Crates, Features, and Workspaces

Modules control names and visibility inside a crate. Crates are compilation and API boundaries. Workspaces coordinate related packages. Features select conditional code and dependencies.

Using all four deliberately keeps a Rust AI product from becoming one crate where HTTP handlers construct prompts, provider adapters parse business policy, and tests require a network.

After this chapter, you will be able to:

  • organize a crate with modules and minimal public visibility;
  • distinguish packages, crates, targets, and workspaces;
  • design an inward-pointing dependency graph;
  • choose a crate boundary from change, ownership, and test needs rather than file count;
  • manage workspace dependencies, lints, editions, and lockfiles centrally;
  • design additive Cargo features without invalid combinations;
  • prevent provider, framework, and transport types from entering the domain layer;
  • inspect the companion workspace with Cargo metadata/tree commands;
  • recognize cyclic and “shared junk drawer” architecture failures.

This chapter depends on traits, generic capability boundaries, visibility, and the six-crate Forge vertical slice. It is the assembly foundation for production services, local inference adapters, multimodal pipelines, and Mosaic.

The current companion:

forge-cli
├── mosaic-domain
├── mosaic-evals ─────▶ mosaic-domain
├── mosaic-harness ───▶ mosaic-domain
├── mosaic-storage ───▶ mosaic-domain + mosaic-harness
└── mosaic-tools

The domain crate has no CLI, HTTP, database, provider, or runtime dependency. Dependencies point toward stable policy vocabulary.

These terms are related but distinct:

  • a package is a Cargo.toml unit that can contain targets;
  • a crate is one compilation unit, either a library or binary;
  • a target is a library, binary, example, test, or benchmark built from the package;
  • a workspace coordinates multiple packages under shared resolution, lockfile, output directory, and settings.

For example, forge-cli is a package with:

  • a library target (src/lib.rs) containing command implementation and testable functions;
  • a binary target (src/main.rs) containing the process entry point.

mosaic-domain has one library target plus several runnable example targets.

One package can emit several binaries. Splitting packages is not required merely to add a command.

Inside a crate:

mod contract;
mod error;
mod run;
pub use contract::{OutputContract, Violation};
pub use error::DomainError;
pub use run::{RunBudget, RunId, Task};

The module tree controls paths and privacy. Keep fields and helpers private unless callers need them. Use:

  • pub for the stable crate API;
  • pub(crate) for cross-module implementation details;
  • pub(super) for one parent layer;
  • private by default for invariants.

Do not mark an entire module tree public to quiet compiler errors. Every pub item becomes a compatibility and security surface.

Callers should import domain concepts from an intentional public facade:

use mosaic_domain::{RunId, Task, Violation};

They should not depend on internal paths that reflect current file organization. Re-exports let the crate reorganize modules without forcing downstream changes.

Avoid re-exporting every dependency. If mosaic-domain exposes provider SDK types, the provider has already crossed the boundary.

Choose crate boundaries from reasons to change

Section titled “Choose crate boundaries from reasons to change”

A crate boundary is useful when it provides one or more of:

  • a stable domain/API vocabulary;
  • dependency isolation;
  • separate compilation/features;
  • independent reuse by CLI and API;
  • clear ownership and review;
  • a testable external capability;
  • security/unsafe containment;
  • platform-specific implementation.

The companion crates:

CrateResponsibilityMust not own
mosaic-domainIDs, tasks, budgets, contracts, trace vocabularynetwork, filesystem, SQL, CLI
mosaic-harnessbounded generate/verify/repair state machineprovider SDKs, HTTP, persistence format
mosaic-toolsroot-confined source capabilitymodel routing, product policy
mosaic-storageJSONL trace persistence/replay inputrun decision policy
mosaic-evalscases, deterministic expectations, reportsCLI rendering, live provider credentials
forge-clicommand parsing and process I/Oreusable domain/harness logic

Do not split one crate per struct. Cross-crate APIs slow refactoring and add compilation/feature complexity. Start cohesive, then split at a proven boundary.

Stable policy should not depend on volatile mechanisms:

CLI / HTTP / workers
application orchestration
domain + capability traits
provider / storage / tool adapters

Both transports and adapters depend on domain interfaces. The domain does not depend on Axum, SQLx, Tokio, a vendor SDK, or a local model runtime.

Rust rejects cyclic crate dependencies. That is useful architectural pressure. When two crates seem to need each other:

  1. locate the shared stable concept;
  2. move it into the lower-level owner, not a generic junk crate automatically;
  3. invert mechanism through a trait if product policy calls infrastructure;
  4. merge the crates if they are actually one cohesive component;
  5. avoid callback/type gymnastics that merely conceal a cycle.

Domain, transport, provider, and persistence models

Section titled “Domain, transport, provider, and persistence models”

These types change for different reasons:

HTTP DTO ──validate/map──▶ Domain Task
Provider DTO ──normalize──▶ ModelResponse
Database Row ──hydrate──▶ Stored Run
Domain result ──map/redact──▶ HTTP Response

Separate edge types prevent:

  • provider field names from becoming domain semantics;
  • database nullability from creating invalid domain states;
  • public API versioning from freezing internal structs;
  • secret/operator fields from accidental serialization.

Mapping code is not “boilerplate to eliminate.” It is where trust, validation, defaults, and version compatibility become explicit.

The root rust/rust-ai-engineering/Cargo.toml centralizes:

[workspace]
resolver = "2"
members = [
"crates/mosaic-domain",
"crates/mosaic-harness",
"crates/mosaic-tools",
"crates/mosaic-storage",
"crates/mosaic-evals",
"crates/forge-cli",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.96"
[workspace.lints.rust]
unsafe_code = "forbid"

Member packages inherit common metadata and dependencies with .workspace = true. This reduces version drift. It does not force each member to depend on every shared dependency.

The workspace explicitly uses resolver 2. Feature resolution behavior affects build/dev/target dependencies. Record the resolver rather than inheriting assumptions from edition/toolchain changes.

This application workspace commits Cargo.lock so CI, examples, and release builds resolve the same versions. A published library may use different lockfile expectations for downstream consumers, but the companion is an executable suite.

The lockfile does not eliminate supply-chain risk or guarantee identical native/system libraries. CI also pins the Rust toolchain and audits dependencies.

Workspace dependencies are not automatic dependencies

Section titled “Workspace dependencies are not automatic dependencies”

Root:

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
thiserror = "2"

Member:

[dependencies]
serde.workspace = true
thiserror.workspace = true

This centralizes the version requirement. The member still declares the dependency because it is part of that crate’s API/build graph.

Avoid broad feature unions at the workspace level. Cargo features for one package are additive; a dependency may receive the union required by participating packages. Test the actual combinations you support.

Cargo features model optional compile-time capability

Section titled “Cargo features model optional compile-time capability”

Example future provider crate:

[features]
default = []
cloud-openai = ["dep:reqwest", "dep:provider-sdk"]
local-candle = ["dep:candle-core"]

Good features are:

  • additive;
  • named by capability rather than environment;
  • compatible when enabled together, or explicitly checked;
  • not used for secrets or runtime tenant policy;
  • tested in supported combinations.

Bad feature designs:

  • production versus development;
  • mutually exclusive flags with no compile-time error;
  • a default feature that silently enables network behavior;
  • features that change the meaning of the same serialized domain type;
  • one feature per provider model name.

Runtime model choice belongs in validated configuration/registry metadata, not recompilation.

If two implementation features truly cannot coexist:

#[cfg(all(feature = "backend-a", feature = "backend-b"))]
compile_error!("backend-a and backend-b cannot be enabled together");

But first ask whether separate adapter types could coexist cleanly. Additive features compose better across dependency graphs.

An optional dependency still matters when enabled:

  • transitive code size;
  • build scripts;
  • native libraries;
  • licenses;
  • advisories;
  • platform support;
  • unsafe code;
  • network protocols.

Keep local inference/media integrations in dedicated crates so the CLI’s minimal feature set does not automatically compile every accelerator and codec backend.

Run minimal and maximal feature gates in CI:

Terminal window
cargo test --workspace --no-default-features
cargo test --workspace --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings

As real optional features are added, test meaningful pairwise/platform combinations rather than only the maximal union.

src/main.rs should generally:

  1. parse process configuration/arguments;
  2. initialize telemetry;
  3. assemble concrete adapters;
  4. call a library/application function;
  5. map the final error to exit status.

Putting all behavior in main.rs makes integration tests and reuse from HTTP/workers harder.

Forge’s library owns subcommand behavior; its binary is the terminal boundary. Stdout contains machine/user results and stderr contains diagnostics.

Newtypes only protect invariants when fields are not broadly writable:

pub struct ModelId(String);
impl ModelId {
pub fn new(value: impl Into<String>) -> Result<Self, DomainError>;
pub fn as_str(&self) -> &str;
}

Do not add pub fields or unchecked constructors because an adapter finds mapping inconvenient. Put a validated conversion at the adapter edge.

pub(crate) is useful for construction internals that must not become downstream API. Integration tests outside the crate see only the public surface, which is valuable evidence.

The architecture is healthy if you can:

  1. run harness tests without network or filesystem;
  2. replace ScriptedModel with a provider adapter without changing Harness::run;
  3. use domain tasks from CLI and future HTTP crates;
  4. replay JSONL without live inference;
  5. run evals against the same task/harness types;
  6. build mosaic-domain with its small dependency set;
  7. prevent domain imports of adapter/framework crates.

Automate dependency-direction rules. Options include:

  • a script over cargo metadata;
  • cargo-deny bans;
  • workspace policy tools;
  • code review ownership;
  • negative dependency tests in CI.

Do not rely on a diagram that CI never checks.

This chapter’s runnable artifact is the workspace itself rather than a standalone toy example:

Terminal window
cd rust/rust-ai-engineering
cargo metadata --no-deps --format-version 1
cargo tree --workspace
cargo test --workspace --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo run -q -p forge-cli --bin forge -- \
eval --suite fixtures/forge/eval-suite.json

The final command must report all four Forge cases passing.

Inspect only package names from metadata:

Terminal window
cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[].name' \
| sort

The core commands do not require jq; it is only a display helper. Cargo JSON is the authoritative machine interface.

Failure investigation: domain crate imports a provider SDK

Section titled “Failure investigation: domain crate imports a provider SDK”

Symptom: adding a second provider forces changes throughout domain tasks and tests.

Root cause: provider request/response types escaped their adapter.

Investigation:

  1. run cargo tree -p mosaic-domain;
  2. search domain public signatures for provider modules/types;
  3. identify which fields express real product meaning;
  4. define provider-neutral domain types;
  5. map with TryFrom inside the adapter crate;
  6. add a dependency-direction CI rule.

Fix: dependency inversion plus explicit mapping. Do not move vendor types to a crate named common.

Failure investigation: feature combinations fail only in release

Section titled “Failure investigation: feature combinations fail only in release”

Symptom: default tests pass, but enabling local inference and cloud adapters together creates duplicate symbols or conflicting configuration.

Actions:

  1. enumerate supported feature combinations;
  2. run cargo hack or an equivalent feature matrix in CI;
  3. make features additive where possible;
  4. isolate native backends by crate;
  5. add compile_error! for truly invalid pairs;
  6. record platform/toolchain requirements.

Default-only CI is not evidence that the feature design composes.

It becomes a dependency magnet and often creates cycles. Give shared concepts a clear owner.

Boundaries have costs. Split on cohesive responsibility and dependency isolation.

Provider, HTTP, SQL, and policy changes become coupled; tests require the world.

This makes downstream code depend on volatile external types.

Use runtime configuration for deployment environment; use features for compile-time capabilities.

They bypass validation and freeze representation.

Name modules after domain concepts or behaviors. Move one-off helpers near their caller.

Security:

  • isolate unsafe/native code in small reviewed crates;
  • keep credentials and network dependencies out of domain packages;
  • minimize default features;
  • audit optional dependency trees and build scripts;
  • enforce visibility around authority-bearing types;
  • pin toolchain/lockfile and verify sources in CI.

Performance:

  • more crates can improve parallel/incremental rebuilds but excessive generic public code can monomorphize downstream;
  • feature selection can reduce binary size and native initialization;
  • crate boundaries can inhibit some cross-crate optimization unless LTO/codegen settings compensate;
  • dynamic provider boundaries are irrelevant beside inference latency but hot media kernels may need careful placement.

Architecture should optimize correctness and change first, then measured build/runtime bottlenecks.

  1. What is the difference between a package and a crate?
  2. Why are workspace dependencies not automatically used by every member?
  3. What makes a good Cargo feature additive?
  4. How does visibility protect a newtype invariant?
  1. Split one large module into private submodules and a public facade.
  2. Add a workspace lint inherited by every crate.
  3. Write a metadata check that fails if mosaic-domain depends on a forbidden crate.
  4. Add a provider feature with default = [] and test minimal/maximal builds.
  5. Move a provider-shaped DTO into an adapter and add a TryFrom mapping.

Place these future components into crates:

  • Axum HTTP API;
  • SQLx run repository;
  • Candle local text model;
  • FFmpeg media decoder;
  • cloud provider adapters;
  • object store;
  • routing policy;
  • shared domain model;
  • final Mosaic binary.

Draw dependency arrows and justify every outward dependency. Identify where unsafe code, secrets, and native libraries live.

Create tests/checks for:

  • forbidden domain dependency;
  • default/minimal feature build;
  • all-feature build;
  • invalid feature pair;
  • provider unknown stop reason;
  • accidental public secret field;
  • thin binary invoking library behavior.

The metadata check can parse cargo metadata --no-deps/full resolve JSON and walk dependency edges from mosaic-domain. Keep the forbidden list and rationale version controlled.

Provider features belong in adapter crates. Runtime model IDs remain configuration. A feature should not change Task serialization semantics.

The FFmpeg/native decoder deserves a narrow crate with the unsafe/FFI boundary and owned safe output types. Domain evidence should not depend on FFmpeg structs.

The final binary assembles concrete implementations. The application/harness crates own product policy; adapter crates point inward through traits and domain types.

  • Can one package contain a library and multiple binaries?
  • Does adding a dependency to [workspace.dependencies] compile it into every crate?
  • Why does Rust’s rejection of crate cycles help architecture?
  • When should two modules become two crates?
  • Why is provider DTO mapping valuable even when fields look identical?
  • Should “production” be a Cargo feature?
  • What should own a shared concept that currently causes a cycle?
  • How can CI prove dependency direction?
  • I can explain package, crate, target, module, and workspace.
  • I keep public APIs smaller than internal implementation.
  • My dependency graph points toward domain/policy.
  • Provider, transport, and persistence DTOs map at edges.
  • Features are additive compile-time capabilities.
  • Minimal and maximal supported builds are tested.
  • Lockfile, toolchain, and workspace lints are explicit.
  • I inspected the real workspace and ran Forge’s four-case eval.