Course outline

Rust / Advanced / Lesson 18

API design and a reporting capstone

Integrate ownership, errors, modules, and measurement into a small report API whose validation and compatibility promises are visible to consumers.

About 5 min readContent reviewed
On this page

Lesson goals

  • Use a validated newtype to narrow the accepted domain.
  • Separate boundary parsing, pure computation, and presentation.
  • Review compatibility, minimum toolchain, and failure guarantees.

Decide ownership and failure before choosing syntax

An API should state who owns input and output, whether borrows are retained, and how failures appear. A borrowed slice suits a synchronous read-only report; a queued background task needs owned data or an explicitly retained owner. Return owned summaries when they should outlive the input. Avoid adding Clone, Send, or static bounds solely because one implementation happens to find them convenient. Each public bound restricts callers. Define maximum input size, arithmetic policy, ordering, and error categories before choosing a representation, so performance refactors can preserve a stable observable contract.

Make a valid count a distinct type

A newtype wraps a representation and can hide unchecked construction behind a validating function. The first example permits counts through one thousand and exposes an observing accessor. In a library the field remains private to its module, preventing external callers from inventing an invalid value through the ordinary safe API. Choosing Result preserves a reason for rejection. A newtype does not automatically supply every arithmetic or serialization policy; operations that combine counts must decide whether the result remains within the same domain or belongs to a different aggregate type.

rust Validation at construction
mod counts {
    pub struct Count(u32);
    impl Count {
        pub fn new(value: u32) -> Result<Self, &'static str> {
            if value > 1000 { Err("count too large") } else { Ok(Self(value)) }
        }
        pub fn get(&self) -> u32 { self.0 }
    }
}
fn main() {
    let count = counts::Count::new(42).expect("known valid input");
    println!("{} {}", count.get(), counts::Count::new(1001).is_err());
}
Expected output
42 true

Forty-two becomes a valid Count. One thousand one is rejected before a value of that type is created.

Keep report computation separate from I/O

The second program takes a borrowed slice and returns an owned tuple containing count and total. try_fold stops on the first overflowing addition, returning None without mutating caller data. For a real API, a named summary struct and structured error may be clearer than this compact tuple and Option. The example prints a normal result and an intentional checked overflow result; neither depends on debug-mode overflow settings. Empty input naturally yields zero records and zero total. A console adapter can parse records and format this result without placing terminal operations inside the pure calculation.

rust A deterministic borrowed computation with checked failure
fn report(records: &[u32]) -> Option<(usize, u32)> {
    let total = records.iter().try_fold(0u32, |sum, value| sum.checked_add(*value))?;
    Some((records.len(), total))
}
fn main() {
    let (count, total) = report(&[2, 4, 6]).expect("small total");
    println!("{count} {total}");
    println!("{:?}", report(&[u32::MAX, 1]));
}
Expected output
3 12
None

Three records total twelve. The second input cannot fit its sum in u32, so the explicit failure value is None.

Public changes affect more than compilation here

Semantic versioning is a consumer-facing policy, not a guarantee inferred from a local build. Changing method signatures, trait bounds, public fields, or enum exhaustiveness can break callers. Adding an enum variant may require consumer match changes unless the published design already permits non-exhaustive handling. Raising rust-version excludes older supported toolchains and should be intentional. Document edition, enabled features, panic behavior, allocation expectations, and error stability. Keep private helpers private so implementation changes do not become accidental compatibility obligations. Integration tests and documentation examples provide evidence from the consumer view.

Deliver a reviewable small system

Build a package with a library for validated types and report logic, plus a binary for synthetic line input and presentation. Reject malformed lines with contextual structured errors, impose record limits, and preserve deterministic ordering. Test empty input, numeric boundaries, duplicate policy, overflow, and unchanged input after failure. If concurrency is justified, define ownership, joining, and shutdown before parallelizing. If async I/O is required, state the runtime dependency and cancellation contract. Profile a representative release workload only after correctness tests pass, and retain the measurements with the source revision. The completed design should let another developer understand obligations without reading every implementation detail.

Common errors and fixes

A read-only API unnecessarily consumes every record.

Why it happens
Implementation convenience dictated the ownership contract.
How to fix it
Borrow a slice when the operation does not retain data.
Prevent it next time
Choose ownership from lifetime requirements.

An added enum variant breaks downstream matches.

Why it happens
Exhaustiveness was part of the public contract.
How to fix it
Plan compatibility or a documented non-exhaustive design.
Prevent it next time
Review public changes from consumer code.

An optimization silently raises the minimum compiler.

Why it happens
A newer API was introduced without support review.
How to fix it
Use a supported alternative or update rust-version deliberately.
Prevent it next time
Verify the declared minimum toolchain in checks.

Check your understanding

1. State the report result for an empty slice, then design a clearer public result type for a library that must explain overflow.

Show worked solution

The current function returns Some((0, 0)). A public API could return Result<Summary, ReportError>, with named records and total fields and an Overflow variant. This separates a successful empty report from arithmetic failure without asking callers to infer meanings from tuple positions or absence. Tests should preserve zero-work success as well as the overflow category.

2. A caller wants to queue report work after dropping its input vector. Explain the required change from the borrowed synchronous interface.

Show worked solution

The queued task must own the records or retain an appropriate shared immutable owner until execution completes. Accept a Vec by value and move it into the task, or use a documented shared-owner design. A slice alone cannot supply the needed lifetime. Include queue limits, cancellation, failure delivery, and shutdown in the asynchronous contract before comparing performance.

Key takeaways

  • Ownership, errors, and compatibility are public obligations.
  • Separate validation, pure computation, and presentation.
  • Integrate measurements only after preserving correctness.

Official references