Skip to content

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.

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.

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 adapters

These are related but separately versioned surfaces.

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.

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 than set_used;
  • artifact.verify_hash() rather than mutable hash bytes;
  • approval.execute(...) rather than set_approved(true).

Common choices:

TypeMeaning
&str, &[T], &Pathtemporary read-only view
String, Vec<T>, PathBuffunction 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.

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 build fail?
  • 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.

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.

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.

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.

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.

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.

Prefer:

  • ModelId over String;
  • Duration over u64 timeout;
  • ContentHash over String;
  • PathBuf over path string;
  • RiskClass enum over "high";
  • ArtifactId distinct from TaskId.

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.

Map at adapter boundaries into application vocabulary.

Use it only where genuine dynamic structure exists. Typed contracts give validation, docs, and exhaustive handling.

Split ownership/capabilities; use immutable state, messages, or narrower locks.

Return typed errors. expect is appropriate in tests when fixture invalidity is a test bug.

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.

Erase types only at real runtime choices.

Pure CPU/domain logic stays synchronous. Async propagates where suspension/I/O is part of the contract.

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.

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.

An AI run may need:

IdentityPurpose
crate/application versioncompiled code release
program/harness revisionprompt/tools/validators/policy
task/trace schema versionserialized meaning
model/adapter/tokenizer revisioninference behavior
eval suite versionacceptance evidence
native runtime/driver versionlocal 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.

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.

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.

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.

Run:

Terminal window
cd rust/rust-ai-engineering
cargo test -p mosaic-domain --test public_api
cargo doc --workspace --no-deps
cargo clippy --workspace --all-targets --all-features -- -D warnings

Open generated docs locally from:

target/doc/mosaic_domain/index.html

This 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:

  1. was the enum marked non-exhaustive?
  2. did the documented policy promise closed variants?
  3. can cancellation map to an existing stable category without losing needed behavior?
  4. is a major version appropriate?
  5. 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.

Use private representation and semantic operations.

Use Into/AsRef at construction/edge APIs, not every internal function.

Require explicit security/cost policy.

Expose variants/codes/categories.

Minimize required methods and document laws.

Include behavior, wire, feature, MSRV, and performance/cost expectations.

Record independent model/program/schema/eval/native identities.

Security:

  • keep authority-bearing constructors/fields narrow;
  • avoid Default/Clone on 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.

  1. Why does privacy improve compatibility?
  2. What non-signature changes can break an API?
  3. When is #[non_exhaustive] useful?
  4. Why are schema and crate versions separate?
  1. Add a validating TaskBuilder with no boolean parameters.
  2. Mark/evolve an error enum using stable categories.
  3. Add rustdoc examples for TaskId and RunBudget.
  4. Run a semver checker against a baseline release.
  5. Add an external integration test for malformed deserialization.

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.

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.

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.

  • Should a helper accepting a prompt take String or &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?
  • 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.