DEV Community

Cover image for Stillwater 1.0: Pragmatic Effect Composition and Validation for Rust
Glen Baker
Glen Baker

Posted on • Originally published at entropicdrift.com

Stillwater 1.0: Pragmatic Effect Composition and Validation for Rust

Originally published on Entropic Drift


I'm proud to announce the release of stillwater 1.0, a production-ready Rust library for pragmatic effect composition and validation.

What is Stillwater?

Stillwater implements the pure core, imperative shell pattern for Rust. The name is a mental model: like a still pond with streams flowing through it, your business logic stays calm and predictable at the center while effects (I/O, side effects) flow at the boundaries.

       Still Waters
      ╱            ╲
 Pure Logic      Effects
     ↓              ↓
  Unchanging     Flowing
 Predictable    Performing I/O
  Testable      At boundaries
Enter fullscreen mode Exit fullscreen mode

This isn't Haskell-in-Rust. It's an attempt at better Rust, leveraging pragmatic functional patterns where they make code more testable and maintainable, while respecting Rust's ownership model and idioms.

What Are Effects?

If you're coming from OOP, "effects" might be unfamiliar. The concept is simple:

Typical Rust functions execute immediately:

fn add(a: i32, b: i32) -> i32 {
    a + b  // eager evaluation, happens NOW, returns result
}
Enter fullscreen mode Exit fullscreen mode

An effect is a description of what to do, executed later:

fn fetch_user(id: UserId) -> impl Effect<Output = User, Error = DbError, Env = AppEnv> {
    asks(|env| env.db.get_user(id))  // describes the operation, deferred evaluation
}

// Nothing happens yet - we just have a description
let effect = fetch_user(42);

// NOW it executes
let user = effect.run(&env).await;
Enter fullscreen mode Exit fullscreen mode

This separation lets you:

  • Compose descriptions before running them
  • Test logic without executing I/O
  • Control when and where side effects actually happen

If you've used async Rust, you already know this pattern - async fn returns a Future (a description), which runs when you .await it. Effects are the same idea, extended with environment access, typed errors, and composition tools.

The Core Problem

Most code mixes business logic with I/O:

fn process_user(id: UserId, db: &Database) -> Result<User, Error> {
    let user = db.fetch_user(id)?;           // I/O
    if user.age < 18 {                        // Logic
        return Err(Error::TooYoung);
    }
    let discount = if user.premium { 0.15 } else { 0.05 };  // Logic
    db.save_user(&user)?;                    // I/O
    Ok(user)
}
Enter fullscreen mode Exit fullscreen mode

Testing this requires mocking the database. Reasoning about it requires mentally separating what transforms data from what performs I/O. Reusing the discount logic means extracting it anyway.

Stillwater separates these concerns by design, using multiple features working together:

use stillwater::prelude::*;
use stillwater::refined::{Refined, NonEmpty, InRange};
use stillwater::predicate::*;

// Refined types - validity proven at compile time
type Username = Refined<String, And<NonEmpty, MaxLength<20>>>;
type Age = Refined<u8, InRange<18, 120>>;
type Email = Refined<String, NonEmpty>;  // simplified
// See https://github.com/iepathos/stilltypes for full Email type implementation

// Pure validation - accumulates ALL errors at once
fn validate_registration(input: RawInput) -> Validation<ValidUser, Vec<String>> {
    Validation::all((
        Username::new(input.username)
            .map_err(|_| vec!["Username must be 1-20 characters".into()]),
        Age::new(input.age)
            .map_err(|_| vec!["Age must be 18-120".into()]),
        Email::new(input.email)
            .map_err(|_| vec!["Email required".into()]),
    ))
    .map(|(username, age, email)| ValidUser { username, age, email })
}

// Pure business logic - no I/O, easily testable
fn calculate_discount(user: &ValidUser) -> Discount {
    if user.age.get() >= 65 { Discount(0.20) }  // Senior discount
    else { Discount(0.05) }
}

// Effect composition - describes I/O, doesn't execute it
fn register_user(input: RawInput) -> impl Effect<Output = User, Error = AppError, Env = AppEnv> {
    // Validate (pure) -> transform to effect
    from_validation(validate_registration(input))
        .map_err(|errs| AppError::Validation(errs))
        // Apply pure business logic
        .map(|valid| {
            let discount = calculate_discount(&valid);
            (valid, discount)
        })
        // Describe the I/O - asks() doesn't execute, it builds a description
        .and_then(|(valid, discount)| {
            asks(move |env: &AppEnv| env.db.create_user(&valid, discount))
        })
        .context("registering new user")
}

// I/O executes at the application boundary
async fn handle_registration(input: RawInput, env: &AppEnv) -> Result<User, AppError> {
    register_user(input).run(env).await  // <-- I/O happens here
}

// In tests - pure functions need no mocks
#[test]
fn senior_gets_20_percent_discount() {
    let user = ValidUser {
        username: Username::new("alice".into()).unwrap(),
        age: Age::new(70).unwrap(),  // 70 years old
        email: Email::new("alice@example.com".into()).unwrap(),
    };
    assert_eq!(calculate_discount(&user), Discount(0.20));
}
Enter fullscreen mode Exit fullscreen mode

