Rust Instructions
June 9, 2026 · View on GitHub
Conventions for Rust development targeting the 2021 edition.
Project Structure
Crates follow a standard layout:
Cargo.toml
Cargo.lock
src/
main.rs # Binary crate entry point
lib.rs # Library crate root
module_name.rs # Top-level module
module_name/
mod.rs # Module with submodules
submodule.rs
tests/
integration_test.rs
Cargo.tomlandCargo.lockat crate root.- Commit
Cargo.lockfor binary crates to ensure reproducible builds. Exclude it from version control for library crates. src/contains all source files.- Binary crates use
main.rs; library crates uselib.rs. - Test projects use a sibling
tests/directory for integration tests. - Keep crate roots thin with module declarations and re-exports.
Project folder organization scales with complexity. Keep all files at root-level modules when fewer than 10 source files exist. When folders become necessary, organize by domain responsibility: config, error, handlers, models, services.
Cargo.toml Conventions
Standard Fields
[package]
name = "service-name"
version = "0.1.0"
edition = "2021"
license = "MIT"
[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Sections below reference additional crates (reqwest, tokio-retry, async-trait). Add them to [dependencies] when using those patterns.
Dependency Management
- Use caret ranges for stable public crates:
version = "1". - Pin exact versions for private or unstable SDKs:
version = "=1.1.3". - Disable default features when targeting WASM or minimal builds:
default-features = false. - Specify only needed feature flags to reduce compile time and binary size.
Release Profile
[profile.release]
strip = true
lto = true
codegen-units = 1
panic = "abort"
Use strip = true to remove debug symbols. Enable lto and codegen-units = 1 for optimized builds. Set panic = "abort" for smaller binaries when stack unwinding is unnecessary.
Coding Conventions
Naming
| Element | Convention | Example |
|---|---|---|
| Types & Structs | PascalCase | UserService, DeviceConfig |
| Traits | PascalCase | Repository, Serializer |
| Enum Variants | PascalCase | Status::Active |
| Functions | snake_case | process_request |
| Variables | snake_case | device_url, retry_count |
| Constants | SCREAMING_SNAKE_CASE | DEFAULT_TIMEOUT |
| Modules | snake_case | error_handler |
| Crate Names | kebab-case | my-service |
| Feature Flags | kebab-case | onnx-runtime |
Type Naming Suffixes
Apply these suffixes consistently for domain types:
- Error types:
*Errorsuffix (ServiceError,ParseError) - Config types:
*Configsuffix (AppConfig,DatabaseConfig) - Builder types:
*Buildersuffix (RequestBuilder,SessionBuilder) - Result type aliases:
pub type Result<T> = std::result::Result<T, ServiceError>;
Module Structure
Member ordering within a module:
usedeclarations (standard library, external crates, internal modules)- Constants and statics
- Type definitions (structs, enums, type aliases)
- Trait definitions
- Trait implementations
- Inherent implementations
- Free functions
- Test module
Within categories, order: pub items before pub(crate) before private.
Variable Declarations
Prefer type inference with let when the type is obvious from the right side. Use explicit type annotations when inference is ambiguous or the type aids readability:
let service = UserService::new(repo, logger);
let lookup: HashMap<String, Vec<Item>> = HashMap::new();
Prefer early returns over deep nesting. Use if let and let ... else for option/result unwrapping at control flow boundaries:
let Some(user) = repository.find(id).await? else {
return Err(ServiceError::not_found("User not found"));
};
Error Handling
Custom Error Types
Use thiserror for library-style error enums. Define a module-scoped Result type alias to reduce boilerplate.
use thiserror::Error;
pub type Result<T> = std::result::Result<T, ServiceError>;
#[derive(Error, Debug)]
pub enum ServiceError {
#[error("Not found: {message}")]
NotFound { message: String },
#[error("Invalid input: {message}")]
InvalidInput { message: String },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
}
impl ServiceError {
pub fn not_found<S: Into<String>>(message: S) -> Self {
Self::NotFound { message: message.into() }
}
pub fn invalid_input<S: Into<String>>(message: S) -> Self {
Self::InvalidInput { message: message.into() }
}
}
Error Handling Rules
- Use
thiserrorfor error types in libraries and modules with structured error variants. - Use
anyhowonly in application-levelmainfunctions or CLI tools where error granularity is unnecessary. - Prefer
?operator for error propagation over explicitmatchorunwrap. - Never use
unwrap()orexpect()in production paths. Reserve them for cases with compile-time or initialization guarantees. - Initialization paths include startup config loading in
main,OnceLock/LazyLockinitializers, and one-time setup that runs before the service accepts work. Useexpect()with a descriptive message in these contexts. - Provide context-aware error messages that include relevant state.
- Implement
#[from]for delegating errors from external crates. - Add helper constructors on error types for ergonomic creation.
Async Patterns
Tokio Runtime
Use Tokio as the async runtime. Select the flavor based on workload characteristics:
// Multi-threaded for high-concurrency services
#[tokio::main]
async fn main() -> Result<()> {
// ...
}
// Single-threaded for lightweight or resource-constrained services
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
// ...
}
Concurrent Task Management
- Use
tokio::select!for racing independent tasks that should run concurrently until one completes. - Use
tokio::try_join!for collecting results from concurrent tasks that must all succeed. - Use
tokio::spawnfor background tasks that run independently. - Handle task cancellation and shutdown gracefully via
CancellationTokenortokio::select!. - Never block the async runtime with synchronous operations; use
tokio::task::spawn_blockinginstead.
tokio::select! {
result = background_task() => result?,
result = server.run() => result?,
}
Async Trait Implementations
Use async-trait for trait definitions requiring async methods. The async-trait crate is required for the 2021 edition. Native async trait support is available in the 2024 edition and later.
use async_trait::async_trait;
#[async_trait]
pub trait Repository: Send + Sync {
async fn find_by_id(&self, id: &str) -> Result<Option<Item>>;
async fn save(&self, item: &Item) -> Result<()>;
}
Observability
Structured Logging
Use the tracing crate for all logging. Never use println! or eprintln! in production code.
use tracing::{info, warn, error, debug};
use tracing_subscriber::filter::EnvFilter;
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
info!(
endpoint = %endpoint,
interval_secs = %interval,
"Starting service with configuration"
);
error!(
error_code = "PUBLISH_FAILED",
topic = %topic,
"Failed to publish message: {:?}", err
);
OpenTelemetry Integration
When distributed tracing is required, integrate OpenTelemetry via tracing-opentelemetry. Add tracing-opentelemetry, opentelemetry, opentelemetry-sdk (with the rt-tokio feature), and opentelemetry-otlp to [dependencies].
- Check for
OTEL_EXPORTER_OTLP_ENDPOINTenvironment variable before enabling the exporter. - Fall back to console-only logging when the variable is absent.
- Use
TraceContextPropagatorfor W3C trace context propagation.
use std::env;
use tracing_subscriber::layer::SubscriberExt;
fn init_tracing() {
let subscriber = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env());
if env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok() {
let tracer = opentelemetry_otlp::new_pipeline()
.tracing()
.install_batch(opentelemetry_sdk::runtime::Tokio)
.expect("OpenTelemetry pipeline must initialize at startup");
let telemetry = tracing_opentelemetry::layer().with_tracer(tracer);
tracing::subscriber::set_global_default(
subscriber.finish().with(telemetry),
).expect("Global subscriber must be set once at startup");
} else {
subscriber.init();
}
}
Serialization
Serde Patterns
- Derive
SerializeandDeserializeusingserdefor all data transfer types. - Use
#[serde(rename_all = "camelCase")]when interfacing with JSON APIs that use camelCase. - Implement
Defaultfor configuration types to provide sensible fallback values. - Use
#[serde(default = "...")]for fields with non-trivial defaults.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AppConfig {
pub endpoint: String,
pub polling_interval_secs: u64,
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
}
fn default_timeout() -> u64 { 5000 }
impl Default for AppConfig {
fn default() -> Self {
Self {
endpoint: String::new(),
polling_interval_secs: 10,
timeout_ms: default_timeout(),
}
}
}
Configuration Management
Environment Variables
Define environment variable names as constants. Use env::var with clear error messages or sensible defaults.
const ENDPOINT_VAR: &str = "SERVICE_ENDPOINT";
const INTERVAL_VAR: &str = "POLLING_INTERVAL";
let endpoint = env::var(ENDPOINT_VAR)
.expect("SERVICE_ENDPOINT must be set");
let interval = env::var(INTERVAL_VAR)
.unwrap_or_else(|_| "10".to_string())
.parse::<u64>()
.expect("POLLING_INTERVAL must be a valid u64");
File-Based Configuration
Support YAML and JSON configuration with validation:
impl AppConfig {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = std::fs::read_to_string(&path)?;
let config: Self = serde_json::from_str(&content)?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
if self.endpoint.is_empty() {
return Err(ServiceError::invalid_input("Endpoint must not be empty"));
}
Ok(())
}
}
Static Initialization
Use OnceLock for thread-safe, one-time initialization of global state:
use std::sync::OnceLock;
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
Resilience Patterns
Retry Logic
Use tokio_retry with exponential backoff for transient failures:
use tokio_retry::{strategy::ExponentialBackoff, Retry};
use std::time::Duration;
let retry_strategy = ExponentialBackoff::from_millis(2000)
.max_delay(Duration::from_secs(10))
.take(5);
let result = Retry::spawn(retry_strategy, || async {
client.send(&payload).await
}).await;
Visibility
- Default to private. Expose only what is needed at module boundaries.
- Use
pub(crate)for types shared across modules within the same crate. - Mark public API types with
pubonly when they form the crate's external contract.
Feature Flags
Use Cargo feature flags for optional functionality:
pub enum Backend {
#[cfg(feature = "backend-a")]
BackendA(BackendAImpl),
#[cfg(feature = "backend-b")]
BackendB(BackendBImpl),
}
Declare features and gate optional dependencies in Cargo.toml:
[features]
default = []
backend-a = ["dep:backend-a-crate"]
backend-b = ["dep:backend-b-crate"]
Gate heavy dependencies behind features so downstream consumers control binary size.
Code Documentation
Public and protected items require documentation comments.
Guidelines:
- Use
///for item-level documentation on public types, functions, and modules. - Use
//!for module-level documentation at the top oflib.rsormod.rs. - Document parameters and return values in the description when non-obvious.
- Include
# Examplessections for public API functions. - Include
# Errorssections for functions returningResult. - Include
# Panicssections for functions that can panic.
/// Processes the input data and returns the transformed result.
///
/// # Arguments
///
/// * `input` - Raw input bytes to process.
/// * `config` - Processing configuration.
///
/// # Returns
///
/// The processed output as a byte vector.
///
/// # Errors
///
/// Returns `ServiceError::InvalidInput` if the input cannot be parsed.
///
/// # Examples
///
/// ```
/// let result = process(&input, &config)?;
/// assert!(!result.is_empty());
/// ```
pub fn process(input: &[u8], config: &ProcessConfig) -> Result<Vec<u8>> {
// ...
}
Clippy and Formatting
Clippy
- Run
cargo clippyand resolve all warnings before committing. - Do not suppress Clippy lints without documented justification.
- Use
#[allow(...)]on specific items rather than crate-wide#![allow(...)]when suppression is necessary.
Formatting
- Run
cargo fmtbefore committing. - Use default
rustfmtconfiguration unless the project includes arustfmt.toml.
Additional Conventions
- Prefer
&stroverStringin function parameters when ownership is not needed. - Use
impl Into<String>for constructors and builders accepting string arguments. - Use
Cow<'_, str>when a function may or may not need to allocate:
use std::borrow::Cow;
fn normalize_name(name: &str) -> Cow<'_, str> {
if name.contains(' ') {
Cow::Owned(name.replace(' ', "_"))
} else {
Cow::Borrowed(name)
}
}
- Use iterators and combinators over manual loops where readability is maintained.
- Prefer
Vec<u8>and slices over raw pointer manipulation.
Patterns to Avoid
println!oreprintln!in production code (usetracingmacros).unwrap()orexpect()in production paths without compile-time guarantees.- Shared mutable state without synchronization primitives (
Mutex,RwLock,OnceLock). - Blocking operations inside async contexts (use
tokio::task::spawn_blocking). - Overly broad feature sets on dependencies (minimize with
default-features = false). - Global mutable statics without
OnceLock,LazyLock, or equivalent. - Suppressing Clippy lints without documented justification.
unsafeblocks without a// SAFETY:comment explaining the invariant.
Complete Example
Demonstrates naming, structure, error handling, async patterns, configuration, observability, and testing:
// Rust uses modules, not namespaces — see the mod declarations below.
use std::env;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio_retry::{strategy::ExponentialBackoff, Retry};
use tracing::{error, info};
use tracing_subscriber::filter::EnvFilter;
const ENDPOINT_VAR: &str = "SERVICE_ENDPOINT";
const INTERVAL_VAR: &str = "POLLING_INTERVAL";
// --- Error ---
pub type Result<T> = std::result::Result<T, ServiceError>;
#[derive(Error, Debug)]
pub enum ServiceError {
#[error("Not found: {message}")]
NotFound { message: String },
#[error("Request failed: {message}")]
Request { message: String },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
impl ServiceError {
pub fn not_found<S: Into<String>>(message: S) -> Self {
Self::NotFound { message: message.into() }
}
pub fn request<S: Into<String>>(message: S) -> Self {
Self::Request { message: message.into() }
}
}
// --- Config ---
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub endpoint: String,
#[serde(default = "default_interval")]
pub polling_interval_secs: u64,
}
fn default_interval() -> u64 { 10 }
impl AppConfig {
pub fn from_env() -> Self {
Self {
endpoint: env::var(ENDPOINT_VAR)
.expect("SERVICE_ENDPOINT must be set"),
polling_interval_secs: env::var(INTERVAL_VAR)
.unwrap_or_else(|_| "10".to_string())
.parse()
.expect("POLLING_INTERVAL must be a valid u64"),
}
}
}
// --- Service ---
/// Polls an endpoint and processes responses.
pub struct PollingService {
config: AppConfig,
client: reqwest::Client,
}
impl PollingService {
pub fn new(config: AppConfig) -> Self {
Self {
config,
client: reqwest::Client::new(),
}
}
/// Starts the polling loop.
///
/// # Errors
///
/// Returns `ServiceError::Request` on repeated fetch failures.
pub async fn run(&self) -> Result<()> {
let interval = Duration::from_secs(self.config.polling_interval_secs);
loop {
match self.fetch_with_retry().await {
Ok(data) => info!(items = data.len(), "Fetched data"),
Err(err) => error!(?err, "Fetch failed after retries"),
}
tokio::time::sleep(interval).await;
}
}
async fn fetch_with_retry(&self) -> Result<Vec<u8>> {
let strategy = ExponentialBackoff::from_millis(1000)
.max_delay(Duration::from_secs(8))
.take(3);
Retry::spawn(strategy, || self.fetch()).await
}
async fn fetch(&self) -> Result<Vec<u8>> {
let response = self.client
.get(&self.config.endpoint)
.send()
.await
.map_err(|e| ServiceError::request(e.to_string()))?;
if !response.status().is_success() {
return Err(ServiceError::request(
format!("HTTP {}", response.status()),
));
}
response.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| ServiceError::request(e.to_string()))
}
}
// --- Entry Point ---
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let config = AppConfig::from_env();
info!(endpoint = %config.endpoint, "Starting service");
let service = PollingService::new(config);
service.run().await
}