Rust Patterns
The borrow checker is a design-feedback tool: when it fights you, the ownership story of the code is unclear, and the fix is almost always restructuring, not sprinkling clone() until it compiles. The costly mistake this skill prevents is exactly that clone-and-unwrap() dialect - code that compiles, ships, then panics in production or silently copies megabytes per request.
Operating procedure
Step 1: Gather inputs
- Binary or library? Libraries need typed errors and conservative signatures; binaries can be looser.
- Threading/async story: single-threaded, threaded, or async (which runtime - assume Tokio unless told otherwise).
- Performance sensitivity: hot path or glue code. Label guesses as guesses; don't optimize glue.
- Existing conventions (error crate in use, MSRV constraints stated by the user).
Step 2: Decide ownership per signature - the four-question rule
For every function parameter, in order:
- Only reads? Take
&T- and prefer the borrowed view type:&strover&String,&[T]over&Vec<T>,&Pathover&PathBuf. This makes the API maximally callable. - Mutates in place? Take
&mut T. - Stores or consumes the value (moves it into a struct, sends it to a channel, returns it)? Take
Tby value - taking&Tand cloning inside hides the cost from the caller; takingTlets callers who are done with the value move it for free. - Sometimes borrows, sometimes owns?
impl Into<String>/Cow<'_, str>for ergonomic APIs - but only at public boundaries; internally, pick one.
Return owned values from constructors. Default to borrowing everywhere else. If a signature needs a lifetime annotation you can't explain in one sentence, restructure instead: most lifetimes should stay elided.
Step 3: Choose the sharing tier - escalate only on proof
Work down this ladder and stop at the first rung that works; each step adds runtime cost and API noise.
- Plain ownership + borrows (the default, ~95% of code).
Rc<T>- single-threaded shared ownership (e.g. a graph node referenced by several parents).Rc<RefCell<T>>- add interior mutability; runtime borrow panics replace compile errors, so keep borrow scopes tiny.Arc<T>/Arc<Mutex<T>>(orRwLock) - cross-thread. In async code, never hold astd::sync::Mutexguard across an.await; use the runtime's mutex or restructure so the guard drops first.
Prefer owning data in structs over storing references with lifetimes - a struct with a lifetime parameter infects every type that contains it. Self-referential structs are a design smell: use indices into a Vec, or an arena, not Pin gymnastics.
Step 4: Design errors by audience
- Library:
thiserror- a typed enum,#[error(...)]messages,#[from]for source errors. Callers must be able to match on variants. - Application:
anyhow-Result<T, anyhow::Error>with.context("reading config")at each layer, so failures carry a breadcrumb trail. ?for all recoverable errors;panic!/expectonly for invariant violations that indicate a bug, andexpect("why this cannot fail")with a reason, never bareunwrap()on production paths.
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
fn load(path: &str) -> Result<String, AppError> {
Ok(std::fs::read_to_string(path)?)
}
Step 5: Apply the idiom checklist
- Iterators and combinators (
map,filter,collect) over index loops - they eliminate bounds-check noise and off-by-ones. Option/Resultcombinators (map,and_then,ok_or,unwrap_or_else) over nestedmatch; but amatchis still clearer than a four-combinator chain.- Newtypes for domain values (
struct UserId(u64)) so the compiler catches transposed arguments; implementFromfor conversions; deriveDebug, Clone, PartialEqwhere sensible. - Make illegal states unrepresentable: an enum with
Connected(Session)/Disconnectedbeats a struct withis_connected: booland a nullable session. - Generics for hot-path polymorphism (monomorphized, inlined);
dyn Traitfor heterogeneous collections and to keep compile times and binary size down at cold boundaries.
Step 6: Performance and hygiene passes
- Grep for
clone(): each one is either justified (write a word of why), replaced by a borrow, or replaced by a move. Vec::with_capacitywhen size is known; avoid collecting intermediates you immediately iterate again.- Profile before optimizing anything beyond these defaults.
unsafestays tiny, wrapped in a safe API, with a// SAFETY:comment stating the invariant.cargo clippy -- -D warningsandcargo fmt --checkrun in CI, not just locally.
Worked artifact: signature and sharing, bad/good
Bad - fights the checker, hides costs, panics:
fn process(names: Vec<String>, cfg: &Config) -> Vec<String> {
let mut out = Vec::new();
for i in 0..names.len() {
let n = names[i].clone(); // clone to dodge a move error
out.push(format!("{}-{}", cfg.prefix.clone(), n));
}
out
}
let shared = Arc::new(Mutex::new(state)); // single-threaded code!
let v = map.get("k").unwrap().clone(); // panic + copy
Good:
fn process<'a>(names: impl Iterator<Item = &'a str>, prefix: &str) -> Vec<String> {
names.map(|n| format!("{prefix}-{n}")).collect()
}
let shared = Rc::new(RefCell::new(state)); // right tier for one thread
let v = map.get("k").ok_or(AppError::NotFound("k".into()))?;
The good version borrows what it reads, allocates only the output it must own, uses the cheapest sharing tier, and turns the panic into a typed error.
Deliverable
Produce code (or a review) where every signature is justified by the four-question rule, sharing primitives are at the lowest sufficient tier, the error strategy matches the audience (thiserror for libraries, anyhow for apps), and every remaining clone(), expect, and unsafe block carries its one-line justification.
Do NOT
- Do not
clone()to silence a borrow error - restructure ownership; the checker is pointing at a real design ambiguity. - Do not take
&String/&Vec<T>/&PathBufin signatures; the borrowed view types cost nothing and accept more callers. - Do not reach for
Arc<Mutex<T>>in single-threaded code, orRc<RefCell<T>>as a default - they are escalations, not conveniences. - Do not hold a sync mutex guard across
.await- it deadlocks or blocks the executor. - Do not
unwrap()on production paths;?orexpect("reason"). - Do not store references in structs when owning is feasible; lifetime parameters propagate to every containing type.
- Do not block the async executor with sync I/O; use the runtime's
spawn_blockingfor it.
Quality bar
cargo clippy -- -D warningsclean;cargo fmtclean.- No bare
unwrap()outside tests; everyexpectstates why it can't fail. - Every
clone()in the diff is annotated or eliminated. - Public API takes borrowed view types and returns owned values.
unsafeblocks each have a// SAFETY:invariant comment.
Escalation
For migrating a codebase between language versions or from another language, use language-version-migrator. Async architecture questions beyond Rust idiom (queues, services) route to microservices or kafka-pipelines; API surface design to api-design.