The key insight: register_user returns an effect description, not a result. No I/O executes until .run(env) is called. This keeps your business logic pure and testable - the register_user function itself does no I/O, it just describes what I/O should happen.

This example showcases several stillwater features:

  • Refined types (Username, Age, Email) encode invariants in the type system - once constructed, they're guaranteed valid
  • Validation accumulates all errors - users see every problem at once, not one at a time
  • Pure functions (calculate_discount) are trivially testable with no mocks
  • Deferred I/O - asks() describes database operations; actual I/O happens only at .run()
  • Context chaining preserves error trails for debugging

Key Features in 1.0

Zero-Cost Effect System

Following the futures crate pattern, effects are zero-cost by default with opt-in boxing:

use stillwater::prelude::*;

// No heap allocation - each combinator returns a concrete type
let effect = pure::<_, String, ()>(42)
    .map(|x| x + 1)
    .and_then(|x| pure(x * 2));

// Type: AndThen<Map<Pure<i32, ...>, ...>, ...>
// Compiler inlines everything - zero allocation!
Enter fullscreen mode Exit fullscreen mode

Use .boxed() only when you need type erasure - like match arms with different effect types:

// Different branches produce different concrete types
fn fetch_user(id: UserId, use_cache: bool) -> BoxedEffect<User, Error, Env> {
    if use_cache {
        asks(|env: &Env| env.cache.get(id))        // type A
            .boxed()
    } else {
        asks(|env: &Env| env.db.fetch(id))         // type B
            .and_then(|user| cache_user(user))
            .boxed()
    }
}
Enter fullscreen mode Exit fullscreen mode

If you've worked with async Rust, you already understand the pattern.

Validation with Error Accumulation

Rust's Result short-circuits on the first error. Stillwater's Validation accumulates all of them:

use stillwater::Validation;

fn validate_registration(input: RawInput) -> Validation<User, Vec<String>> {
    Validation::all((
        validate_email(input.email),
        validate_age(input.age),
        validate_username(input.username),
    ))
    .map(|(email, age, username)| User { email, age, username })
}

// Returns ALL errors at once:
// Failure(["Invalid email format", "Age must be 18+", "Username too short"])
Enter fullscreen mode Exit fullscreen mode

No more frustrating round-trips where users fix one error only to discover another.

Predicate Combinators

Composable, reusable predicates for declarative validation:

use stillwater::predicate::*;

let valid_username = len_between(3, 20)
    .and(all_chars(|c| c.is_alphanumeric() || c == '_'));

let valid_port = between(1024, 65535);

// Combine with validation
Validation::success(username)
    .ensure(valid_username, "Invalid username format")
Enter fullscreen mode Exit fullscreen mode

Refined Types

The "parse, don't validate" pattern - type-level invariants that guarantee validity:

use stillwater::refined::{Refined, NonEmpty, Positive, InRange};

type NonEmptyString = Refined<String, NonEmpty>;
type PositiveI32 = Refined<i32, Positive>;
type Port = Refined<u16, InRange<1024, 65535>>;

// Validate at boundaries
let name = NonEmptyString::new("Alice".to_string())?;
let port = Port::new(8080)?;

// Use freely inside - validity guaranteed by construction
fn process_user(name: NonEmptyString, port: Port) {
    // No need to re-check: types prove validity
}
Enter fullscreen mode Exit fullscreen mode

Zero runtime overhead - same memory layout as the inner type.

Bracket Pattern for Resource Management

Guaranteed acquire/use/release semantics:

use stillwater::effect::bracket::*;

let result = bracket(
    open_connection(),                          // Acquire
    |conn| async move { conn.close().await },   // Release (always runs!)
    |conn| fetch_user(conn, user_id),           // Use
).run(&env).await;

// Fluent builder for multiple resources
let result = acquiring(open_db(), |db| async move { db.close().await })
    .and(open_file(), |f| async move { f.close().await })
    .with_flat2(|db, file| process(db, file))
    .run(&env)
    .await;
Enter fullscreen mode Exit fullscreen mode

Compile-Time Resource Tracking

Type-level resource tracking prevents leaks at compile time:

use stillwater::effect::resource::*;

fn open_file(path: &str) -> impl ResourceEffect<Acquires = Has<FileRes>> {
    pure(FileHandle::new(path)).acquires::<FileRes>()
}

fn close_file(handle: FileHandle) -> impl ResourceEffect<Releases = Has<FileRes>> {
    pure(()).releases::<FileRes>()
}

// Bracket guarantees resource neutrality - won't compile if unbalanced!
fn read_file_safe(path: &str) -> impl ResourceEffect<Acquires = Empty, Releases = Empty> {
    bracket::<FileRes>()
        .acquire(open_file(path))
        .release(|h| async move { close_file(h).run(&()).await })
        .use_fn(|h| read_contents(h))
}
Enter fullscreen mode Exit fullscreen mode

