| 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.
fn calculate_total(items: &[Item]) -> Decimal {
items.iter().map(|i| i.price).sum()
}
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.
fn process_order(order: Order) -> ProcessedOrder {
}
fn process_order<'a>(order: &'a Order) -> ProcessedOrder<'a> {
}
Arc for Shared State
Use Arc<dyn Trait> for sharing services. This avoids lifetime annotations and works well with async.
pub struct AppState {
pub user_service: Arc<dyn UserService>,
pub order_service: Arc<dyn OrderService>,
}
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<()>;
}
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>.
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)
let order = order_repo.find_by_id(order_id).await?;
let updated = order.apply_discount(discount_code)?;
order_repo.save(&updated).await?;
Commands return new versions, not mutations:
impl Order {
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) |
#[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));
}
#[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)
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.rs style 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.rs initially
- Extract
*_service.rs when business logic grows beyond route handlers
- Extract
*_data.rs when queries become complex or reusable
- Shared infrastructure (config, errors, DB pool, layouts) goes in
core/