Skip to content
rusthacks.dev
Architecture

Public API Design for Rust Crates That Survive Releases

Decide what is public on purpose: small surface, private fields, builders, non_exhaustive types and re-exports. Check for accidental breakage with cargo-semver-checks.

3 min read
Summarize in AI
Flat illustration for the post “Public API Design for Rust Crates That Survive Releases”, composed around web.

A public API is a contract you sign every time you type pub. The compiler will not stop you from signing too much, and the cost shows up a year later when a fix needs a breaking change. The habits below are small: private fields, a few attributes and one tool. They are what lets a crate stay at the same major version while it improves. It is the outward-facing version of the module choices in organizing Rust projects.

Rust 1.90, edition 2024.

Hide representation

rust
pub struct Duration2 {
    secs: u64,
    nanos: u32,
}

impl Duration2 {
    pub fn new(secs: u64, nanos: u32) -> Self { Duration2 { secs, nanos: nanos % 1_000_000_000 } }
    pub fn secs(&self) -> u64 { self.secs }
}

If secs were public, callers could construct invalid values and depend on the field. With private fields you can change the storage to a single u128 tomorrow. A struct with only public fields is a data record by design, and that is fine when it is truly one.

Leave room to grow

rust
#[non_exhaustive]
pub enum Error {
    NotFound,
    Timeout,
}

#[non_exhaustive] forces other crates' match expressions to include a wildcard arm, so adding PermissionDenied later is not breaking. On a struct, it forbids constructing it with a literal outside the crate, which means you can add fields. It has a cost: callers cannot match exhaustively, so use it on types that really will grow, such as error enums.

Options: builders

When a function has many optional settings, a builder avoids a signature that changes on every release:

rust
pub struct Client { timeout_ms: u64, retries: u32 }

pub struct ClientBuilder { timeout_ms: u64, retries: u32 }

impl ClientBuilder {
    pub fn new() -> Self { ClientBuilder { timeout_ms: 5_000, retries: 3 } }
    pub fn timeout_ms(mut self, v: u64) -> Self { self.timeout_ms = v; self }
    pub fn retries(mut self, v: u32) -> Self { self.retries = v; self }
    pub fn build(self) -> Client { Client { timeout_ms: self.timeout_ms, retries: self.retries } }
}

Adding .proxy(...) later breaks nobody.

Control who implements your traits

A public trait that users may implement can never gain a required method. Seal it if you intend to add methods:

rust
mod private { pub trait Sealed {} }
pub trait Backend: private::Sealed { fn name(&self) -> &str; }

Only types in your crate can implement Sealed, so only they can implement Backend.

Types from other crates

If a public function returns reqwest::Error, upgrading reqwest to a new major is a breaking change for you. Wrap it in your own error, or re-export the dependency (pub use reqwest;) and treat its version as part of your API.

Take general, return concrete

rust
pub fn read_names(path: impl AsRef<std::path::Path>) -> Result<Vec<String>, std::io::Error> {
    Ok(std::fs::read_to_string(path)?.lines().map(str::to_owned).collect())
}

Accepting impl AsRef<Path> serves &str, String and PathBuf. Returning Vec<String> is concrete and clear.

Catch accidents

Run cargo semver-checks in CI, which implements the compatibility rules from the Cargo book. It reports breaking changes such as a removed function or a changed field type. Add the API guidelines to your review checklist.

Where this does not apply

Application crates with no external users can change freely. The guidelines are the Rust project's API Guidelines checklist, the primary reference for naming, traits and documentation.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.