Lesson 1 of 9 · 20m
Ownership and Borrowing in Service Code
Move semantics, &T vs &mut T, own at the boundaries borrow in the middle, and when Arc actually earns its keep.
Every service you write in Rust passes data through the same gauntlet: a request comes in, gets parsed, gets validated, hits a repository, and produces a response. In a garbage-collected language you never ask who owns that data. In Rust the question is the whole design — and getting it right once means the compiler defends your invariants forever after.
The rules that matter
Rust's ownership model has three rules that explain 95% of what you'll hit in service code:
- Every value has exactly one owner. When the owner goes out of scope, the value is dropped.
- You can have any number of shared borrows (
&T) or exactly one exclusive borrow (&mut T), never both. - Borrows can't outlive the thing they point at.
Assignment, function calls, and returns all move ownership by default:
fn handle(body: String) -> usize {
body.len()
} // `body` is dropped here
let payload = String::from("...");
let n = handle(payload);
// `payload` is unusable from here on — it was moved into handle()If handle only needs to read, take &str instead. The caller keeps ownership and the function works with String, &String, string literals, and slices:
fn handle(body: &str) -> usize {
body.len()
}Where this shows up in real services
The pattern that separates idiomatic Rust from C++-in-Rust: own at the boundaries, borrow in the middle. A request handler deserializes into owned types (String, Vec<u8>, HashMap). The layers beneath it — validation, transformation, serialization — should take references and only allocate when they genuinely produce something new.
This is the signature that causes the most reviewer friction, because it's also the most common real bug:
// Smells wrong: does the repo really need to own the name?
fn find_user(&self, name: String) -> Option<User>
// Better: borrow the lookup key, return an owned result
fn find_user(&self, name: &str) -> Option<User>Owned input is for when the callee stores or consumes the value. Borrowed input is for everything else. If you're cloning to satisfy a signature, the signature is probably wrong — fix the signature, don't add .clone().
Shared ownership: Arc and when you actually need it
Inside a single task, borrows are enough. Once you cross into tokio::spawn or shared state across workers, you need Arc<T> — atomically reference-counted shared ownership:
let config = Arc::new(AppConfig::load()?);
for _ in 0..workers {
let cfg = Arc::clone(&config); // cheap: bumps a refcount
tokio::spawn(async move { serve(cfg).await });
}Arc::clone is cheap. config.clone() where config: AppConfig is not — it deep-copies every field. Read .clone() calls in code review as a smell asking: "why can't this be a borrow or an Arc?"
The bug that ships
The classic service-level ownership bug isn't a compile error — it's the .clone() explosion. Someone couldn't figure out lifetimes, sprinkled clones until it compiled, and now every request allocates four copies of the user's session. It works. It's also slower than the Go service it replaced. The fix is almost always "change who owns the thing," not "clone smarter."
Try this
Take any function in a codebase you work on that takes String or Vec<T> and ask: does it store that value past its return? If not, change the signature to &str / &[T], fix the call sites, and count how many .clone() calls you just deleted.
Field notes
Rc<T>isArcwithout thread safety — it won't compile across.awaitpoints in async code, which is the compiler saving you.Cow<'a, str>is the "borrow if possible, own if necessary" type — ideal for functions that usually return a slice of the input but sometimes must allocate.- If you find
&mutused for "the accumulator," ask whether an iterator with.fold()or.collect()expresses the same thing without exclusive access.