Skip to content

The Rust Engineering Handbook / Chapter 51

API Review and Compatibility Case Study

Run a senior API review that turns caller needs, invariants, operational contracts, compatibility, and evidence into a release decision.

The review panel has ninety minutes and one consequential question: should other teams be allowed to depend on ledger-core 1.0?

The proposal looks modest. It validates monetary entries, stores them in a ledger, returns identifiers, and computes totals. Its test suite is green. Its authors want a stable release so reporting, reconciliation, and import services can share the model. That last sentence changes the work. Once callers compile policy into their code, every public name, ownership choice, trait bound, error category, feature, and documented behavior becomes a coordination cost.

A senior API review is not a style discussion and not a search for theoretical perfection. It is a release-risk investigation. The panel must determine whether the proposed surface represents caller jobs faithfully, protects invariants, leaves credible evolution paths, states operational behavior, and has evidence strong enough for the claims. A release may proceed with recorded follow-ups; it must not proceed by averaging away a soundness, data-loss, or migration blocker.

This review closes Part VIII by weighing all those contracts together. The output is not “looks good.” It is a decision record that another engineer can challenge months later.

Release finding: conditionally approve an experimental 0.x release; block 1.0 for financial production use. The API shape is promising, but overflow, identifier durability, the empty feature promise, and poison recovery remain release blockers. The rest of the review shows why those findings cannot be averaged into one score.

Begin with caller journeys, not methods

The panel writes four user stories before reading individual signatures:

  1. An importer parses an external amount, posts it exactly once, and records the returned identifier.
  2. A reporting job borrows a ledger and computes a total without copying its entries.
  3. A service maps stable rejection categories to correct, retry, or operator action without parsing text.
  4. A future parallel importer transfers ledger ownership to a worker or deliberately shares synchronized access.

These stories reveal decisions that a method inventory hides. Validation must happen before an amount can enter storage. Posting consumes or otherwise establishes ownership of validated input. Iteration should not expose the backing collection. Errors need stable caller meaning but private diagnostic freedom. Thread capability must be intentional even before the crate itself spawns a thread.

The fixture’s core surface is therefore narrow:

pub struct Amount(i64);

impl Amount {
    pub fn new(cents: i64) -> Result<Self, PostError>;
    pub fn cents(self) -> i64;
}

pub struct Ledger {
    amounts: Vec<Amount>,
}

impl Ledger {
    pub fn post(&mut self, amount: Amount) -> Result<EntryId, PostError>;
    pub fn total_cents(&self) -> i64;
    pub fn entries(&self) -> impl ExactSizeIterator<Item = Amount> + '_;
}

Private fields are doing architectural work. Callers cannot construct an invalid Amount, choose an identifier, or depend on Vec as storage. post takes an already validated value and returns success evidence. entries lends a traversal whose lifetime is tied to &self, while the hidden concrete iterator leaves room to change representation.

One challenge remains: the method can currently fail only when a contrived capacity limit is reached. Why retain Result? Because capacity or persistence failure is part of the intended service boundary, and removing a fallible signature now would create a harder source change later. That rationale belongs in the decision record. “We might need it” alone would not justify speculative complexity.

Evaluate promises through explicit gates

The panel uses gates rather than a single score. Scores help compare quality and expose disagreement; gates prevent a high documentation score from compensating for an invariant leak.

A compatibility review map carries a proposed v1 surface through caller, invariant, operation, compatibility, and evidence gates, while a deprecated route takes an explicit migration path toward an evolvable v2.

The image is a review retrieval map. Every v1 promise must pass each gate or enter a migration route. “Evolvable” does not mean no future breakage. It means likely changes have an identified compatible path and unavoidable breaks have a migration mechanism.

Use this scorecard during the discussion:

Gate Pass question ledger-core evidence Blocker threshold
Caller fit Can each priority journey be expressed without wasteful cloning, hidden global state, or representation knowledge? owned Amount, borrowed totals and iteration, typed result a priority journey requires bypassing validation
Invariants Can safe public code create an invalid value or mutate state around checks? private fields, smart constructor, &mut self posting safe construction of zero amount or forged ID
Abstraction Is each trait, generic, callback, and feature justified by substitution or variability? inherent methods; hidden iterator type; no speculative trait public generic or trait exists only “for flexibility”
Operations Are errors, panic, blocking, cancellation, callbacks, and cleanup stated where relevant? stable error kind; synchronous bounded in-memory operations ambiguous commit or retry semantics for effects
Concurrency Are Send and Sync consequences intentional and witnessed? compile-time assertions for Ledger an internal field silently changes a promised capability
Compatibility Are likely additions and replacements possible without accidental source or behavioral breaks? private representation; non-exhaustive error kind; migration ledger no path for a known near-term caller requirement
Cost Are allocation, complexity, copying, dispatch, lock, and dependency costs bounded? one vector allocation path; static calls; zero dependencies undocumented unbounded work on a latency-sensitive path
Evidence Do tests, docs, examples, and supported toolchains exercise the promises? unit tests, positive assertions, compile-fail doctest, MSRV run a key claim rests on inspection alone

