惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Y
Y Combinator Blog
IT之家
IT之家
博客园_首页
人人都是产品经理
人人都是产品经理
博客园 - Franky
I
InfoQ
Recent Announcements
Recent Announcements
P
Proofpoint News Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
GbyAI
GbyAI
大猫的无限游戏
大猫的无限游戏
aimingoo的专栏
aimingoo的专栏
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
月光博客
月光博客
Microsoft Security Blog
Microsoft Security Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
B
Blog RSS Feed
MongoDB | Blog
MongoDB | Blog
雷峰网
雷峰网
博客园 - 聂微东
N
Netflix TechBlog - Medium
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
The GitHub Blog
The GitHub Blog
D
Docker

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Pure Core, Imperative Shell in Rust with Stillwater
Glen Baker · 2026-05-24 · via DEV Community

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