Skip to content

The Rust Engineering Handbook

Appendix J — Public API Review Checklist

Review Rust APIs across naming, ownership, traits, failure, async cancellation, features, platforms, documentation, and SemVer.

A library proposes this apparently simpler change:

// Before: validates and borrows caller storage.
pub fn parse_key(input: &str) -> Result<ReportKey<'_>, KeyError>;

// Proposed: owns the input and returns owned text.
pub fn parse_key(input: String) -> Result<String, KeyError>;

Both signatures compile. The second even removes a lifetime parameter from the visible return type. Yet three callers lose different properties:

  • a protocol parser can no longer return a zero-copy view into its receive buffer;
  • a caller with &str must allocate merely to request validation;
  • downstream code loses the guarantee that the result is a validated domain key rather than arbitrary text.

The change is not a syntax cleanup. It reallocates ownership, cost, and proof. Public API review asks which obligations move between library and caller, which programs or behaviors change, and which release promise can honestly contain the change.

Use this ledger for every proposed public item or modification:

Gate Contract to write Evidence Release risk if changed
name and role how callers read the operation and distinguish variants representative call sites and search vocabulary source churn, ambiguity
ownership and validity who allocates, borrows, mutates, stores, and validates lifetime trace, allocation path, rejected misuse source, performance, or soundness break
trait surface required bounds, associated types, object use, common impls generic and consumer compile tests inference/coherence/implementation break
failure and safety errors, absence, panic, unsafe obligations, partial effects failure tests and rustdoc sections runtime, security, or soundness break
async and cancellation suspension, blocking, Send, cancellation points, cleanup cancellation/timeout test and state trace leaked work or duplicated effects
features and platforms items and behavior per supported configuration feature/target/MSRV matrix conditional source or build break
documentation caller model, examples, links, guarantees versus advice doctests and review misuse despite compiler success
compatibility previous callers and behavior that must continue downstream suite and change classification wrong patch/minor/major decision

An API passes only when every applicable gate has an answer. “No compiler errors in this repository” is one evidence row, not a compatibility argument.

The gates are not independent. A feature can add a trait implementation and change inference; an ownership change can move allocation into an async cancellation path; an MSRV increase can alter which documented examples compile. Review the intersections that the proposed change actually crosses rather than signing each column in isolation.

Names should predict behavior

Rust naming conventions reduce documentation lookup and encode cost expectations:

  • as_ usually exposes a cheap borrowed view;
  • to_ usually computes or allocates a new value;
  • into_ consumes self;
  • getters normally use the field concept directly, such as body(), rather than get_body();
  • iterator-producing collection methods follow iter, iter_mut, and into_iter ownership distinctions;
  • conversion implementations prefer From/TryFrom, AsRef, and related standard traits when their semantics fit.

These are conventions, not type-system guarantees. to_socket_addrs may perform resolution work; as_bytes is cheap. Document important cost, blocking, allocation, and caching behavior even when the name is conventional.

Name the domain, not the representation accident. ReportKey::parse states validation and produces a key. make_string says neither. Avoid encoding the current algorithm, backing collection, or transport in a public name unless callers are meant to depend on it.

Check word order across a family. If the API has read_report, delete_report, and report_exists, decide whether verb-first or noun-first is the coherent discovery model. Search rustdoc and editor completion as a caller would. A locally clever name can make the whole family harder to find.

Feature names are public names too. json, tls, or simd can identify capabilities; unstable, full, and extras hide what enabling them commits the package to support. Do not use a feature as a substitute for a runtime setting when consumers should not rebuild the graph to change behavior.

Ownership is part of the output

For every parameter and return value, answer five questions:

  1. Does the callee only observe, mutate, retain, or consume it?
  2. How long may the callee or returned value retain access?
  3. Who pays allocation, cloning, locking, and conversion?
  4. Which invalid states does the type exclude?
  5. Can the contract be expressed without forcing 'static or shared ownership?