Do not mechanically total the rows. Record green, conditional, or blocked for each and attach the evidence. A conditional release has named owners and deadlines; a blocked release names the smallest change needed to reopen review.

Ownership ergonomics are workflow ergonomics

The proposal originally accepted &Amount and cloned it internally. That compiles, but it obscures the posting transition. An amount is small today, yet the semantic cost matters more than the copy: the signature implies that caller and ledger retain equivalent ownership after posting. Taking Amount by value makes the transition explicit and stays efficient if the type later carries owned metadata.

There are credible alternatives. post(&mut self, cents: i64) minimizes types at the call site but lets primitive obsession spread and repeats validation entry points. post(&mut self, amount: &Amount) supports reuse but forces an internal copy or shared representation. post(&mut self, amount: Amount) -> Result<EntryId, (PostError, Amount)> returns expensive input after failure, useful when retry would otherwise reconstruct it. The fixture’s small copyable amount does not need that complexity, but an attachment or batch API might.

Review returns with the same care. entries(&self) -> &[Amount] is allocation-free and convenient, but it promises contiguous slice representation. impl Iterator hides representation and can add filtering, though its exact capabilities must satisfy callers. A named iterator type offers documentation and implementation control at the price of more public surface. Returning Vec<Amount> would allocate and detach a snapshot; that is correct only if snapshot ownership is the actual product.

Ask who owns data before, during, and after every call; whether a borrow couples lifetimes more than necessary; and whether a convenient clone conceals a missing ownership decision. An API review that debates spelling but ignores those flows is reviewing the wrong system.

Protect invariants without freezing construction

Amount::new validates once and produces a value that downstream methods may trust. The private tuple field prevents safe bypass. The constructor rejects zero because the domain in this case study models postings, not balance snapshots. Negative values remain valid reversals. Those rules are domain policy, not Rust facts, so documentation and tests must state them.

The panel asks how the type can evolve. Adding a currency field to a public struct would break literals and pattern matches. Keeping representation private allows a new constructor, a builder for richer entries, or a new Money type. But privacy alone does not guarantee compatibility: changing whether negative amounts are accepted is a behavioral break even if every caller recompiles.

Construction options form a ladder. A single smart constructor fits one required scalar. Named constructors fit a few distinct intents. A builder earns its cost when optional settings, defaults, and cross-field validation become substantial. Typestate is justified only if sequencing errors are important enough to pay in type complexity, compilation, and diagnostics. The panel rejects a proposed generic typestate builder for the current Amount; it solves no caller story.

The error surface follows the same principle. PostError keeps fields private and exposes a stable kind(). PostErrorKind is non-exhaustive, so downstream matches require a fallback. This permits new categories in a compatible release under the crate’s declared policy, while callers still receive machine-readable decisions. Display remains operator text and must not become a parsing protocol.

Challenge every trait and variability point

Early ledger-core had a public Summarize trait with one implementation because “traits improve testability.” The panel removes it from the 1.0 proposal. Callers need a total, not substitution among independently meaningful summarizers. An inherent method is smaller, easier to discover, and avoids coherence and evolution obligations.

A trait would become defensible if multiple owned types must participate in the same generic algorithm, downstream implementations are a product requirement, or dynamic objects cross a plugin boundary. Even then, the review must address trait laws, object safety where relevant, blanket implementations, associated-type evolution, and whether implementations should be sealed.

The same audit applies to generic parameters. Generic inputs can avoid allocation and accept caller representations, but they expand inference and monomorphization. Concrete outputs stabilize use and diagnostics. impl Trait is useful here because callers need iterator behavior rather than the concrete adapter stack. A boxed trait object would add allocation and dynamic dispatch without enabling a current requirement. An enum could model a closed set of traversal strategies if runtime selection later appears.

