My first few months writing Rust professionally were mostly a fight with the borrow checker, and the code I shipped in that period, while it compiled, reads nothing like the code I write now. A year in, on a services team that adopted the 2021 edition early and moved onto 2024 edition once it stabilized, a set of patterns stuck that no single tutorial handed me all at once.
My earliest instinct whenever the compiler complained about ownership was to sprinkle .clone() until it went away. That works, and it's genuinely fine for small values, but on anything larger it's a sign the actual data flow through the function isn't clear yet. Restructuring functions to borrow with & where possible and only take ownership when the function genuinely needs to consume the value made the code both faster and, unexpectedly, easier to read six months later.
Early code was full of .unwrap() calls that I told myself I'd fix later. Adopting the ? operator to propagate errors up through Result-returning functions, combined with a proper error type using thiserror instead of stringly-typed errors, turned functions that used to panic into functions that fail predictably and give the caller a real decision to make.
#[derive(thiserror::Error, Debug)]
enum ConfigError {
#[error("missing field: {0}")]
MissingField(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
fn load_config(path: &str) -> Result {
let raw = std::fs::read_to_string(path)?;
parse(&raw)
}
Coming from languages where a for loop with an index is the default, Rust's iterator combinators, map, filter, collect, fold, felt unnecessarily indirect at first. They stopped feeling indirect once I noticed the compiler optimizes them down to the same code as a hand-written loop in release builds, with none of the off-by-one bugs a manual index-based loop invites. Now a manual loop in my code is the exception that needs a specific justification, not the default.
Passing raw u64 values around for both a UserId and an OrderId invited exactly the kind of parameter-order bug the type system exists to prevent. Wrapping primitive types in a newtype struct, struct UserId(u64), costs almost nothing at runtime and turns an entire class of "passed the wrong id into the wrong function" bugs into compile errors instead of production incidents.
I over-used dyn Trait objects early on because the syntax felt more familiar coming from interface-based languages. Generics with trait bounds get monomorphized at compile time and avoid the dynamic dispatch cost, and for anything in a hot path that distinction matters. My rule now: generics by default, trait objects specifically when I need a heterogeneous collection of different types behind one interface, not as the default choice.
Rust's module system rewards deliberate organization more than I expected, and a single lib.rs that grows past a few hundred lines starts fighting you on visibility and imports. Splitting into focused modules early, with pub(crate) rather than pub as the default visibility unless something genuinely needs to be part of the public API, kept the crate's actual public surface small and intentional rather than accidental.
Running cargo clippy as part of CI, not just occasionally by hand, caught patterns I didn't know were non-idiomatic, unnecessary .to_string() calls, redundant clone-then-borrow patterns, needless returns. Treating clippy warnings as CI failures rather than suggestions forced the whole team toward a consistent style faster than a written style guide ever did.
A config struct with eight optional fields and a handful of required ones led to construction sites full of Default::default() spread syntax that was easy to get subtly wrong. Switching to a builder, a separate ConfigBuilder with chained methods and a final .build() that returns a Result, moved validation of required combinations to one place instead of scattering it across every call site that constructed the struct directly.
Early on I structured code specifically to dodge explicit lifetime annotations, cloning or owning data just to sidestep the syntax. That avoidance cost real performance in a couple of hot-path functions that didn't need to own their inputs at all. Actually learning to read and write &'a annotations, rather than treating them as something to engineer around, opened up borrowing patterns that were both faster and, once the syntax stopped feeling foreign, no harder to read than the clone-heavy alternative.
Moving the crate from the 2021 to the 2024 edition, mostly for the tightened closure capture rules and the updated prelude, was a change I expected to eat a full day of fighting the compiler. cargo fix --edition handled the bulk of it automatically, and the manual cleanup afterward was smaller and more mechanical than the migration guides made it sound, mostly adjusting a handful of places where the new closure capture behavior changed which fields got moved into a closure.
The patterns that mattered most weren't clever tricks, they were consistently choosing the boring, explicit option: borrow instead of clone, propagate errors instead of unwrapping, newtypes instead of raw primitives. None of that shows up in a "10 Rust tips" listicle, but it's the difference between code that merely compiles and code a teammate can actually maintain a year later.