stillwater 0.11.0

Pragmatic effect composition and validation for Rust - pure core, imperative shell
Documentation

Stillwater

CI Coverage Security Crates.io License

A Rust library for pragmatic effect composition and validation, emphasizing the pure core, imperative shell pattern.

Philosophy

Stillwater embodies a simple idea:

  • Still = Pure functions (unchanging, referentially transparent)
  • Water = Effects (flowing, performing I/O)

Keep your business logic pure and calm like still water. Let effects flow at the boundaries.

What Problems Does It Solve?

1. "I want ALL validation errors, not just the first one"

use stillwater::Validation;

// Standard Result: stops at first error โŒ
let email = validate_email(input)?;  // Stops here
let age = validate_age(input)?;      // Never reached if email fails

// Stillwater: accumulates all errors โœ“
let user = Validation::all((
    validate_email(input),
    validate_age(input),
    validate_name(input),
))?;
// Returns: Err(vec![EmailError, AgeError, NameError])

2. "How do I validate that all items have the same type before combining?"

use stillwater::validation::homogeneous::validate_homogeneous;
use std::mem::discriminant;

#[derive(Clone, Debug, PartialEq)]
enum Aggregate {
    Sum(f64),     // Can combine Sum + Sum
    Count(usize), // Can combine Count + Count
    // But Sum + Count is a type error!
}

// Without validation: runtime panic ๐Ÿ’ฅ
let mixed = vec![Aggregate::Count(5), Aggregate::Sum(10.0)];
// items.into_iter().reduce(|a, b| a.combine(b))  // PANIC!

// With validation: type-safe error accumulation โœ“
let result = validate_homogeneous(
    mixed,
    |a| discriminant(a),
    |idx, _, _| format!("Type mismatch at index {}", idx),
);

match result {
    Validation::Success(items) => {
        // Safe to combine - all same type!
        let total = items.into_iter().reduce(|a, b| a.combine(b));
    }
    Validation::Failure(errors) => {
        // All mismatches reported: ["Type mismatch at index 1"]
    }
}

3. "How do I test code with database calls?"

use stillwater::prelude::*;

// Pure business logic (no DB, easy to test)
fn calculate_discount(customer: &Customer, total: Money) -> Money {
    match customer.tier {
        Tier::Gold => total * 0.15,
        _ => total * 0.05,
    }
}

// Effects at boundaries (mockable)
fn process_order(id: OrderId) -> impl Effect<Output = Invoice, Error = AppError, Env = AppEnv> {
    from_fn(move |env: &AppEnv| env.db.fetch_order(id))  // I/O
        .and_then(|order| {
            let total = calculate_total(&order);  // Pure!
            from_fn(move |env: &AppEnv| env.db.fetch_customer(order.customer_id))
                .map(move |customer| (order, customer, total))
        })
        .map(|(order, customer, total)| {
            let discount = calculate_discount(&customer, total);  // Pure!
            create_invoice(order.id, total - discount)            // Pure!
        })
        .and_then(|invoice| from_fn(move |env: &AppEnv| env.db.save(invoice))) // I/O
}

// Test with mock environment
#[tokio::test]
async fn test_with_mock_db() {
    let env = MockEnv::new();
    let result = process_order(id).run(&env).await?;
    assert_eq!(result.total, expected);
}

4. "My errors lose context as they bubble up"

use stillwater::prelude::*;

fetch_user(id)
    .context("Loading user profile")
    .and_then(|user| process_data(user))
    .context("Processing user data")
    .run(&env).await?;

// Error output:
// Error: UserNotFound(12345)
//   -> Loading user profile
//   -> Processing user data

5. "I need clean dependency injection without passing parameters everywhere"

use stillwater::prelude::*;

#[derive(Clone)]
struct Config {
    timeout: u64,
    retries: u32,
}

// Functions don't need explicit config parameters
fn fetch_data() -> impl Effect<Output = String, Error = String, Env = Config> {
    // Ask for config when needed
    asks(|cfg: &Config| format!("Fetching with timeout={}", cfg.timeout))
}

fn fetch_with_extended_timeout() -> impl Effect<Output = String, Error = String, Env = Config> {
    // Temporarily modify environment for specific operations
    local(
        |cfg: &Config| Config { timeout: cfg.timeout * 2, ..*cfg },
        fetch_data()
    )
}

# tokio_test::block_on(async {
let config = Config { timeout: 30, retries: 3 };
let result = fetch_with_extended_timeout().run(&config).await?;
// Uses timeout=60 without changing the original config
# });

6. "Retry logic is scattered and hard to test"

use stillwater::{Effect, RetryPolicy};
use std::time::Duration;

// Traditional approach: retry logic mixed with business logic โŒ
// - Can't test retry behavior without calling the API
// - Can't reuse the same policy across different operations
// - Hard to tune or modify without code changes

