Skip to content

Readable & Maintainable Rust: A Practical Guide

This document contains a pragmatic set of principles, patterns, and guardrails for writing Rust that's easy to understand and safe to evolve. It draws from The Rust Book and the Rust API Guidelines.

Incan contributors are expected to follow these principles and patterns when writing Rust code.

Table of Contents


What "Readable" Means in Rust

Readable Rust is code that a competent Rustacean can scan and understand quickly, without excessive file-hopping or ownership gymnastics.

  1. Idiomatic ownership & borrowing
  2. Prefer borrowing (&T, &mut T) over cloning; clone only at clear boundaries.
  3. Lifetimes are implicit when possible; explicit lifetimes are localized and named meaningfully when needed.
  4. Avoid Rc<RefCell<_>> in single-threaded code and Arc<Mutex<_>> in multi-threaded code unless truly necessary.

  5. Type-first design

  6. Use newtypes to encode domain invariants (e.g., UserId, NonEmptyStr).
  7. Prefer expressive enums over ad-hoc booleans/strings (e.g., enum State { Pending, Running, Failed }).
  8. Return domain-specific results: Result<Order, OrderError> beats Result<T, String>.

  9. Clear error handling

  10. Use Result<T, E> pervasively; reserve panics for programmer errors (unreachable!, expect with invariant context).
  11. Propagate with ?; keep error messages actionable; don't lose context when mapping.

  12. Minimal magic

  13. Macros remove boilerplate but don't hide logic or create opaque DSLs.
  14. Generics are purposeful; trait bounds are explicit and readable.

  15. Consistent naming & layout

  16. Follow conventions: snake_case (functions/vars), CamelCase (types/traits), SCREAMING_SNAKE_CASE (consts).
  17. Module/file sizes are reasonable; split by responsibility, not arbitrary layers.

  18. Local reasoning

  19. Functions are small, pure where possible, with limited side effects and clear inputs/outputs.

What "Maintainable" Means in Rust

Maintainable Rust withstands change with minimal risk and effort—thanks to stable seams, guardrails, and predictable behavior.

  1. Stable, minimal public API
  2. Public types/traits are small and composable; prefer capability-oriented traits over "god traits."
  3. Keep internals private (mod visibility) unless exposure is justified.
  4. Avoid leaking concrete types when impl Trait or trait objects suffice.

  5. Explicit boundaries & layering

  6. Separate pure domain logic, adapters, and IO. Keep async at the edges.
  7. One clear responsibility per module; lifecycle ownership is explicit.

  8. Error taxonomy

  9. Categorize errors (user, infra, programming) and indicate recoverability.
  10. Prefer stable, typed public errors (e.g., via thiserror) and keep error messages actionable and consistent.
  11. In this repo: compiler diagnostics are typically built with miette, and shared runtime/semantic errors should stay aligned (see Layering Rules).

  12. Concurrency discipline

  13. Make Send + Sync requirements explicit; guard shared state predictably.
  14. Use structured concurrency (task groups, cancellation) over "fire-and-forget."
  15. Prefer channels/async streams for coordination when appropriate.

  16. Tooling baked in

  17. rustfmt and clippy are non-negotiable in CI; tune lints to project needs.
  18. Dependency hygiene: cargo-audit, cargo-deny, cargo-udeps; pin MSRV.
  19. Tests include unit, integration, doctests, property-based (proptest) for parsers.

  20. Safety & invariants

  21. unsafe is rare, isolated, and annotated with SAFETY: comments plus tests.
  22. Invariants are encoded in types and constructors, not just comments.

  23. Performance with restraint

  24. Measure before optimizing (e.g., criterion); avoid premature micro-optimizations.
  25. Favor simpler code that compiles fast unless profiling proves otherwise.

Pragmatic Checklist