Zero runtime overhead - all tracking happens at compile time.

Retry Policies as Data

Policies are composable, testable values - not scattered implementation:

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

// Define policy as pure data
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)));

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

// Conditional retry
Effect::retry_if(
    || api_call(),
    api_policy,
    |err| matches!(err, ApiError::Timeout | ApiError::ServerError(_))
);
Enter fullscreen mode Exit fullscreen mode

Writer Effect for Audit Trails

Accumulate logs without threading state through every function:

use stillwater::effect::writer::prelude::*;

fn process_order(order: Order) -> impl WriterEffect<
    Output = Receipt, Error = String, Env = (), Writes = Vec<String>
> {
    tell_one("Processing order".to_string())
        .and_then(move |_| validate_order(order))
        .tap_tell(|_| vec!["Validation passed".to_string()])
        .and_then(|order| charge_card(order))
        .tap_tell(|receipt| vec![format!("Charged: ${}", receipt.amount)])
}

let (result, logs) = process_order(order).run_writer(&()).await;
// logs: ["Processing order", "Validation passed", "Charged: $42.00"]
Enter fullscreen mode Exit fullscreen mode

Parallel Effect Execution

Run independent effects concurrently:

use stillwater::prelude::*;

// Combine independent effects
fn load_profile(id: UserId) -> impl Effect<Output = Profile, Error = AppError, Env = AppEnv> {
    zip3(fetch_user(id), fetch_settings(id), fetch_preferences(id))
        .map(|(user, settings, prefs)| Profile { user, settings, prefs })
}

// Homogeneous collection
let results = par_all(effects, &env).await;

// Race - first success wins
let result = race(fetch_from_primary(), fetch_from_backup(), &env).await;
Enter fullscreen mode Exit fullscreen mode

Production Ready

1.0 represents a stable API ready for production use:

  • 355+ unit tests passing
  • 113+ documentation tests
  • 21 comprehensive runnable examples
  • Zero clippy warnings
  • Full async/await support
  • CI/CD with security audits

Philosophy

Stillwater is built on six core beliefs:

  1. Pure Core, Imperative Shell - Separate logic from I/O for testability
  2. Fail Completely, Not Partially - Accumulate all errors, not just the first
  3. Errors Tell Stories - Context chaining preserves error trails
  4. Composition Over Complexity - Build complex from simple pieces
  5. Types Guide, Don't Restrict - Pragmatic type system use
  6. Pragmatism Over Purity - Better Rust, not academic FP

We don't fight the borrow checker. We don't replace the standard library. We work with ?, integrate with async/await, and follow Rust idioms.

When to Use Stillwater

Good fit:

  • Complex form/config validation (error accumulation shines)
  • Business logic that needs extensive testing (pure core)
  • Deep call stacks needing error context
  • Long-lived codebases prioritizing maintainability
  • Effects with dependency injection (Reader pattern)
  • Resource management with guaranteed cleanup

Less suitable:

  • Simple CRUD applications (standard Result is fine)
  • Performance-critical hot paths (profile first)
  • Teams not aligned on functional patterns

The Ecosystem

Stillwater is part of a family of libraries sharing the same philosophy:

Library Purpose
premortem Configuration validation and multi-source loading
postmortem JSON schema validation with precise path tracking
mindset Zero-cost effect-based state machines
stilltypes Domain-specific refined types

Getting Started

Add to your Cargo.toml:

[dependencies]
stillwater = "1.0"
Enter fullscreen mode Exit fullscreen mode

Start with validation - it's the most immediately useful part:

use stillwater::Validation;

fn validate(input: &Input) -> Validation<Valid, Vec<String>> {
    Validation::all((
        validate_field1(&input.field1),
        validate_field2(&input.field2),
        validate_field3(&input.field3),
    ))
    .map(|(f1, f2, f3)| Valid { f1, f2, f3 })
}
Enter fullscreen mode Exit fullscreen mode

Then explore effects when you need testable I/O composition:

use stillwater::prelude::*;

fn fetch_and_process(id: Id) -> impl Effect<Output = Result, Error = AppError, Env = AppEnv> {
    asks(|env: &AppEnv| env.api.fetch(id))
        .and_then(|data| pure(transform(data)))
        .context("fetching and processing data")
}

// In tests - no real API needed
let result = fetch_and_process(id).run(&mock_env).await;
Enter fullscreen mode Exit fullscreen mode

What's Next

With 1.0 stable, future work focuses on:

  • More refined type predicates stilltypes
  • Additional effect combinators
  • Performance optimizations
  • Ecosystem integration examples

Check out stillwater on GitHub or crates.io.


Want more content like this? Follow me on Dev.to or subscribe to Entropic Drift for posts on AI-powered development workflows, Rust tooling, and technical debt management.

Check out my open-source projects:

  • Debtmap - Technical debt analyzer
  • Prodigy - AI workflow orchestration

Top comments (0)