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

  • 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.
  • Ambiguous errors: Result<T, String> in public API → hard to evolve safely.
  • Panics on recoverable paths: Reserve panics for invariants; use Result elsewhere.
  • Async everywhere: Keep async at IO boundaries; don’t make pure functions async.
  • Global mutable state: Hide state behind interfaces; avoid implicit singletons.

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.