If these are true, your Rust is both readable and maintainable:

  • Formatting & lints: cargo +nightly fmt --all -- --check and cargo clippy --deny warnings pass; lint level tuned to project.
  • Boundaries: Modules align with responsibilities; public surface area small and documented.
  • Errors: Clear Result<T, E> with actionable messages; categorized E.
  • Ownership: Minimal clones; predictable lifetimes; borrowing favored.
  • Tests: Unit + integration + doctests exist; critical logic property-tested.
  • Docs: rustdoc examples compile; README shows usage; SAFETY: annotations present where needed.
  • CI: Audit, deny, udeps, MSRV checks run; semantic versioning for releases.
  • Async/concurrency: Structured, cancellable; no hidden global mutable state.

Idiomatic Examples

1) Errors: Internal vs External

// External API error (stable, documented)
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ClientError {
    #[error("network error: {0}")]
    Network(String),
    #[error("invalid request: {0}")]
    InvalidRequest(String),
    #[error("timeout after {0:?}")]
    Timeout(std::time::Duration),
}

// Internal plumbing should preserve context while avoiding opaque stringly errors.
fn perform_io() -> Result<(), std::io::Error> {
    let _data = std::fs::read("config.json")?;
    // ...
    Ok(())
}

2) Public API: Trait-Oriented, Minimal Exposure

pub trait Storage {
    fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError>;
    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, StorageError>;
}

pub struct S3Storage { /* fields private */ }

impl Storage for S3Storage {
    fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
        // ...
        Ok(())
    }
    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, StorageError> {
        // ...
        Ok(None)
    }
}

// Factory returns impl Trait to avoid leaking concrete type
pub fn storage_from_env() -> impl Storage {
    S3Storage { /* ... */ }
}

3) Ownership Clarity: Borrow When Possible

fn normalize(input: &str) -> String {
    input.trim().to_lowercase()
}

Avoid:

fn normalize(input: String) -> String {
    // Forces ownership and can cause unnecessary clones elsewhere
    input.trim().to_lowercase()
}

4) Macro Discipline

/// Generates simple enum Display impls.
/// Use sparingly; document the expansion in examples.
#[macro_export]
macro_rules! impl_display {
    ($t:ty, $($v:ident),+ $(,)?) => {
        impl std::fmt::Display for $t {
            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
                match self {
                    $(Self::$v => write!(f, stringify!($v)),)+
                }
            }
        }
    };
}

Project Scaffolding

lib.rs and main.rs

#![forbid(unsafe_code)]
#![deny(clippy::all, clippy::cargo)]
#![warn(clippy::pedantic)]
#![allow(clippy::module_name_repetitions)] // tune to taste

rustfmt.toml

edition = "2024"
max_width = 120

Cargo.toml

[package]
edition = "2024"
rust-version = "1.85" # MSRV pinned (keep in sync with CI)

[dependencies]
thiserror = "1"

[features]
default = []

Typical CI Steps

cargo +nightly fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all --verbose
cargo audit
cargo deny check
cargo +nightly udeps --all-targets

Note: MSRV is enforced via CI running build/test with a pinned toolchain.


Common Pitfalls & Anti-Patterns

Quick-reference

Instead of Prefer Why
.unwrap() / .expect() in library code ?, .context(), or explicit match Panics crash the process; propagate errors instead
.clone() to appease the borrow checker Restructure ownership or borrow Hides design issues and adds unnecessary allocations
&String, &Vec<T>, &Box<T> in parameters &str, &[T], &T More general — accepts owned and borrowed callers alike
x as u32 (silent truncation) x.try_into() or From/Into as silently wraps/truncates; conversions should be explicit
use foo::* (wildcard imports) use foo::{Bar, Baz} Makes origins clear; avoids surprise breakage on upstream changes
.collect::<Vec<_>>() just to re-iterate Chain iterators directly Avoids an unnecessary allocation + copy
pub on everything pub(crate) or private by default Minimize public surface; promote visibility only when needed
Blocking I/O in async fn tokio::fs, spawn_blocking Blocks the executor and starves other tasks
Result<T, String> in public APIs A typed error enum (thiserror) Stringly-typed errors are hard to match on and evolve
Rc<RefCell<T>> everywhere Restructure data / ownership Usually signals a design that fights the borrow checker