// Stillwater: Policy as Data โœ“
// Define retry policies as pure, composable, testable values
let api_policy = RetryPolicy::exponential(Duration::from_millis(100))
    .with_max_retries(5)
    .with_max_delay(Duration::from_secs(2))
    .with_jitter(0.25);

// Test the policy without any I/O
assert_eq!(api_policy.delay_for_attempt(0), Some(Duration::from_millis(100)));
assert_eq!(api_policy.delay_for_attempt(1), Some(Duration::from_millis(200)));
assert_eq!(api_policy.delay_for_attempt(2), Some(Duration::from_millis(400)));

// Reuse the same policy across different effects
Effect::retry(|| fetch_user(id), api_policy.clone());
Effect::retry(|| save_order(order), api_policy.clone());

// Conditional retry: only retry transient failures
Effect::retry_if(
    || api_call(),
    api_policy,
    |err| matches!(err, ApiError::Timeout | ApiError::ServerError(_))
    // Don't retry client errors (4xx) - they won't succeed on retry
);

// Observability: hook into retry events for logging/metrics
Effect::retry_with_hooks(
    || api_call(),
    policy,
    |event| log::warn!(
        "Attempt {} failed: {}, retrying in {:?}",
        event.attempt, event.error, event.next_delay
    )
);

Why "Policy as Data" matters:

  • Testable: Test retry timing without mocks or network calls
  • Reusable: One policy definition, many use sites
  • Composable: Builder pattern for flexible configuration
  • Inspectable: Query policy parameters before execution
  • Safe: Enforces bounds (max_retries OR max_delay required)

Core Features

  • Validation<T, E> - Accumulate all errors instead of short-circuiting
  • NonEmptyVec<T> - Type-safe non-empty collections with guaranteed head element
  • Effect trait - Zero-cost effect composition following the futures crate pattern
    • Zero heap allocations by default
    • Explicit .boxed() when type erasure is needed
    • Returns impl Effect for optimal performance
  • Parallel effect execution - Run independent effects concurrently
    • Zero-cost: par2(), par3(), par4() for heterogeneous effects
    • Boxed: par_all(), race(), par_all_limit() for homogeneous collections
  • Retry and resilience - Policy-as-data approach: define retry policies as pure, testable values with exponential, linear, constant, and Fibonacci backoff strategies. Includes jitter (proportional, full, decorrelated), conditional retry with predicates, retry hooks for observability, and timeout support
  • Traverse and sequence - Transform collections with traverse() and sequence() for both validations and effects
    • Validate entire collections with error accumulation
    • Process collections with effects using fail-fast semantics
  • Reader pattern helpers - Clean dependency injection with ask(), asks(), and local()
  • Semigroup trait - Associative combination of values
    • Extended implementations for HashMap, HashSet, BTreeMap, BTreeSet, Option
    • Wrapper types: First, Last, Intersection for alternative semantics
  • Monoid trait - Identity elements for powerful composition patterns
  • Testing utilities - Ergonomic test helpers
    • MockEnv builder for composing test environments
    • Assertion macros: assert_success!, assert_failure!, assert_validation_errors!
    • TestEffect wrapper for deterministic effect testing
    • Optional proptest feature for property-based testing
  • Context chaining - Never lose error context
  • Tracing integration - Instrument effects with semantic spans using the standard tracing crate
  • Zero-cost abstractions - Follows futures crate pattern: concrete types, no allocation by default
  • Works with ? operator - Integrates with Rust idioms
  • No heavy macros - Clear types, obvious behavior

Quick Start

use stillwater::prelude::*;

// 1. Validation with error accumulation
fn validate_user(input: UserInput) -> Validation<User, Vec<Error>> {
    Validation::all((
        validate_email(&input.email),
        validate_age(input.age),
        validate_name(&input.name),
    ))
    .map(|(email, age, name)| User { email, age, name })
}

// 2. Effect composition (zero-cost by default)
fn create_user(input: UserInput) -> impl Effect<Output = User, Error = AppError, Env = AppEnv> {
    // Validate (pure, accumulates errors)
    from_validation(validate_user(input).map_err(AppError::Validation))
        // Check if exists (I/O)
        .and_then(|user| {
            from_fn(move |env: &AppEnv| env.db.find_by_email(&user.email))
                .and_then(move |existing| {
                    if existing.is_some() {
                        fail(AppError::EmailExists)
                    } else {
                        pure(user)
                    }
                })
        })
        // Save user (I/O)
        .and_then(|user| {
            from_fn(move |env: &AppEnv| env.db.insert_user(&user))
                .map(move |_| user)
        })
        .context("Creating new user")
}

// 3. Run at application boundary
let env = AppEnv { db, cache, logger };
let result = create_user(input).run(&env).await?;

