Tech Wiki

TOPICSSERIES

[Rust Zero to Production 11] Refactor Rust APIs Around Ownership Instead of Reaching for `clone()`

Removing clone() does not automatically improve a Rust API. If every parameter becomes a reference, code that stores data will clone inside the function instead. The caller can no longer see where that ownership cost appears. At the other extreme, a read-only function that consumes its input forces callers to surrender or preemptively clone a value.

This refactor uses a different test: the signature should say who ultimately owns each value. Endpoint registration owns an EndpointDraft, validation borrows its fields briefly, and registry lookup returns a reference. Only an independent snapshot performs deliberate cloning.

1. Ownership Decision Example

The standalone Rust 2024 crate lives at examples/article-11-ownership-api-refactor. It does not depend on the shared endpoint-monitor, and it uses no external crates.

[package]
name = "article-11-ownership-api-refactor"
version = "0.1.0"
edition = "2024"
publish = false

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
all = "warn"
pedantic = "warn"

Run the complete example with these commands:

cd examples/article-11-ownership-api-refactor
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

The rule comes down to ownership duration. Take ownership when a function stores a value or transfers it into another owner. Borrow when the function only reads or checks the value during the call. Clone when an independent result must outlive the borrowed source and taking that source is not an option.

2. Borrow-Then-Clone APIs

An early registration API often looks like this:

fn register(&mut self, draft: &EndpointDraft) -> Endpoint {
    let endpoint = Endpoint {
        name: draft.name.clone(),
        url: draft.url.clone(),
        tags: draft.tags.clone(),
    };
    self.endpoints.push(endpoint.clone());
    endpoint
}

The function accepts &EndpointDraft, yet it needs to own every field. It also clones the completed Endpoint so it can both store and return one. A reference in the signature does not make this call cheap. It merely hides the cost in the function body.

If a function requires ownership, take the argument by value instead of borrowing and cloning it. If the function does not require ownership, borrow instead of taking a value only to drop it. This is not a rule to put & everywhere. The operation determines the boundary.

3. Own Conversion, Borrow Validation

Separate request and domain types make the conversion point visible. EndpointDraft is unvalidated input. Endpoint is the value allowed into storage.

#[derive(Debug, PartialEq, Eq)]
pub struct EndpointDraft {
    name: String,
    url: String,
    tags: Vec<String>,
}

#[derive(Debug, PartialEq, Eq)]
pub struct Endpoint {
    id: EndpointId,
    name: String,
    url: String,
    tags: Vec<String>,
}

impl TryFrom<EndpointDraft> for Endpoint {
    type Error = ValidationError;

    fn try_from(draft: EndpointDraft) -> Result<Self, Self::Error> {
        let EndpointDraft { name, url, tags } = draft;
        validate_name(&name)?;
        validate_url(&url)?;

        Ok(Self {
            id: EndpointId(0),
            name,
            url,
            tags,
        })
    }
}

validate_name and validate_url accept &str. They inspect text but never retain it, so borrowing fits. After validation, name, url, and tags move directly into Endpoint. The successful path has no clone() call.

The conversion can fail, which makes TryFrom a better fit than From. TryFrom represents controlled conversions that may fail. Use the standard From and TryFrom traits when those conversions fit. Validation now sits on an explicit type boundary rather than in an ad hoc from_request helper.

EndpointDraft::new accepts impl Into<String> for the name and URL. A caller with an existing String can move it. A caller with &str creates owned text at this construction boundary. The convenience does not erase cost: converting &str to String may allocate and copy, while passing an owned String does not clone it again.

4. Own Writes/Borrow Reads

The registry must keep an endpoint after the call returns, so registration consumes the draft. Lookup only observes stored data.

pub fn register(&mut self, draft: EndpointDraft) -> Result<EndpointId, RegisterError> {
    let mut endpoint = Endpoint::try_from(draft)?;
    if self.find_by_name(endpoint.name()).is_some() {
        return Err(RegisterError::DuplicateName(endpoint.name));
    }

    let id = EndpointId(self.next_id);
    self.next_id += 1;
    endpoint.id = id;
    self.endpoints.push(endpoint);
    Ok(id)
}

#[must_use]
pub fn find_by_name(&self, name: &str) -> Option<&Endpoint> {
    self.endpoints.iter().find(|endpoint| endpoint.name == name)
}

register takes EndpointDraft by value and returns a small EndpointId. It does not clone a stored endpoint merely to return it. A caller that wants to inspect the new record borrows the registry through find_by_name.

The duplicate-name path avoids a clone too. It borrows endpoint.name() for the lookup, then moves endpoint.name into DuplicateName(String) because that endpoint will not be stored. The error owns its message data and does not borrow a local value.

Accessors follow the same rule. name() and url() return &str; tags() returns &[String]. Reading a field does not warrant a fresh String or Vec<String>. Returning &str exposes a general borrowed string view while hiding the internal choice to store the field as String.

5. When Cloning Fits the Boundary

Cloning is not inherently a defect. If a result must outlive borrowed data and cannot take the original owner, it needs its own value. EndpointSnapshot has exactly that contract.

