Idiomatic API Design, SemVer, and Rust Anti-Patterns
An API is every assumption another crate, process, fixture, or operator can depend on—not only its
pub fn signatures. Error variants, feature flags, serialized fields, default behavior, model
identity, trace order, and minimum Rust version can all become contracts.
Idiomatic Rust APIs make ownership, failure, and capability visible while keeping representation and future choices private.
Learning objectives
Section titled “Learning objectives”After this chapter, you will be able to:
- define the public surface from caller needs and invariants;
- choose borrowed/owned parameters and return types intentionally;
- use constructors, builders, newtypes, and typestate without needless ceremony;
- preserve useful errors while allowing compatible evolution;
- identify common Rust API anti-patterns in AI systems;
- apply Semantic Versioning to types, traits, features, and wire formats;
- distinguish crate version, schema version, program release, and model release;
- write external integration tests and rustdoc examples;
- inspect the companion’s public API and generated documentation.
Dependencies and downstream use
Section titled “Dependencies and downstream use”This chapter integrates the entire Rust-language block: ownership, errors, traits, modules, serialization, and FFI. Production Rust chapters will apply these principles to CLI, HTTP, storage, workers, and observability.
private representation │ constructors + validated transitions ▼small public Rust API │ versioned mapping ├── CLI contract ├── HTTP contract ├── trace/task schemas └── provider/native adaptersThese are related but separately versioned surfaces.
Start from the caller’s job
Section titled “Start from the caller’s job”Before adding an item, write:
- who calls it;
- what they already own;
- what capability they need;
- what failures they can handle;
- what behavior/order/effects are guaranteed;
- what must remain changeable.
For the harness:
pub async fn run( &self, run_id: RunId, task: Task,) -> Result<RunSummary, RunError>The run consumes an owned task because it is durable run input and the harness may move/clone pieces
into requests. The harness borrows self because the assembled adapters can serve multiple runs.
The result distinguishes success from typed failure.
Do not expose internal attempt-loop state unless callers genuinely need to drive it. Expose events or a state-machine interface only with a stable semantic contract.
Privacy is future freedom
Section titled “Privacy is future freedom”pub struct ModelId(String);Private inner representation allows:
- stronger validation;
- interned/compact storage later;
- additional metadata;
- controlled serialization;
- no unchecked mutation.
Public fields make construction easy but freeze representation and bypass invariants:
pub struct Budget { pub max_calls: u32, pub max_repairs: u32,}If every combination is valid and direct struct literals improve clarity, public fields may be appropriate for simple data-transfer types. For invariant-bearing domain values, use constructors and accessors.
Avoid getters mechanically for every field. Expose semantic operations:
budget.try_reserve()rather thanset_used;artifact.verify_hash()rather than mutable hash bytes;approval.execute(...)rather thanset_approved(true).
Parameter ownership
Section titled “Parameter ownership”Common choices:
| Type | Meaning |
|---|---|
&str, &[T], &Path | temporary read-only view |
String, Vec<T>, PathBuf | function takes durable ownership |
impl Into<String> | convenience at a construction edge |
impl AsRef<Path> | accepts several borrowed/owned path-like inputs |
Arc<T> | caller transfers one shared-ownership handle |
Prefer the least ownership required. Do not make every internal function generic over AsRef/Into;
concrete types improve errors and compile time.
Returning a borrowed value ties it to an input/self lifetime. Return owned data when it must outlive the source or when representation should be decoupled.
Do not return &Vec<T>; return &[T]. Do not accept &String when &str is enough. Use path types
for paths.
Constructors and builders
Section titled “Constructors and builders”One clear required-input constructor:
impl TaskId { pub fn new(value: impl Into<String>) -> Result<Self, DomainError>;}For many optional fields, a builder can prevent an unreadable parameter list:
let task = TaskBuilder::new(id, objective) .risk(RiskClass::High) .budget(budget) .build()?;Builder design questions:
- can
buildfail? - are defaults safe and versioned?
- can required fields be omitted?
- is reuse supported?
- are setters consuming or
&mut self?
Typestate builders can enforce required fields at compile time but create complex types/docs. Prefer a fallible simple builder unless compile-time state materially prevents high-consequence misuse.
Do not use Default for types with no safe obvious default. RunBudget::default is a policy choice;
changing it can alter cost/quality even if signatures stay stable. Treat such changes as behavior
compatibility decisions.
Errors are public policy
Section titled “Errors are public policy”A library error enum lets callers match recovery classes:
match error { RunError::InvalidOutput { violations } => repair(violations), RunError::Model(error) => provider_policy(error), RunError::Trace(error) => fail_closed(error), // ...}Adding an enum variant can break exhaustive downstream matches. Options:
- mark public error enum
#[non_exhaustive]; - expose stable category methods and keep detailed source opaque;
- document that major versions may add variants;
- use private error representation with public codes.
#[non_exhaustive] requires downstream wildcard matching. That improves evolution but reduces
compiler-enforced exhaustive policy. For closed domain state enums where every variant must be
handled, exhaustive breaking changes may be the right tradeoff.
Never require callers to parse Display. Error prose is for humans and may change. Preserve
structured variants/codes and source chains.
#[must_use]
Section titled “#[must_use]”Result is already must_use. Domain types whose ignored value likely indicates a bug may also use:
#[must_use = "a reservation must be held for the protected operation"]pub struct Permit { /* ... */ }Do not overuse it on ordinary data. A lint is guidance, not a resource policy; Drop and state
ownership must still make abandonment safe.
Iterators over owned storage
Section titled “Iterators over owned storage”Good collection APIs may expose:
pub fn events(&self) -> impl Iterator<Item = &TraceEvent> { self.events.iter()}This hides the concrete collection while allowing streaming access. But impl Iterator captures
lifetimes and is a public opaque return type whose behavior/order should be documented.
Returning Vec<T> transfers an allocated snapshot. Returning &[T] exposes contiguous storage and
borrows. Choose from caller/lifetime/evolution needs.
Traits as public extension points
Section titled “Traits as public extension points”Publishing a trait invites downstream implementations. That makes adding required methods breaking unless a sound default exists.
For sealed/internal traits:
- keep trait private or
pub(crate); - use a sealed supertrait pattern when public bounds are needed but implementations must remain controlled;
- expose constructors/configuration instead of implementation hooks.
For true extension traits:
- document semantic laws;
- minimize required methods;
- provide contract tests;
- plan dyn compatibility;
- avoid exposing private/vendor types.
Adding a provided method is often compatible. Adding a required associated type/method is not.
Avoid boolean blindness
Section titled “Avoid boolean blindness”This call is unreadable:
run(task, true, false, true);Use enums/options/config types:
run(task, RepairPolicy::OneAttempt, TraceMode::Full);Multiple booleans also create invalid combinations. Enums express one choice; structs with validation express related policy.
Likewise, avoid (String, String, u64) return tuples when fields have meaning. Name a struct.
Avoid stringly typed domain values
Section titled “Avoid stringly typed domain values”Prefer:
ModelIdoverString;Durationoveru64 timeout;ContentHashoverString;PathBufover path string;RiskClassenum over"high";ArtifactIddistinct fromTaskId.
Newtypes prevent argument swaps and centralize validation/serialization.
Do not create newtypes for every local integer. Use them at semantic/API boundaries where confusion or invalid values matter.
Anti-patterns in Rust AI applications
Section titled “Anti-patterns in Rust AI applications”Provider SDK types everywhere
Section titled “Provider SDK types everywhere”Map at adapter boundaries into application vocabulary.
serde_json::Value as the whole domain
Section titled “serde_json::Value as the whole domain”Use it only where genuine dynamic structure exists. Typed contracts give validation, docs, and exhaustive handling.
Arc<Mutex<AppState>>
Section titled “Arc<Mutex<AppState>>”Split ownership/capabilities; use immutable state, messages, or narrower locks.
unwrap on user/model/network input
Section titled “unwrap on user/model/network input”Return typed errors. expect is appropriate in tests when fixture invalidity is a test bug.
Clone-driven architecture
Section titled “Clone-driven architecture”Decide owners. Clone small IDs/handles intentionally; avoid whole payload copies to appease borrows.
Generic abstraction before two real implementations
Section titled “Generic abstraction before two real implementations”Use ordinary concrete code until variability/capability is known. Extract a trait from the consumer.
Dynamic dispatch inside every layer
Section titled “Dynamic dispatch inside every layer”Erase types only at real runtime choices.
Async everywhere
Section titled “Async everywhere”Pure CPU/domain logic stays synchronous. Async propagates where suspension/I/O is part of the contract.
Errors converted to strings early
Section titled “Errors converted to strings early”Preserve typed/source information until the CLI/HTTP/log boundary.
One configuration struct for every component
Section titled “One configuration struct for every component”Pass narrow validated config/capabilities, not secret-bearing global state.
Semantic Versioning for Rust crates
Section titled “Semantic Versioning for Rust crates”SemVer versions are MAJOR.MINOR.PATCH:
- patch: compatible bug fixes;
- minor: backward-compatible additions;
- major: breaking API/behavior changes.
For 0.y.z, compatibility expectations are more conservative and ecosystem conventions vary; Cargo
interprets caret requirements with special zero-major rules. State project policy.
Rust breaking changes include:
- removing/renaming public items;
- changing signatures, bounds, auto traits, lifetimes;
- adding required trait methods;
- adding enum variants for exhaustive matches;
- making fields private;
- changing feature/default behavior;
- raising MSRV under your policy;
- behavior changes callers reasonably rely on;
- serialized representation changes.
Compilation compatibility is not the whole contract.
Use tools such as cargo-semver-checks as evidence, not absolute proof. They cannot fully judge
behavior, wire formats, performance, or operational policy.
Separate release identities
Section titled “Separate release identities”An AI run may need:
| Identity | Purpose |
|---|---|
| crate/application version | compiled code release |
| program/harness revision | prompt/tools/validators/policy |
| task/trace schema version | serialized meaning |
| model/adapter/tokenizer revision | inference behavior |
| eval suite version | acceptance evidence |
| native runtime/driver version | local execution behavior |
Bumping a crate patch version does not automatically migrate stored traces. Changing a hosted model alias can alter behavior without any crate version change. Record them independently.
MSRV and edition policy
Section titled “MSRV and edition policy”The companion declares:
edition = "2024"rust-version = "1.96"MSRV (minimum supported Rust version) affects consumers and CI. Test it explicitly. Raising MSRV may be minor or major according to your published policy/ecosystem, but must never be accidental through dependency resolution.
An edition is per crate and changes opt-in language behavior. It is not a runtime or wire version. Workspace members should align unless a migration requires staging.
Deprecation and migration
Section titled “Deprecation and migration”For compatible transitions:
#[deprecated(since = "0.2.0", note = "use ModelId::new")]pub fn parse_model_id(...) -> ... { ... }Provide:
- replacement path;
- behavior differences;
- migration example;
- removal version/policy;
- compatibility fixtures for wire formats.
Do not leave unsafe behavior indefinitely under deprecation. Security fixes may require breaking change with clear release notes.
Documentation is part of the API
Section titled “Documentation is part of the API”Rustdoc should include:
- one-sentence purpose;
- errors;
- panics (ideally none for untrusted input);
- safety for unsafe functions;
- examples that compile;
- effect/cancellation/order semantics;
- version/feature requirements.
Doc tests keep examples synchronized. Integration tests exercise the crate as an external caller and cannot access private internals.
The companion adds crates/mosaic-domain/tests/public_api.rs, which proves public construction and
serialized terminal status from outside the library crate.
Runnable API inspection
Section titled “Runnable API inspection”Run:
cd rust/rust-ai-engineeringcargo test -p mosaic-domain --test public_apicargo doc --workspace --no-depscargo clippy --workspace --all-targets --all-features -- -D warningsOpen generated docs locally from:
target/doc/mosaic_domain/index.htmlThis chapter does not add a separate toy executable because the subject is the compiled public surface itself. The integration test and rustdoc output are its runnable artifacts.
Failure investigation: minor release breaks downstream matches
Section titled “Failure investigation: minor release breaks downstream matches”Symptom: adding a RunError::Cancelled variant breaks consumers’ exhaustive matches.
Questions:
- was the enum marked non-exhaustive?
- did the documented policy promise closed variants?
- can cancellation map to an existing stable category without losing needed behavior?
- is a major version appropriate?
- do internal and public error types need separation?
Do not add wildcard matches inside your own safety-critical policy merely to avoid decisions. Internal exhaustive handling can coexist with a more evolvable public category API.
Failure investigation: patch release changes cost
Section titled “Failure investigation: patch release changes cost”Symptom: default repair count increases, doubling model calls.
Signature and schema may be unchanged, but behavior/cost contract changed. Treat defaults as API:
- version program policy;
- include budget in trace;
- compare eval quality/latency/cost;
- announce migration;
- require explicit opt-in if impact is material.
SemVer review must include operational behavior, not only compiler checks.
Common failure modes
Section titled “Common failure modes”Public fields on invariant types
Section titled “Public fields on invariant types”Use private representation and semantic operations.
Excessive generic convenience
Section titled “Excessive generic convenience”Use Into/AsRef at construction/edge APIs, not every internal function.
Default without safe meaning
Section titled “Default without safe meaning”Require explicit security/cost policy.
Error strings as machine contract
Section titled “Error strings as machine contract”Expose variants/codes/categories.
Public traits without evolution plan
Section titled “Public traits without evolution plan”Minimize required methods and document laws.
SemVer equated with compilation only
Section titled “SemVer equated with compilation only”Include behavior, wire, feature, MSRV, and performance/cost expectations.
One release version for every artifact
Section titled “One release version for every artifact”Record independent model/program/schema/eval/native identities.
Security and performance implications
Section titled “Security and performance implications”Security:
- keep authority-bearing constructors/fields narrow;
- avoid
Default/Cloneon approvals/secrets; - document and test panic-free untrusted input;
- expose effect/capability semantics, not broad state handles;
- audit new public trait implementations and feature flags;
- version security policy and serialized contracts.
Performance:
- borrowed inputs avoid unnecessary ownership;
- generic convenience/monomorphization can increase compile/binary cost;
- opaque iterator returns avoid snapshots but retain borrows;
- stable abstractions may constrain later optimization if representation leaks;
- behavioral compatibility includes latency/memory/cost where callers depend on bounds.
Do not prematurely expose zero-copy internals. A clear owned API can evolve more safely until profiling proves a borrowing contract necessary.
Exercises
Section titled “Exercises”Recall
Section titled “Recall”- Why does privacy improve compatibility?
- What non-signature changes can break an API?
- When is
#[non_exhaustive]useful? - Why are schema and crate versions separate?
Implementation
Section titled “Implementation”- Add a validating
TaskBuilderwith no boolean parameters. - Mark/evolve an error enum using stable categories.
- Add rustdoc examples for
TaskIdandRunBudget. - Run a semver checker against a baseline release.
- Add an external integration test for malformed deserialization.
Design
Section titled “Design”Audit every public item in mosaic-domain:
- caller;
- invariant;
- ownership;
- errors;
- serialization;
- evolution risk;
- whether it should be public.
Propose a v1 public surface and separate internal types where needed.
Failure lab
Section titled “Failure lab”Classify each as patch/minor/major or policy-dependent:
- add a private helper;
- add an error variant;
- add a default trait method;
- add a required trait method;
- change JSON field name;
- raise MSRV;
- reduce max output default;
- make model routing deterministic;
- remove a default feature.
Explain source, behavior, and wire compatibility separately.
Solution guidance
Section titled “Solution guidance”A builder should own required id/objective from construction, accept optional validated policy, and
return Result<Task, DomainError> from build. Avoid hidden network/filesystem work.
Public error categories can be stable (InvalidInput, Unavailable, PolicyDenied, Internal)
while detailed internal enums/source chains drive local policy before mapping.
For the API audit, use cargo doc --document-private-items internally plus public rustdoc output.
Search downstream workspace uses before reducing visibility.
Semver tools catch structural changes; maintain golden schema fixtures and behavior/eval gates for the rest.
Check your understanding
Section titled “Check your understanding”- Should a helper accepting a prompt take
Stringor&str? - When is a builder preferable to a constructor?
- Can adding an enum variant be breaking?
- Is changing a safe default always a patch?
- What does a sealed trait prevent?
- Why can a public trait be harder to evolve than a struct?
- What identity changes when only a prompt changes?
- Why are integration tests useful for visibility/API claims?
Completion checklist
Section titled “Completion checklist”- Every public item has a real caller and documented contract.
- Invariant-bearing fields remain private.
- Ownership in signatures matches required lifetime.
- Errors are machine-actionable without parsing prose.
- Defaults are safe, explicit, and versioned.
- Public traits have laws and an evolution plan.
- SemVer review includes behavior, schema, features, and MSRV.
- Model/program/schema/eval/native identities remain distinct.
- External integration tests and rustdoc build cleanly.