DEV Community

Glen Baker
Glen Baker

Posted on • Originally published at entropicdrift.com

Pure Core, Imperative Shell in Rust with Stillwater

Originally published on Entropic Drift


Most Rust services start clean.

There is a handler, a database call, a few validation checks, a small amount of business logic, and a response. The code is direct. You can read it top to bottom.

Then the service grows.

Validation gets more detailed. A second database lookup appears. Password hashing moves into the flow. You add a uniqueness check. There is a welcome email, a retry policy for the email provider, a metric, and a bit of context on errors so production logs are useful.

The handler still works, but the business rule is now buried inside infrastructure code.

async fn register_user(
    State(state): State<AppState>,
    Json(input): Json<RegistrationInput>,
) -> Result<Json<User>, ApiError> {
    if input.username.len() < 3 {
        return Err(ApiError::BadRequest("username too short".into()));
    }

    if !input.email.contains('@') {
        return Err(ApiError::BadRequest("invalid email".into()));
    }

    if input.password.len() < 12 {
        return Err(ApiError::BadRequest("password too short".into()));
    }

    if state.db.username_exists(&input.username).await? {
        return Err(ApiError::Conflict("username already exists".into()));
    }

    let password_hash = state.hasher.hash(&input.password).await?;

    let user = state
        .db
        .insert_user(&input.username, &input.email, &password_hash)
        .await?;

    state.email.send_welcome(&user.email).await?;

    Ok(Json(user))
}
Enter fullscreen mode Exit fullscreen mode

This is idiomatic enough. The problem is not that the code is ugly. The problem is that it has too many responsibilities:

  • Parse and validate external input
  • Check application invariants
  • Call storage
  • Call a password hasher
  • Call an email provider
  • Decide the domain result
  • Translate failures into HTTP responses

Those responsibilities change for different reasons. They also want different testing strategies. Validation wants table tests. Business rules want pure unit tests. Storage and email want integration tests or mocks. HTTP wants boundary tests.

Pure core, imperative shell is a useful way to split that apart.

The Pattern

The rule is simple:

  • The core takes data and returns data.
  • The shell performs effects.

In Rust terms, the core should mostly be ordinary functions:

fn build_user(
    id: UserId,
    input: ValidRegistration,
    password_hash: PasswordHash,
) -> User {
    User {
        id,
        username: input.username,
        email: input.email,
        password_hash,
    }
}
Enter fullscreen mode Exit fullscreen mode

No database. No async. No clock. No global config. No logging side effect hidden in the middle.

That does not mean the application avoids I/O. It means I/O is described and composed at the boundary.

Stillwater is my attempt to make this style practical in Rust without turning the code into a macro language or a Haskell translation exercise. The two most important pieces are:

  • Validation<T, E> for checks that should collect every error
  • Effect for computations that need an environment and may perform I/O

The goal is not purity for its own sake. The goal is to make the code easier to test, easier to change, and more honest about where side effects happen.

Validation Is Not Error Propagation

Rust's Result is great when later work depends on earlier work. Open a file, then read it. Fetch a user, then fetch their orders. If the first step fails, stop.

Validation is different. If a registration form has four bad fields, the user should get four errors.

With Result, this usually turns into either fail-fast behavior or manual error collection. With Stillwater:

use stillwater::Validation;