#[derive(Debug, PartialEq, Eq)]
pub struct EndpointSnapshot {
    pub id: EndpointId,
    pub name: String,
    pub url: String,
    pub tags: Vec<String>,
}

impl From<&Endpoint> for EndpointSnapshot {
    fn from(endpoint: &Endpoint) -> Self {
        Self {
            id: endpoint.id,
            name: endpoint.name.clone(),
            url: endpoint.url.clone(),
            tags: endpoint.tags.clone(),
        }
    }
}

The snapshot remains valid after the registry is dropped. Those field clones implement lifetime independence, rather than patching over an inconvenient compiler error. The owned return type also makes that contract visible. Borrowed-to-owned to_ conversions may be expensive. This example uses the standard From<&Endpoint> conversion to mark the boundary.

Clone itself carries no promise of being cheap. String::clone() copies heap data, and a clone call is a visible sign that arbitrary, potentially expensive code may run. Each type implements the Clone::clone method, so its cost depends on that implementation. EndpointId, by contrast, implements Copy because it is a small plain value. Scattering clone() calls across a large model does not make that model behave like a Copy type.

6. Compile-Testing Signatures

Behavior tests alone may not stop a future edit from changing EndpointDraft back to &EndpointDraft. Function pointer types can lock down the core ownership contract at compile time.

#[test]
fn public_signatures_express_the_ownership_boundary() {
    let _: fn(EndpointDraft) -> Result<Endpoint, ValidationError> = Endpoint::try_from;
    let _: fn(&mut Registry, EndpointDraft) -> Result<EndpointId, RegisterError> =
        Registry::register;
    let _: for<'a> fn(&'a Registry, &str) -> Option<&'a Endpoint> = Registry::find_by_name;
}

#[test]
fn snapshot_is_owned_and_survives_the_registry() {
    let snapshot = {
        let mut registry = Registry::new();
        registry
            .register(draft("api", "https://example.com/health"))
            .expect("valid draft");
        EndpointSnapshot::from(registry.find_by_name("api").expect("stored endpoint"))
    };

    assert_eq!(snapshot.name, "api");
    assert_eq!(snapshot.url, "https://example.com/health");
}

The first test checks that conversion and registration consume values, while lookup ties its returned reference to the registry borrow. The second proves that the owned snapshot remains usable outside the registry's scope. The intended signatures live in executable tests instead of a compile-fail snippet that can quietly become stale.

The seven tests cover empty names, unsupported URL schemes, successful registration, duplicate names, borrowed lookup, the owned snapshot, and public signatures.

7. Total Cost Beyond clone()

This example does not report timing benchmarks. The inputs are tiny, and there is no representative traffic, string-size distribution, allocator, or build profile. A number produced under those conditions would not generalize. What the code does establish is narrower: successful registration moves its input String and Vec<String> values without an explicit clone, while the independent snapshot has three explicit clone calls.

For a real service, start with a profile and choose a metric that matches the suspected bottleneck: allocation count and bytes, throughput, or latency. Fix representative name, URL, and tag sizes as well as the build profile, then compare both designs under the same conditions. A Clippy lint such as redundant_clone can find candidate copies, but it cannot design the API's ownership contract.

There is also a cost to eliminating clones by adding lifetimes across an owned model. If a long-lived object borrows an external buffer, lifetime parameters may spread through constructors, storage, and asynchronous work. For small data stored for a long time or written infrequently, taking ownership once may be the simpler and cheaper design overall. Borrowing is more promising for a large buffer that is inspected briefly. The useful measurement covers the whole design, not the count of one method name.

8. API Check

The example targets stable rustc 1.98.1, Cargo 1.98.1, and Rust 2024. It has no external dependencies.

The block below is the first test run from cargo test --all-features. The binary and documentation test runs that follow each contain 0 tests.

running 7 tests
test tests::conversion_rejects_empty_name ... ok
test tests::conversion_rejects_unsupported_scheme ... ok
test tests::duplicate_name_returns_the_owned_name ... ok
test tests::lookup_borrows_the_registry ... ok
test tests::public_signatures_express_the_ownership_boundary ... ok
test tests::registration_moves_owned_draft_into_storage ... ok
test tests::snapshot_is_owned_and_survives_the_registry ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The next three lines are the exact stdout from cargo run --quiet.

registered 1 api -> https://example.com/health
tags: production, critical
snapshot survives registry: api -> https://example.com/health

An ownership-friendly API is not one with the most references. It consumes values that must be stored, borrows values that are only observed, and makes an independent copy explicit when a result needs one. With those boundaries in place, clone() becomes a reviewable design decision instead of a reflex for silencing the borrow checker.

Full source code

The complete runnable source for this article is available in the Chapter 11 project on GitHub.

Sources


2 responses

  1. […] Next articleRefactor Rust APIs Around Ownership Instead of Reaching for `clone()` […]

  2. […] Previous articleRefactor Rust APIs Around Ownership Instead of Reaching for `clone()` […]

Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.