Architecting Rust Projects: A Comprehensive Guide¶
Rust rewards a little up-front thought about structure more than most languages do. The compiler enforces ownership, visibility, and thread-safety at module and crate boundaries, so the shape of your project directly affects how easy it is to change. A well-structured Rust codebase feels effortless to extend; a badly structured one turns every feature into a fight with the borrow checker.
This guide covers both halves of that: how to lay out and architect a project, and the core coding concepts that make individual pieces of it idiomatic. It's written for someone who can already write a working Rust program and wants their next one to be well organised, testable, and pleasant to maintain.
It assumes you're comfortable with ownership and borrowing. If not, start with Understanding Ownership, Moves, and Borrowing.
Contents¶
- Part 1: Project structure: packages, crates, modules, layouts, workspaces,
Cargo.toml - Part 2: Application architecture: layering, traits as boundaries, ownership-driven design, config, errors, logging, async, CLIs, web services
- Part 3: Core coding concepts: type-driven design, traits, iterators, strings, patterns, builders, concurrency, macros,
unsafe, performance - Part 4: Quality and delivery: testing, documentation, tooling, CI, release builds, dependencies, versioning
- Part 5: Anti-patterns
- New-project checklist
- Further reading
Part 1: Project structure¶
Vocabulary: packages, crates, modules, workspaces¶
Rust's terms overlap with other ecosystems in confusing ways, so pin them down first:
| Term | What it is | Defined by |
|---|---|---|
| Module | A namespace inside a crate. Controls visibility. | mod name; or mod name { … } |
| Crate | One compilation unit: either a library (lib.rs) or a binary (main.rs). The unit of pub visibility for the outside world. |
A crate root file |
| Package | A directory with a Cargo.toml. Contains at most one library crate and any number of binary crates. |
Cargo.toml [package] |
| Workspace | Several packages that share one Cargo.lock and one target/ directory, built together. |
Cargo.toml [workspace] |
Crates are also the unit of parallel compilation and incremental rebuilds. That's one of the main practical reasons to split a large project into several crates.
Start with a library, keep main.rs thin¶
The single most useful structural habit: put almost all your logic in src/lib.rs (and its modules), and keep src/main.rs as a thin shell.
my-tool/
├── Cargo.toml
└── src/
├── main.rs # parse args, set up logging, call into the library, map errors to exit codes
├── lib.rs # the public API of your crate
├── config.rs
└── backup.rs
// src/main.rs
use std::process::ExitCode;
fn main() -> ExitCode {
match my_tool::run(std::env::args_os()) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err:#}");
ExitCode::FAILURE
}
}
}
Why this matters:
- Integration tests in
tests/can only access a crate's library API. Logic inmain.rsis untestable from there. - Doc tests only run for library crates.
- Reuse is free: a second binary, a benchmark, or another crate in the workspace can call the same code.
- It forces you to define an actual API boundary instead of a 900-line
main.
Modules and visibility¶
File layout¶
A module named config can live in either src/config.rs or src/config/mod.rs. Since the 2018 edition, the preferred style for modules with children is:
src/
├── lib.rs # mod storage;
├── storage.rs # mod sqlite; mod memory;
└── storage/
├── sqlite.rs
└── memory.rs
This avoids editor tabs full of identical mod.rs names. Both styles work; pick one and be consistent.
Visibility levels¶
| Modifier | Visible to |
|---|---|
| (none) | The current module and its children. |
pub(super) |
The parent module. |
pub(crate) |
Anywhere in the current crate, but not to other crates. |
pub |
Everyone, if every enclosing module is also reachable. |
Default to private. Use pub(crate) liberally for internals that several modules share, and reserve pub for what you deliberately expose. In a binary-only crate, pub and pub(crate) are effectively the same, but writing pub(crate) documents intent and keeps the code library-ready.
Curate the public API with re-exports¶
Your internal module tree doesn't have to be your public API. Re-export the important items at the crate root so users write my_crate::Client instead of my_crate::net::http::client::Client:
// src/lib.rs
mod client;
mod config;
mod error;
pub use client::Client;
pub use config::Config;
pub use error::{Error, Result};
Keeping the modules themselves private (mod, not pub mod) means you can reorganise files later without breaking anyone.
Avoid a junk-drawer utils module¶
utils.rs, helpers.rs, and common.rs grow without bound because nothing obviously belongs elsewhere. Name modules after what they're about (paths, retry, time_fmt), not after the fact that they're small.
Layouts that scale¶
A single-purpose CLI¶
backup-tool/
├── Cargo.toml
├── src/
│ ├── main.rs # thin: clap parse → lib::run → exit code
│ ├── lib.rs
│ ├── cli.rs # clap derive structs
│ ├── config.rs
│ ├── error.rs
│ └── backup.rs # the actual domain logic
└── tests/
└── cli.rs # end-to-end tests that run the binary
A CLI with many subcommands¶
Give each subcommand its own module with a run function; the top-level cli.rs only dispatches:
src/
├── main.rs
├── lib.rs
├── cli.rs # Cli { command: Command } + match dispatch
└── commands/
├── mod.rs (or commands.rs)
├── backup.rs # pub fn run(args: BackupArgs, ctx: &Context) -> Result<()>
├── restore.rs
└── status.rs
When subcommands grow large or pull in heavy dependencies, promote each to its own crate in a workspace (next section).
A web service¶
Organise by layer at the top, feature within layers, or by feature at the top, layer within features. For small-to-medium services, layer-first is easier to navigate:
src/
├── main.rs # build config, telemetry, DB pool, router; serve
├── lib.rs
├── config.rs
├── error.rs # AppError + IntoResponse
├── domain/ # pure types and rules; no axum, no sqlx
│ ├── job.rs
│ └── company.rs
├── storage/ # repository traits' implementations (sqlx/SQLite)
│ └── jobs.rs
├── services/ # use cases that orchestrate domain + storage
│ └── jobs.rs
└── http/ # routes, handlers, extractors, DTOs
├── mod.rs
├── jobs.rs
└── health.rs
migrations/
templates/
tests/
As the service grows past a dozen or so features, flip to feature-first (jobs/{domain,storage,http}.rs) so that working on one feature touches one directory.
Workspaces¶
A workspace lets several packages share one lockfile, one build directory, and one set of dependency versions. Reach for one when:
- you have a library plus one or more binaries that you want to version or test separately,
- a single crate is getting slow to compile (crates build in parallel; modules within one crate mostly don't),
- you want to enforce dependency direction (a
corecrate physically cannot import fromhttpunless you add the dependency), - you're building a multi-tool where each subcommand is substantial.
Layout¶
toolkit/
├── Cargo.toml # [workspace] root, no [package] (a "virtual manifest")
├── Cargo.lock
├── Makefile # or justfile: build/test/lint/release shortcuts
├── crates/
│ ├── toolkit-core/ # shared types, config, errors
│ ├── toolkit-ssl/ # one subcommand's logic, as a library
│ ├── toolkit-links/
│ └── toolkit/ # the binary: clap + dispatch to the library crates
└── tests/ # (optional) workspace-level integration tests live in a crate
Root Cargo.toml¶
[workspace]
resolver = "3"
members = ["crates/*"]
[workspace.package]
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
repository = "https://gitlab.com/example/toolkit"
[workspace.dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tracing = "0.1"
# internal crates, referenced by path
toolkit-core = { path = "crates/toolkit-core" }
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
Member Cargo.toml¶
[package]
name = "toolkit-ssl"
version = "0.3.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
toolkit-core.workspace = true
thiserror.workspace = true
tracing.workspace = true
[lints]
workspace = true
Every member inherits the same dependency versions, edition, and lint policy; upgrading tokio happens in one place.
Dependency direction¶
Draw the crate graph and keep it a DAG pointing inward: binaries depend on feature crates, feature crates depend on core, and core depends on nothing internal. If two feature crates need to talk to each other, the shared piece probably belongs in core.
toolkit (bin) ──► toolkit-ssl ──► toolkit-core
│ ▲
└──────► toolkit-links ────────┘
Workspace caveats¶
- Don't split prematurely. Each crate adds a
Cargo.toml, a public API to maintain, and friction when moving code. A single crate with good modules is fine up to tens of thousands of lines. - Features are unified across the workspace during a build: if any member enables
tokio/fs, every member sees it. That's usually harmless, but it can hide a missing feature declaration that only shows up when a crate is built alone (cargo build -p name).
Cargo.toml essentials¶
| Setting | Recommendation |
|---|---|
edition |
Use the latest ("2024" as of Rust 1.85) for new projects. Editions are opt-in per crate and interoperate freely. |
rust-version |
Declare your MSRV (minimum supported Rust version). With resolver 3, Cargo prefers dependency versions compatible with it. |
resolver |
"3" is the default for edition 2024 packages; set it explicitly in virtual workspaces. |
Cargo.lock |
Commit it for both applications and libraries. It makes CI and bisecting reproducible; downstream users of a library ignore it anyway. |
[lints] |
Configure rustc and Clippy lints in the manifest instead of scattering #![warn(...)] attributes. |
rust-toolchain.toml |
Optional: pins the toolchain (and components like clippy, rustfmt) for everyone working on the repo. |
Features¶
Cargo features are compile-time switches for optional functionality and optional dependencies:
[features]
default = ["sqlite"]
sqlite = ["dep:sqlx", "sqlx/sqlite"]
postgres = ["dep:sqlx", "sqlx/postgres"]
tls = ["dep:rustls"]
[dependencies]
sqlx = { version = "0.8", optional = true, default-features = false, features = ["runtime-tokio"] }
rustls = { version = "0.23", optional = true }
Rules that keep features sane:
- Features must be additive. Enabling a feature may add functionality, never remove or change it, because Cargo unifies features across the dependency graph and any crate can turn one on.
- Never make two features mutually exclusive if you can avoid it; prefer runtime configuration.
- Test the important combinations in CI:
--no-default-features, default,--all-features.
Part 2: Application architecture¶
Layering and dependency direction¶
Almost every non-trivial program has the same three concerns, whether or not you name them:
- Domain: the types and rules that make your program your program (a
Jobcan't move fromRejectedback toApplied; a backup has a retention policy). - Application / services: use cases that orchestrate the domain and the outside world ("record an application", "rotate old backups").
- Adapters / infrastructure: everything that talks to something external: HTTP handlers, CLI parsing, databases, files, APIs, clocks.
The rule that makes this valuable: dependencies point inward. Adapters know about the domain; the domain knows nothing about adapters. Your Job type shouldn't import axum or sqlx. When the domain is pure, it's trivially unit-testable, and swapping SQLite for Postgres, or a CLI for a web UI, touches only the outer layer.
You don't need ceremony to get this. In a small crate it's just three modules and a habit of not importing infrastructure types into domain/.
Ports and adapters with traits¶
Where the domain or services need something external (storage, an HTTP client, the current time), define a trait describing what they need, in terms of domain types. That trait is a port. The real implementation, and a test fake, are adapters.
// domain/job.rs: pure types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct JobId(pub i64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Applied,
Interviewing,
Offer,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Job {
pub id: JobId,
pub company: String,
pub status: Status,
}
// services/jobs.rs: the port, expressed in domain terms
pub trait JobRepository {
fn get(&self, id: JobId) -> Result<Option<Job>, RepoError>;
fn save(&self, job: &Job) -> Result<(), RepoError>;
}
#[derive(Debug, thiserror::Error)]
#[error("storage failure: {0}")]
pub struct RepoError(String);
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
#[error("job {0:?} not found")]
NotFound(JobId),
#[error("cannot move a job from {from:?} to {to:?}")]
InvalidTransition { from: Status, to: Status },
#[error(transparent)]
Repo(#[from] RepoError),
}
pub struct JobService<R> {
repo: R,
}
impl<R: JobRepository> JobService<R> {
pub fn new(repo: R) -> Self {
Self { repo }
}
pub fn get(&self, id: JobId) -> Result<Job, ServiceError> {
self.repo.get(id)?.ok_or(ServiceError::NotFound(id))
}
pub fn advance(&self, id: JobId, to: Status) -> Result<Job, ServiceError> {
let mut job = self.repo.get(id)?.ok_or(ServiceError::NotFound(id))?;
if job.status == Status::Rejected {
return Err(ServiceError::InvalidTransition { from: job.status, to });
}
job.status = to;
self.repo.save(&job)?;
Ok(job)
}
}
The real adapter (SqliteJobRepository) lives in storage/ and implements JobRepository with sqlx. Tests use an in-memory fake:
#[cfg(test)]
mod tests {
use super::*;
use std::{cell::RefCell, collections::HashMap};
#[derive(Default)]
struct FakeRepo(RefCell<HashMap<JobId, Job>>);
impl JobRepository for FakeRepo {
fn get(&self, id: JobId) -> Result<Option<Job>, RepoError> {
Ok(self.0.borrow().get(&id).cloned())
}
fn save(&self, job: &Job) -> Result<(), RepoError> {
self.0.borrow_mut().insert(job.id, job.clone());
Ok(())
}
}
#[test]
fn rejected_jobs_cannot_advance() {
let repo = FakeRepo::default();
let id = JobId(1);
repo.save(&Job { id, company: "Acme".into(), status: Status::Rejected }).unwrap();
let svc = JobService::new(repo);
assert!(matches!(
svc.advance(id, Status::Offer),
Err(ServiceError::InvalidTransition { .. })
));
}
}
Don't over-abstract¶
A trait for every struct is Java-in-Rust and makes code harder to read. Introduce a port when there's a real reason:
- the dependency is slow, flaky, or external (network, DB, clock, filesystem) and you want fast deterministic tests, or
- there are, or will soon be, genuinely multiple implementations.
For everything else, concrete types are simpler and the compiler will tell you everything that breaks when you refactor.
Generics or dyn?¶
JobService<R: JobRepository> uses static dispatch: the compiler generates specialised code for each R. The alternative is JobService { repo: Arc<dyn JobRepository> }, dynamic dispatch through a vtable. For application wiring, both are fine; see Generics vs. trait objects for the trade-offs.
Ownership-driven design¶
Ownership isn't just a local rule about variables; it's an architectural tool. Most "fighting the borrow checker" at scale comes from an unclear answer to "who owns this data?"
Make ownership a tree. Ideally, your program's data forms a tree: App owns Config and Services; each service owns its repository; each repository owns its connection pool. Borrowing flows down the tree for the duration of a call. Trees are what Rust's ownership model expresses naturally.
When you need a graph, use IDs, not references. Structures where many things point at many things (graphs, ECS-style game state, a document with cross-references) don't fit single ownership. Rather than Rc<RefCell<Node>> everywhere, store nodes in a Vec or HashMap and refer to them by index or ID:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct NodeId(usize);
struct Graph {
nodes: Vec<Node>,
}
struct Node {
name: String,
edges: Vec<NodeId>, // no lifetimes, no Rc, no RefCell
}
This is how most production Rust handles graph-shaped data: it's simple, cache-friendly, and trivially serialisable. (Crates such as slotmap add generational IDs to catch stale references.)
Share immutable state with Arc; isolate mutable state. Long-lived shared state (configuration, connection pools, caches) goes behind an Arc. If it must be mutable, put the smallest possible piece behind a Mutex/RwLock, or give it to a single task that owns it and talk to it over a channel (the actor pattern).
Short borrows, owned boundaries. Inside a function, borrow freely. Across long-lived boundaries (struct fields, thread/task spawns, channel messages, return values from service layers) prefer owned data. Structs with lifetime parameters are excellent for short-lived views (parsers, iterators, request-scoped helpers) and painful as long-lived application state.
Split structs to split borrows. If two methods conflict because both need &mut self, the struct may be doing two jobs. Borrowing two different fields mutably at once is fine; borrowing self twice isn't. Splitting state into sub-structs lets each method borrow only what it needs.
Configuration¶
Most programs end up with configuration from several sources. Establish one precedence order and stick to it:
built-in defaults < config file < environment variables < command-line flags
Practical guidance:
- Deserialise into one strongly typed
Configstruct withserde, validate it once at startup, and pass it (or the relevant sub-struct) down. Don't callstd::env::varfrom deep inside business logic. - Represent durations, sizes, URLs, and paths as real types (
Duration,Url,PathBuf), not strings. - Keep secrets out of config files where you can: read them from the environment, a secret manager, or a password store at startup, and wrap them in a type that doesn't print its contents (for example
secrecy::SecretString, or your own newtype with a redactingDebug). - Follow platform conventions for file locations (the
directories/dirscrates resolve XDG paths on Linux). clapcan read env vars directly (#[arg(long, env = "APP_PORT")]), which covers the top two layers for small tools. Crates likefigmentorconfighandle full layering for larger services.
use serde::Deserialize;
use std::{path::PathBuf, time::Duration};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)] // catch typos in config files
pub struct Config {
pub database_url: String,
pub backup_dir: PathBuf,
#[serde(default = "default_retention_days")]
pub retention_days: u32,
#[serde(with = "humantime_serde", default = "default_timeout")]
pub timeout: Duration,
}
fn default_retention_days() -> u32 { 14 }
fn default_timeout() -> Duration { Duration::from_secs(30) }
Error handling strategy¶
Error handling is an architectural decision, not just syntax. The widely used convention:
| Where | Use | Why |
|---|---|---|
| Libraries and domain/service layers | Your own error enums, typically derived with thiserror |
Callers can match on specific failures and decide what to do. |
Application edges (main, CLI command handlers, background jobs) |
anyhow::Error (or eyre) |
You mostly need to report the error with context, not branch on it. |
Typed errors with thiserror¶
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum BackupError {
#[error("source directory {} does not exist", .0.display())]
MissingSource(PathBuf),
#[error("failed to write archive {}", path.display())]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("retention must be at least 1 day, got {0}")]
InvalidRetention(u32),
}
Guidelines for error types:
- Describe what failed, not what the caller should do. Messages are lowercase, without trailing punctuation, so they compose in chains:
failed to run backup: failed to write archive /x: permission denied. - Keep the cause with
#[source]or#[from]so the full chain is available for reporting. - One error enum per module or layer, not one giant crate-wide enum that every function returns. A function that can only fail in two ways shouldn't advertise twenty.
- Don't leak dependency types in a library's public error unless you're fine with that dependency becoming part of your semver contract. Wrap them.
- Mark public error enums
#[non_exhaustive]if you may add variants later.
Context at the edges with anyhow¶
use anyhow::{Context, Result};
pub fn run(config_path: &std::path::Path) -> Result<()> {
let raw = std::fs::read_to_string(config_path)
.with_context(|| format!("reading config {}", config_path.display()))?;
let config: Config = toml::from_str(&raw).context("parsing config")?;
backup::run(&config).context("running backup")?;
Ok(())
}
Printing with {:#} shows the whole chain on one line; {:?} shows it as a multi-line report (with a backtrace if RUST_BACKTRACE=1).
Panics are for bugs¶
panic!, unwrap(), expect(), out-of-bounds indexing, and integer overflow in debug builds all signal a bug in the program, not a runtime condition. Use them when an invariant is violated and continuing would be wrong. Return Result for anything that can legitimately fail: I/O, parsing, user input, network calls.
expect("…") beats unwrap() when you do assert something, because the message documents why it can't fail:
let re = regex::Regex::new(r"^\d+\.\d+\.\d+$").expect("version regex is valid");
Mapping errors to the outside world¶
The outermost layer translates internal errors into whatever the outside world understands: exit codes for a CLI, HTTP status codes for a service. Do that translation in exactly one place (see Web service design), not scattered through handlers.
Logging and observability¶
Use the tracing ecosystem rather than println! or the older log crate. It gives you structured fields, spans that carry context across async boundaries, and pluggable output.
use tracing::{info, instrument, warn};
#[instrument(skip(repo), fields(job_id = id.0))]
pub fn advance(repo: &impl JobRepository, id: JobId, to: Status) -> Result<Job, ServiceError> {
info!(?to, "advancing job");
let mut job = repo.get(id)?.ok_or(ServiceError::NotFound(id))?;
if job.status == Status::Rejected {
warn!(from = ?job.status, "refusing transition");
return Err(ServiceError::InvalidTransition { from: job.status, to });
}
job.status = to;
repo.save(&job)?;
Ok(job)
}
Initialise the subscriber once, in main, and let libraries only emit events:
fn init_tracing() {
use tracing_subscriber::{fmt, EnvFilter};
fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
}
- Control verbosity with
RUST_LOG=my_crate=debug,tower_http=info. - For services, emit JSON (
.json()) so log shippers (Vector, Logstash, Loki) can index fields. - Log errors once, where they're handled, not at every layer they pass through. Re-logging the same error at each
?creates noise. tracing-opentelemetryconnects spans to distributed tracing backends when you need it.- For metrics, the
metricscrate with a Prometheus exporter keeps instrumentation decoupled from the backend.
Async and concurrency architecture¶
Do you need async?¶
Async Rust shines when you have many concurrent I/O-bound tasks: web servers, proxies, crawlers, anything juggling hundreds of sockets. It adds real complexity (Send bounds, pinning, runtime selection, colored functions).
| Workload | Good default |
|---|---|
| CLI that does a few sequential things | Plain synchronous code |
| CLI that makes a handful of HTTP calls | Sync (ureq) or reqwest::blocking, or async if you want concurrency |
| CPU-bound parallel work | Threads, or rayon for data parallelism |
| Network service, many connections | Async with tokio |
If most of the ecosystem you need is async (axum, sqlx, reqwest), just use async; fighting it is worse.
Async rules of thumb¶
- Pick one runtime (in practice,
tokio) and use it consistently. - Never block the runtime. Long CPU work or blocking I/O (
std::fs, a sync DB driver, heavy compression) inside anasync fnstalls every task on that worker thread. Wrap it intokio::task::spawn_blocking, or use async equivalents (tokio::fs). - Don't hold a
std::sync::Mutexguard across.await. Lock, copy or modify, drop the guard, then await. Astd::sync::Mutexis fine (and faster) for short critical sections; usetokio::sync::Mutexonly when you truly must hold a lock across an await point. - Spawned tasks need
'static + Send. Anything moved intotokio::spawnmust be owned (no borrowed references) and thread-safe. This is whereArcand cloning cheap handles come in. - Bound your concurrency. Spawning one task per item in a 100,000-item list will happily open 100,000 sockets. Use
futures::stream::iter(..).buffer_unordered(n), aSemaphore, orJoinSetwith a limit. - Plan for cancellation. Dropping a future cancels it at its current
.await. Make sure partial work is safe to abandon, and usetokio::select!with a shutdown signal (tokio::signal::ctrl_c(), aCancellationToken) for graceful shutdown.
use futures::stream::{self, StreamExt};
async fn check_all(urls: Vec<String>, client: &reqwest::Client) -> Vec<(String, bool)> {
stream::iter(urls)
.map(|url| async move {
let ok = client.head(&url).send().await.is_ok_and(|r| r.status().is_success());
(url, ok)
})
.buffer_unordered(16) // at most 16 in flight
.collect()
.await
}
Concurrency shapes¶
| Shape | When to use |
|---|---|
Shared state (Arc<Mutex<T>>, Arc<RwLock<T>>) |
Small, simple state touched briefly by many tasks (a counter, a cache). |
Message passing (mpsc, broadcast, watch channels) |
One owner of mutable state; others send it requests. Avoids lock contention and lock-ordering bugs. |
Actor (a task owning state + an mpsc::Receiver of commands) |
Stateful subsystems with complex invariants: connection managers, schedulers, rate limiters. |
| Pipeline (stages connected by bounded channels) | Streaming processing; bounded channels provide natural back-pressure. |
CLI design¶
clap with the derive API is the standard:
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
/// Back up and restore application data.
#[derive(Debug, Parser)]
#[command(version, about)]
pub struct Cli {
/// Path to the config file.
#[arg(short, long, env = "BACKUP_CONFIG", default_value = "backup.toml")]
pub config: PathBuf,
/// Increase log verbosity (-v, -vv).
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
pub verbose: u8,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Create a new backup.
Create(CreateArgs),
/// List existing backups.
List,
/// Delete backups older than the retention period.
Prune {
/// Show what would be deleted without deleting it.
#[arg(long)]
dry_run: bool,
},
}
#[derive(Debug, Args)]
pub struct CreateArgs {
/// Database name to back up.
pub database: String,
/// Compress the archive with zstd.
#[arg(long)]
pub compress: bool,
}
Conventions that make CLIs pleasant:
- Output data to stdout, diagnostics to stderr. That way
tool list | grep xworks and logs don't corrupt piped output. - Offer
--json(or--output json) for anything a script might consume. - Meaningful exit codes:
0success,1general failure,2usage error (clap does this automatically). Document any others. --dry-runfor anything destructive; confirm interactively only when stdin is a TTY (std::io::IsTerminal).- Respect
NO_COLOR, and disable colour when stdout isn't a terminal. - Generate shell completions and man pages from the same definitions (
clap_complete,clap_mangen).
Web service design¶
The axum + tokio + tower stack is the common choice. Architectural guidance:
Shared application state lives in one cloneable struct. Cheap-to-clone handles (connection pools, HTTP clients) are already Arc-based internally:
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub jobs: Arc<JobService<SqliteJobRepository>>,
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/health", get(|| async { "ok" }))
.route("/jobs/{id}", get(get_job))
.with_state(state)
}
Handlers stay thin: extract inputs, call a service, convert the result. No SQL, no business rules:
#[derive(serde::Serialize)]
pub struct JobDto {
id: i64,
company: String,
status: String,
}
impl From<Job> for JobDto {
fn from(job: Job) -> Self {
Self { id: job.id.0, company: job.company, status: format!("{:?}", job.status) }
}
}
async fn get_job(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<JobDto>, AppError> {
let job = state.jobs.get(JobId(id))?;
Ok(Json(job.into()))
}
(The repository in this example is synchronous for brevity. With an async driver such as sqlx, the trait methods become async fn and the handler .awaits the service call; the structure is identical.)
One error type for the HTTP layer, converted from service errors, mapped to status codes in exactly one place:
pub enum AppError {
NotFound,
BadRequest(String),
Internal(anyhow::Error),
}
impl From<ServiceError> for AppError {
fn from(err: ServiceError) -> Self {
match err {
ServiceError::NotFound(_) => AppError::NotFound,
ServiceError::InvalidTransition { .. } => AppError::BadRequest(err.to_string()),
ServiceError::Repo(e) => AppError::Internal(e.into()),
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match self {
AppError::NotFound => StatusCode::NOT_FOUND.into_response(),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
AppError::Internal(err) => {
tracing::error!(error = ?err, "internal error");
// Don't leak internal details to clients.
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}
Separate DTOs from domain types. The JSON shape your API exposes (JobDto) is a contract with clients; your domain Job should be free to change. From impls convert between them.
Cross-cutting concerns as tower middleware: request tracing (tower_http::trace::TraceLayer), timeouts, compression, CORS, request IDs, and auth. Don't hand-roll them per handler.
Graceful shutdown: axum::serve(listener, app).with_graceful_shutdown(signal) lets in-flight requests finish when the process gets SIGTERM, which matters under Kubernetes.
Health and readiness: expose a cheap /health (process is up) and, for orchestrators, a /ready that checks dependencies like the database.
Persistence¶
sqlxgives you async SQL with optional compile-time checked queries (query!,query_as!) against a real database or offline metadata (cargo sqlx prepare).dieselis a mature alternative with a typed query builder;rusqliteis a good synchronous choice for SQLite in CLIs.- Keep migrations in the repo (
migrations/) and run them at startup (sqlx::migrate!()) or as a separate deployment step. - Put SQL behind a repository (the port from earlier), returning domain types, so handlers and services never see rows.
- Use transactions for multi-statement operations; pass
&mut Transactiondown into repository methods that must participate. - For SQLite in a service, enable WAL mode and set a busy timeout; SQLite allows one writer at a time.
Part 3: Core coding concepts¶
Type-driven design¶
Rust's type system is expressive enough to make many bugs unrepresentable. This is the single biggest lever for code quality.
Enums for states and alternatives¶
An enum (a sum type) says "exactly one of these". Use it instead of multiple booleans or optional fields that are only valid in some combinations:
// Fragile: what does `connected: false, session: Some(..)` mean?
struct ConnectionBad {
connected: bool,
session: Option<String>,
error: Option<String>,
}
// Each state carries exactly the data that's valid in it.
enum Connection {
Disconnected,
Connecting { attempt: u32 },
Connected { session: String },
Failed { error: String },
}
match on an enum is exhaustive: add a variant, and the compiler lists every place that needs updating. That's what makes enums so valuable during refactoring.
Replace boolean parameters with enums¶
// What do these mean at the call site?
export(&data, true, false);
// Self-documenting, and impossible to swap by accident:
export(&data, Format::Json, Overwrite::No);
The newtype pattern¶
Wrap a primitive in a single-field struct to give it meaning and invariants:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct UserId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct OrderId(u64);
fn cancel(order: OrderId, by: UserId) { /* ... */ }
// cancel(user_id, order_id) is now a compile error instead of a data bug.
Newtypes cost nothing at runtime. They also let you implement foreign traits on foreign types (you can't impl Display for Vec<String>, but you can for struct Lines(Vec<String>)).
Parse, don't validate¶
Instead of passing around a String and checking it's a valid email/hostname/version in several places, parse it once into a type that can only hold valid values:
use std::{fmt, str::FromStr};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hostname(String);
#[derive(Debug, thiserror::Error)]
#[error("invalid hostname: {0:?}")]
pub struct InvalidHostname(String);
impl FromStr for Hostname {
type Err = InvalidHostname;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let valid = !s.is_empty()
&& s.len() <= 253
&& s.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
});
if valid {
Ok(Hostname(s.to_ascii_lowercase()))
} else {
Err(InvalidHostname(s.to_owned()))
}
}
}
impl fmt::Display for Hostname {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for Hostname {
fn as_ref(&self) -> &str {
&self.0
}
}
The inner field is private, so the only way to get a Hostname is through from_str. Every function that takes a Hostname can rely on it being valid with no further checks. With FromStr implemented, clap and serde (via #[serde(try_from = "String")] plus a TryFrom<String> impl) can parse straight into it.
Typestate: encode protocol steps in types¶
When an object must go through steps in order, make each step a different type so calling things out of order doesn't compile:
pub struct Draft { body: String }
pub struct Reviewed { body: String, reviewer: String }
pub struct Published { url: String }
impl Draft {
pub fn new(body: impl Into<String>) -> Self { Draft { body: body.into() } }
pub fn review(self, reviewer: &str) -> Reviewed {
Reviewed { body: self.body, reviewer: reviewer.to_owned() }
}
}
impl Reviewed {
pub fn publish(self, slug: &str) -> Published {
// ... persist self.body, record self.reviewer ...
Published { url: format!("/posts/{slug}") }
}
}
// Draft::new("hi").publish(); // error: no method `publish` on `Draft`
Each transition takes self by value, consuming the old state. Use typestate for genuinely sequential APIs (builders, connection handshakes, request pipelines); don't force it onto everything.
Traits¶
Generics vs. trait objects¶
Generics (fn f<T: Trait>(x: T) / impl Trait) |
Trait objects (&dyn Trait, Box<dyn Trait>) |
|
|---|---|---|
| Dispatch | Static: resolved at compile time, inlinable | Dynamic: vtable call at runtime |
| Performance | Fastest | Tiny indirection cost, usually irrelevant |
| Binary size / compile time | Grows with each concrete type used (monomorphisation) | One copy of the code |
| Heterogeneous collections | No: Vec<T> holds one type |
Yes: Vec<Box<dyn Plugin>> |
| Requirements | None | Trait must be dyn-compatible (no generic methods, no Self return by value, etc.) |
Default to generics for functions and small helpers. Use dyn for plugin systems, heterogeneous collections, and to cut compile times in large application wiring.
impl Trait in argument position is shorthand for a generic. In return position it hides a concrete type behind a trait (very common for iterators and closures):
fn evens(limit: u32) -> impl Iterator<Item = u32> {
(0..limit).filter(|n| n % 2 == 0)
}
Associated types vs. generic parameters¶
Use an associated type when each implementing type has exactly one natural choice (Iterator::Item, FromStr::Err). Use a generic parameter when a type might implement the trait several times for different parameters (From<T>, Add<Rhs>).
The orphan rule¶
You can implement a trait for a type only if either the trait or the type is defined in your crate. You can't impl Display for Vec<u8>. The workaround is a newtype. This rule is what keeps two crates from providing conflicting impls.
Extension traits¶
To add methods to a type you don't own, define a trait and implement it for that type:
pub trait StrExt {
fn truncate_ellipsis(&self, max: usize) -> String;
}
impl StrExt for str {
fn truncate_ellipsis(&self, max: usize) -> String {
if self.chars().count() <= max {
self.to_owned()
} else {
let cut: String = self.chars().take(max.saturating_sub(1)).collect();
format!("{cut}…")
}
}
}
Async functions in traits¶
async fn in traits is stable (since Rust 1.75) and works well with generics. Such traits aren't dyn-compatible, so if you need Box<dyn Trait> with async methods, use the async-trait crate or return boxed futures manually.
Standard traits to implement¶
Implementing the right standard traits makes your types work smoothly with the rest of the ecosystem. For a typical value type, consider:
| Trait | Derive? | Why |
|---|---|---|
Debug |
Always derive | Needed for {:?}, assert_eq!, unwrap messages. Hand-write it only to redact secrets. |
Clone |
Usually | Lets callers duplicate when they need to. |
Copy |
Only for small plain-data types | Changes move semantics; removing it later is a breaking change. |
PartialEq, Eq |
Usually | Equality, assert_eq!. Eq if there are no floats. |
Hash |
With Eq |
Use as a HashMap/HashSet key. |
PartialOrd, Ord |
When ordering is meaningful | Sorting, BTreeMap keys. Remember derive compares fields in declaration order. |
Default |
When a sensible empty value exists | ..Default::default(), #[serde(default)], Option::unwrap_or_default. |
Display |
Hand-written | User-facing representation; gives you .to_string() for free. |
From / TryFrom |
Hand-written | Conversions; From also enables ? error conversion and .into(). |
FromStr |
Hand-written | "…".parse::<T>(), clap argument parsing. |
AsRef<T> |
Hand-written | Cheap borrowed view; enables impl AsRef<Path> style parameters. |
Serialize, Deserialize |
Derive (serde) | Config, JSON APIs, persistence. |
Send, Sync |
Automatic | Implemented by the compiler when all fields are; don't fight it. |
Implement From, never Into directly. The blanket impl gives you Into automatically.
Error handling in code¶
Beyond the architecture covered in Error handling strategy, a few everyday idioms:
// `?` propagates errors and converts them via `From`.
fn load(path: &Path) -> Result<Config, AppError> {
let text = std::fs::read_to_string(path)?; // io::Error -> AppError via From
let cfg = toml::from_str(&text)?; // toml error -> AppError via From
Ok(cfg)
}
// `?` also works on Option inside functions returning Option.
fn first_word_len(s: &str) -> Option<usize> {
Some(s.split_whitespace().next()?.len())
}
// Converting between Option and Result.
let port: u16 = env_port.ok_or(ConfigError::MissingPort)?;
let maybe: Option<u16> = "8080".parse().ok();
// Defaults without unwrap.
let retries = cfg.retries.unwrap_or(3);
let name = cfg.name.unwrap_or_default();
let dir = cfg.dir.unwrap_or_else(default_dir); // lazily computed
// Collecting an iterator of Results stops at the first error.
let nums: Result<Vec<u32>, _> = ["1", "2", "x"].iter().map(|s| s.parse::<u32>()).collect();
Useful combinators to know: map, map_err, and_then, ok_or/ok_or_else, unwrap_or/unwrap_or_else/unwrap_or_default, is_some_and/is_ok_and, as_ref/as_deref, transpose, inspect_err.
Iterators and closures¶
Iterators are lazy, composable, and compile down to the same code as hand-written loops (often better, since bounds checks are elided).
let active_emails: Vec<&str> = users
.iter() // borrow each User
.filter(|u| u.active)
.map(|u| u.email.as_str())
.collect();
let total_bytes: u64 = files.iter().map(|f| f.size).sum();
let by_domain: std::collections::HashMap<&str, usize> =
emails.iter().fold(Default::default(), |mut acc, e| {
let domain = e.rsplit('@').next().unwrap_or("");
*acc.entry(domain).or_default() += 1;
acc
});
The three ways to iterate a collection:
| Call | Yields | Collection afterwards |
|---|---|---|
.iter() / for x in &v |
&T |
Unchanged |
.iter_mut() / for x in &mut v |
&mut T |
Modified in place |
.into_iter() / for x in v |
T |
Consumed (moved) |
Closures capture their environment in the least restrictive way the body allows. The trait they implement tells callers how they can be used:
| Trait | Closure can… | Can be called… |
|---|---|---|
Fn |
read captures | many times, even concurrently |
FnMut |
mutate captures | many times, one at a time |
FnOnce |
move captures out | once |
Accept the most permissive bound you can as a function parameter (FnOnce > FnMut > Fn in what callers may pass). Add move to a closure to force it to take ownership of its captures, which is required when the closure outlives the current scope (threads, spawned tasks, returned closures).
Prefer a plain for loop when the body has side effects, early returns, or ?. Iterator chains are for transforming data; don't contort logic into fold for style points.
Strings, paths, and conversions¶
| Owned | Borrowed | Use for |
|---|---|---|
String |
&str |
UTF-8 text |
PathBuf |
&Path |
Filesystem paths (may not be UTF-8!) |
OsString |
&OsStr |
Raw OS strings: env vars, args |
Vec<u8> |
&[u8] |
Arbitrary bytes |
Cow<'a, str> |
– | "Usually borrowed, sometimes owned" return values |
API guidelines:
- Accept borrowed, return owned. Take
&str,&[T],&Path; returnString,Vec<T>,PathBuf. - For flexible path parameters, take
impl AsRef<Path>, so callers can pass&str,String,PathBuf, or&Path. - If a function will store a string, take
impl Into<String>(or justString) so callers who already own one can hand it over without a copy. - Don't convert paths to
Stringto manipulate them. UsePath::join,extension,file_stem,parent. Usepath.display()to print one.
use std::path::{Path, PathBuf};
pub struct Archive {
name: String,
dir: PathBuf,
}
impl Archive {
pub fn new(name: impl Into<String>, dir: impl AsRef<Path>) -> Self {
Self { name: name.into(), dir: dir.as_ref().to_path_buf() }
}
pub fn path(&self) -> PathBuf {
self.dir.join(format!("{}.tar.zst", self.name))
}
}
Pattern matching idioms¶
// let-else: bind or bail out early, keeping the happy path unindented.
let Some(user) = repo.find(id)? else {
return Err(Error::NotFound(id));
};
// if let chains (Rust 2024 edition, 1.88+): combine pattern and boolean tests.
if let Some(cfg) = config
&& let Some(port) = cfg.port
&& port != 0
{
listen(port);
}
// matches! for boolean pattern tests.
let is_terminal = matches!(status, Status::Offer | Status::Rejected);
// Bind and test at once with @.
match code {
c @ 200..=299 => println!("success {c}"),
c @ 400..=499 => println!("client error {c}"),
other => println!("other {other}"),
}
// Match on a reference to avoid moving out of the value.
match &state {
Connection::Connected { session } => println!("{session}"), // session: &String
_ => {}
}
Avoid a catch-all _ => arm on your own enums where you can. Listing every variant means adding a new one produces a compile error at every match that needs a decision. (See Fixing "use of partially moved value" for how bindings in patterns move or borrow.)
Builders¶
Rust has no named or default arguments. For types with many optional settings, use a builder:
#[derive(Debug, Clone)]
pub struct Client {
base_url: String,
timeout: std::time::Duration,
retries: u32,
user_agent: String,
}
#[derive(Debug, Default)]
pub struct ClientBuilder {
timeout: Option<std::time::Duration>,
retries: Option<u32>,
user_agent: Option<String>,
}
impl Client {
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
}
impl ClientBuilder {
pub fn timeout(mut self, t: std::time::Duration) -> Self {
self.timeout = Some(t);
self
}
pub fn retries(mut self, n: u32) -> Self {
self.retries = Some(n);
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
pub fn build(self, base_url: impl Into<String>) -> Client {
Client {
base_url: base_url.into(),
timeout: self.timeout.unwrap_or(std::time::Duration::from_secs(30)),
retries: self.retries.unwrap_or(3),
user_agent: self.user_agent.unwrap_or_else(|| "my-tool/1.0".into()),
}
}
}
// let client = Client::builder().retries(5).build("https://api.example.com");
Required values go in build() (or builder()) so they can't be forgotten; optional ones get setters. For simpler cases, a Default impl plus struct-update syntax is often enough:
let opts = Options { verbose: true, ..Default::default() };
Concurrency primitives¶
Rust's thread-safety guarantees rest on two auto-traits:
Send: a value can be moved to another thread.Sync: a value can be shared by reference between threads (&TisSend).
The compiler implements them automatically and refuses to compile code that would share non-thread-safe types (like Rc or RefCell) across threads. That's the "fearless concurrency" promise: data races are compile errors.
Tools, from simplest to most involved:
// Scoped threads can borrow from the enclosing stack frame, so no Arc is needed.
let data = vec![1, 2, 3, 4, 5, 6];
let (left, right) = data.split_at(data.len() / 2);
let (a, b) = std::thread::scope(|s| {
let ha = s.spawn(|| left.iter().sum::<i32>());
let hb = s.spawn(|| right.iter().sum::<i32>());
(ha.join().unwrap(), hb.join().unwrap())
});
assert_eq!(a + b, 21);
// Data parallelism with rayon: change .iter() to .par_iter().
// use rayon::prelude::*;
// let total: u64 = files.par_iter().map(|f| hash_file(f)).sum();
// Channels for message passing.
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for i in 0..3 {
tx.send(i).unwrap();
}
});
let received: Vec<i32> = rx.iter().collect();
One-time global initialisation without external crates:
use std::sync::LazyLock;
static VERSION_RE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^\d+\.\d+\.\d+$").expect("valid regex"));
Prefer passing state explicitly over globals. LazyLock/OnceLock are for genuinely global, immutable things like compiled regexes or lookup tables.
Macros¶
Declarative macros (macro_rules!) eliminate repetitive code that functions and generics can't express, such as generating similar impls for several types:
macro_rules! impl_id {
($($name:ident),* $(,)?) => {
$(
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct $name(pub i64);
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
)*
};
}
impl_id!(JobId, CompanyId, ContactId);
Procedural macros (derives like #[derive(Serialize)], attributes like #[tokio::main]) are powerful but live in their own crate, slow down compilation, and are harder to debug.
Reach for a function first, generics second, and a macro only when both fall short. Macros are harder to read, harder for IDEs to analyse, and produce worse error messages.
Unsafe code¶
unsafe doesn't turn off the borrow checker; it unlocks five extra abilities (dereferencing raw pointers, calling unsafe functions including FFI, accessing mutable statics, implementing unsafe traits, accessing union fields). In those blocks you are responsible for upholding Rust's invariants.
For application code, you almost never need it. If you do:
- Put
# in every crate that doesn't need it. - Encapsulate unsafe code in a small module with a safe public API.
- Write a
// SAFETY:comment on everyunsafeblock explaining why the invariants hold. - Test it under Miri (
cargo +nightly miri test), which detects undefined behaviour at runtime. - Prefer a well-reviewed crate over your own unsafe code.
Performance¶
Rust is fast by default; most performance problems in Rust code are algorithmic or come from needless allocation and I/O patterns.
- Measure first. Use
cargo build --release(debug builds can be 10–100× slower), then benchmark withcriterionordivanand profile withperf/cargo flamegraph. - Avoid needless allocation: borrow instead of
clone(); use&stroverStringin parameters;Vec::with_capacitywhen you know the size; reuse buffers in hot loops (buf.clear()keeps capacity). - Buffer I/O: wrap files and stdout in
BufReader/BufWriter. Printing in a loop withprintln!locks stdout on every call; lock it once (let mut out = std::io::stdout().lock();). - Pick the right collection:
Vecfor almost everything;HashMapfor lookup;BTreeMapfor ordered iteration;VecDequefor queues. Small vectors and inline strings (smallvec,compact_str) for hot paths with many tiny collections. - Choose faster hashers for non-adversarial keys (
ahash,rustc-hash/FxHashMap); the default SipHash is DoS-resistant but slower. - Release profile tuning (
lto,codegen-units = 1) typically yields a few to ~20% for free; see Building and releasing.
Part 4: Quality and delivery¶
Testing strategy¶
Rust has testing built in. Use all three kinds:
| Kind | Location | Can access | Good for |
|---|---|---|---|
| Unit tests | #[cfg(test)] mod tests in the same file |
Private items | Domain logic, parsers, edge cases |
| Integration tests | tests/*.rs (each file is its own crate) |
Public API only | Exercising the crate as a user would |
| Doc tests | Code blocks in /// docs |
Public API | Examples that are guaranteed to stay correct |
pub fn parse_size(s: &str) -> Option<u64> {
let (num, shift) = match s.as_bytes().last()? {
b'K' => (&s[..s.len() - 1], 10),
b'M' => (&s[..s.len() - 1], 20),
b'G' => (&s[..s.len() - 1], 30),
_ => (s, 0),
};
num.parse::<u64>().ok()?.checked_mul(1 << shift)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_suffixes() {
assert_eq!(parse_size("10"), Some(10));
assert_eq!(parse_size("2K"), Some(2048));
assert_eq!(parse_size("1G"), Some(1 << 30));
}
#[test]
fn rejects_garbage() {
assert_eq!(parse_size("abc"), None);
assert_eq!(parse_size("K"), None);
}
}
Practices that pay off:
- Test behaviour through ports. The trait-based fakes from Ports and adapters with traits make service tests fast and deterministic.
- Inject time and randomness. Pass a clock (
trait Clock { fn now(&self) -> DateTime }) or seed instead of callingSystemTime::now()deep in the code. - Test the binary end-to-end with
assert_cmd+predicates(CLI) or by spinning the router up on a random port and hitting it withreqwest(service). For axum,tower::ServiceExt::oneshotcalls the router without a network socket. - Snapshot tests with
instafor large outputs (rendered tables, JSON responses, generated configs): review diffs instead of hand-writing expected strings. - Property tests with
proptestfor parsers and round-trips: "for any validVer,parse(display(v)) == v". - Temporary files and dirs with
tempfile; never write tests that depend on/tmp/somethingexisting. - Database tests against a real database (SQLite in-memory, or Postgres in a container);
#[sqlx::test]gives each test a fresh database. - Use
cargo nextestfor faster parallel runs and better output on large suites.
Return Result from tests to use ? instead of a wall of unwrap():
#[test]
fn round_trip() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("cfg.toml");
std::fs::write(&path, "retention_days = 7\n")?;
let cfg = load(&path)?;
assert_eq!(cfg.retention_days, 7);
Ok(())
}
Documentation¶
//!at the top oflib.rsdocuments the crate: what it does, a quick-start example.///on every public item. The first line is a one-sentence summary; follow with detail.- Use the conventional sections where they apply:
# Examples,# Errors(when the function returnsResult),# Panics,# Safety(forunsafe fn). - Examples in docs are compiled and run by
cargo test, so they can't rot. cargo doc --openrenders everything locally, including your dependencies' docs, which is useful offline.- Intra-doc links (
[`Config`],[`crate::backup::run`]) are checked byrustdoc.
/// Parses a human-readable size such as `"512"`, `"4K"`, or `"2G"` into bytes.
///
/// Suffixes are binary (`K` = 1024).
///
/// # Examples
///
/// ```
/// assert_eq!(my_tool::parse_size("4K"), Some(4096));
/// ```
///
/// Returns `None` if the input isn't a valid size or the result overflows `u64`.
pub fn parse_size(s: &str) -> Option<u64> {
// ...
}
Tooling¶
| Tool | Purpose | Typical use |
|---|---|---|
rustfmt |
Formatting | cargo fmt --all --check in CI. Configure sparingly via rustfmt.toml. |
clippy |
Lints for bugs, style, performance | cargo clippy --all-targets --all-features -- -D warnings |
rust-analyzer |
IDE support | Editor integration; run Clippy on save for instant feedback. |
cargo-deny |
License, advisory, duplicate-version, and source checks | cargo deny check |
cargo-audit |
Security advisories from the RustSec database | cargo audit (also covered by cargo-deny) |
cargo-machete / cargo-udeps |
Find unused dependencies | Periodic cleanup |
cargo-nextest |
Faster test runner | cargo nextest run |
cargo-llvm-cov |
Code coverage | cargo llvm-cov --html |
cargo-outdated / cargo upgrade (cargo-edit) |
Dependency updates | Periodic upgrades |
cargo-expand |
Show macro expansion | Debugging macros |
bacon / cargo-watch |
Re-run checks on save | Local dev loop |
Clippy's pedantic group catches many real issues but includes some noisy lints. Enabling it at warn with a few targeted allows in [lints.clippy] (as in the workspace example above) is a good balance. When you must silence a lint locally, prefer #[expect(clippy::lint_name, reason = "...")] over #[allow]: expect warns you when the suppression is no longer needed.
Wrap the common commands in a Makefile or justfile so humans and CI run identical steps:
.PHONY: check fmt lint test build release
check: fmt lint test
fmt:
cargo fmt --all --check
lint:
cargo clippy --all-targets --all-features --locked -- -D warnings
test:
cargo test --all-features --locked
build:
cargo build --locked
release:
cargo build --release --locked
Install git hooks that run fmt and clippy before commit (and tests before push) so CI failures become rare.
Continuous integration¶
A minimal but solid pipeline, shown in GitLab CI syntax (the steps translate directly to GitHub Actions):
stages: [check, test, build]
default:
image: rust:1
before_script:
- rustup component add rustfmt clippy
cache:
key:
files: [Cargo.lock]
paths:
- .cargo/registry/index
- .cargo/registry/cache
- target/
variables:
CARGO_HOME: ${CI_PROJECT_DIR}/.cargo
CARGO_TERM_COLOR: always
fmt:
stage: check
script: cargo fmt --all --check
clippy:
stage: check
script: cargo clippy --all-targets --all-features --locked -- -D warnings
deny:
stage: check
script:
- cargo install --locked cargo-deny
- cargo deny check
test:
stage: test
script: cargo test --all-features --locked
msrv:
stage: test
image: rust:1.85
script: cargo check --all-features --locked
build:
stage: build
script: cargo build --release --locked
artifacts:
paths: [target/release/my-tool]
rules:
- if: $CI_COMMIT_TAG
Notes:
--lockedfails the build ifCargo.lockis out of date, so CI builds exactly what you tested.- Check the MSRV you declare in
rust-version, or don't declare one. - Caching
target/helps a lot but grows without bound; clear it periodically or usesccache. - Installing tools from source on every run is slow; use a prebuilt CI image or
cargo binstall.
Building and releasing¶
Release profile¶
[profile.release]
lto = "thin" # or true ("fat") for maximum optimisation, slower builds
codegen-units = 1 # better optimisation, slower build
strip = true # strip symbols: much smaller binary
# panic = "abort" # smaller/faster, but no unwinding (no catch_unwind, no cleanup on panic)
[profile.dev.package."*"]
opt-level = 2 # optimise dependencies in dev builds: faster tests/runs, same rebuild times for your code
Static Linux binaries¶
For CLIs you copy around, or containers built FROM scratch, build against musl for a fully static binary:
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
Prefer pure-Rust dependencies (rustls over OpenSSL, bundled SQLite) to avoid C toolchain headaches; most HTTP and DB crates have a rustls feature. For other architectures, cross builds inside preconfigured containers.
Container images¶
A multi-stage build keeps the toolchain out of the final image. cargo-chef caches the dependency build as its own layer, so code-only changes rebuild in seconds:
FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked --bin my-service
FROM gcr.io/distroless/cc-debian12 AS runtime
COPY --from=builder /app/target/release/my-service /usr/local/bin/my-service
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/my-service"]
Use distroless/cc for glibc-linked binaries, or scratch/distroless/static for musl builds (add CA certificates if the binary makes TLS calls and doesn't bundle roots).
Versioning and release automation¶
- Follow semver for anything others depend on. For binaries, semver of the CLI interface is a useful discipline even if nobody enforces it.
- Embed version info:
#[command(version)]in clap usesCARGO_PKG_VERSION; abuild.rsor thevergencrate can add the git hash. cargo-releaseorrelease-plzautomate version bumps, changelogs, and tags;cargo-distbuilds and publishes cross-platform binaries and installers.- A self-update command (e.g. via the
self_updatecrate) is handy for internal tools distributed as binaries.
Choosing dependencies¶
The ecosystem is rich, and cargo add makes it frictionless, which is exactly why you should be deliberate:
- Prefer widely used, actively maintained crates. Check recent releases, open issues, download counts on crates.io, and reverse dependencies.
- Check the transitive cost.
cargo tree -p some-crateshows what a dependency drags in. A crate that pulls 80 others for one helper function isn't a bargain. - Disable default features you don't need (
default-features = false) to cut compile times and attack surface. - Audit continuously with
cargo deny/cargo auditin CI. - A short, conventional baseline covers most projects:
| Need | Common choice |
|---|---|
| CLI parsing | clap (derive) |
| Serialisation | serde + serde_json / toml / serde_yaml_ng |
| Errors | thiserror (libraries), anyhow (applications) |
| Logging / tracing | tracing + tracing-subscriber |
| Async runtime | tokio |
| HTTP server | axum (+ tower-http) |
| HTTP client | reqwest (async), ureq (sync) |
| SQL | sqlx, diesel, rusqlite |
| Time | jiff or chrono; humantime for durations |
| Testing | assert_cmd, insta, proptest, tempfile, wiremock |
API stability¶
If your crate is used by others (including your own other crates published separately), the public API is a contract:
- Keep fields private and expose methods; adding a private field is then non-breaking.
- Mark enums and structs that may grow
#[non_exhaustive], which forces downstreammatches to include a wildcard and prevents struct-literal construction outside your crate. - Sealed traits (a public trait with a private supertrait) let you add methods later without breaking implementors, because nobody outside can implement them.
- Adding a trait impl, removing
Copy, changing apubtype in a function signature, or bumping a dependency whose types appear in your API are all breaking changes.cargo-semver-checkscatches many of these automatically. - Re-export dependency crates whose types appear in your API (
pub use reqwest;) so users can name matching versions.
Part 5: Anti-patterns¶
| Anti-pattern | Why it hurts | Better |
|---|---|---|
.clone() everywhere to silence the borrow checker |
Hides unclear ownership; costs allocations | Decide who owns the data; borrow; restructure; clone deliberately |
Rc<RefCell<T>> / Arc<Mutex<T>> for everything |
Runtime borrow panics, lock contention, OOP-style object graphs | Ownership trees, IDs/indices, message passing |
.unwrap() on fallible operations in production paths |
Crashes on ordinary failures | ? with context; expect only for true invariants |
String for everything (IDs, states, paths, errors) |
No compiler help; validation scattered everywhere | Newtypes, enums, PathBuf, typed errors |
A crate-wide Error enum with 40 variants returned everywhere |
Callers can't tell what can actually fail | Smaller per-module error types |
Boolean flags parameters (run(true, false, true)) |
Unreadable call sites, easy to swap | Enums or an options struct |
| A trait for every struct "for testability" | Indirection without benefit | Traits only at real boundaries (I/O, external services) |
| Long-lived structs with lifetime parameters | Lifetimes infect every type that holds them | Own the data; keep borrows short |
Blocking calls inside async fn |
Stalls the runtime, mysterious latency | spawn_blocking or async APIs |
Holding a lock guard across .await |
Deadlocks, !Send futures |
Scope the guard; copy what you need out |
Huge main.rs |
Untestable, unreusable | Thin main, logic in lib.rs |
pub on everything |
Accidental API surface; can't refactor freely | Private by default; pub(crate); curated re-exports |
| Generic-heavy code nobody asked for | Slow compiles, unreadable errors | Concrete types until a second use case appears |
Deref-based "inheritance" (impl Deref to fake a base class) |
Surprising method resolution | Composition plus explicit delegation, or traits |
| Ignoring Clippy | Misses real bugs and idioms | Run it in CI with -D warnings |
New-project checklist¶
Structure
- [ ] Logic in
lib.rs;main.rsonly wires things up and maps errors to exit codes - [ ] Modules named for what they do; no
utilsdumping ground - [ ] Private by default, public API curated with
pub use - [ ] Workspace only once there's a real reason (separate binaries, compile times, enforced boundaries)
Manifest and tooling
- [ ]
edition = "2024",rust-versiondeclared,Cargo.lockcommitted - [ ]
[lints]configured (Clippy at leastall, ideally selectivepedantic);unsafe_code = "forbid"unless needed - [ ]
[profile.release]tuned (lto,codegen-units,strip) - [ ] Makefile/justfile with
fmt,lint,test,build,release - [ ] Git hooks for
fmt+clippy; CI running fmt, clippy, tests,cargo deny, MSRV
Design
- [ ] Domain types free of framework/DB types; dependencies point inward
- [ ] Traits only at real boundaries (storage, network, clock)
- [ ] Clear ownership tree; shared state minimal and behind
Arc - [ ] Config parsed once into a typed struct; secrets never logged
- [ ] Typed errors in library/service code,
anyhowwith context at the edges, nounwrapon fallible paths - [ ]
tracinginitialised once inmain; structured fields; JSON logs for services - [ ] Enums for states, newtypes for IDs and validated values
Quality
- [ ] Unit tests beside the code, integration tests in
tests/, doc examples on public items - [ ] End-to-end tests for the binary or HTTP router
- [ ] README with install, usage, configuration, and development instructions
Further reading¶
Official
- The Rust Programming Language: "the Book"; chapter 7 covers packages, crates, and modules
- The Cargo Book: workspaces, features, profiles, manifest reference
- Rust API Guidelines: the checklist for idiomatic public APIs
- Rust Reference: precise language semantics
- Asynchronous Programming in Rust
- The Rustonomicon: unsafe Rust, in depth
- Clippy lint list
Community
- Rust Design Patterns: idioms, patterns, and anti-patterns
- Effective Rust: 35 specific ways to improve your Rust code
- Zero To Production In Rust: building a production web service with axum/actix, sqlx, tracing, and CI
- Rust for Rustaceans: intermediate-to-advanced idioms and API design
- Tokio tutorial: async fundamentals with the most common runtime
- Command Line Applications in Rust: CLI-specific guidance
On this site