Skip to content

Latest commit

 

History

History
416 lines (326 loc) · 11.2 KB

File metadata and controls

416 lines (326 loc) · 11.2 KB

Guidelines for Code Structure

Audience: LLM coding agent Scope: Apply in Rust.

Objectives

  • Maximize clarity of dataflow.
  • Minimize incidental complexity.
  • Prefer explicitness over indirection.
  • Embrace simple data + pure functions; use OO-style encapsulation narrowly.

Core Principles

  1. 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.
  1. 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.
  1. 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.
  1. Composition over inheritance
  • Rust has no class inheritance; keep it that way.
  • Use composition, traits, and enums (tagged unions) for polymorphism.
  • Prefer enums + exhaustive match for closed sets of variants.
  • Use traits for behavior abstraction across multiple types.
  1. Minimize mutation
  • Default to immutable bindings; use mut only 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.
  1. 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.
  1. 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.rs or the module root.
  1. Dependency management
  • Accept dependencies as parameters (function args or struct fields).
  • Avoid global singletons.
  • For tests, inject fakes via traits or function parameters.
  1. 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.
  1. Side effects at the edges
  • Pure core: data in, data out.
  • Isolate I/O behind trait-based ports and concrete adapters.
  1. Testing posture
  • Unit-test pure functions.
  • Use contract tests for adapters via trait bounds and in-memory fakes.
  • Property tests where feasible.
  1. Naming and APIs
  • Name by behavior and effect.
  • Avoid vague “Manager,” “Helper,” “Util.”
  • Prefer small, orthogonal functions over large types with sprawling APIs.

Rust Conventions

  • Model domain with struct and enum (discriminated unions).
  • Derive Clone, Debug, PartialEq, Eq where appropriate.
  • Use #[non_exhaustive] only when the set is intentionally open.
  • Prefer &self and immutability; return new values instead of mutating when clearer.
  • Use From/Into/TryFrom for conversions; Display for user-facing strings.
  • Implement small traits with tight semantics; avoid “god traits.”
  • Keep pub surface minimal; use modules to hide internals.

Patterns to Prefer

  • Data + pure transforms:

    • Validators: fn validate(x: Input) -> Result<Clean, Error>
    • Mappers/formatters: fn to_dto(d: &Domain) -> Dto
  • 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.rs or a builder).
  • Pipelines:

    • Compose small steps with clear contracts; prefer iterator adaptors where apt.
  • Enums for workflows:

    • Encode states and handle with exhaustive match.

Patterns to Avoid

  • 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.

Code Templates

  • 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())
}

Review Checklist (apply per PR/file)

  • 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?

Practical Notes for the Agent

  • 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.

Summary

  • “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.