Audience: LLM coding agent Scope: Apply in Rust.
- Maximize clarity of dataflow.
- Minimize incidental complexity.
- Prefer explicitness over indirection.
- Embrace simple data + pure functions; use OO-style encapsulation narrowly.
- Data-first design
- Represent domain state with simple, transparent structs and enums.
- Prefer immutable data by default; create new values when transforming.
- Pass data explicitly; avoid hidden coupling and ambient state.
- Functions over methods
- Write small, side-effect-free functions operating on data structures.
- Keep input/output explicit; avoid interior mutability unless necessary.
- Compose functions rather than build large, stateful types with many methods.
- Encapsulation with intent
- Encapsulate only when protecting invariants or managing real resources:
- External systems: files, sockets, DB pools, queues.
- Long-lived state with strict invariants and a minimal API.
- Protocol adapters with clear lifecycle and ownership rules.
- Do not turn every noun into a type with behavior; modules and free functions often suffice.
- Composition over inheritance
- Rust has no class inheritance; keep it that way.
- Use composition, traits, and enums (tagged unions) for polymorphism.
- Prefer enums + exhaustive
matchfor closed sets of variants. - Use traits for behavior abstraction across multiple types.
- Minimize mutation
- Default to immutable bindings; use
mutonly with clear, local scope. - If mutation is necessary:
- Confine it; document invariants.
- Prefer owning transformations over shared mutation.
- Use interior mutability (
Cell,RefCell,Mutex,RwLock) only when required by aliasing, and hide it behind a tight API.
- Make control flow explicit
- Avoid indirection that hides dataflow.
- Prefer orchestration functions that call pure helpers.
- Keep async flows linear and readable; propagate errors with
Result.
- Keep modules small and cohesive
- One module = one responsibility.
- Public API should be minimal; keep helpers
pub(crate)or private. - Re-export a narrow facade from
lib.rsor the module root.
- Dependency management
- Accept dependencies as parameters (function args or struct fields).
- Avoid global singletons.
- For tests, inject fakes via traits or function parameters.
- Errors and invariants
- Use
Result<T, E>for expected failures; define small error enums. - Validate at boundaries; keep internals simple.
- Prefer total functions; document preconditions precisely when not total.
- Side effects at the edges
- Pure core: data in, data out.
- Isolate I/O behind trait-based ports and concrete adapters.
- Testing posture
- Unit-test pure functions.
- Use contract tests for adapters via trait bounds and in-memory fakes.
- Property tests where feasible.
- Naming and APIs
- Name by behavior and effect.
- Avoid vague “Manager,” “Helper,” “Util.”
- Prefer small, orthogonal functions over large types with sprawling APIs.
- Model domain with
structandenum(discriminated unions). - Derive
Clone,Debug,PartialEq,Eqwhere appropriate. - Use
#[non_exhaustive]only when the set is intentionally open. - Prefer
&selfand immutability; return new values instead of mutating when clearer. - Use
From/Into/TryFromfor conversions;Displayfor user-facing strings. - Implement small traits with tight semantics; avoid “god traits.”
- Keep
pubsurface minimal; use modules to hide internals.
-
Data + pure transforms:
- Validators:
fn validate(x: Input) -> Result<Clean, Error> - Mappers/formatters:
fn to_dto(d: &Domain) -> Dto
- Validators:
-
Ports-and-adapters:
- Define traits (ports) in the domain or application layer.
- Implement adapters for I/O separately.
- Wire them at a composition root (e.g.,
main.rsor a builder).
-
Pipelines:
- Compose small steps with clear contracts; prefer iterator adaptors where apt.
-
Enums for workflows:
- Encode states and handle with exhaustive
match.
- Encode states and handle with exhaustive
- Over-abstracted trait hierarchies without need.
- Types as bags of mutable state with many methods.
- Hidden global state.
- Overuse of macros to hide control/dataflow.
- Overengineering with design patterns that obscure simple code.
- Domain types
// domain/types.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OrderStatus {
Pending,
Paid,
Shipped,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LineItem {
pub sku: String,
pub qty: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Order {
pub id: String,
pub items: Vec<LineItem>,
pub total_cents: u64,
pub status: OrderStatus,
}- Pure functions
// domain/order.rs
use super::types::{LineItem, Order, OrderStatus};
pub fn can_ship(o: &Order) -> bool {
matches!(o.status, OrderStatus::Paid) && !o.items.is_empty()
}
pub fn add_item(mut o: Order, item: LineItem, price_cents: u64) -> Order {
// Ownership-based transform; mutation is local and explicit.
o.total_cents = o
.total_cents
.saturating_add(price_cents.saturating_mul(item.qty as u64));
o.items.push(item);
o
}- Tagged unions and exhaustive handling
// domain/workflow.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaymentResult {
Ok { auth_id: String },
Declined { reason: String },
Error { message: String },
}
pub fn handle_payment(r: &PaymentResult) -> String {
match r {
PaymentResult::Ok { auth_id } => format!("auth:{auth_id}"),
PaymentResult::Declined { reason } => format!("declined:{reason}"),
PaymentResult::Error { message } => format!("error:{message}"),
}
}- Ports and adapters with composition root
// app/ports.rs
use async_trait::async_trait;
use crate::domain::workflow::PaymentResult;
#[async_trait]
pub trait PaymentsPort: Send + Sync {
async fn charge(&self, cents: u64, card: &str) -> PaymentResult;
}// infra/stripe.rs
use async_trait::async_trait;
use crate::app::ports::PaymentsPort;
use crate::domain::workflow::PaymentResult;
pub struct StripePayments {
api_key: String,
}
impl StripePayments {
pub fn new(api_key: impl Into<String>) -> Self {
Self { api_key: api_key.into() }
}
}
#[async_trait]
impl PaymentsPort for StripePayments {
async fn charge(&self, cents: u64, card: &str) -> PaymentResult {
// Call SDK/client here; map to domain result.
// Placeholder:
let _ = (cents, card, &self.api_key);
PaymentResult::Ok { auth_id: "abc123".into() }
}
}// app/place_order.rs
use crate::app::ports::PaymentsPort;
use crate::domain::types::{Order, OrderStatus};
pub enum PlaceOrderError {
InvalidState,
PaymentDeclined(String),
PaymentError(String),
}
pub async fn place_order<P: PaymentsPort>(
mut order: Order,
payments: &P,
card: &str,
) -> Result<Order, PlaceOrderError> {
if !matches!(order.status, OrderStatus::Pending) {
return Err(PlaceOrderError::InvalidState);
}
let result = payments.charge(order.total_cents, card).await;
match result {
crate::domain::workflow::PaymentResult::Ok { .. } => {
order.status = OrderStatus::Paid;
Ok(order)
}
crate::domain::workflow::PaymentResult::Declined { reason } => {
Err(PlaceOrderError::PaymentDeclined(reason))
}
crate::domain::workflow::PaymentResult::Error { message } => {
Err(PlaceOrderError::PaymentError(message))
}
}
}// main.rs (composition root)
mod domain {
pub mod types;
pub mod workflow;
}
mod app {
pub mod ports;
pub mod place_order;
}
mod infra {
pub mod stripe;
}
use app::place_order::place_order;
use domain::types::{LineItem, Order, OrderStatus};
use infra::stripe::StripePayments;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let payments = StripePayments::new(std::env::var("STRIPE_KEY").unwrap_or_default());
let order = Order {
id: "o1".into(),
items: vec![LineItem { sku: "sku-1".into(), qty: 2 }],
total_cents: 2_000,
status: OrderStatus::Pending,
};
let updated = place_order(order, &payments, "4111111111111111").await?;
println!("{updated:#?}");
Ok(())
}- Encapsulating real resources
// infra/queue.rs
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct Job {
pub id: String,
pub payload: serde_json::Value,
}
pub struct JobQueue<C> {
conn: C,
timeout: Duration,
}
impl<C> JobQueue<C>
where
C: Send + Sync,
{
pub fn new(conn: C, timeout: Duration) -> Self {
Self { conn, timeout }
}
pub async fn enqueue<F>(&self, topic: &str, job: &Job, publish: F) -> anyhow::Result<()>
where
F: Fn(&C, &str, &str) -> futures::future::BoxFuture<'static, anyhow::Result<()>>,
{
if topic.is_empty() {
anyhow::bail!("empty topic");
}
let msg = serde_json::to_string(job)?;
// enforce invariants at the boundary
tokio::time::timeout(self.timeout, publish(&self.conn, topic, &msg)).await??;
Ok(())
}
}- Error handling at boundaries
// http/handlers.rs
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
struct CreateOrderInput {
items: Vec<ItemInput>,
}
#[derive(Deserialize)]
struct ItemInput {
sku: String,
qty: u32,
}
pub fn create_order_handler(body: &str) -> (u16, String) {
let parsed: Result<CreateOrderInput, _> = serde_json::from_str(body);
let Ok(input) = parsed else {
return (400, json!({ "error": "InvalidPayload" }).to_string());
};
if input.items.is_empty() {
return (400, json!({ "error": "EmptyItems" }).to_string());
}
// Call pure/app functions with validated data...
(201, json!({ "ok": true }).to_string())
}-
Data
- Are domain structs/enums simple and transparent?
- Are invariants documented and validated at boundaries?
-
Functions
- Are transformations small and side-effect-free?
- Is mutation localized and justified?
-
Structure
- Is polymorphism expressed via enums or small traits, not deep hierarchies?
- Are modules cohesive with minimal public API?
-
Dependencies
- Are external effects isolated behind traits/adapters?
- Are dependencies injected explicitly?
-
Control flow
- Is dataflow explicit end-to-end?
- Are async paths linear with
Result-based error handling?
-
Tests
- Do pure functions have unit/property tests?
- Do adapters have contract/integration tests using fakes where possible?
- Generate pure domain modules first; wire adapters separately.
- When encapsulation is needed, define minimal types that enforce invariants.
- If a requirement suggests a pattern, justify it in comments and choose the simplest form.
- Surface trade-offs explicitly in PR notes: dataflow clarity, testability, mutation scope.
- “OOP is bad” when it obscures data and control flow via indirection and mutable objects.
- “OOP is good*” when used narrowly to encapsulate effects and invariants, while most logic remains simple data plus pure functions with explicit composition.