Why Stillwater?

Compared to existing solutions:

vs. frunk:

  • โœ“ Focused on practical use cases, not type-level programming
  • โœ“ Better documentation and examples
  • โœ“ Effect composition, not just validation

vs. monadic:

  • โœ“ No awkward macro syntax (rdrdo! { ... })
  • โœ“ Zero-cost by default (follows futures crate pattern)
  • โœ“ Idiomatic Rust, not Haskell port

vs. hand-rolling:

  • โœ“ Validation accumulation built-in
  • โœ“ Error context handling
  • โœ“ Testability patterns established
  • โœ“ Composable, reusable

What makes it "Rust-first":

  • โŒ No attempt at full monad abstraction (impossible without HKTs)
  • โœ“ Works with ? operator via Try trait
  • โœ“ Zero-cost via concrete types and monomorphization (like futures)
  • โœ“ Integrates with async/await
  • โœ“ Borrows checker friendly
  • โœ“ Clear error messages

Installation

Add to your Cargo.toml:

[dependencies]
stillwater = "0.8"

# Optional: async support
stillwater = { version = "0.8", features = ["async"] }

# Optional: tracing integration
stillwater = { version = "0.8", features = ["tracing"] }

# Optional: property-based testing
stillwater = { version = "0.8", features = ["proptest"] }

# Optional: multiple features
stillwater = { version = "0.8", features = ["async", "tracing", "proptest"] }

Examples

Run any example with cargo run --example <name>:

Example Demonstrates
form_validation Validation error accumulation
homogeneous_validation Type-safe validation for discriminated unions before combining
nonempty NonEmptyVec type for guaranteed non-empty collections
user_registration Effect composition and I/O separation
error_context Error trails for debugging
data_pipeline Real-world ETL pipeline
testing_patterns Testing pure vs effectful code
reader_pattern Reader pattern with ask(), asks(), and local()
validation Validation type and error accumulation patterns
effects Effect type and composition patterns
parallel_effects Parallel execution with par_all, race, and par_all_limit
retry_patterns Retry policies, backoff strategies, timeouts, and resilience patterns
io_patterns IO module helpers for reading/writing
pipeline Data transformation pipelines
traverse Traverse and sequence for collections of validations and effects
monoid Monoid and Semigroup traits for composition
extended_semigroup Semigroup for HashMap, HashSet, Option, and wrapper types
tracing_demo Tracing integration with semantic spans and context
boxing_decisions When to use .boxed() vs zero-cost effects

See examples/ directory for full code.

Production Readiness

Status: 0.8 - Production Ready for Early Adopters

  • โœ… 295 unit tests passing (includes property-based tests)
  • โœ… 122 documentation tests passing
  • โœ… Zero clippy warnings
  • โœ… Comprehensive examples (16 runnable examples)
  • โœ… Full async support
  • โœ… Homogeneous validation for type-safe combining
  • โœ… Testing utilities with MockEnv and assertion macros
  • โœ… CI/CD pipeline with security audits

This library is stable and ready for use. The 0.x version indicates the API may evolve based on community feedback.

Documentation

  • ๐Ÿ“š User Guide - Comprehensive tutorials
  • ๐Ÿ“– API Docs - Full API reference
  • ๐Ÿค” FAQ - Common questions
  • ๐Ÿ›๏ธ Design - Architecture and decisions
  • ๐Ÿ’ญ Philosophy - Core principles
  • ๐ŸŽฏ Patterns - Common patterns and recipes
  • ๐Ÿ”„ Comparison - vs other libraries
  • ๐Ÿš€ Migration Guide - Upgrading from 0.10.x to 0.11.0

Migrating from Result

Already using Result everywhere? No problem! Stillwater integrates seamlessly:

// Your existing code works as-is
fn validate_email(email: &str) -> Result<Email, Error> {
    // ...
}

// Upgrade to accumulation when you need it
fn validate_form(input: FormInput) -> Validation<Form, Vec<Error>> {
    Validation::all((
        Validation::from_result(validate_email(&input.email)),
        Validation::from_result(validate_age(input.age)),
    ))
}

// Convert back to Result when needed
let result: Result<Form, Vec<Error>> = validation.into_result();

Start small, adopt progressively. Use Validation only where you need error accumulation.

Contributing

Contributions welcome! This is a young library with room to grow:

  • ๐Ÿ› Bug reports and feature requests via issues
  • ๐Ÿ“– Documentation improvements
  • ๐Ÿงช More examples and use cases
  • ๐Ÿ’ก API feedback and design discussions

Before submitting PRs, please open an issue to discuss the change.

License

MIT ยฉ Glen Baker iepathos@gmail.com


"Like a still pond with water flowing through it, stillwater keeps your pure business logic calm and testable while effects flow at the boundaries."