#[derive(Clone)]
struct RegistrationInput {
    username: String,
    email: String,
    password: String,
    confirm_password: String,
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct Username(String);

impl Username {
    fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct Email(String);

impl Email {
    fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct PlaintextPassword(String);

#[derive(Clone, Debug, PartialEq, Eq)]
struct ValidRegistration {
    username: Username,
    email: Email,
    password: PlaintextPassword,
}

fn validate_registration(
    input: RegistrationInput,
) -> Validation<ValidRegistration, Vec<String>> {
    Validation::all((
        validate_username(&input.username),
        validate_email(&input.email),
        validate_password_policy(&input.password),
        validate_password_match(&input.password, &input.confirm_password),
    ))
    .map(|_| ValidRegistration {
        username: Username(input.username),
        email: Email(input.email),
        password: PlaintextPassword(input.password),
    })
}
Enter fullscreen mode Exit fullscreen mode

Each validator can stay small:

fn validate_password_policy(password: &str) -> Validation<(), Vec<String>> {
    let mut errors = Vec::new();

    if password.len() < 12 {
        errors.push("password must be at least 12 characters".to_string());
    }

    if !password.chars().any(char::is_uppercase) {
        errors.push("password must contain an uppercase letter".to_string());
    }

    if !password.chars().any(char::is_numeric) {
        errors.push("password must contain a number".to_string());
    }

    if errors.is_empty() {
        Validation::success(())
    } else {
        Validation::failure(errors)
    }
}
Enter fullscreen mode Exit fullscreen mode

There is no database in this layer. There is no web framework. The function is deterministic and cheap to test.

#[test]
fn password_policy_reports_all_failures() {
    let result = validate_password_policy("short");

    assert!(matches!(result, Validation::Failure(errors) if errors.len() == 3));
}
Enter fullscreen mode Exit fullscreen mode

This is password policy validation for registration, not login authentication. Checking a submitted login password against a stored hash needs the stored user record and password verifier, so it belongs in the effectful shell.

That is the "pure core" part. It is boring in the best way.

Effects Put I/O at the Boundary

The next step in registration needs infrastructure:

  • Check whether the username exists
  • Hash the password
  • Insert the user
  • Send the welcome email

Stillwater represents that as an Effect. An effect has three important associated types:

  • Output: what it produces
  • Error: how it can fail
  • Env: what it needs to run

Conceptually:

trait Effect {
    type Output;
    type Error;
    type Env;

