name: rust-style description: Rust coding style guide and architecture patterns. Use when writing, reviewing, or modifying Rust code (.rs files), creating new Rust projects, or answering questions about Rust conventions in this codebase.
Rust Style Guide
Our approach to Rust: treat it like a high-level language to get most benefits with few downsides.
Philosophy
Write simple, readable code: types-first, immutable, functional-ish. Get the performance, memory safety, and portability benefits of Rust (80-90%) without fighting the borrow checker.
Core Principles
Immutable Data and Pure Functions
These rarely have issues with the borrow checker or lifetimes.
// Good: pure function, easy to test and reason about
fn calculate_total(items: &[Item]) -> Decimal {
items.iter().map(|i| i.price).sum()
}
// Good: immutable by default
let user = fetch_user(id)?;
let updated = User { name: new_name, ..user };
save_user(&updated)?;
Clone Liberally
Favor clarity over micro-optimization. Clone often - just be mindful of large objects.
// Good: clear ownership, no lifetime complexity
fn process_order(order: Order) -> ProcessedOrder {
// order is owned, can be transformed freely
}
// Avoid: fighting the borrow checker for marginal gains
fn process_order<'a>(order: &'a Order) -> ProcessedOrder<'a> {
// lifetime annotations everywhere
}
Arc for Shared State
Use Arc<dyn Trait> for sharing services. This avoids lifetime annotations and works well with async.
// Good: Arc provides 'static lifetime, works everywhere
pub struct AppState {
pub user_service: Arc<dyn UserService>,
pub order_service: Arc<dyn OrderService>,
}
// Services reference each other via traits
impl OrderServiceImpl {
pub fn new(user_service: Arc<dyn UserService>) -> Self {
Self { user_service }
}
}
Domain Driven Design
Traits as Interfaces
Each service/repository is defined by a trait. Good for dependency injection and testing.
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: UserId) -> Result<Option<User>>;
async fn save(&self, user: &User) -> Result<()>;
}
// Implementation
pub struct PgUserRepository {
pool: PgPool,
}
#[async_trait]
impl UserRepository for PgUserRepository {
// ...
}
Services at Context Root
Services are assembled at the application root and passed down via Arc<dyn Trait>.
// In main.rs or app setup
let user_repo: Arc<dyn UserRepository> = Arc::new(PgUserRepository::new(pool.clone()));
let user_service: Arc<dyn UserService> = Arc::new(UserServiceImpl::new(user_repo));
let state = AppState { user_service };
Functional Domain Objects
Domain logic follows the Impure-Pure-Impure sandwich pattern:
Load (impure) -> Transform (pure) -> Save (impure)
// Load
let order = order_repo.find_by_id(order_id).await?;
// Pure transform - easy to test
let updated = order.apply_discount(discount_code)?;
// Save
order_repo.save(&updated).await?;
Commands return new versions, not mutations:
impl Order {
// Returns new Order, doesn't mutate self
pub fn apply_discount(self, code: DiscountCode) -> Result<Order> {
let discount = code.calculate_discount(&self)?;
Ok(Order {
total: self.total - discount,
applied_discounts: self.applied_discounts.with(code),
..self
})
}
}
Each request owns its data - no shared mutable state.
Testing Strategy
| Layer | Approach |
|---|---|
| Pure functions | Unit test thoroughly (data in, data out) |
| Services | Integration test with real DB or mocks at trait boundary |
| External services | Mock only (email, payments, third-party APIs) |
// Unit test pure logic
#[test]
fn apply_discount_reduces_total() {
let order = Order::new(items, Decimal::new(100, 0));
let result = order.apply_discount(ten_percent_code()).unwrap();
assert_eq!(result.total, Decimal::new(90, 0));
}
// Integration test with mock at trait boundary
#[tokio::test]
async fn create_order_notifies_user() {
let mock_notifier = Arc::new(MockNotifier::new());
let service = OrderService::new(mock_notifier.clone());
service.create_order(order_data).await.unwrap();
assert!(mock_notifier.was_called_with(expected_notification));
}
What This Avoids
| Pain Point | How We Avoid It |
|---|---|
| Lifetime annotations | Arc everywhere for shared state |
| Generic explosion | dyn Trait instead of monomorphization |
| Borrow checker fights | Functional transforms, clone liberally |
| Async + lifetime pain | Arc is 'static, works with async |
What This Keeps
| Benefit | How |
|---|---|
| Type safety | Strong types, discriminated unions |
| Exhaustive matching | Enums for state machines |
| No GC | Predictable performance (2-3x faster than C#/F#, 10x faster than TS) |
| Explicit errors | Result types, no exceptions |
| Testable code | Pure functions, trait-based DI |
Module Organization
Use the modern Rust 2018+ module style. Instead of mod.rs files, use a file alongside a directory with the same name.
Modern Style (Preferred)
// src/users.rs declares the module and its public exports
pub mod users_routes;
pub mod users_service;
pub mod users_types;
pub use users_routes::*;
pub use users_service::UserService;
src/
├── users.rs # Module declaration
└── users/ # Module contents
├── users_routes.rs
├── users_service.rs
└── users_types.rs
Avoid: Old mod.rs Style
src/
└── users/
├── mod.rs # Don't use this pattern
├── users_routes.rs
└── ...
Why modern style is better:
- File names are meaningful in editors/tabs (no more 5 tabs all named
mod.rs) - Clearer which file corresponds to which module
- Easier to navigate in file trees
- The
mod.rsstyle is a holdover from before Rust 2018
Vertical Slice Architecture
Organize code by feature (vertical slice) rather than by technical layer (horizontal). Each slice is a self-contained module with everything needed for that feature.
Structure
src/
├── main.rs # Entry point, assembles slices
├── core.rs # Core module declaration
├── core/ # Cross-cutting concerns
│ ├── config.rs
│ ├── error.rs
│ ├── db.rs
│ └── views.rs
├── health.rs # Health slice declaration
├── health/ # Health check slice
│ ├── health_routes.rs
│ └── health_service.rs
├── users.rs # Users slice declaration
├── users/ # Example feature slice
│ ├── users_routes.rs
│ ├── users_service.rs
│ ├── users_data.rs
│ ├── users_types.rs
│ └── users_views.rs
Slice Contents
Each slice may contain (only include what's needed):
| File | Purpose |
|---|---|
*_routes.rs |
HTTP handlers, route definitions |
*_service.rs |
Business logic, orchestration |
*_data.rs |
Database queries, repository functions |
*_types.rs |
Slice-specific types, DTOs |
*_views.rs |
Maud HTML templates |
Benefits
- Cohesion: Related code lives together
- Independence: Slices can be developed/tested in isolation
- Discoverability: Find all user-related code in
src/users/ - Minimal coupling: Slices depend on
core/, not each other
Guidelines
- Start simple - a slice might just be
*_routes.rsinitially - Extract
*_service.rswhen business logic grows beyond route handlers - Extract
*_data.rswhen queries become complex or reusable - Shared infrastructure (config, errors, DB pool, layouts) goes in
core/