How to Maintain Sanity in Large Rust Codebases

Small Rust projects are easy. Large ones require discipline.

I’ve worked on Rust codebases with hundreds of thousands of lines. Here’s what I’ve learned about keeping them manageable.

Module Structure

Flat is better than nested. Three levels deep is usually the max. If you need more, your modules are doing too much.

src/
  lib.rs
  config.rs
  database/
    mod.rs
    connection.rs
    queries.rs
  api/
    mod.rs
    routes.rs
    handlers.rs

Each module should have one job. If you can’t describe it in one sentence, split it.

Error Handling

Define error types per module. Use thiserror for the definitions and anyhow for application code that just needs to propagate errors up.

#[derive(Debug, thiserror::Error)]
pub enum DatabaseError {
    #[error("connection failed: {0}")]
    Connection(#[from] sqlx::Error),
    #[error("record not found: {0}")]
    NotFound(String),
}

Don’t use strings as errors. Don’t use Box<dyn Error> except at boundaries.

Traits for Abstraction

Define traits at module boundaries. Implement them for concrete types. This lets you swap implementations and write tests.

pub trait UserRepository {
    fn get(&self, id: UserId) -> Result<User>;
    fn save(&self, user: &User) -> Result<()>;
}

Don’t over-abstract. If there’s only one implementation, you probably don’t need a trait yet.

Compilation Times

Large Rust projects compile slowly. Fight this:

  • Split into multiple crates. Parallel compilation helps.
  • Use cargo check during development.
  • Minimize proc macro dependencies.
  • Consider sccache for CI.

The workspace pattern works well: a root Cargo.toml with multiple member crates.

Testing

Unit tests go in the same file as the code. Integration tests go in /tests. This isn’t just convention. It affects compilation.

Use #[cfg(test)] modules for test utilities that shouldn’t ship:

#[cfg(test)]
mod tests {
    use super::*;

    fn test_user() -> User {
        User { id: 1, name: "test".into() }
    }
}

Documentation

Document public APIs. Skip obvious things. Focus on why, not what.

/// Retries the operation with exponential backoff.
///
/// Returns immediately if the operation succeeds.
/// Gives up after `max_retries` attempts.
pub fn with_retry<T, F>(op: F, max_retries: u32) -> Result<T>

Run cargo doc --open regularly. If the docs are confusing, the API is probably confusing too.

The Meta-Rule

Rust gives you powerful tools: ownership, traits, macros. The temptation is to use them all.

Resist. Write boring code. The best large codebases look like they could have been written in any language, just with fewer bugs.

Complexity should be proportional to the problem. Most problems are simpler than they look.