Andrew Mercer
on this page

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

  1. Guiding principles
  2. Part 1: Project structure: crates and workspaces, modules, repository layouts, root files, Cargo.toml, toolchain config
  3. Part 2: Application architecture: layering, traits as boundaries, ownership-driven design, config, errors, logging, async, CLIs, web services, persistence
  4. Part 3: Core coding concepts: type-driven design, traits, iterators, strings, patterns, builders, concurrency, macros, unsafe, performance
  5. Part 4: Quality and supply chain: testing, documentation, tooling, git hooks, security, dependencies, API stability
  6. Part 5: Build, deploy, and release: versioning, release builds, Makefile, containers, Compose, Helm, systemd, CI, releases
  7. Part 6: Anti-patterns
  8. New-project checklist
  9. Further reading

Guiding principles

  1. 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.
  2. Thin binary, fat library. main.rs parses args, sets up logging, calls into the library, and maps errors to an exit code. All logic lives in lib.rs and below, where it can be tested.
  3. Convention over configuration. Use Cargo's standard locations (src/, tests/, benches/, examples/, build.rs). Every tool in the ecosystem already knows where to look.
  4. 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.
  5. 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.
  6. Fail locally before you fail in CI. fmt, clippy and test should 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 core crate that cannot accidentally depend on axum.

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 in main.rs is 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 makes cargo -p obvious.
  • One binary crate. Subcommand crates are libraries exposing something like pub fn run(args: Args) -> Result<()> plus a clap Args struct. The bin crate just wires them into a Subcommand enum.
  • 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 (derive clap::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 = false on large crates (sqlx, reqwest) keeps you from silently pulling in OpenSSL, extra runtimes or TLS backends. Prefer rustls over native-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 with abort a panic inside any request handler kills the whole process instead of just that task. Leave it at the default unwind for 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 around lib.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-powerset checks 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:

  1. Domain: the types and rules that make your program your program (a Job can't move from Rejected back to Applied; a backup has a retention policy).
  2. Application / services: use cases that orchestrate the domain and the outside world ("record an application", "rotate old backups").
  3. 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 Config struct with serde, validate it once at startup, and pass it (or the relevant sub-struct) down. Don't call std::env::var from 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 redacting Debug).
  • Follow platform conventions for file locations (the directories / dirs crates resolve XDG paths on Linux).
  • clap can read env vars directly (#[arg(long, env = "APP_PORT")]), which covers the top two layers for small tools. Crates like figment or config handle 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 check subcommand 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 like pass. Wrap secret strings in a type (e.g. secrecy::SecretString) so they never appear in Debug output or logs.
  • Ship examples/config.example.toml with 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-opentelemetry connects spans to distributed tracing backends when you need it.
  • For metrics, the metrics crate 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::TraceLayer for 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 an async fn stalls every task on that worker thread. Wrap it in tokio::task::spawn_blocking, or use async equivalents (tokio::fs).
  • Don't hold a std::sync::Mutex guard across .await. Lock, copy or modify, drop the guard, then await. A std::sync::Mutex is fine (and faster) for short critical sections; use tokio::sync::Mutex only when you truly must hold a lock across an await point.
  • Spawned tasks need 'static + Send. Anything moved into tokio::spawn must be owned (no borrowed references) and thread-safe. This is where Arc and 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), a Semaphore, or JoinSet with a limit.
  • Plan for cancellation. Dropping a future cancels it at its current .await. Make sure partial work is safe to abandon, and use tokio::select! with a shutdown signal (tokio::signal::ctrl_c(), a CancellationToken) 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 x works and logs don't corrupt piped output.
  • Offer --json (or --output json) for anything a script might consume.
  • Meaningful exit codes: 0 success, 1 general failure, 2 usage error (clap does this automatically). Document any others.
  • --dry-run for 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 healthcheck subcommand lets distroless/scratch images (which have no curl) 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 copy static/ and templates/.
  • Embed in the binary (rust-embed, include_str!, or compile-time templates with askama): one self-contained file, nothing to forget in the image. Usually the better choice for small homelab apps.

Persistence

  • sqlx gives you async SQL with optional compile-time checked queries (query!, query_as!) against a real database or offline metadata (cargo sqlx prepare). diesel is a mature alternative with a typed query builder; rusqlite is 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 Transaction down into repository methods that must participate.

Migrations

  • Keep migrations in migrations/ at the repo root, named YYYYMMDDHHMMSS_description.sql (what sqlx migrate add generates).
  • 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 migrate subcommand 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 by cargo sqlx prepare and set SQLX_OFFLINE=true in 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, a ReadWriteOnce PVC, and strategy: Recreate so the old pod releases the file before the new one starts.
  • Back up with SQLite's online backup (VACUUM INTO 'backup.db' or sqlite3 .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; return String, 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 just String) so callers who already own one can hand it over without a copy.
  • Don't convert paths to String to manipulate them. Use Path::join, extension, file_stem, parent. Use path.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 (&T is Send).

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 #![forbid(unsafe_code)] (or the workspace lint) 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 every unsafe block 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.

  1. Measure first. Use cargo build --release (debug builds can be 10–100× slower), then benchmark with criterion or divan and profile with perf/cargo flamegraph.
  2. Avoid needless allocation: borrow instead of clone(); use &str over String in parameters; Vec::with_capacity when you know the size; reuse buffers in hot loops (buf.clear() keeps capacity).
  3. Buffer I/O: wrap files and stdout in BufReader/BufWriter. Printing in a loop with println! locks stdout on every call; lock it once (let mut out = std::io::stdout().lock();).
  4. Pick the right collection: Vec for almost everything; HashMap for lookup; BTreeMap for ordered iteration; VecDeque for queues. Small vectors and inline strings (smallvec, compact_str) for hot paths with many tiny collections.
  5. Choose faster hashers for non-adversarial keys (ahash, rustc-hash/FxHashMap); the default SipHash is DoS-resistant but slower.
  6. 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 calling SystemTime::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 with reqwest (service). For axum, tower::ServiceExt::oneshot calls the router without a network socket.
  • Snapshot tests with insta for large outputs (rendered tables, JSON responses, generated configs): review diffs instead of hand-writing expected strings.
  • Property tests with proptest for parsers and round-trips: "for any valid Ver, parse(display(v)) == v".
  • Temporary files and dirs with tempfile; never write tests that depend on /tmp/something existing.
  • Database tests against a real database (SQLite in-memory, or Postgres in a container); #[sqlx::test] gives each test a fresh database.
  • Use cargo nextest for 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 nextest runs tests faster and gives better output; worth adopting in CI.
  • Fixtures go in tests/fixtures/; reference them with concat!(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 tempfile dirs.

Documentation

  • //! at the top of lib.rs documents 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 returns Result), # Panics, # Safety (for unsafe fn).
  • Examples in docs are compiled and run by cargo test, so they can't rot.
  • cargo doc --open renders everything locally, including your dependencies' docs, which is useful offline.
  • Intra-doc links ([`Config`], [`crate::backup::run`]) are checked by rustdoc.
/// 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

  1. One-line description and a status line (badges for pipeline/version if you like).
  2. Install — cargo install, container image, or package.
  3. Quick start — the 3 commands to see it work.
  4. Usage — subcommands with examples (paste real --help output).
  5. Configuration — table of every key, env var, default.
  6. Deployment — pointers to deploy/compose, deploy/helm, deploy/systemd.
  7. Development — make check, how to run tests, how to add a migration.
  8. 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 of lib.rs and each major module explaining what lives here and why; /// on every pub item. #![warn(missing_docs)] (or the missing_docs lint in [lints.rust]) for libraries.
  • Doc tests double as tests — prefer them for small usage examples.
  • --help is 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 warnings and cargo 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:

  1. Master copies in one place, symlinked into each repo's .git/hooks/. Editing the master updates every repo at once.
  2. Versioned in the repo under .githooks/ with git config core.hooksPath .githooks (set once per clone, e.g. by make hooks). Hooks then travel with the repo and are reviewed like code.
  3. 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 deny with a committed deny.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 needs unsafe, isolate it in its own small crate with deny and 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, or gitleaks) 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 cyclonedx or cargo auditable (embeds dependency info in the binary so scanners like Trivy can audit it later) are cheap additions to a release job.
  • cargo update regularly 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-crate shows 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 audit in 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 downstream matches 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 a pub type in a function signature, or bumping a dependency whose types appear in your API are all breaking changes. cargo-semver-checks catches 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 from Cargo.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-release or release-plz automate version bumps, changelogs, and tags; cargo-dist builds and publishes cross-platform binaries and installers.
  • A self-update command (e.g. via the self_update crate) 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] (the sed above picks the first version = line, which is that one in a workspace root).
  • helm package --app-version $(VERSION) keeps the chart's appVersion in lockstep with Cargo.toml without a manual edit.
  • make check is 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.)
  • --locked fails the build if Cargo.lock is out of date instead of silently resolving new versions.
  • distroless cc contains glibc and libgcc (which Rust binaries dynamically link) plus CA certificates and tzdata, and nothing else — no shell, no package manager. The :nonroot tag runs as UID 65532.
  • healthcheck subcommand because there's no curl in the image.
  • Pin the Rust image to the same version as rust-toolchain.toml once things are stable; latest-rust-1 is used above only for brevity. Note that rust-toolchain.toml in 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> from Cargo.toml (immutable, what deployments reference)
  • :<git-sha> for every main-branch build (traceability)
  • :latest only 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 lint and helm template ... | kubeconform -strict in CI catch most mistakes without a cluster.
  • helm test with templates/tests/test-connection.yaml gives 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
  • %h makes this a user unit (~/.config/systemd/user/, systemctl --user). For system units, use absolute paths, User=/DynamicUser=yes, and StateDirectory=myapp (systemd creates /var/lib/myapp with correct ownership).
  • Logging to stdout lands in journald automatically; journalctl --user -u myapp reads it.
  • Add a make install-units target that copies units and runs systemctl --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_HOME inside the project dir — GitLab can only cache paths under $CI_PROJECT_DIR. Your local global target-dir setting does not apply in CI; the in-repo target/ is what gets cached.
  • Keying the cache on Cargo.lock means a dependency change starts a fresh cache rather than growing an old one forever.
  • Separate fmt and clippy jobs 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 main and 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 advisories catches 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 on Cargo.lock resets it periodically, or use sccache.
  • Installing tools from source on every run is slow; use a prebuilt image (as with cargo-deny above) or cargo binstall.

Releases

A clean, low-ceremony flow:

  1. Bump version in Cargo.toml (cargo set-version 0.5.0), update CHANGELOG.md, merge to main.
  2. CI on main reads the version from Cargo.toml; if tag v0.5.0 doesn't exist, it creates it.
  3. 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.lock committed; rust-toolchain.toml pinned with rustfmt + 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.rs only wires things up and maps errors to exit codes
  • [ ] Modules named for what they do; no utils dumping ground; private by default, public API curated with pub 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.rs with clap derive, env = on deployable flags, --version with git sha
  • [ ] Typed errors in library/service code, anyhow with context at the edges, no unwrap on fallible paths
  • [ ] Config: defaults → file → env → flags, deny_unknown_fields, validated at startup, example config committed, secrets never logged
  • [ ] tracing initialised once; structured fields; logs to stdout; JSON option for services
  • [ ] SIGTERM handling and graceful shutdown; /healthz, /readyz, healthcheck subcommand (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 or oneshot)
  • [ ] cargo deny configured; 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; .dockerignore excludes target/ and .git/
  • [ ] deploy/compose/compose.yaml + .env.example
  • [ ] deploy/helm/<app>/ copied from your reference chart; appVersion set at package time; replicas: 1 + Recreate for SQLite
  • [ ] deploy/systemd/ units for timers/daemons
  • [ ] CI: fmt, clippy, test, deny, MSRV, helm lint; auto-tag from Cargo.toml version; release builds image, binaries, chart
  • [ ] CHANGELOG.md updated in the same MR as the version bump

Further reading

Official

Community

On this site