IO API Analysis - read and write
Status
Accepted and implemented.
The current IO helper API is:
#![allow(unused)]
fn main() {
IO::read(...)
IO::write(...)
IO::read_async(...)
IO::write_async(...)
}
The previous design discussion considered more database-oriented and generic names. This document preserves that rationale while describing the current implementation accurately.
Current Design
The IO module provides a small namespace for creating boxed effects from service operations:
#![allow(unused)]
fn main() {
use stillwater::IO;
let find_user = IO::read(|db: &Database| db.find_user(id));
let save_user = IO::write(|repo: &UserRepository| repo.save_user(user));
}
Both read and write receive &T, not &mut T. This follows Stillwater’s effect model: Effect::run receives &Env, and services that mutate state should expose safe interior mutation through handles such as connection pools, Arc<Mutex<T>>, channels, or client types that perform mutation behind an immutable reference.
Async operations use explicit async variants:
#![allow(unused)]
fn main() {
let find_user = IO::read_async(|db: &Database| async move {
db.find_user(id).await
});
let save_user = IO::write_async(|repo: &UserRepository| async move {
repo.save_user(user).await
});
}
Intent Of The Names
The names are semantic, not borrow-mode distinctions:
readmeans the operation is conceptually query-like and returns information.writemeans the operation is conceptually command-like and changes external state.read_asyncandwrite_asyncmirror the same split for futures.
This lets code communicate intent even though both closures receive immutable references:
#![allow(unused)]
fn main() {
IO::read(|cache: &Cache| cache.get(key))
IO::write(|cache: &Cache| cache.set(key, value))
}
Environment Extraction
IO helpers use AsRef<T> to extract a service from a larger application environment:
#![allow(unused)]
fn main() {
#[derive(Clone)]
struct AppEnv {
db: Database,
cache: Cache,
}
impl AsRef<Database> for AppEnv {
fn as_ref(&self) -> &Database {
&self.db
}
}
impl AsRef<Cache> for AppEnv {
fn as_ref(&self) -> &Cache {
&self.cache
}
}
}
The closure parameter tells the compiler which service type to extract:
#![allow(unused)]
fn main() {
let user_effect = IO::read(|db: &Database| db.find_user(id));
let cache_effect = IO::write(|cache: &Cache| cache.set(key, value));
}
This keeps the effect code decoupled from the concrete layout of AppEnv.
Mutation Pattern
Because the environment is immutable, mutable services should use interior mutability or handle types that are already designed for shared access.
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct Cache {
entries: Arc<Mutex<HashMap<String, String>>>,
}
impl Cache {
fn get(&self, key: &str) -> Option<String> {
self.entries.lock().unwrap().get(key).cloned()
}
fn set(&self, key: String, value: String) {
self.entries.lock().unwrap().insert(key, value);
}
}
let read_effect = IO::read(|cache: &Cache| {
cache.get("theme")
});
let write_effect = IO::write(|cache: &Cache| {
cache.set("theme".to_string(), "dark".to_string());
});
}
This pattern aligns with async Rust service handles such as database pools and HTTP clients, which are commonly cloneable and internally synchronized.
Naming Alternatives Considered
read / write
#![allow(unused)]
fn main() {
IO::read(|db: &Database| db.find_user(id))
IO::write(|repo: &UserRepository| repo.save_user(user))
}
Pros:
- Clear across files, databases, network clients, caches, and loggers.
- Matches common I/O vocabulary.
- Short and easy to scan in effect pipelines.
- Communicates conceptual side effect without exposing mutable borrows.
Cons:
writecan sound storage-specific, even though it also covers commands such as sending email or publishing events.- Operations that both read and write require judgment.
Verdict: best default.
access / modify
#![allow(unused)]
fn main() {
IO::access(|db: &Database| db.find_user(id))
IO::modify(|repo: &UserRepository| repo.save_user(user))
}
Pros:
- General enough for any resource.
modifyclearly suggests state change.
Cons:
accessis vague.- Less familiar than read/write.
Verdict: reasonable but less readable.
get / set
#![allow(unused)]
fn main() {
IO::get(|cache: &Cache| cache.get(key))
IO::set(|cache: &Cache| cache.set(key, value))
}
Pros:
- Short and familiar.
Cons:
- Too narrow for deletes, inserts, publishes, and sends.
- Suggests property access rather than general I/O.
Verdict: too limited.
run / run_mut
#![allow(unused)]
fn main() {
IO::run(|db: &Database| db.find_user(id))
IO::run_mut(|repo: &UserRepository| repo.save_user(user))
}
Pros:
- Generic.
- Familiar Rust suffix pattern.
Cons:
- Loses the semantic distinction between query-like and command-like operations.
run_mutwould be misleading because the current API does not pass&mut T.
Verdict: too generic and no longer matches the implementation model.
Database-Oriented Names
Database APIs often use names equivalent to “query” for reads and “execute” for writes. That convention is familiar in database code, but Stillwater’s IO helpers are not database-specific. They also wrap file systems, caches, message buses, HTTP clients, loggers, and application services.
Verdict: too database-centric for a general effect library.
Comparison Matrix
| Option | Clarity | Generality | Matches Current Env Model | Verdict |
|---|---|---|---|---|
read / write | High | High | Yes | Accepted |
access / modify | Medium | High | Yes | Acceptable but less clear |
get / set | Medium | Low | Yes | Too limited |
run / run_mut | Low | High | No | Misleading |
| Database-oriented names | Medium | Low | Mixed | Too narrow |
Real-World Examples
Database
#![allow(unused)]
fn main() {
IO::read(|db: &Database| db.find_user(id))
IO::write(|db: &Database| db.insert_user(user))
}
Cache
#![allow(unused)]
fn main() {
IO::read(|cache: &Cache| cache.get(key))
IO::write(|cache: &Cache| cache.set(key, value))
}
Logger
#![allow(unused)]
fn main() {
IO::read(|logger: &Logger| logger.level())
IO::write(|logger: &Logger| logger.info("user registered"))
}
File System Abstraction
#![allow(unused)]
fn main() {
IO::read(|fs: &FileSystem| fs.read_to_string(path))
IO::write(|fs: &FileSystem| fs.write_string(path, contents))
}
Async HTTP Client
#![allow(unused)]
fn main() {
IO::read_async(|client: &HttpClient| async move {
client.get_json(url).await
})
IO::write_async(|client: &HttpClient| async move {
client.post_json(url, body).await
})
}
Decision
Use read and write as the conceptual split:
#![allow(unused)]
fn main() {
IO::read(...)
IO::write(...)
IO::read_async(...)
IO::write_async(...)
}
The helpers intentionally use AsRef<T> and immutable service references. This keeps environments shareable and compatible with the broader Effect model. Services that need mutation should provide internally safe mutation, not require &mut Env.
Consequences
Positive:
- The API works consistently with
Effect::run(&env). - Environments remain easy to share across async and parallel effects.
- Service extraction is explicit through
AsRef<T>. - The names are understandable outside database code.
Tradeoffs:
writeis semantic rather than enforced by the borrow type.- Users must understand that mutation requires interior mutability or shared service handles.
- A composite environment can only implement one
AsRef<T>per service type, so duplicate services of the same concrete type may need newtype wrappers.
Guidance
Prefer IO::read for:
- Lookup operations
- Configuration access
- Cache reads
- Database selects
- HTTP GET-like operations
Prefer IO::write for:
- Inserts, updates, and deletes
- Cache writes
- Logging
- Event publishing
- Email sending
- HTTP POST/PUT/PATCH-like operations
Use from_fn or from_async directly when the operation needs the whole environment or when service extraction through AsRef<T> is not the right fit.