Detailed guidance

Panics on recoverable paths

Reserve .unwrap() and .expect() for cases where you can prove the value is always Some/Ok — and add a comment explaining the invariant. Everywhere else, propagate with ?:

// Bad — panics with no context if the file is missing
let file = File::open(path).unwrap();

// Good — propagates a meaningful error to the caller
let file = File::open(path)
    .map_err(|e| anyhow!("failed to open {}: {e}", path.display()))?;

Cloning — question every .clone()

A .clone() is sometimes the right answer (crossing an API boundary, shared ownership), but treat every occurrence as a review signal. Ask: can I borrow instead? Can I restructure to remove the simultaneous borrow?

Stringly-typed APIs

Use enums or newtypes instead of raw strings for fixed sets of values:

// Bad — typo-prone, no exhaustiveness checking
fn set_role(role: &str) { /* ... */ }

// Good — the compiler enforces valid values
enum Role { Admin, User }
fn set_role(role: Role) { /* ... */ }

Parameter types — accept the most general borrow

// Bad — forces callers to own a String / Vec
fn process(input: &String) { /* ... */ }
fn filter(items: &Vec<Item>) { /* ... */ }

// Good — accepts &String, &str, string literals, slices, etc.
fn process(input: &str) { /* ... */ }
fn filter(items: &[Item]) { /* ... */ }

Type casting — prefer safe conversions

as silently truncates or wraps. Prefer TryFrom/TryInto for fallible conversions and From/Into for infallible ones:

// Bad — silently wraps on overflow
let small = big_number as u16;

// Good — panics with a message instead of silently producing garbage
let small: u16 = big_number.try_into().expect("value fits in u16");

Iterator chains — don't collect just to iterate again

// Bad — allocates a Vec only to loop over it
let v: Vec<_> = items.iter().filter(|x| x.is_valid()).collect();
for item in &v { process(item); }

// Good — zero extra allocation
for item in items.iter().filter(|x| x.is_valid()) {
    process(item);
}

Visibility — start private, widen deliberately

Default to private. Use pub(crate) for crate-internal sharing. Only use pub for your actual public API. This keeps refactoring safe and communicates intent to future readers.

Match arms — factor out shared logic

// Bad — duplicated setup/cleanup in every arm
match kind {
    A => { setup(); handle_a(); cleanup(); }
    B => { setup(); handle_b(); cleanup(); }
}

// Good — shared logic lives outside the match
setup();
match kind {
    A => handle_a(),
    B => handle_b(),
}
cleanup();

#[must_use] on important return values

If a function returns a value that callers should never silently discard (validation results, builders, etc.), mark it #[must_use]. Rust already enforces this for Result, but custom types often miss it.

Architecture-level pitfalls

  • Overusing generics: Complex bounds and many type params — prefer trait objects or smaller traits.
  • Lifetime gymnastics: If lifetimes leak everywhere, restructure ownership or use owned buffers at boundaries.
  • Macro-heavy APIs: If consumers must "think in macros," reconsider the design.
  • Async everywhere: Keep async at IO boundaries; don't make pure functions async.
  • Global mutable state: Hide state behind interfaces; avoid implicit singletons.

Clippy

cargo clippy is mandatory in CI. Common catches worth internalizing:

  • if let Some(_) = x — use x.is_some() instead
  • Manual Default impl when #[derive(Default)] works
  • .map(|x| foo(x)) — use .map(foo) (redundant closure)
  • .map().unwrap_or() — use .map_or()

Simple Heuristic

If a teammate can fix a bug or add a feature without asking you to explain ownership or lifetimes, and CI tells them when they're wrong — your Rust is both readable and maintainable.