Feature flags also represent variability. The fixture declares an additive, off-by-default audit-tags feature but does not yet change public behavior. Before release, the panel requires either implementing and documenting its concrete capability or removing it. Empty future-facing feature names are promises with no user value. If kept, CI must cover no-default, default, feature-enabled, and all-feature builds; feature unification means it cannot act as a mutually exclusive mode switch.

Review behavior that signatures cannot express

This in-memory API is synchronous, has no callbacks, does not perform I/O, and has no cancellation boundary. The review record should say that rather than paste an async contract template onto it. post performs work proportional to current vector growth behavior and total capacity limits; total_cents is linear in the number of entries; entries itself is constant-cost and iteration is linear. Allocation may occur during posting. Arithmetic overflow is a policy question that the toy fixture does not solve and therefore becomes a release blocker for financial production use.

That blocker illustrates why tests passing is insufficient. In debug builds, integer overflow may panic; in optimized builds, behavior follows Rust’s overflow settings and operations. A financial ledger must choose checked arithmetic, a bounded domain, or a wider/decimal representation and document failure. The panel can approve the API shape for teaching while refusing production 1.0 until the arithmetic invariant is explicit.

Panic review covers lock poisoning only for SharedLedger, introduced as a separate synchronized wrapper. It recovers the inner value from a poisoned mutex, but that mechanical recovery does not prove the ledger’s logical invariant survived the panic. A real release should decide whether to quarantine, validate, or fail closed. The wrapper also serializes every operation; Arc<Mutex<_>> is a correct mechanism, not evidence of acceptable throughput.

Dependency exposure is currently simple because the crate has none. If public signatures named a dependency’s type, that version and its trait implementations would become part of caller coordination. Private dependencies still affect MSRV, compile time, licensing, supply-chain review, target support, and feature unification. The review inventory records both public exposure and operational cost rather than approving a dependency because Cargo resolved it.

Build a compatibility ledger before assigning a version

SemVer review must cover source shape and behavioral decisions. Cargo’s compatibility guidance is a strong starting point, but it explicitly cannot classify every runtime effect. The panel writes likely v2 changes against migration paths:

Proposed change Compatibility risk Preferred path
Add private fields to Ledger low while construction remains private compatible minor release plus tests
Add PostErrorKind variant downstream exhaustive matching retain #[non_exhaustive]; document fallback and new action
Replace Vec storage iterator order, performance, and ID semantics may change preserve documented order/IDs or introduce a new type
Change entries to yield references item type and ownership break add entry_refs; deprecate with migration example
Add currency to amounts constructor and equality semantics change introduce Money and conversion; keep old path through a deprecation window
Make posting async/persistent call shape, failure, cancellation, and commit semantics break create a separate repository/service API, not an async method swap
Remove Send due to a local handle downstream worker placement breaks preserve capability or introduce a deliberately local type
Enable a heavy default feature build graph and runtime cost change additive opt-in feature first; announce any default change prominently

Deprecation is a communication mechanism, not magic compatibility. A replacement needs equivalent or intentionally changed semantics, a code example, a release window, and a way to measure remaining use where the organization controls callers. Type aliases preserve names but not necessarily constructor or trait behavior. Conversion traits can ease migration but may make costly or fallible transitions look cheap. Choose the migration tool from the actual change.

Performance compatibility also deserves a budget. Moving from a vector to a database might preserve results while turning a nanosecond borrow into blocking I/O. Reordering entries may break deterministic reports. Adding internal locking may preserve signatures while introducing contention or deadlock exposure around callbacks. The review record distinguishes language/source compatibility, behavioral compatibility, and operational compatibility.

Make the test strategy mirror the claims

The fixture does more than execute a happy path. It tests that validation rejects zero, posting returns stable identifiers, totals and iterator lengths agree, and synchronized access crosses a real thread. Generic functions assert that Ledger and SharedLedger are both Send and Sync. A doctest intentionally tries to require Send from ThreadAffineToken and must fail.

Those witnesses are deliberately small. They prove compiler capabilities for the pinned toolchains; they do not prove lock fairness, panic recovery, capacity, financial arithmetic, or SemVer compatibility. The release evidence plan adds:

  • property tests for totals, reversals, and identifier uniqueness within declared bounds;
  • compile tests for public capability and rejected construction;
  • rustdoc examples under every supported feature set;
  • MSRV resolution and test runs;
  • benchmarks with allocation and tail-latency budgets for representative ledger sizes;
  • downstream compatibility checks against selected real consumers;
  • fault tests for persistence and cancellation if a future repository layer is added;
  • changelog and migration review for every caller-visible behavior change.

