Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Designing a Crate

The chapters so far have been about values, types and the relationships between them. This one is about the container they ship in.

A crate is the unit of compilation and the unit of distribution: one library or one binary, compiled together, published as one thing, versioned as one thing. Inside it, modules organize the code and — more importantly — decide what the outside world can see. Getting that boundary right is the part of Rust design that most resembles the architecture work you already do, and it is where a decision made casually on the first day becomes a breaking change on the hundredth.

Modules are the privacy boundary

Chapter 5 mentioned that private in Rust means private to the module rather than private to the type. That is the fact the module system turns on.

mod billing {
    pub struct Invoice {
        number: u64,
        total_cents: i64,
    }

    impl Invoice {
        pub fn new(number: u64, total_cents: i64) -> Invoice {
            Invoice { number, total_cents }
        }

        pub fn total_cents(&self) -> i64 {
            self.total_cents
        }
    }

    pub fn apply_discount(invoice: &mut Invoice, percent: i64) {
        invoice.total_cents -= invoice.total_cents * percent / 100;
    }

    fn audit_log(_invoice: &Invoice) {}
}

fn main() {
    let mut invoice = billing::Invoice::new(1, 10_000);
    billing::apply_discount(&mut invoice, 10);
    println!("{}", invoice.total_cents());
}

apply_discount is a free function, not a method, and it reaches straight into Invoice’s private field because it lives in the same module. From main, that field does not exist. audit_log is not visible outside billing at all.

This has a direct consequence for design. A module, not a type, is the unit whose invariants you reason about. A set of types that genuinely need to cooperate — a parser and its token type, a connection and its pool, a document and its nodes — belong in one module, where they can see each other’s internals and the outside world sees only the public surface. That is closer to how you would have wanted package-private to work than how it does.

Four visibility levels cover everything: private by default, pub(super) for the parent module, pub(crate) for everywhere in this crate but nowhere outside, and pub for the world. pub(crate) is the one people underuse. A type that several modules share but that no external user should depend on is pub(crate), and marking it so means you can change it freely forever.

Files map onto modules by name — src/billing.rs or src/billing/mod.rs is the billing module, src/lib.rs is the crate root — and mod billing; in the parent pulls it in. The layout follows from the module structure rather than the other way round.

The public surface is a promise

Anything pub in a library is something you have promised not to break. Rust’s tooling holds you to semantic versioning, and the changes that constitute a break are broader than they first appear.

Adding a field to a public struct breaks every caller who constructed it with a struct literal. Adding a variant to a public enum breaks every caller who matched on it exhaustively — which, given chapter 7, is all of them. Adding a method to a public trait breaks every implementor, unless the method has a default body. Tightening a bound, renaming anything, changing a parameter from &str to &Path: all breaking.

Two habits make this manageable.

The first is to expose less. Every public item is a commitment; a field that could be a method, a struct that could be opaque, a helper that could be pub(crate) all cost nothing to hide now and cost a major version to hide later. The default answer to “should this be public” is no.

The second is #[non_exhaustive], which tells the compiler that a type may grow:

#![allow(unused)]
fn main() {
#[non_exhaustive]
pub struct Settings {
    pub timeout_seconds: u32,
    pub retries: u8,
}

#[non_exhaustive]
pub enum Failure {
    Timeout,
    Refused,
}
}

An external crate can no longer build Settings with a struct literal — it must go through a constructor you provide — and must include a wildcard arm when matching Failure. Both restrictions are precisely what lets you add a field or a variant later without a major version. Put it on error enums and on configuration structs from the start, because adding it afterward is itself a breaking change.

Signatures that are kind to callers

The Rust API Guidelines are worth reading in full; a handful of their recommendations account for most of the difference between an API that feels native and one that fights its users.

Take the most general parameter you can use. A function that needs to read a path should take impl AsRef<Path>, which accepts &str, String, &Path and PathBuf without the caller converting anything. A function that will store a string should take impl Into<String>, which accepts both &str and String and copies only when it must.

use std::path::Path;

fn describe(path: impl AsRef<Path>) -> String {
    let path = path.as_ref();
    format!("{} ({})", path.display(), if path.is_absolute() { "absolute" } else { "relative" })
}

fn main() {
    println!("{}", describe("/etc/hosts"));
    println!("{}", describe(String::from("notes/todo.md")));
}

Return concrete types. Generality helps in parameters and hurts in return values, because a caller cannot get back what you erased. Return Vec<T> rather than impl Iterator when the caller may want to index or keep it; return your own struct rather than Box<dyn Trait> when the type is nameable.

Follow the naming conventions, because they carry cost information. as_ borrows and is cheap, to_ allocates or converts, into_ consumes. iter, iter_mut and into_iter mean what they mean everywhere else. A getter is name(), not get_name(). Constructors are new, then with_* and from_*.

