Rust Development Best Practices
Project Structure
Single crate layout:
my-crate/
Cargo.toml
src/
lib.rs # library root
main.rs # binary entry point (calls into lib.rs)
config.rs
error.rs
tests/ # integration tests
api_test.rs
benches/
throughput.rs
examples/
basic_usage.rs
Library + thin binary pattern — keep logic in lib.rs, binary is a thin wrapper:
// src/main.rs
fn main() -> anyhow::Result<()> {
let config = my_crate::Config::from_env()?;
my_crate::run(config)
}
Workspace layout for multi-crate projects:
# Cargo.toml (workspace root)
[workspace]
resolver = "2"
members = ["crates/*"]
[workspace.package]
edition = "2024"
rust-version = "1.85" # set to your project's minimum supported version
license = "MIT"
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
anyhow = "1"
tracing = "0.1"
Each member crate inherits shared deps:
# crates/my-service/Cargo.toml
[package]
name = "my-service"
edition.workspace = true
rust-version.workspace = true
[dependencies]
tokio.workspace = true
serde.workspace = true
Ownership & Borrowing
Accept borrowed types in function signatures — let callers decide allocation:
&stroverString&[T]overVec<T>&PathoverPathBufimpl AsRef<str>when accepting both&strandString
// Good: accepts &str, String, Cow<str>
fn process(name: impl AsRef<str>) {
let name = name.as_ref();
// ...
}
// Good: borrows slice, works with Vec<T>, arrays, slices
fn sum(values: &[i32]) -> i32 {
values.iter().sum()
}
Use Cow<'_, str> when a function sometimes allocates and sometimes borrows:
use std::borrow::Cow;
fn normalize(input: &str) -> Cow<'_, str> {
if input.contains(' ') {
Cow::Owned(input.replace(' ', "_"))
} else {
Cow::Borrowed(input)
}
}
Every .clone() must have a reason. If you clone to satisfy the borrow checker, restructure the code first.
Error Handling
Library crates — define a typed error enum with thiserror:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error at line {line}: {message}")]
Parse { line: usize, message: String },
#[error("not found: {0}")]
NotFound(String),
}
pub type Result<T> = std::result::Result<T, Error>;
Never use String as an error type. Never use Box<dyn std::error::Error> in library public APIs.
Application crates — use anyhow::Result with .context():
use anyhow::{Context, Result};
fn load_config(path: &Path) -> Result<Config> {
let contents = std::fs::read_to_string(path)
.context("failed to read config file")?;
let config: Config = toml::from_str(&contents)
.context("failed to parse config")?;
Ok(config)
}
let-else for early returns on pattern match failure:
let Some(user) = db.find_user(id)? else {
return Err(Error::NotFound(id.to_string()));
};
// user is unwrapped here — no nesting
Rules:
- No
.unwrap()in library code. expect("reason")only when provably safe (e.g., after a check or on a known-valid constant).#[expect(lint_name)]over#[allow(lint_name)]— warns when the suppression becomes unnecessary.- Propagate with
?. Add.context()when the surrounding context would otherwise be lost.
See patterns/error-handling-patterns.md for conversion chains, backtrace, and decision trees.
Naming Conventions
| Kind | Convention | Example |
|---|---|---|
| Types, traits, enums | PascalCase | HttpClient, ParseError |
| Functions, methods, variables | snake_case | read_file, total_count |
| Constants, statics | SCREAMING_SNAKE | MAX_RETRIES, DEFAULT_PORT |
| Modules, crates | snake_case | my_crate, config |
Constructors:
new()— default constructor, takes required fieldswith_capacity(),with_timeout()— constructor variants
Conversions:
from_bytes(),from_str()— fallible or infallible construction from another typeto_string(),to_vec()— potentially expensive conversion, returns ownedinto_inner(),into_bytes()— consumes self, returns ownedas_str(),as_bytes()— cheap reference conversion, returns borrowed
No get_ prefix for getters. Use the field name directly:
impl Config {
pub fn port(&self) -> u16 { self.port } // not get_port()
pub fn is_debug(&self) -> bool { self.debug } // booleans: is_/has_/can_
}
Builder pattern — set_ or bare name with mut self:
impl ServerBuilder {
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn build(self) -> Result<Server, Error> { /* ... */ }
}
Traits
Derive Debug on everything. Derive Clone, PartialEq when the type semantics support it.
#[derive(Debug, Clone, PartialEq)]
pub struct Endpoint {
pub host: String,
pub port: u16,
}
Standard trait usage:
| Trait | When |
|---|---|
Display |
User-facing output, error messages |
Debug |
Always — developer output, logging |
From<T> / TryFrom<T> |
Infallible / fallible type conversions |
Default |
Type has a meaningful zero/empty state |
Clone |
Value can be duplicated |
PartialEq / Eq |
Type supports equality comparison |
Hash |
Type used as HashMap key (requires Eq) |
Serialize / Deserialize |
Data crosses a boundary (network, disk, config) |
Trait upcasting (1.86+) — coerce &dyn SubTrait to &dyn SuperTrait without a manual as_base() method:
trait Animal: std::fmt::Display {
fn name(&self) -> &str;
}
fn print_animal(a: &dyn Animal) {
let displayable: &dyn std::fmt::Display = a; // upcasting, no cast needed
println!("{displayable}");
}
Prefer trait bounds over concrete types in public APIs:
// Good: accepts any iterator of strings
pub fn join_names(names: impl IntoIterator<Item = impl AsRef<str>>) -> String {
names.into_iter()
.map(|n| n.as_ref().to_owned())
.collect::<Vec<_>>()
.join(", ")
}
Implement From<T> for infallible conversions — callers use .into():
impl From<Config> for Settings {
fn from(config: Config) -> Self {
Settings {
timeout: config.timeout_ms,
retries: config.max_retries,
}
}
}
See trait-cheatsheet.md for derive-vs-manual guidance per trait.
Tracing
Use the tracing crate for structured, span-based instrumentation. Replaces log for async code.
use tracing::{info, warn, instrument};
#[instrument(skip(db), fields(user_id = %id))]
async fn get_user(db: &Pool, id: &str) -> Result<User> {
info!("fetching user");
let user = db.query_one("SELECT ...", &[&id])
.await
.context("query failed")?;
Ok(user)
}
#[instrument]auto-creates a span named after the function. Useskip(secret)to avoid logging sensitive arguments.- Use
tracing_subscriberfor output. JSON formatter for production, pretty formatter for development. - Spans propagate across
.awaitpoints — each log line carries the full call chain context.
Shared State
| Pattern | Use when |
|---|---|
Arc<Mutex<T>> |
Multiple writers, low contention |
Arc<RwLock<T>> |
Many readers, few writers |
Arc<DashMap<K, V>> |
Concurrent map, high contention |
mpsc / watch channels |
State owned by one task, others send/observe |
Use tokio::sync::Mutex only when holding the lock across .await. For synchronous critical sections, std::sync::Mutex is faster.
Concurrency vs Parallelism
- tokio — async I/O concurrency (network, disk, timers). Use for servers, HTTP clients, database queries.
- rayon — CPU-bound data parallelism. Use for batch processing, image manipulation, number crunching:
use rayon::prelude::*; let results: Vec<Output> = inputs .par_iter() // parallel iterator .map(|item| expensive_computation(item)) .collect(); - Never mix blocking CPU work into the tokio runtime — it starves async tasks. Use
tokio::task::spawn_blockingor rayon's thread pool.
Async (tokio)
Use #[tokio::main] for the entry point. Configure flavor and worker threads only when needed:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::init();
let config = Config::from_env()?;
run(config).await
}
Async closures (1.85+/2024 edition) — capture environment in async closures:
let prefix = String::from("user");
let fetch = async || {
reqwest::get(format!("https://api.example.com/{prefix}")).await
};
let resp = fetch().await?;
Use AsyncFn, AsyncFnMut, AsyncFnOnce as trait bounds for higher-order async functions.
Rules:
tokio::spawnfor independent concurrent tasks.tokio::select!for racing multiple futures (first one wins).- Never block the runtime — use
tokio::task::spawn_blockingfor CPU-bound or synchronous IO work. tokio::sync::Mutexwhen holding the lock across.awaitpoints.std::sync::Mutexwhen the critical section is synchronous and short.- Channels:
mpscfor multi-producer work queues,oneshotfor single request-response,broadcastfor fan-out. FutureandIntoFutureare in the 2024 edition prelude — no manualuse std::future::Futureneeded.
Graceful shutdown: use tokio_util::sync::CancellationToken to propagate shutdown across tasks. Spawn a signal listener that cancels the token, and select! on the token in task loops.
See patterns/async-patterns.md for shutdown, channels, connection pooling, and select! patterns.
Testing
Unit tests — colocated in the same file:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_input() {
let result = parse("42").unwrap();
assert_eq!(result, 42, "should parse integer string");
}
}
Integration tests — in tests/ directory, test the public API only.
Async tests — use #[tokio::test]:
#[tokio::test]
async fn fetch_returns_ok() {
let client = Client::new();
let response = client.fetch("https://example.com").await.unwrap();
assert_eq!(response.status(), 200);
}
Table-driven tests:
#[test]
fn parse_duration_variants() {
let cases = [
("10s", Duration::from_secs(10)),
("5m", Duration::from_secs(300)),
("1h", Duration::from_secs(3600)),
];
for (input, expected) in cases {
let result = parse_duration(input).unwrap();
assert_eq!(result, expected, "input: {input}");
}
}
Assertions:
assert_eq!(left, right, "context message with {variable}")— always include a message.assert!(matches!(result, Err(Error::NotFound(_))))— match enum variants.LazyLockfor expensive test fixtures shared across tests.
See patterns/testing-patterns.md for mockall, proptest, and helper module patterns.
Unsafe Code
- Avoid unless there is no safe alternative.
- Minimize the unsafe block scope to the smallest possible expression.
- Document every unsafe block with
// SAFETY: reasonexplaining the invariant:
// SAFETY: pointer is guaranteed non-null by the C API contract,
// and the buffer has been initialized by init_buffer() above.
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
- Wrap unsafe code in a safe public API. Callers must never need
unsafethemselves. - Test every unsafe block — undefined behavior hides until production.
Performance
Profile first — don't guess. Use
cargo benchwith criterion:[dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } [[bench]] name = "throughput" harness = falseIterators over manual loops — the compiler optimizes iterator chains aggressively.
Avoid allocations in hot paths — reuse buffers, use
&stroverString,SmallVecfor typically-small collections.#[inline]only for small functions called across crate boundaries. Never on large functions — let the compiler decide.String::with_capacity(n)when the final size is known or estimable.
Recommended Crates
See crate-recommendations.md for the full table (axum, reqwest, serde, clap, sqlx, tracing, thiserror, anyhow, proptest, tokio).
Cargo Conventions
Cargo.toml ordering: [package], [features], [dependencies], [dev-dependencies], [build-dependencies], [[bin]], [[bench]], [profile.*].
Dependency pinning:
- Libraries: use semver ranges (
"1","0.5"). Let downstream resolve. - Applications: use
Cargo.lock(commit it) with semver ranges. Use=pinning only when a specific version is required for compatibility.
Features:
- No default features unless genuinely needed by most users.
- Additive only — enabling a feature must never remove functionality.
- Gate optional heavy dependencies behind features.
MSRV: set rust-version in [package] to your project's minimum supported stable version (check releases.rs for the latest). Test against it in CI.
Release profile:
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
Use lto = "fat" for maximum binary size reduction when build time is not a concern.
New crate workflow
- [ ] cargo init --name my-crate (or --lib for library)
- [ ] Set edition, rust-version, license, description in Cargo.toml
- [ ] Create src/error.rs with thiserror enum (library) or add anyhow (application)
- [ ] Create src/lib.rs with public API surface
- [ ] Add #[derive(Debug)] on all types
- [ ] Add integration test in tests/
- [ ] Configure CI (fmt, clippy, test, doc)
- [ ] Run validation loop (below)
Validation loop
cargo fmt --check— fix formatting violationscargo clippy -- -D warnings— fix all clippy lints (treat as errors)cargo test— fix failing testscargo doc --no-deps— fix documentation warningscargo audit— check for known vulnerabilities in dependencies- Repeat until all five pass clean
Deep-dive references
Error handling: See patterns/error-handling-patterns.md for thiserror/anyhow patterns, conversion chains, backtrace Serde: See patterns/serde-patterns.md for rename_all, deny_unknown_fields, tagged enums, flatten Async: See patterns/async-patterns.md for tokio runtime, channels, select!, shutdown, connection pooling Testing: See patterns/testing-patterns.md for table-driven, mockall, proptest, test helpers Traits: See trait-cheatsheet.md for derive-vs-manual guidance per standard trait Crates: See crate-recommendations.md for recommended crates by category
Official references
- Rust API Guidelines — naming, interoperability, documentation, type safety
- The Rust Reference — language specification, syntax, semantics
- Clippy Lints — full lint list with explanations and examples