Evidence has scope. Record compiler version, edition, target, features, dependency resolution, dataset, and build profile behind a claim. “Benchmark passed” is not useful without a budget. “Miri passed” would not prove business correctness. “No unsafe code” reduces one proof surface but does not remove overflow, deadlock, or data-loss risks.

Return to the release finding with evidence

The panel’s decision is conditional approval for an experimental 0.x release; block 1.0 for financial production use.

Accepted findings:

  • ownership and borrowing match the four caller journeys;
  • safe callers cannot bypass amount validation or forge identifiers;
  • the public surface avoids speculative traits and exposes no storage type;
  • error classification and auto-trait claims are machine-readable and tested;
  • representation, iterator implementation, and diagnostics retain evolution room;
  • the dependency-free fixture passes the declared stable and MSRV matrix.

Blocking findings:

  • amount range and overflow behavior lack a production invariant;
  • identifier durability and scope are not specified beyond one process value;
  • the empty audit-tags feature must become a documented capability or disappear;
  • poison recovery in SharedLedger lacks a logical validation policy;
  • no persistence layer means durability, cancellation, idempotency, and crash recovery are outside the current product promise.

The distinction matters. The panel is not demanding a database from an in-memory crate. It is preventing the name “ledger” and version “1.0” from implying contracts the implementation does not offer. An approved experimental boundary can still be useful if its exclusions are visible.

Exercise: conduct the release review

Take the fixture and propose a v2 that adds currency, batch posting, optional audit tags, and a persistent repository. Produce these artifacts:

  1. Four prioritized caller journeys, including one latency-sensitive and one recovery journey.
  2. An ownership map for single post, batch success, partial failure, and iteration.
  3. An invariant list with the safe public operation that establishes or preserves each invariant.
  4. A trait and generic-surface inventory; justify every variability point and delete one that is speculative.
  5. A feature matrix covering defaults, dependencies, public items, target support, cost, and combinations.
  6. An operation contract for persistence: errors, panic, blocking, cancellation, commit evidence, retry, idempotency, resource ownership, and cleanup.
  7. A Send/Sync table for every public handle, future, callback, error, and iterator.
  8. A compatibility ledger for at least eight v1-to-v2 changes, separating source, behavior, and operations.
  9. A migration example and deprecation window for one unavoidable break.
  10. A test plan mapping each release claim to a command, model, benchmark, or downstream witness.
  11. A one-page decision record: approve, conditionally approve, or block; evidence; risks accepted; blockers; owners; and re-review trigger.

Run an adversarial panel. Assign one reviewer to callers, one to invariants and unsafe boundaries, one to operations and performance, and one to compatibility and evidence. The exercise fails if the decision is a numeric average, if a blocker has no reproduction, if a migration path changes semantics silently, or if the panel claims durability from an in-memory unit test.

Senior review questions

  • Which caller journey justifies every public item?
  • Does ownership express the domain transition, or merely make the implementation convenient?
  • Can safe code violate an invariant or depend on private representation?
  • Which traits and generics pay for real substitution, and which only advertise flexibility?
  • Do features remain additive under graph unification and carry explicit cost?
  • Are errors, panic, blocking, cancellation, retry, callbacks, and destruction documented where applicable?
  • Are Send and Sync properties intentional, minimal, and compile-tested?
  • Which performance and dependency assumptions have budgets rather than adjectives?
  • Can each likely evolution use an additive API, non-exhaustive shape, deprecation, conversion, or new type?
  • Does the evidence actually cover the claim, toolchain, target, feature set, and failure mode?
  • Is the release decision explicit about exclusions and the smallest path to reconsideration?

The case study’s durable lesson is that API quality is the intersection of contracts. A beautiful signature can still hide an invalid state, a lost update, an irreversible compatibility choice, or an untested operational claim. Once the panel has made those intersections explicit, Part IX can investigate the first concurrency promise in depth: what it really means for Ledger or any other value to cross a thread boundary.

Sources and version note

The fixture targets Rust 2024 Edition and declares Rust 1.85 as its MSRV; its claims are verified separately with the installed stable 1.97.0 and 1.85.0 toolchains. Cargo’s official SemVer compatibility guide classifies common source changes while warning that runtime compatibility often requires maintainer judgment. The Rust API Guidelines remain design guidance rather than a substitute for a crate-specific compatibility policy. Thread capability claims rely on the standard library’s Send and Sync contracts and are developed in Chapter 52.