    fn run(self, env: &Self::Env) -> impl Future<Output = Result<Self::Output, Self::Error>>;
}
Enter fullscreen mode Exit fullscreen mode

That Env type is the dependency boundary. Instead of passing a database, hasher, email client, metrics client, and config object through every function, the effect declares the environment it needs.

use stillwater::{from_fn, from_validation, Effect, EffectExt};

#[derive(Clone)]
struct AppEnv {
    db: Database,
    hasher: PasswordHasher,
    email: EmailClient,
}

fn register_user(
    input: RegistrationInput,
) -> impl Effect<Output = User, Error = AppError, Env = AppEnv> {
    from_validation(validate_registration(input).map_err(AppError::Validation))
        .and_then(check_username_available)
        .and_then(hash_password)
        .and_then(create_user_record)
        .and_then(send_welcome_email)
}

fn check_username_available(
    valid: ValidRegistration,
) -> impl Effect<Output = ValidRegistration, Error = AppError, Env = AppEnv> {
    from_fn(move |env: &AppEnv| {
        if env.db.username_exists(valid.username.as_str()) {
            Err(AppError::UsernameTaken)
        } else {
            Ok(valid)
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

This is simplified for illustration, but the shape is the important part. Validation turns external strings into domain data. Each effectful step declares what it needs from AppEnv. The workflow is a value that can be composed, decorated, tested, and run at the edge.

Registration and login also want different failure models. Registration can collect independent field errors: bad email, weak password, mismatched confirmation. Login usually should not do that. It fetches the user, verifies the candidate password against the stored hash, and fails fast without revealing which part was wrong.

The HTTP handler becomes the shell:

async fn register_handler(
    State(env): State<AppEnv>,
    Json(input): Json<RegistrationInput>,
) -> Result<Json<User>, ApiError> {
    let user = register_user(input)
        .run(&env)
        .await
        .map_err(ApiError::from)?;

    Ok(Json(user))
}
Enter fullscreen mode Exit fullscreen mode

The handler does HTTP work. The registration workflow does application work. The validation functions do validation work. Each layer is smaller because it has less to know.

Why This Helps Testing

Pure functions need no mocks:

#[test]
fn build_user_preserves_valid_registration_data() {
    let input = ValidRegistration {
        username: Username("glen".to_string()),
        email: Email("glen@entropicdrift.com".to_string()),
        password: PlaintextPassword("CorrectHorse12".to_string()),
    };

    let user = build_user(
        UserId(42),
        input,
        PasswordHash("hash".to_string()),
    );

    assert_eq!(user.id, UserId(42));
    assert_eq!(user.email.as_str(), "glen@entropicdrift.com");
}
Enter fullscreen mode Exit fullscreen mode

Effects can be tested with a test environment:

#[tokio::test]
async fn registration_saves_user_and_sends_email() {
    let env = AppEnv::test();

    let input = RegistrationInput {
        username: "glen".to_string(),
        email: "glen@entropicdrift.com".to_string(),
        password: "CorrectHorse12".to_string(),
        confirm_password: "CorrectHorse12".to_string(),
    };

    let user = register_user(input).run(&env).await.unwrap();

    assert_eq!(env.db.user_count(), 1);
    assert_eq!(env.email.sent_count(), 1);
    assert_eq!(user.email.as_str(), "glen@entropicdrift.com");
}
Enter fullscreen mode Exit fullscreen mode

This is still an integration-style test, but it does not need the real database or the real email provider. The dependency boundary is explicit in Env.

The split changes what each test needs:

Concern Before After
Password policy Handler test Pure table test
User construction Mock-heavy flow Pure unit test
Username uniqueness Full service path Effect test with a test environment
Welcome email retry Embedded loop test Retry policy test
HTTP response mapping End-to-end only Thin boundary test

Error Context Belongs in the Workflow

Production errors often lack the context needed to debug them. A database error by itself is rarely enough. You want to know what the application was trying to do when the database failed.

Stillwater effects can carry context through the workflow. The context wrapper changes the error type to ContextError<AppError>, which makes that extra information explicit:

use stillwater::{ContextError, EffectContext};

fn register_user(
    input: RegistrationInput,
) -> impl Effect<Output = User, Error = ContextError<AppError>, Env = AppEnv> {
    from_validation(validate_registration(input).map_err(AppError::Validation))
        .and_then(check_username_available)
        .and_then(hash_password)
        .and_then(create_user_record)
        .and_then(send_welcome_email)
        .context("registering user")
}
Enter fullscreen mode Exit fullscreen mode

For deeper trails, add context at smaller workflow boundaries and use context_chain once the error is already wrapped. The boundary can decide how much of that context to expose to users, logs, traces, or metrics.

Retry Is Policy, Not Plumbing

Retry logic is another place where service code tends to decay. A quick loop appears around one call. Then there is a sleep. Then there is backoff. Then someone asks for jitter. Then a permanent validation failure gets retried five times because all errors share the same type.

Stillwater treats retry policy as data:

use std::time::Duration;
use stillwater::effect::retry::retry;
use stillwater::RetryPolicy;

fn send_welcome_email(
    user: User,
) -> impl Effect<Output = User, Error = AppError, Env = AppEnv> {
    retry(
        move || {
            let user = user.clone();
            from_fn(move |env: &AppEnv| {
                env.email.send_welcome(user.email.as_str())?;
                Ok(user.clone())
            })
        },
        RetryPolicy::exponential(Duration::from_millis(100))
            .with_max_retries(3),
    )
    .map(|success| success.into_value())
}
Enter fullscreen mode Exit fullscreen mode

The retry decision is visible because the retried effect is small and the policy is a value. Permanent checks should happen before this step, so the retry boundary only wraps failures that are actually worth retrying. The policy can be tested without sending email.

Tradeoffs

Stillwater does not remove the hard parts of application design. It introduces a vocabulary. Validation, Effect, Env, and combinators like and_then are simple concepts, but they are still concepts.

The trade is worth considering when:

  • Validation needs to report all independent failures
  • Business logic is tangled with I/O
  • Tests require too much infrastructure
  • Retry and recovery policies are duplicated
  • Error context is added inconsistently
  • Dependency passing has become noisy

If none of those are true, keep the code simple.

The Useful Constraint

The main benefit of pure core, imperative shell is not aesthetics. It is the constraint it puts on design.

When a function is pure, it cannot quietly call the database. When validation returns Validation, it cannot accidentally stop at the first field. When a workflow returns an Effect, it cannot run until the boundary supplies an environment. When retry is a policy value, it can be inspected and reused.

Those constraints make the code a little more explicit. In application code, explicit is usually cheaper than clever.

Stillwater is built around that bet: use Rust's type system to keep side effects visible, keep validation complete, and keep domain logic calm enough to test without ceremony.

Links:


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)