Structuring and 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 the whole life of a Rust project: how to lay out the repository, how to architect the code inside it, the core coding concepts that make individual pieces idiomatic, and the files that build, ship, and run it (Makefile, Dockerfile, Compose, Helm, systemd, CI). It's opinionated; where there's a real trade-off, it says so and gives the reasoning so you can choose differently.
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.
Three project shapes come up repeatedly, and the guide shows a layout for each:
| Archetype | Example | Shape |
|---|---|---|
| A. CLI tool | an admin CLI, a timezone tool | single crate, lib.rs + thin main.rs |
| B. Web service | an axum + SQLite tracker app | single crate (or small workspace), migrations, container, Helm chart |
| C. Multi-tool workspace | one binary with many sizable subcommands | Cargo workspace, one crate per subcommand, one bin crate |
Contents¶
- Guiding principles
- Part 1: Project structure: crates and workspaces, modules, repository layouts, root files,
Cargo.toml, toolchain config - Part 2: Application architecture: layering, traits as boundaries, ownership-driven design, config, errors, logging, async, CLIs, web services, persistence
- Part 3: Core coding concepts: type-driven design, traits, iterators, strings, patterns, builders, concurrency, macros,
unsafe, performance - Part 4: Quality and supply chain: testing, documentation, tooling, git hooks, security, dependencies, API stability
- Part 5: Build, deploy, and release: versioning, release builds, Makefile, containers, Compose, Helm, systemd, CI, releases
- Part 6: Anti-patterns
- New-project checklist
- Further reading
Guiding principles¶
- One source of truth per fact. The version lives in
Cargo.toml. The CI header lives in a shared template. Lint rules live in one[lints]table. Everything else reads from those rather than restating them. - Thin binary, fat library.
main.rsparses args, sets up logging, calls into the library, and maps errors to an exit code. All logic lives inlib.rsand below, where it can be tested. - Convention over configuration. Use Cargo's standard locations (
src/,tests/,benches/,examples/,build.rs). Every tool in the ecosystem already knows where to look. - Deployment files are part of the app. The Dockerfile, chart and units are versioned with the code that they deploy, so a tag describes both.
- Same shape across projects. When every repo has the same Makefile targets, the same
deploy/layout and the same CI stages, you can move between projects without re-learning anything, and shared tooling (template repos, CI includes, sync tools) works across all of them. - Fail locally before you fail in CI.
fmt,clippyandtestshould run in a pre-commit/pre-push hook with the same flags CI uses.
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.
Single crate or workspace?¶
Start with a single crate unless you have a concrete reason not to. A single crate with good module boundaries is simpler to build, version and release.
Move to a workspace when one or more of these is true:
- You have several independently meaningful units (subcommands that are each hundreds of lines, with their own dependencies).
- Compile times hurt: changing one module rebuilds everything. Crates are Cargo's unit of incremental and parallel compilation, so splitting heavy code into crates lets unchanged ones stay cached.
- You want to share code between multiple binaries (a CLI and a server, say) without either depending on the other.
- Some code should be reusable outside the project (a client library for your own API).
- You want hard dependency boundaries: a
corecrate that cannot accidentally depend onaxum.
Signs you split too early: crates with one file each, constant cross-crate refactoring, or pub everything just to make it visible to a sibling.
A useful middle ground: a single package that contains both src/lib.rs and src/main.rs. You get the library/binary split (for testing) without workspace overhead.
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.
For an async service the shape is the same: main also initialises telemetry, and lib.rs becomes a table of contents with a top-level run that dispatches subcommands.
// src/main.rs — the whole file
use std::process::ExitCode;
#[tokio::main]
async fn main() -> ExitCode {
let cli = <myservice::cli::Cli as clap::Parser>::parse();
if let Err(e) = myservice::telemetry::init(&cli.log) {
eprintln!("failed to init logging: {e:#}");
return ExitCode::FAILURE;
}
match myservice::run(cli).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
tracing::error!(error = ?e, "fatal");
eprintln!("error: {e:#}");
ExitCode::FAILURE
}
}
}
// src/lib.rs
pub mod cli;
pub mod config;
pub mod error;
pub mod telemetry;
mod db;
mod domain;
mod storage;
mod http;
mod services;
mod state;
pub use error::{Error, Result};
pub async fn run(cli: cli::Cli) -> anyhow::Result<()> {
let cfg = config::Config::load(&cli)?;
match cli.command {
cli::Command::Serve => http::serve(cfg).await,
cli::Command::Migrate => db::migrate(&cfg).await,
cli::Command::Healthcheck => http::health::probe(&cfg).await,
}
}
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.
Organise by layer or by feature¶
By layer (http/, services/, storage/, domain/) works well for small-to-medium web apps — everyone knows where SQL lives.
By feature (jobs/{http,service,storage,domain}.rs, companies/...) scales better once you have many domains, because a change to one feature touches one directory.
A practical path: start by layer; when a layer directory gets past ~10 files, regroup by feature.
Whichever you choose, keep lib.rs as a table of contents: module declarations, a few re-exports, and the top-level run.
Repository layouts¶
The trees below show everything that typically lives in the repository, not just src/. The deployment directories are covered in Part 5.
Archetype A — CLI tool (single crate)¶
myapp/
├── .cargo/
│ └── config.toml # project-level cargo aliases (optional)
├── .gitlab-ci.yml
├── .gitignore
├── .editorconfig
├── Cargo.toml
├── Cargo.lock # committed
├── CHANGELOG.md
├── LICENSE
├── Makefile
├── README.md
├── clippy.toml # optional
├── deny.toml
├── rust-toolchain.toml
├── rustfmt.toml
├── build.rs # optional: embeds git sha
├── deploy/
│ └── systemd/ # only if it runs as a service/timer
│ ├── myapp.service
│ └── myapp.timer
├── docs/
│ └── adr/ # architecture decision records
├── examples/
│ └── config.example.toml
├── src/
│ ├── main.rs # thin: parse, init logging, call lib, exit code
│ ├── lib.rs # pub mod declarations + run()
│ ├── cli.rs # clap structs
│ ├── config.rs
│ ├── error.rs
│ └── commands/
│ ├── mod.rs # dispatch
│ ├── list.rs
│ └── sync.rs
└── tests/
├── cli.rs # end-to-end: run the binary
└── fixtures/
Archetype A, variant — 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).
Archetype B — Web service (axum + SQLite)¶
myservice/
├── .dockerignore
├── .gitlab-ci.yml
├── .sqlx/ # sqlx offline query metadata (committed)
├── Cargo.toml
├── Cargo.lock
├── CHANGELOG.md
├── Dockerfile # at repo root (CI templates detect it here)
├── Makefile
├── README.md
├── build.rs
├── deny.toml
├── rust-toolchain.toml
├── rustfmt.toml
├── deploy/
│ ├── compose/
│ │ ├── compose.yaml
│ │ └── .env.example
│ └── helm/
│ └── myservice/
│ ├── Chart.yaml
│ ├── values.yaml
│ └── templates/...
├── migrations/
│ ├── 20261001120000_init.sql
│ └── 20261008093000_add_tags.sql
├── static/ # css/js/images, embedded or served
├── templates/ # askama/minijinja templates
├── src/
│ ├── main.rs # thin: parse, init telemetry, call lib::run, exit code
│ ├── lib.rs
│ ├── cli.rs # `serve`, `migrate`, `healthcheck`, `export`
│ ├── config.rs
│ ├── error.rs # domain/service error types
│ ├── telemetry.rs
│ ├── state.rs # AppState
│ ├── db.rs # pool setup + migrations
│ ├── domain/ # pure types and rules; no axum, no sqlx
│ │ ├── job.rs
│ │ └── company.rs
│ ├── services/ # use cases that orchestrate domain + storage; no HTTP types
│ │ └── jobs.rs
│ ├── storage/ # repository trait implementations (SQL lives here)
│ │ └── jobs.rs
│ └── http/ # router, handlers, extractors, DTOs, AppError
│ ├── mod.rs
│ ├── error.rs
│ ├── jobs.rs
│ └── health.rs
└── tests/
├── common/
│ └── mod.rs # spawn app on random port, temp DB
├── health.rs
└── jobs_api.rs
This is layer-first, which is easiest to navigate for small-to-medium services. 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.
Archetype C — Multi-tool workspace¶
mytools/
├── Cargo.toml # [workspace] only, no [package] (virtual manifest)
├── Cargo.lock
├── Makefile
├── README.md
├── CHANGELOG.md
├── deny.toml
├── rust-toolchain.toml
├── rustfmt.toml
├── .gitlab-ci.yml
├── crates/
│ ├── mytools/ # the ONE binary crate; clap + dispatch only
│ │ ├── Cargo.toml
│ │ └── src/main.rs
│ ├── mytools-core/ # shared: config, errors, output formatting, http client
│ │ ├── Cargo.toml
│ │ └── src/lib.rs
│ ├── mytools-certs/ # one library crate per subcommand
│ │ ├── Cargo.toml
│ │ └── src/lib.rs
│ ├── mytools-links/
│ └── mytools-ssl/
├── xtask/ # optional: build automation written in Rust
│ ├── Cargo.toml
│ └── src/main.rs
└── tests/ # optional workspace-level e2e tests live in the bin crate instead
Workspaces¶
A workspace lets several packages share one lockfile, one build directory, and one set of dependency versions. Single crate or workspace? covers when to reach for one; this section covers how to run one well.
Conventions that keep a workspace sane:
crates/directory, flat. Avoid nesting crates inside crates.- Prefix crate names with the project name (
mytools-certs), and use the directory name equal to the crate name. This avoids clashes on crates.io and makescargo -pobvious. - One binary crate. Subcommand crates are libraries exposing something like
pub fn run(args: Args) -> Result<()>plus a clapArgsstruct. The bin crate just wires them into aSubcommandenum. - Dependency direction is one-way: bin → subcommand crates → core. Subcommand crates never depend on each other; anything they share moves down to
core. - Each subcommand crate owns its clap
Args(deriveclap::Args). This keeps argument definitions next to the code that uses them.
// crates/mytools/src/main.rs
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(flatten)]
global: mytools_core::GlobalArgs,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Manage certificates
Certs(mytools_certs::Args),
/// Check links in markdown
Links(mytools_links::Args),
/// Inspect TLS endpoints
Ssl(mytools_ssl::Args),
}
fn main() -> std::process::ExitCode {
let cli = Cli::parse();
mytools_core::telemetry::init(&cli.global);
let result = match cli.command {
Command::Certs(a) => mytools_certs::run(&cli.global, a),
Command::Links(a) => mytools_links::run(&cli.global, a),
Command::Ssl(a) => mytools_ssl::run(&cli.global, a),
};
mytools_core::exit::report(result)
}
Optional: cargo features per subcommand. If some subcommands pull in heavy dependencies, gate them behind features in the bin crate (default = ["certs", "links", "ssl"]) so a slim build is possible. Only bother if build size or time actually matters.
Root Cargo.toml¶
[workspace]
resolver = "3" # required explicitly in a virtual manifest
members = ["crates/*", "xtask"]
default-members = ["crates/mytools"]
[workspace.package]
version = "1.6.0" # single version for the whole workspace
edition = "2024"
rust-version = "1.85"
license = "MIT"
repository = "https://gitlab.com/<namespace>/mytools"
publish = false
[workspace.dependencies]
# internal crates
mytools-core = { path = "crates/mytools-core" }
mytools-certs = { path = "crates/mytools-certs" }
mytools-links = { path = "crates/mytools-links" }
mytools-ssl = { path = "crates/mytools-ssl" }
# external crates: versions pinned ONCE here
anyhow = "1"
clap = { version = "4", features = ["derive", "env"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
tracing = "0.1"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
Member Cargo.toml¶
# crates/mytools-ssl/Cargo.toml
[package]
name = "mytools-ssl"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
mytools-core.workspace = true
anyhow.workspace = true
clap.workspace = true
reqwest.workspace = true
# a member can add features on top of the workspace definition:
serde = { workspace = true, features = ["rc"] }
[lints]
workspace = true
Why this matters: dependency versions, lint policy and the release version are each defined in exactly one place. Bumping reqwest or the app version is a one-line change.
Version strategy in a workspace: for an application (as opposed to a set of published libraries), one shared workspace.package.version is almost always right. The internal crates are implementation details; they don't need their own semver.
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.
mytools (bin) ──► mytools-ssl ──► mytools-core
│ ▲
└──────► mytools-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).
Root-level files¶
| File | Purpose | Commit? |
|---|---|---|
Cargo.toml |
manifest | yes |
Cargo.lock |
exact dependency versions | yes, for applications and (per current Cargo guidance) libraries too |
rust-toolchain.toml |
pins compiler + components | yes |
rustfmt.toml |
formatter settings | yes |
clippy.toml |
clippy thresholds (not lint levels) | yes, if used |
deny.toml |
cargo-deny policy | yes |
.cargo/config.toml |
project aliases, rustflags | yes, but no machine-specific paths |
build.rs |
build-time codegen/metadata | yes |
Makefile |
uniform entrypoints | yes |
Dockerfile / .dockerignore |
container build | yes |
.gitlab-ci.yml |
pipeline | yes |
.editorconfig |
whitespace for non-Rust files (YAML, TOML, Markdown) | yes |
README.md, CHANGELOG.md, LICENSE |
docs/legal | yes |
.env |
local secrets | no — commit .env.example instead |
target/ |
build output | no |
.gitignore¶
/target
**/*.rs.bk
*.pdb
# local env / secrets
.env
*.local.toml
# editor
.idea/
.vscode/
*.swp
# runtime data from local runs
/data/
*.db
*.db-journal
*.db-wal
*.db-shm
# helm
deploy/helm/**/charts/*.tgz
If you keep a global target-dir (for example under ~/.cache) in ~/.cargo/config.toml, /target in the repo will stay empty locally — keep the ignore line anyway, because CI and Docker builds use the in-repo target/.
.editorconfig¶
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
[*.rs]
indent_style = space
indent_size = 4
[*.{toml,yml,yaml,json,md}]
indent_style = space
indent_size = 2
[Makefile]
indent_style = tab
Cargo.toml in depth¶
| 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. |
Single-crate manifest¶
[package]
name = "myservice"
version = "0.4.2"
edition = "2024"
rust-version = "1.85" # MSRV: oldest compiler you promise to support
description = "Tracks companies and their addresses"
license = "MIT"
repository = "https://gitlab.com/<namespace>/myservice"
readme = "README.md"
publish = false # prevents accidental `cargo publish`
default-run = "myservice"
[[bin]]
name = "myservice"
path = "src/main.rs"
[dependencies]
anyhow = "1"
axum = "0.8"
clap = { version = "4", features = ["derive", "env"] }
serde = { version = "1", features = ["derive"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "migrate", "macros", "chrono"] }
thiserror = "2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
tower-http = { version = "0.6", features = ["trace", "fs", "compression-gzip"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tempfile = "3"
insta = { version = "1", features = ["yaml"] }
[build-dependencies]
# none needed for a simple git-sha build.rs
[lints.rust]
unsafe_code = "forbid"
missing_debug_implementations = "warn"
[lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
# pedantic is noisy; opt out of the ones you disagree with
module_name_repetitions = "allow"
missing_errors_doc = "allow"
unwrap_used = "warn"
expect_used = "warn"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
# panic = "abort" # smaller/faster, but see note below
[profile.dev.package."*"]
opt-level = 1 # dependencies compiled with light optimization; your code stays fast to rebuild
Notes:
- Version numbers in
[dependencies]. Use caret ranges at the granularity you care about ("1","0.8"). The lockfile gives you reproducibility; the manifest expresses compatibility. default-features = falseon large crates (sqlx, reqwest) keeps you from silently pulling in OpenSSL, extra runtimes or TLS backends. Preferrustlsovernative-tls— it removes the system OpenSSL dependency, which makes static and distroless builds far simpler.panic = "abort"shrinks binaries and skips unwinding. For a CLI it is usually fine. For a web server, remember that withaborta panic inside any request handler kills the whole process instead of just that task. Leave it at the defaultunwindfor servers unless you have a supervisor you trust and no panics you expect.[lints]in the manifest (stable since Rust 1.74) replaces#![deny(...)]attributes scattered aroundlib.rs, and Cargo passes them consistently to every target.
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. - Name features after capabilities (
postgres,sqlite,tls), not after the dependencies that implement them. - If the matrix grows,
cargo hack --feature-powersetchecks every combination.
Toolchain and tooling config¶
rust-toolchain.toml¶
[toolchain]
channel = "1.90" # pin to the stable version you actually test with; bump deliberately
components = ["rustfmt", "clippy"]
profile = "minimal"
# targets = ["x86_64-unknown-linux-musl"]
Pinning means "it built yesterday and fails today" can only be caused by your changes, not by a new clippy release. Bump the pin in its own commit, fix the new lints, move on. (The alternative, channel = "stable", is reasonable for personal tools if you prefer always-current.)
Keep rust-version in Cargo.toml (your minimum supported) and the toolchain pin (what you build with) distinct. For apps they are often the same; for libraries MSRV is usually lower.
rustfmt.toml¶
Keep it short; the defaults are good and most options are nightly-only.
edition = "2024"
max_width = 100
use_field_init_shorthand = true
use_try_shorthand = true
clippy.toml¶
Thresholds, not levels (levels go in [lints]):
too-many-arguments-threshold = 8
cognitive-complexity-threshold = 30
allow-unwrap-in-tests = true
allow-expect-in-tests = true
.cargo/config.toml (project-level)¶
Good for aliases and target-specific settings. Do not put target-dir or absolute paths here — those belong in your user-level ~/.cargo/config.toml, otherwise Docker builds and CI break.
[alias]
lint = "clippy --all-targets --all-features -- -D warnings"
t = "test --all-targets --all-features"
xtask = "run --package xtask --"
[target.x86_64-unknown-linux-musl]
rustflags = ["-C", "target-feature=+crt-static"]
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) }
A few more rules that pay off:
- Validate at startup, fail fast with a clear message. A
config checksubcommand that loads and prints the effective config (with secrets redacted) is cheap and very handy. - Secrets never come from the repo. Read them from env vars, from a file path (
DATABASE_PASSWORD_FILE→ works with Docker/Kubernetes secrets mounted as files), or at the CLI level from a password manager likepass. Wrap secret strings in a type (e.g.secrecy::SecretString) so they never appear inDebugoutput or logs. - Ship
examples/config.example.tomlwith every key documented, and keep it in sync (a test that parses it is a cheap way to enforce that).
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.
A reusable telemetry module¶
In practice, wrap subscriber setup in a small telemetry module so verbosity and output format come from flags or the environment:
// src/telemetry.rs
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use crate::cli::{LogArgs, LogFormat};
pub fn init(args: &LogArgs) -> anyhow::Result<()> {
let default = match args.verbose {
0 => "info",
1 => "debug",
_ => "trace",
};
// RUST_LOG wins if set, e.g. RUST_LOG=myservice=debug,tower_http=info
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(format!("{}={default},tower_http=info", env!("CARGO_CRATE_NAME"))));
let registry = tracing_subscriber::registry().with(filter);
match args.log_format {
LogFormat::Json => registry.with(fmt::layer().json().with_current_span(true)).try_init()?,
LogFormat::Pretty => registry.with(fmt::layer().with_target(false)).try_init()?,
}
Ok(())
}
Guidelines:
- Logs go to stdout/stderr, never directly to files, for anything running under Docker, Kubernetes or systemd. Collection (journald, Vector, Loki, Elasticsearch) is the platform's job.
- JSON in production, pretty locally. Make it a flag/env var, not a compile-time choice.
- Use structured fields (
tracing::info!(company_id = %id, "created company")) instead of interpolating values into the message; they become queryable fields downstream. - Instrument key functions with
#[tracing::instrument(skip(pool))]to get spans with timing. - For web apps add
tower_http::trace::TraceLayerfor per-request spans, and a request-ID layer (tower_http::request_id) so a single request can be traced across log lines.
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).
Global flags, environment variables, and operational subcommands¶
For tools that run in containers or under systemd, flatten shared flags into a struct, give every deployable flag an environment variable, and add a few operational subcommands:
// src/cli.rs
use clap::{Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(version = crate::build_info::VERSION_LONG, about, propagate_version = true)]
pub struct Cli {
/// Path to config file
#[arg(short, long, global = true, env = "MYSERVICE_CONFIG")]
pub config: Option<PathBuf>,
#[command(flatten)]
pub log: LogArgs,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Args)]
pub struct LogArgs {
/// Increase verbosity (-v, -vv)
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
pub verbose: u8,
/// Log output format
#[arg(long, global = true, value_enum, default_value_t = LogFormat::Pretty, env = "MYSERVICE_LOG_FORMAT")]
pub log_format: LogFormat,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum LogFormat { Pretty, Json }
#[derive(Debug, Subcommand)]
pub enum Command {
/// Run the HTTP server
Serve,
/// Apply database migrations and exit
Migrate,
/// Exit 0 if the running server is healthy (for container healthchecks)
Healthcheck,
}
- Every flag that a deployment might set gets an
env = "...". Containers and systemd units can then configure the app without a config file. - A
healthchecksubcommand lets distroless/scratch images (which have nocurl) run Docker healthchecks. - Output format flag (
--output table|json) on CLIs that print data — makes them scriptable.
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.
The router assembly below keeps state minimal (pool plus config) for brevity; in a layered app, AppState holds the services built from them, as above.
Router assembly and graceful shutdown¶
// src/http/mod.rs
use axum::{routing::get, Router};
use tower_http::{compression::CompressionLayer, services::ServeDir, trace::TraceLayer};
use crate::{config::Config, state::AppState};
pub mod companies;
pub mod error;
pub mod health;
pub fn router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(health::live))
.route("/readyz", get(health::ready))
.nest("/api/companies", companies::router())
.nest_service("/static", ServeDir::new("static"))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.with_state(state)
}
pub async fn serve(cfg: Config) -> anyhow::Result<()> {
let db = crate::db::connect(&cfg).await?;
crate::db::run_migrations(&db).await?;
let state = AppState { db, cfg: cfg.clone().into() };
let listener = tokio::net::TcpListener::bind(cfg.listen).await?;
tracing::info!(addr = %cfg.listen, version = env!("CARGO_PKG_VERSION"), "listening");
axum::serve(listener, router(state))
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async { tokio::signal::ctrl_c().await.ok(); };
#[cfg(unix)]
let term = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! { () = ctrl_c => {}, () = term => {} }
tracing::info!("shutdown signal received");
}
Handling SIGTERM is not optional for containers: Kubernetes and docker stop send SIGTERM and wait; without a handler, in-flight requests are cut off and SQLite writes may be interrupted.
Health endpoints¶
/healthz(liveness): process is up and the event loop responds. No dependencies checked — otherwise a DB blip causes restart loops./readyz(readiness): dependencies are reachable (e.g.SELECT 1). Failing readiness removes the pod from service without restarting it.
Static assets and templates¶
Two options:
- Serve from disk (
ServeDir): simple, lets you tweak CSS without rebuilding; the Dockerfile must copystatic/andtemplates/. - Embed in the binary (
rust-embed,include_str!, or compile-time templates withaskama): one self-contained file, nothing to forget in the image. Usually the better choice for small homelab apps.
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.- 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.
Migrations¶
- Keep migrations in
migrations/at the repo root, namedYYYYMMDDHHMMSS_description.sql(whatsqlx migrate addgenerates). - Migrations are forward-only and immutable once released. Fix mistakes with a new migration.
- Embed them:
sqlx::migrate!("./migrations").run(&pool).await?compiles the SQL into the binary, so the image doesn't need the files. - Run them at startup for single-instance apps, and also expose a
migratesubcommand for when you want to run them separately (a Kubernetes init container or Helm hook). - With sqlx's compile-time checked macros (
query!,query_as!), commit the.sqlx/directory produced bycargo sqlx prepareand setSQLX_OFFLINE=truein CI and Docker so builds don't need a live database. Add a CI check (cargo sqlx prepare --check) so the metadata can't go stale.
SQLite in production¶
- Enable WAL mode and a busy timeout on connect (
journal_mode=WAL,busy_timeout=5000), and foreign keys (foreign_keys=ON— off by default in SQLite). - SQLite apps are single-writer, single-replica. In Kubernetes:
replicas: 1, aReadWriteOncePVC, andstrategy: Recreateso the old pod releases the file before the new one starts. - Back up with SQLite's online backup (
VACUUM INTO 'backup.db'orsqlite3 .backup), not by copying the live file.
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 the worked partial-move example 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 supply chain¶
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(())
}
Laying out integration tests¶
Structure for integration tests:
tests/
├── common/
│ └── mod.rs # shared helpers (spawn_app, temp db, fixtures)
├── health.rs
└── companies_api.rs
Each file in tests/ compiles as a separate crate, which is slow when there are many. Once you have more than a handful, switch to one integration test binary:
tests/
└── it/
├── main.rs # mod common; mod health; mod companies_api;
├── common.rs
├── health.rs
└── companies_api.rs
A typical helper for a web service:
// tests/common/mod.rs
pub struct TestApp { pub addr: String, _dir: tempfile::TempDir }
pub async fn spawn_app() -> TestApp {
let dir = tempfile::tempdir().unwrap();
let mut cfg = myservice::config::Config::default();
cfg.database_url = format!("sqlite://{}/test.db?mode=rwc", dir.path().display());
cfg.listen = "127.0.0.1:0".parse().unwrap(); // random free port
let listener = tokio::net::TcpListener::bind(cfg.listen).await.unwrap();
let addr = format!("http://{}", listener.local_addr().unwrap());
let app = myservice::test_support::build_app(cfg).await.unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
TestApp { addr, _dir: dir }
}
Expose that build_app from the library behind a test-support feature or a #[doc(hidden)] pub mod test_support so tests don't reach into internals.
Other notes:
cargo nextestruns tests faster and gives better output; worth adopting in CI.- Fixtures go in
tests/fixtures/; reference them withconcat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/x.json")so paths work regardless of the working directory. - Tests must not touch your real home directory or config: point everything at
tempfiledirs.
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> {
// ...
}
README.md structure¶
- One-line description and a status line (badges for pipeline/version if you like).
- Install —
cargo install, container image, or package. - Quick start — the 3 commands to see it work.
- Usage — subcommands with examples (paste real
--helpoutput). - Configuration — table of every key, env var, default.
- Deployment — pointers to
deploy/compose,deploy/helm,deploy/systemd. - Development —
make check, how to run tests, how to add a migration. - License.
Other docs¶
docs/adr/NNNN-title.md— short architecture decision records ("why SQLite", "why one binary with subcommands"). Future-you reading them in a year is the main audience.- Rustdoc —
//!module docs at the top oflib.rsand each major module explaining what lives here and why;///on everypubitem.# for libraries. - Doc tests double as tests — prefer them for small usage examples.
--helpis documentation. Write clap doc comments as if they were the manual, because for most users they are.
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, hooks, and CI run identical steps; The Makefile has a complete example.
Git hooks¶
Run the same checks locally that CI runs:
- pre-commit:
cargo fmt --all -- --check(fast). - pre-push:
cargo clippy --all-targets --all-features -- -D warningsandcargo test.
Running the full chain on both is fine for small projects; for larger ones, keep pre-commit fast so it doesn't get bypassed.
Distribution options:
- Master copies in one place, symlinked into each repo's
.git/hooks/. Editing the master updates every repo at once. - Versioned in the repo under
.githooks/withgit config core.hooksPath .githooks(set once per clone, e.g. bymake hooks). Hooks then travel with the repo and are reviewed like code. - A hook manager (
cargo-husky,lefthook,pre-commit).
Whichever you pick, have your repo-creation tooling install them automatically so a fresh clone is never unprotected.
Security and supply chain¶
cargo denywith a committeddeny.toml:
```toml [advisories] version = 2 yanked = "deny"
[licenses] version = 2 allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "Unicode-3.0", "Zlib"] confidence-threshold = 0.9
[bans] multiple-versions = "warn" wildcards = "deny" deny = [{ name = "openssl-sys" }] # enforce rustls-only, if that's your policy
[sources] unknown-registry = "deny" unknown-git = "deny" ```
unsafe_code = "forbid"in application crates. If a crate genuinely needsunsafe, isolate it in its own small crate withdenyand a// SAFETY:comment on every block.- Secrets: never in the repo, the image, or Helm
values.yaml. Use env vars from CI variables, Kubernetes Secrets (existingSecret), or files mounted at runtime. A secret-scanning job (GitLab's built-in Secret Detection, orgitleaks) catches accidents. - Containers: non-root user, read-only root filesystem, all capabilities dropped, no shell in the runtime image.
- Minimal dependency features — every disabled default feature is code you don't compile or audit.
- SBOM:
cargo cyclonedxorcargo auditable(embeds dependency info in the binary so scanners like Trivy can audit it later) are cheap additions to a release job. cargo updateregularly in its own MR, so dependency churn is reviewable and bisectable.
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: Build, deploy, and release¶
Build metadata and versioning¶
The version is defined once, in Cargo.toml. Everything else derives from it:
- In code:
env!("CARGO_PKG_VERSION"). - In the Makefile, Docker tags, Helm
appVersion, and release tags: read fromCargo.toml(see the Makefile below).
Embedding the git commit is useful for --version output and log lines. A dependency-free build.rs:
// build.rs
use std::process::Command;
fn main() {
// Prefer an explicit value (CI/Docker, where .git may be absent)
let sha = std::env::var("GIT_SHA").ok().filter(|s| !s.is_empty()).or_else(|| {
Command::new("git").args(["rev-parse", "--short=10", "HEAD"]).output().ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}).unwrap_or_else(|| "unknown".into());
println!("cargo:rustc-env=BUILD_GIT_SHA={sha}");
println!("cargo:rerun-if-env-changed=GIT_SHA");
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs");
}
// src/build_info.rs
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const GIT_SHA: &str = env!("BUILD_GIT_SHA");
pub const VERSION_LONG: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("BUILD_GIT_SHA"), ")");
Keep build.rs minimal: anything it does runs on every clean build and is easy to make slow. If you need more (shell completions, man pages, code generation), consider an xtask instead so it only runs when asked.
Versioning policy (SemVer for apps):
- MAJOR: breaking change for users (CLI flags removed/renamed, config keys changed incompatibly, API breaking, a migration that can't be rolled back).
- MINOR: new features, new subcommands, new config keys with defaults.
- PATCH: bug fixes only.
cargo set-version (from cargo-edit) bumps the version, including workspace.package.version.
Release tooling¶
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.
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 = 1 # 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.
For container images, see Containers below.
The Makefile¶
A Makefile gives every repo the same verbs regardless of what's under the hood. Keep targets thin — they should call cargo, docker and helm, not contain logic.
SHELL := /usr/bin/env bash
.DEFAULT_GOAL := help
APP ?= $(shell sed -n 's/^name *= *"\(.*\)"/\1/p' Cargo.toml | head -1)
VERSION ?= $(shell sed -n 's/^version *= *"\(.*\)"/\1/p' Cargo.toml | head -1)
GIT_SHA ?= $(shell git rev-parse --short=10 HEAD 2>/dev/null || echo unknown)
REGISTRY ?= registry.gitlab.com/<namespace>
IMAGE ?= $(REGISTRY)/$(APP)
CHART ?= deploy/helm/$(APP)
.PHONY: help fmt fmt-check lint test check build release install run \
audit docker-build docker-push helm-lint helm-template helm-package clean version
help: ## Show targets
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-16s\033[0m %s\n",$$1,$$2}'
version: ## Print the version from Cargo.toml
@echo $(VERSION)
fmt: ## Format code
cargo fmt --all
fmt-check: ## Verify formatting
cargo fmt --all -- --check
lint: ## Clippy with warnings as errors
cargo clippy --all-targets --all-features --locked -- -D warnings
test: ## Run tests
cargo test --all-targets --all-features --locked
check: fmt-check lint test ## Everything CI runs before build
audit: ## Dependency advisories, licenses, sources
cargo deny check
build: ## Debug build
cargo build --locked
release: ## Optimized build
GIT_SHA=$(GIT_SHA) cargo build --release --locked
install: ## Install binary to cargo's install root
GIT_SHA=$(GIT_SHA) cargo install --path . --locked --force
run: ## Run locally
cargo run -- serve
docker-build: ## Build container image
docker build --build-arg GIT_SHA=$(GIT_SHA) -t $(IMAGE):$(VERSION) -t $(IMAGE):latest .
docker-push: docker-build ## Push container image
docker push $(IMAGE):$(VERSION)
docker push $(IMAGE):latest
helm-lint: ## Lint the chart
helm lint $(CHART)
helm-template: ## Render chart locally
helm template $(APP) $(CHART) --set image.tag=$(VERSION)
helm-package: ## Package chart with appVersion synced to Cargo.toml
helm package $(CHART) --app-version $(VERSION) -d dist/
clean: ## Remove build outputs
cargo clean
rm -rf dist/
Notes:
- For a workspace,
cargo install --path crates/mytools, and read the version from the root[workspace.package](thesedabove picks the firstversion =line, which is that one in a workspace root). helm package --app-version $(VERSION)keeps the chart'sappVersionin lockstep withCargo.tomlwithout a manual edit.make checkis exactly what the pre-push hook and the CI lint/test stages run. Same command, three places.
When to reach for xtask instead: if Makefile recipes start growing loops, conditionals or string munging, move that logic into an xtask crate (a small Rust program run via cargo xtask <cmd>). It's cross-platform, typed, and testable. The Makefile can then just call cargo xtask ....
Containers¶
Multi-stage Dockerfile with dependency caching¶
# syntax=docker/dockerfile:1
############################
# Base with cargo-chef
############################
FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef
WORKDIR /app
############################
# Compute dependency recipe
############################
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
############################
# Build
############################
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Dependencies only — this layer stays cached until Cargo.toml/Cargo.lock change
RUN cargo chef cook --release --locked --recipe-path recipe.json
COPY . .
ARG GIT_SHA=unknown
ENV GIT_SHA=${GIT_SHA} \
SQLX_OFFLINE=true
RUN cargo build --release --locked --bin myservice
############################
# Runtime
############################
FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=builder /app/target/release/myservice /usr/local/bin/myservice
# Only needed if assets are NOT embedded:
# COPY --from=builder /app/static ./static
# COPY --from=builder /app/templates ./templates
ENV MYSERVICE_LOG_FORMAT=json \
MYSERVICE_LISTEN=0.0.0.0:8080
EXPOSE 8080
USER nonroot:nonroot
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD ["/usr/local/bin/myservice", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/myservice"]
CMD ["serve"]
Why each piece:
- cargo-chef splits "build dependencies" from "build my code" so a one-line change doesn't recompile 300 crates. (Alternative: BuildKit cache mounts —
RUN --mount=type=cache,target=/usr/local/cargo/registry --mount=type=cache,target=/app/target ...— which works well for local builds but is harder to reuse across CI runners.) --lockedfails the build ifCargo.lockis out of date instead of silently resolving new versions.- distroless
cccontains glibc and libgcc (which Rust binaries dynamically link) plus CA certificates and tzdata, and nothing else — no shell, no package manager. The:nonroottag runs as UID 65532. healthchecksubcommand because there's nocurlin the image.- Pin the Rust image to the same version as
rust-toolchain.tomlonce things are stable;latest-rust-1is used above only for brevity. Note thatrust-toolchain.tomlin the build context will make rustup install that exact toolchain inside the container anyway.
Fully static (musl) alternative¶
For CLIs and very small services you can build a static binary and ship it in scratch or distroless/static:
FROM rust:1-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY . .
RUN cargo build --release --locked --target x86_64-unknown-linux-musl
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
ENTRYPOINT ["/myapp"]
Trade-offs: musl's default allocator is notably slower for allocation-heavy, multi-threaded workloads (swap in mimalloc or jemalloc if it matters); C dependencies must be built for musl (bundled SQLite via libsqlite3-sys's bundled feature works fine; OpenSSL is painful — another reason to use rustls).
.dockerignore¶
target/
.git/
.gitlab-ci.yml
.env
*.db
*.db-*
data/
deploy/
docs/
**/*.md
!README.md
Excluding target/ is essential — otherwise you ship gigabytes of build artifacts into the build context. Excluding .git/ is why build.rs takes GIT_SHA from a build arg.
Image tagging¶
:<version>fromCargo.toml(immutable, what deployments reference):<git-sha>for every main-branch build (traceability):latestonly as a convenience; never reference it in Helm values or Compose files that matter.
Docker Compose¶
Keep it under deploy/compose/ so the root stays clean, or at the root as compose.yaml if it's the primary deployment method — just be consistent across repos.
# deploy/compose/compose.yaml
name: myservice
services:
myservice:
image: registry.gitlab.com/<namespace>/myservice:${MYSERVICE_VERSION:-latest}
container_name: myservice
restart: unless-stopped
env_file: .env
environment:
MYSERVICE_LISTEN: 0.0.0.0:8080
MYSERVICE_DATABASE_URL: sqlite:///data/app.db?mode=rwc
MYSERVICE_LOG_FORMAT: json
volumes:
- myservice-data:/data
ports:
- "127.0.0.1:8080:8080" # bind to loopback; the reverse proxy fronts it
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
healthcheck:
test: ["CMD", "/usr/local/bin/myservice", "healthcheck"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
logging:
driver: journald # or json-file with rotation
volumes:
myservice-data:
.env.example (committed) documents every variable; .env (ignored) holds real values.
For local development a second file, compose.dev.yaml, can build from source (build: ../..) and mount a local data directory.
Helm chart¶
Location and layout¶
deploy/helm/myservice/
├── Chart.yaml
├── values.yaml
├── values.schema.json # optional but catches bad values early
├── .helmignore
├── README.md # values reference
└── templates/
├── _helpers.tpl
├── NOTES.txt
├── deployment.yaml
├── service.yaml
├── ingress.yaml
├── pvc.yaml
├── configmap.yaml
├── secret.yaml # only if you template secrets; prefer existingSecret
├── serviceaccount.yaml
└── tests/
└── test-connection.yaml
Once one chart is "the good one", treat it as the template: copy it into new projects (ideally from your shared scaffold repo) and change only names and values. Diverging chart structure across apps is what makes fleet-wide changes painful.
Chart.yaml¶
apiVersion: v2
name: myservice
description: Company tracking service
type: application
version: 0.3.0 # chart version: bump when the CHART changes
appVersion: "0.4.2" # app version: overridden by `helm package --app-version`
Two versions, two meanings. version tracks the chart's own templates; appVersion tracks the Rust app. Default the image tag to appVersion so they can't drift:
# templates/deployment.yaml (excerpt)
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
values.yaml essentials¶
image:
repository: registry.gitlab.com/<namespace>/myservice
tag: "" # defaults to Chart.appVersion
pullPolicy: IfNotPresent
imagePullSecrets: []
replicaCount: 1 # SQLite => must stay 1
strategy:
type: Recreate # SQLite => old pod must release the DB file first
service:
type: ClusterIP
port: 8080
ingress:
enabled: false
className: ""
hosts:
- host: myservice.example.lan
paths: [{ path: /, pathType: Prefix }]
tls: []
env:
MYSERVICE_LOG_FORMAT: json
MYSERVICE_DATABASE_URL: sqlite:///data/app.db?mode=rwc
existingSecret: "" # name of a Secret to load as env
persistence:
enabled: true
size: 1Gi
storageClass: ""
accessModes: [ReadWriteOnce]
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { memory: 128Mi }
podSecurityContext:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532
seccompProfile: { type: RuntimeDefault }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
probes:
liveness: { path: /healthz, initialDelaySeconds: 5, periodSeconds: 10 }
readiness: { path: /readyz, initialDelaySeconds: 2, periodSeconds: 5 }
nodeSelector: {}
tolerations: []
affinity: {}
Rust services typically idle at a few MB of memory, so requests can be tiny. Set a memory limit but think twice about a CPU limit (CPU throttling causes latency spikes; a request alone is usually enough for small apps).
fsGroup: 65532 matters with distroless nonroot: it makes the mounted PVC writable by the app user.
Checks¶
helm lintandhelm template ... | kubeconform -strictin CI catch most mistakes without a cluster.helm testwithtemplates/tests/test-connection.yamlgives a post-install smoke test.
systemd and native packaging¶
For CLIs that run on a schedule or daemons that run on a host directly, ship units under deploy/systemd/.
# deploy/systemd/myapp.service
[Unit]
Description=myapp sync job
Documentation=https://gitlab.com/<namespace>/myapp
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot # use Type=simple (or notify) for long-running daemons
ExecStart=%h/.local/bin/myapp sync
Environment=MYAPP_LOG_FORMAT=json
# Hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=%h/.local/share/myapp
[Install]
WantedBy=default.target
# deploy/systemd/myapp.timer
[Unit]
Description=Run myapp sync daily
[Timer]
OnCalendar=*-*-* 06:00:00
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
%hmakes this a user unit (~/.config/systemd/user/,systemctl --user). For system units, use absolute paths,User=/DynamicUser=yes, andStateDirectory=myapp(systemd creates/var/lib/myappwith correct ownership).- Logging to stdout lands in journald automatically;
journalctl --user -u myappreads it. - Add a
make install-unitstarget that copies units and runssystemctl --user daemon-reload.
Native packages: cargo-deb and cargo-generate-rpm build .deb/.rpm straight from Cargo.toml metadata ([package.metadata.deb]), including the systemd unit, config example and man page. Worth it once anyone other than you installs the tool.
CI/CD (GitLab)¶
Shape¶
Stages, in order: lint → test → audit → build → release (plus deploy if the pipeline deploys).
If you keep shared CI building blocks in a central repo, every project's .gitlab-ci.yml should be: a standard include: + stages: header (identical across repos, ideally kept in sync by tooling), followed by only the jobs unique to that project.
# ---- standard header (shared, do not edit by hand) ----
include:
- project: <namespace>/skel
ref: main
file:
- /release/.gitlab-ci-auto-release.yml
- /build/.gitlab-ci-docker-build.yml
- /build/.gitlab-ci-binary-build.yml
stages: [lint, test, audit, build, release]
# ---- end standard header ----
variables:
CARGO_HOME: $CI_PROJECT_DIR/.cargo-home
CARGO_TERM_COLOR: always
SQLX_OFFLINE: "true"
default:
image: rust:1-bookworm # match rust-toolchain.toml
cache:
key:
files: [Cargo.lock]
paths:
- .cargo-home/registry/index/
- .cargo-home/registry/cache/
- .cargo-home/git/db/
- target/
policy: pull-push
fmt:
stage: lint
script:
- rustup component add rustfmt
- cargo fmt --all -- --check
clippy:
stage: lint
script:
- rustup component add clippy
- cargo clippy --all-targets --all-features --locked -- -D warnings
test:
stage: test
script:
- cargo test --all-targets --all-features --locked
deny:
stage: audit
image: { name: "embarkstudios/cargo-deny:latest", entrypoint: [""] }
cache: []
script:
- cargo-deny check
allow_failure: true # advisories appear asynchronously; don't block unrelated MRs
helm:
stage: lint
image: { name: "alpine/helm:latest", entrypoint: [""] }
cache: []
rules:
- exists: [deploy/helm/**/Chart.yaml]
script:
- helm lint deploy/helm/*
Notes¶
- Cache
CARGO_HOMEinside the project dir — GitLab can only cache paths under$CI_PROJECT_DIR. Your local globaltarget-dirsetting does not apply in CI; the in-repotarget/is what gets cached. - Keying the cache on
Cargo.lockmeans a dependency change starts a fresh cache rather than growing an old one forever. - Separate
fmtandclippyjobs so a formatting slip doesn't hide lint results. - Make detection automatic. Build jobs in shared templates can use
rules: - exists: [Dockerfile]to decide between "build image" and "build binary", so projects never have to choose a variant. - Protect
mainand land changes via merge requests; run the pipeline on MRs (rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"). - Scheduled pipeline (weekly) running
cargo deny check advisoriescatches newly published advisories against code that hasn't changed. -
Treat a red pipeline as information: understanding why it failed (and fixing forward) usually costs less than recreating the repo, and the fix often belongs in the shared template so every repo benefits.
-
If you declare a
rust-version(MSRV), check it in CI, or don't declare one:
yaml
msrv:
stage: test
image: rust:1.85 # matches rust-version in Cargo.toml
script:
- cargo check --all-features --locked
- Caching
target/helps a lot but grows without bound; keying onCargo.lockresets it periodically, or usesccache. - Installing tools from source on every run is slow; use a prebuilt image (as with
cargo-denyabove) orcargo binstall.
Releases¶
A clean, low-ceremony flow:
- Bump
versioninCargo.toml(cargo set-version 0.5.0), updateCHANGELOG.md, merge tomain. - CI on
mainreads the version fromCargo.toml; if tagv0.5.0doesn't exist, it creates it. - The tag pipeline builds the release artifacts: container image
:0.5.0, static binaries, the packaged Helm chart (with--app-version 0.5.0), and creates a GitLab Release with the changelog section and links to artifacts.
Why read the version from Cargo.toml rather than derive it from commit messages: the version is then a deliberate, reviewed change in the MR, the binary's --version always matches the tag, and there's no tool parsing commit text to guess intent.
CHANGELOG.md in Keep a Changelog format:
## [Unreleased]
## [0.5.0] - 2026-10-15
### Added
- `export --format csv` subcommand
### Changed
- Config key `db_path` renamed to `database_url` (old key still read, logs a warning)
### Fixed
- Healthcheck timed out when listening on IPv6
Binary distribution options: attach binaries to the GitLab Release, publish to the GitLab generic package registry, or let users cargo install --git <repo> --tag v0.5.0 --locked.
A self-update subcommand (checking the release API for a newer tag and replacing the binary) is a nice touch for tools you install on several machines.
Part 6: 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¶
Repo and manifest
- [ ] Created from your template repo (gets
.gitignore,.editorconfig,rustfmt.toml,deny.toml, CI header, Makefile, hooks) - [ ]
Cargo.toml:edition = "2024",rust-version,publish = false,license,repository,[lints], tuned[profile.release] - [ ]
Cargo.lockcommitted;rust-toolchain.tomlpinned withrustfmt+clippy - [ ] Workspace only once there's a real reason; if so:
resolver = "3",[workspace.dependencies],[workspace.lints],[workspace.package]
Structure and design
- [ ] 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 withpub use - [ ] 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 - [ ] Enums for states, newtypes for IDs and validated values
Runtime behaviour
- [ ]
cli.rswith clap derive,env =on deployable flags,--versionwith git sha - [ ] Typed errors in library/service code,
anyhowwith context at the edges, nounwrapon fallible paths - [ ] Config: defaults → file → env → flags,
deny_unknown_fields, validated at startup, example config committed, secrets never logged - [ ]
tracinginitialised once; structured fields; logs to stdout; JSON option for services - [ ] SIGTERM handling and graceful shutdown;
/healthz,/readyz,healthchecksubcommand (services) - [ ] Migrations in
migrations/, embedded,.sqlx/committed if using macros
Quality
- [ ] Unit tests beside the code, integration tests in
tests/, doc examples on public items, temp dirs only - [ ] End-to-end tests for the binary (
assert_cmd) or HTTP router (spawned server oroneshot) - [ ]
cargo denyconfigured; pre-commit / pre-push hooks installed - [ ] README: install, quick start, usage, config table, deployment, development; ADRs for non-obvious decisions
Build, deploy, and release
- [ ] Makefile with the standard targets; version read from
Cargo.toml - [ ] Dockerfile at root: cargo-chef,
--locked, distroless nonroot, healthcheck;.dockerignoreexcludestarget/and.git/ - [ ]
deploy/compose/compose.yaml+.env.example - [ ]
deploy/helm/<app>/copied from your reference chart;appVersionset at package time;replicas: 1+Recreatefor SQLite - [ ]
deploy/systemd/units for timers/daemons - [ ] CI: fmt, clippy, test, deny, MSRV, helm lint; auto-tag from
Cargo.tomlversion; release builds image, binaries, chart - [ ]
CHANGELOG.mdupdated in the same MR as the version bump
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