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 checkduring development. - Minimize proc macro dependencies.
- Consider
sccachefor 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.