Implement the standard traits. A public type should almost always derive Debug — a user cannot write a useful test or log line without it. Clone, Default, PartialEq where they make sense. Display for anything a user will print. From for the conversions that are natural and infallible, TryFrom for the ones that are not. Each one is small and each one removes a paper cut.

Keep Send and Sync. These are derived automatically, so a single Rc or RefCell in a private field silently makes your public type unusable across threads. That is a real breaking change that no signature shows, so if a type is meant to be used from threaded code, a test that asserts it is Send + Sync is cheap insurance.

The three kinds of test

Rust’s test story is unusual in that the three kinds correspond to three different audiences, and the third one is the reason the ecosystem’s documentation is as reliable as it is.

Unit tests live in the module they test, inside a #[cfg(test)] block, and can see private items:

pub fn normalize(raw: &str) -> String {
    raw.trim().to_lowercase()
}

fn is_blank(value: &str) -> bool {
    value.trim().is_empty()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalizes_case_and_space() {
        assert_eq!(normalize("  David  "), "david");
    }

    #[test]
    fn detects_blank() {
        assert!(is_blank("   "));
    }
}
fn main() {}

Keeping them beside the code rather than in a parallel tree is deliberate: the test is documentation of the unit, it moves when the unit moves, and it has access to internals so that a private invariant can be tested directly.

Integration tests live in tests/, each file a separate crate that links your library from outside. They can only use the public API, which makes them the real check on whether that API is usable. If an integration test needs something you have not exported, your users will need it too.

Documentation tests are the code blocks in your doc comments, and cargo test compiles and runs them:

/// Splits a delimited line into fields, honoring quoted sections.
///
/// ```
/// # fn split_fields(line: &str) -> Vec<String> {
/// #     line.split(',').map(|f| f.trim().to_string()).collect()
/// # }
/// let fields = split_fields("a, b, c");
/// assert_eq!(fields, vec!["a", "b", "c"]);
/// ```
pub fn split_fields(line: &str) -> Vec<String> {
    line.split(',').map(|field| field.trim().to_string()).collect()
}
fn main() {}

An example in the documentation cannot drift out of date, because a change that breaks it breaks the build. This is why Rust library documentation is generally trustworthy and why writing examples in doc comments rather than in a separate README is the convention. /// documents the item that follows; //! documents the module or crate it is inside, which is how a crate’s front page is written at the top of lib.rs.

Re-exports shape what users type

Internal structure and public structure do not have to match. pub use lifts an item to where you want users to find it:

mod parser {
    pub mod tokens {
        #[derive(Debug)]
        pub struct Token(pub String);
    }
}

pub use parser::tokens::Token;

fn main() {
    println!("{:?}", Token(String::from("let")));
}

Users write crate_name::Token while the code lives four levels deep. This lets you organize source by what makes it easy to work on and organize the API by what makes it easy to use, and it means moving a file is not a breaking change.

The convention for a crate with more than a handful of items is a flat public surface with the common types re-exported at the root, and a prelude module for the traits users need in scope. Deep public paths are a sign the internal structure leaked.

When one crate becomes several

A workspace holds several crates that share a lock file, a target directory and a cargo test invocation. It is the right structure when a project has genuinely separable pieces: a core library, a command-line front end, a server, a shared type definition crate used by both ends of a protocol.

The reasons to split are practical rather than aesthetic. Compilation is parallelized across crates and cached per crate, so a large project that recompiles everything on every change is a candidate. A binary that depends on a library forces the library to have a public API somebody uses. And publishing a core separately lets others use it without your command-line interface.

The reason not to split is that crate boundaries are firmer than module boundaries: no shared private access, and circular dependencies are impossible. Start with modules in one crate, and split when the boundary has proven itself.

Conclusion

The crate is what you ship, and the module is where privacy lives. Because private means private-to-module, a module is the unit whose invariants can be reasoned about, and cooperating types belong in one rather than being forced apart by a type-level privacy rule.

A public item is a promise. Exposing less is the main defense, #[non_exhaustive] is the other, and both are far cheaper applied at the start than retrofitted. Signatures should be general in their parameters and concrete in their returns, follow the naming conventions that carry cost information, and implement the standard traits that let a type participate in the ecosystem.

Tests come in three kinds for three audiences, and the documentation test is the one that makes a crate’s examples permanently true.

The next chapter puts the book’s ideas together on a single small system, from requirements to working code.

Questions to think through

Take a library you maintain. List its public API and mark each item as something you would defend in a design review or something that is public because it was easier. How much of the second kind is there?

#[non_exhaustive] on an error enum lets you add variants without a major version. What does it cost the caller, and is that cost different for an application than for another library?

The chapter says an integration test can only use the public API, and that this makes it the real check. What would you learn from writing one for code you currently only unit-test?