Prefer &str or &[T] when the operation only observes during the call. Accept an owned String, Vec<T>, or domain object when consumption or retention is meaningful. Cow<'a, str> can represent “usually borrow, sometimes normalize and own,” but it adds a branch and a lifetime to the API; use it only when both paths are real.

The fixture’s key preserves validation and borrowing:

#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ReportKey<'a>(&'a str);

impl<'a> ReportKey<'a> {
    pub fn parse(value: &'a str) -> Result<Self, KeyError>;
    pub const fn as_str(self) -> &'a str;
}

The private field prevents callers from constructing an unchecked key. The lifetime states that the key cannot outlive the caller text. Copy is appropriate because the value is only a shared reference wrapper. If a later version normalizes and owns text, that is a different representation and likely a different type or conversion, not a silent reason to allocate inside parse.

Returned references need a visible owner. A method fn find(&self, ...) -> Option<&Value> ties the view to self; callers cannot assume it survives mutation or lock release. A guard type can keep a lock or mapped storage alive, but then guard drop timing, Send, contention, and reentrancy become public concerns. Returning a clone may simplify lifetime management at allocation cost. Compare those designs using the actual access pattern.

Avoid requiring 'static because an implementation wants to spawn or cache. 'static on a type parameter usually says the value contains no non-static borrows; it does not mean the value lives forever. If only one adapter needs retention, let that adapter own or clone at its boundary rather than shrinking every caller’s options.

Traits enlarge the compatibility surface

A public trait is both an interface callers use and an implementation contract downstream crates may implement. Adding a required method breaks implementors. Adding a defaulted method can still create name collisions or change method resolution. Adding a supertrait or tightening a bound rejects existing implementations. Sealing a trait limits downstream implementations and should be an intentional extensibility decision, not a surprise.

Review these dimensions:

  • Are associated types appropriate when each implementation chooses one stable type, or should a generic parameter let one implementation participate with several types?
  • Must callers use the trait behind dyn? If so, verify dyn compatibility; Self returns, generic methods, and some opaque return forms can prevent it.
  • Do method receivers express ownership correctly: &self, &mut self, self, Box<Self>, or pinned receiver?
  • Are Send and Sync required by the actual execution boundary rather than added reflexively?
  • Is the trait safe to implement, or is it unsafe trait with explicit implementation obligations?
  • Could a blanket implementation overlap downstream present or future implementations?

Implement common traits when their laws are true and useful. Consider Debug, Clone, Copy, Default, Eq, Ord, Hash, Display, Error, From, TryFrom, AsRef, Borrow, IntoIterator, FromIterator, and Extend. Do not derive them as decoration. Eq promises equivalence; Ord must agree with Eq; Hash must agree with equality; Default should produce a meaningful ordinary value; Display is a user-facing representation, not automatically a stable serialization format.

From<T> should be infallible and lossless in the semantic sense expected by the API. If validation can fail, use TryFrom. AsRef<T> is a cheap borrowed conversion, while Borrow<T> additionally participates in equality, ordering, and hashing equivalence used by collections. Deref creates pervasive method and coercion behavior and should model pointer-like access, not merely avoid writing a forwarding method.

The orphan and coherence rules mean an implementation consumes design space. A blanket impl<T: TraitA> TraitB for T can prevent a downstream crate from choosing its own TraitB implementation for a type that later satisfies TraitA. Review new impls as API additions with overlap consequences.

Failure text is not the failure protocol

Choose how callers distinguish absence, invalid input, retryable failure, partial completion, and invariant violation. Option<T> communicates expected absence without a reason. Result<T, E> communicates a failure vocabulary. A public error enum can provide stable variants or accessors, but adding variants may break exhaustive matches unless the type is intentionally #[non_exhaustive] and callers are prepared for a wildcard.

Document which parts are stable. Human-readable Display text should normally be allowed to improve; machine decisions should use variants, codes, or methods. Preserve useful sources through Error::source where the standard error contract applies. Do not leak secrets through Display, Debug, panic messages, or source chains.

Every fallible function or trait method needs an # Errors rustdoc section precise enough for caller policy. Every intentional panic condition needs # Panics. “May panic” without the violated precondition is not actionable. Expected external input, I/O, capacity, or cancellation failure should not become panic merely to simplify a return type.

Unsafe public functions and traits require a # Safety section that assigns every proof obligation to caller or implementor: validity, alignment, aliasing, initialization, lifetime, provenance, concurrency, and drop behavior as applicable. A safe wrapper needs evidence that it establishes those obligations for every path. Appendix K provides the deeper unsafe audit; this gate decides whether the public boundary exposes or contains the proof.

Review partial effects. If write_batch returns an error after three writes, can the caller identify committed items, retry safely, or roll back? A transaction type, idempotency key, per-item outcome, or atomic guarantee may be required. Error type design cannot repair an unspecified effect boundary.

Async APIs publish a timeline

Changing fn get to async fn get does more than change the return wrapper. The call can suspend, the future may borrow its inputs, the future may or may not be Send, polling must not block an executor thread, cancellation occurs by dropping the future, and effects can exist before completion.

Write the timeline:

construct future -> first poll -> acquire capacity -> send request
-> await response -> validate -> commit local state -> return

At every suspension point, state what resources are held and what dropping the future does. Cancellation safety does not mean “the memory is safe”; Rust already protects memory safety in safe code. It means the surrounding protocol remains valid and retry/re-entry will not duplicate, lose, or corrupt an effect.

For an async public API, review:

  • whether work begins on call, first poll, or in a spawned task;
  • which executor or runtime assumptions exist;
  • whether the future is Send and whether that promise matters;
  • whether inputs or self remain borrowed across suspension;
  • capacity acquisition and backpressure behavior;
  • timeout ownership and whether a timeout cancels underlying work;
  • cancellation points, cleanup, and detached work;
  • ordering, fairness, and concurrency limits;
  • idempotency and ambiguous completion;
  • blocking CPU, filesystem, DNS, or foreign calls;
  • observability that distinguishes cancel, timeout, rejection, and failure.

Avoid imposing boxed futures or a third-party attribute as universal design. Native async trait methods, return-position impl Future, boxed dynamic futures, associated future types, and explicit state machines offer different dyn compatibility, allocation, MSRV, code-size, and ergonomics trade-offs. Choose for the supported caller and implementation set, then test those boundaries.

A timeout wrapper can drop its local future while a remote request continues. Document whether the operation is merely no longer awaited, cooperatively cancelled, or durably revoked. If retrying after timeout can repeat an effect, require an idempotency or reconciliation contract.

Features and platforms are multiple public APIs

Conditional compilation means a crate can expose a family of surfaces. For each supported feature row, record items present, trait implementations, behavior, dependencies, MSRV implications, and documentation configuration.

Features should normally be additive: enabling one should not remove an existing item or change the meaning of an existing call incompatibly. Additive source does not mean zero risk. A new feature can activate a dependency, change a blanket implementation, enable an enum variant through code generation, or make two previously separate graph choices unify.

Test at least no-default, default, important isolated features, supported combinations, and all features. The companion fixture supports:

no default features
default feature: std
compact without defaults
all features: std + compact

Its std feature adds the standard Error implementation while the core types remain no_std plus alloc. That makes allocator availability part of the platform contract. A truly allocation-free target would need a different design; #![no_std] alone does not prove “works on every embedded platform.”

Name supported targets or target families and evidence levels. “Portable Rust” is too vague. A platform contract may include target triple, tier expectation, allocator, atomics, unwinding, threads, filesystem/network assumptions, endian/width behavior, linker, native libraries, and whether the artifact was only checked or actually executed.

MSRV is also API reach. Set package.rust-version, test the stated minimum, and define when it may increase. New syntax, standard-library APIs, Cargo features, dependencies, or build scripts can raise it. Cargo’s resolver can consider Rust-version compatibility, but it does not prove the workspace compiles and tests on the minimum toolchain.

Documentation is where non-type contracts live

Crate-level documentation should explain purpose, core model, first useful example, feature flags, platform/MSRV support, and links to deeper material. Public items need examples that show why the item exists, not mechanical invocations that teach nothing.

Use doctests as executable caller examples. Prefer ? in fallible examples unless the point is a tested panic. Hide setup lines with rustdoc’s # convention when it improves focus, but keep the example honest. Run doctests under the feature configurations in which the documented item exists.

Document:

  • # Errors for fallible operations;
  • # Panics for meaningful panic preconditions;
  • # Safety for unsafe caller/implementor obligations;
  • allocation, copying, blocking, complexity, ordering, and caching where decisions depend on them;
  • cancellation and partial effects for async or streaming work;
  • units, ranges, encoding, timezone, endian, and normalization;
  • feature, platform, and MSRV constraints;
  • guaranteed behavior separately from current implementation details.

Intra-doc links keep concepts navigable and are checked by rustdoc. Avoid exposing private implementation names or unstable internal modules in public docs. Release notes should call out significant behavioral, feature, MSRV, and migration changes even when the version bump is formally compatible.

SemVer classifies the whole caller contract

Cargo’s SemVer guidance focuses heavily on changes that break compilation, while acknowledging that runtime behavior can also be incompatible. Use it as a detailed baseline, then apply the package’s documented behavioral promises.

Proposed change Usual concern Questions before disposition
remove or rename public item major source break is the item actually reachable and supported?
change parameter/return type major source and semantic break can an additive method or conversion stage migration?
add public struct field can break exhaustive construction/patterns was the type already non-exhaustive or opaque?
add enum variant can break exhaustive matches is the enum non-exhaustive and are wildcard matches intended?
add required trait method breaks downstream implementors can it have a sound default or extension trait?
add trait bound/supertrait rejects callers or implementors is the bound already logically guaranteed and tested downstream?
add blanket implementation coherence and inference change can it overlap or make method/trait selection ambiguous?
add default feature changes default graph, build, MSRV, and behavior is opt-in safer; does default remain lightweight?
remove feature or optional dependency conditional source/build break was it public and what migration exists?
raise MSRV toolchain compatibility loss what published policy and release class apply?
change panic/error/cancellation behavior runtime contract break did callers rely on recovery, atomicity, or text?
performance regression operational compatibility risk is complexity, latency, memory, or allocation promised?

Pre-1.0 Cargo compatibility treats the leftmost nonzero component specially, so 0.x does not make all breaking changes free. More importantly, users still pay migration cost regardless of version arithmetic. State the project’s policy.

Patch releases should repair without requiring caller changes or silently moving supported boundaries. Minor releases may add compatible capability and can still carry risk that deserves testing and release notes. Major releases may make intentional incompatible changes, but a major number is permission to migrate deliberately, not to discard design discipline.

Use downstream tests. Compile representative callers against the candidate, exercise behavior, and compare public rustdoc/API inventories with a previous release. Automated API-diff tools are useful evidence, not oracles: macros, conditional features, trait resolution, runtime effects, performance, MSRV, and platform behavior can escape a simple item diff.

A public API passes through ownership, failure, async, platform, and SemVer gates before a patch, minor, or major disposition.
Version selection follows review of the complete caller contract; it does not replace that review.

Run a review board on one change

Review this proposal:

pub async fn submit_batch(
    client: &Client,
    reports: Vec<Report<'static>>,
) -> Result<(), SubmitError>;

Assume a bounded service, optional compression and native-tls features, Linux and Wasm support, and Rust 1.85 MSRV. Produce these artifacts:

  1. Rewrite the ownership contract. Decide whether reports must be owned, whether keys truly require 'static, and whether an iterator or slice better supports callers.
  2. Draw the future timeline from construction through capacity acquisition, send, acknowledgement, and commit. Mark every cancellation point and partial effect.
  3. Define SubmitError caller actions without promising stable prose. State panic conditions, if any.
  4. Check whether the returned future must be Send, what blocks, and whether Wasm changes the implementation or surface.
  5. Build the no-default/default/each-important-feature/combined feature matrix. Explain whether TLS belongs on Wasm and whether compression changes public types.
  6. Write rustdoc # Errors, cancellation, partial completion, and platform sections plus one doctest-shaped example.
  7. Classify these follow-up changes: borrowed batch overload; new optional method; new required trait method; added error variant; added default feature; removed Wasm support; raised MSRV; stronger validation that rejects previously accepted input.
  8. Give a patch/minor/major disposition or withhold it, naming missing evidence.

A strong answer will often split the API. It may accept a borrowed iterator synchronously into a validated owned request, then return a future whose cancellation and effect ownership are explicit. It may provide per-item outcomes or an idempotency key rather than pretending Result<(), E> captures partial completion. Several designs can pass; unexplained ownership transfer and cancellation cannot.

Public API review card

  • Names match Rust conventions and communicate domain role, ownership, and meaningful cost.
  • Inputs borrow, mutate, retain, or consume deliberately; outputs have a visible owner and validity contract.
  • Allocation, copying, indirection, locking, and 'static requirements are justified at the boundary that needs them.
  • Public structs/enums anticipate construction and matching compatibility; non-exhaustiveness is intentional.
  • Traits define receiver, associated type/generic, dyn, Send/Sync, sealing, and downstream implementation policy.
  • Common trait implementations obey their laws; conversions use the narrowest fitting standard trait.
  • Errors provide stable caller decisions; absence, partial effects, panic, and unsafe obligations are distinct and documented.
  • Async APIs document start, suspension, blocking, cancellation, timeout, retry/idempotency, detached work, and Send behavior.
  • No-default, default, isolated, combined, and all-feature configurations match the support contract.
  • Platform and MSRV claims name evidence; no_std does not overclaim allocator-free portability.
  • Crate/item docs contain useful examples, links, error/panic/safety sections, costs, and version-sensitive guarantees.
  • API and behavior are compared with the previous release and representative downstream callers.
  • Patch/minor/major disposition reflects source, behavior, features, targets, MSRV, and operational promises.

Public API quality is the quality of the obligations callers inherit. Names and signatures are the visible edge, but features, scheduling, cancellation, errors, platforms, documentation, and release policy complete the contract. Appendix K narrows this method to unsafe code, where an omitted obligation can cross from compatibility defect into soundness failure.

Sources and version notes

  • The Rust API Guidelines checklist and its sections on naming, interoperability, predictability, and documentation provide ecosystem guidance rather than language mandates.
  • The Cargo SemVer compatibility guide catalogs source-compatibility hazards and explicitly notes that runtime compatibility also requires maintainer judgment.
  • The Rust Reference on attributes documents #[non_exhaustive]; RFC rationale can help, but current Reference/compiler behavior is authoritative.
  • The standard-library documentation for Future, Pin, Send, and Sync defines the core async and thread-transfer contracts. Cancellation behavior beyond dropping a future belongs to the API and runtime being reviewed.
  • The Cargo features, Rust version, and SemVer chapters are authoritative for Cargo configuration and current compatibility guidance.
  • The companion fixture is examples/rust-engineering-handbook/appendices/cargo-public-api-lab/. It uses edition 2024, declares Rust 1.85 MSRV, and contains no unsafe code or runtime dependency. Its local checks demonstrate only the executed feature/target/toolchain rows; they do not establish every downstream, platform, performance, or behavioral promise.