Tech Wiki

TOPICSSERIES

[Rust Zero to Production 16] Organize Rust Modules, Crates, and Cargo Workspaces

Rust gives a project more than one structural boundary. Adding a mod and splitting a Cargo package have different costs and effects. Confusing them can turn a file cleanup into a pile of manifests and dependencies, or leave unrelated responsibilities tangled inside one crate.

The Rust 2024 example in this article separates domain rules, an application use case, and a CLI adapter into three packages. Cargo does not prescribe this architecture. It is a deliberate use of crate dependencies to make outer code depend inward.

1. Packages, Crates, Modules, and Workspaces

Start with the four terms.

  • A **crate** is a Rust compilation unit. It produces a library or an executable binary.
  • A **package** is the set of crates described by one Cargo.toml. It contains at least one crate, no more than one library crate, and any number of binary crates.
  • A **module** organizes paths, scope, and visibility inside one crate. Moving code to another module or file does not create another Cargo package.
  • A **workspace** is a set of packages managed together. Its members share a Cargo.lock and output directory, and Cargo can run commands across them from the workspace root.

The example's endpoint-cli package contains both src/lib.rs and src/main.rs. Cargo therefore builds two crates, one library and one binary, from a single package. The binary imports the library as endpoint_cli, with the package name's hyphen converted to an underscore. Treating package and crate as synonyms hides this useful arrangement.

It does not depend on another example or the shared endpoint-monitor, and it has no external dependencies.

2. Virtual Workspaces and Inheritance

The root Cargo.toml has no [package] table. This is a virtual manifest: the root is not another buildable package, only the place where members and shared settings are declared.

[workspace]
members = [
    "crates/domain",
    "crates/application",
    "crates/adapter-cli",
]
resolver = "3"

[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT"
publish = false

[workspace.lints.rust]
unsafe_code = "forbid"

[workspace.lints.clippy]
all = "warn"
pedantic = "warn"

The explicit resolver = "3" selects the dependency resolver associated with Rust 2024. [workspace.package] and [workspace.lints] define shared values, but members do not inherit them automatically. Each package opts in with keys such as edition.workspace = true and [lints] workspace = true.

Here is the application package manifest:

[package]
name = "endpoint-application"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true

[dependencies]
endpoint-domain = { path = "../domain" }

[lints]
workspace = true

A workspace does not merge its members into one package. The shown cargo metadata --format-version 1 --no-deps result contains three separate packages, and the endpoint-cli package reports two targets: a library and a binary. The shared lockfile and common commands simplify project operations while crate boundaries remain intact.

3. Module APIs

The domain crate root is only two lines:

mod monitor;

pub use monitor::{Endpoint, EndpointError};

mod monitor; declares the module without exposing that module path to consumers. Rust items are private by default. The crate root re-exports only the required types, so callers use endpoint_domain::Endpoint without coupling themselves to the internal file layout.

Nor is pub a simple promise that an item is globally reachable. The path to an item must satisfy Rust's visibility rules. When a narrower scope is appropriate, pub(crate) exposes an item only within the current crate, while pub(super) limits it to the parent module. Starting private and opening the smallest scope needed makes the boundary easier to audit.

A module does not need a one-to-one relationship with a file. The module tree designs the API and namespace; the file tree arranges source text. They often resemble each other, but they are not the same mechanism.

4. Inward Dependencies

The example has one compile-time dependency direction:

endpoint-cli (adapter + binary)
    -> endpoint-application (use case + port)
        -> endpoint-domain (business rules)

endpoint-domain owns endpoint name and URL rules. It knows nothing about I/O or storage. endpoint-application coordinates registration and defines the storage port. endpoint-cli implements an in-memory repository and handles input and output, while main.rs wires the implementations together.

The application's central boundary is a trait plus a use case:

pub trait MonitorRegistry {
    fn insert(&mut self, endpoint: Endpoint) -> u64;
}

pub struct RegisterMonitor;

impl RegisterMonitor {
    pub fn execute(
        registry: &mut impl MonitorRegistry,
        request: RegisterRequest,
    ) -> Result<RegisterResult, RegisterError> {
        let endpoint = Endpoint::new(request.name, request.url)?;
        let result = RegisterResult {
            id: 0,
            name: endpoint.name().to_owned(),
            url: endpoint.url().to_owned(),
        };
        let id = registry.insert(endpoint);

        Ok(RegisterResult { id, ..result })
    }
}

Because the port belongs to the application crate, the use case never imports a concrete repository. The adapter implements the inward-facing trait. Application tests implement the same port with a fake repository, so they can exercise the use case without a file or network resource.

The adapter package also separates its library and binary responsibilities. src/lib.rs exposes the reusable, testable InMemoryRegistry. src/main.rs is limited to process startup, composition, and output.

use endpoint_application::{RegisterMonitor, RegisterRequest};
use endpoint_cli::InMemoryRegistry;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut registry = InMemoryRegistry::default();
    let registered = RegisterMonitor::execute(
        &mut registry,
        RegisterRequest {
            name: "api".into(),
            url: "https://example.com/health".into(),
        },
    )?;

    println!(
        "registered monitor #{}: {} -> {}",
        registered.id, registered.name, registered.url
    );
    println!("registry size: {}", registry.len());
    Ok(())
}

Not every executable needs this lib/bin split. If the composition code is a few lines and no adapter is reusable, one main.rs is enough. The example uses both targets to demonstrate that a package can contain multiple crates and to keep the executable entry point thin.

5. Modules vs. Crates

Modules should be the default. If code shares a release cycle and feature policy, and its internal types collaborate closely, another crate may add little useful isolation. Private modules and restricted visibility can enforce a substantial boundary without another manifest.

A crate split becomes worth considering when at least one concrete benefit appears:

  • You want Cargo's dependency graph to enforce one-way dependencies.
  • Domain code must be reused by several entry points, such as a CLI, server, and batch job.
  • Layers need different dependency or lint policies.
  • A separate test and build unit improves the actual development workflow.

Do not split while the code is small and the boundary moves daily, or when both sides constantly need each other's internal types. Every crate adds a Cargo.toml, a public API, cross-crate type paths, and dependency maintenance. That is too much machinery merely to make the directory tree look tidy. Find responsibilities with modules first; promote a stable dependency boundary to a package later.

A workspace is not mandatory just because a repository has multiple packages either. It earns its place when those packages should build together and share a lockfile and policy. Projects with genuinely separate versioning and release processes do not need to be forced into one workspace or repository.

6. Workspace Check

Use the following command sequence:

cd examples/article-16-cargo-workspace
cargo metadata --format-version 1 --no-deps
cargo fmt --check
cargo check --workspace --all-targets --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo run --quiet -p endpoint-cli

Every command should exit with status 0 on stable rustc 1.98.1, Cargo 1.98.1, and Rust 2024. The workspace test run contains three tests, one each in the domain, application, and adapter libraries. The binary and documentation test targets contain no tests.

The program output is:

registered monitor #1: api -> https://example.com/health
registry size: 1

These packages are not decorative folders. The domain knows nothing about outer layers, the application knows nothing about the storage implementation, and only the adapter assembles the inner crates. Starting this small example as three modules would also be reasonable. The reason to split is the dependency rule you need to preserve, not a line-count threshold.

Full source code

The complete runnable source for this article is available in the Chapter 16 project on GitHub.

Sources


One response

Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.