Migrating from async fn to effects
This appendix is a practical guide for converting existing async Rust code to id_effect. It covers common patterns and their id_effect equivalents, with migration steps for each.
The Mental Model Shift
In typical async Rust, a function returns a Future; when that future is awaited, the work runs:
#![allow(unused)] fn main() { async fn get_user(id: u64, db: &DbClient) -> Result<User, DbError> { db.query_one("SELECT * FROM users WHERE id = $1", &[&id]).await } }
In id_effect, many domain functions return an Effect—a description you run later with an environment:
#![allow(unused)] fn main() { fn get_user<A, E, R>(id: u64) -> Effect<A, E, R> where A: From<User> + 'static, E: From<DbError> + 'static, R: NeedsDb + 'static, { effect!(|r: &mut R| { let db = ~ DbKey; let user = ~ db.get_user(id); A::from(user) }) } }
The database client is no longer a function parameter. It's declared in R and retrieved by the runtime. The business logic is identical; what changes is how dependencies are supplied.
Pattern 1: async fn → fn returning Effect
Before
#![allow(unused)] fn main() { pub async fn process_order( order_id: OrderId, db: &DbClient, mailer: &MailClient, ) -> Result<Receipt, AppError> { let order = db.get_order(order_id).await?; let receipt = db.complete_order(order).await?; mailer.send_receipt(&receipt).await?; Ok(receipt) } }
After
#![allow(unused)] fn main() { pub fn process_order<A, E, R>(order_id: OrderId) -> Effect<A, E, R> where A: From<Receipt> + 'static, E: From<AppError> + 'static, R: NeedsDb + NeedsMailer + 'static, { effect!(|r: &mut R| { let db = ~ DbKey; let mailer = ~ MailerKey; let order = ~ db.get_order(order_id); let receipt = ~ db.complete_order(order); ~ mailer.send_receipt(&receipt); A::from(receipt) }) } }
Migration steps:
- Remove the dependency parameters (
db,mailer) - Add
<A, E, R>generic parameters - Add
wherebounds for each removed dependency - Replace
async move { … }witheffect!(|r: &mut R| { … }) - Replace
.await?with~prefix - Wrap the return value with
A::from(…)
Pattern 2: Wrapping Third-Party Async
Third-party libraries return Futures, not Effects. Use from_async to wrap them:
Before
#![allow(unused)] fn main() { async fn fetch_price(symbol: &str) -> Result<f64, reqwest::Error> { reqwest::get(format!("https://api.example.com/price/{symbol}")) .await? .json::<PriceResponse>() .await .map(|r| r.price) } }
After
#![allow(unused)] fn main() { fn fetch_price<A, E, R>(symbol: String) -> Effect<A, E, R> where A: From<f64> + 'static, E: From<reqwest::Error> + 'static, R: 'static, { from_async(move |_r| async move { let price = reqwest::get(format!("https://api.example.com/price/{symbol}")) .await? .json::<PriceResponse>() .await .map(|r| r.price)?; Ok(A::from(price)) }) } }
The from_async closure still uses .await internally. Only the outermost function signature changes.
Pattern 3: Error Types
Before — single monolithic error enum
#![allow(unused)] fn main() { #[derive(Debug)] enum AppError { DbError(DbError), MailError(MailError), NotFound(String), } }
After — effects propagate errors through From bounds
#![allow(unused)] fn main() { // Keep domain errors as-is #[derive(Debug)] struct NotFoundError(String); // Effect signatures declare what they can fail with: fn get_user<A, E, R>(id: u64) -> Effect<A, E, R> where E: From<DbError> + From<NotFoundError> + 'static, // … }
You still need an AppError at the top level (in main or your HTTP handler), but individual functions no longer need to know about unrelated error variants.
Pattern 4: Shared State
Before — Arc<Mutex<T>> passed through function calls
#![allow(unused)] fn main() { async fn handler(state: Arc<Mutex<AppState>>) -> Response { let mut s = state.lock().unwrap(); s.request_count += 1; // … } }
After — shared state in a service, accessed via R
#![allow(unused)] fn main() { service_key!(AppStateKey: Arc<Mutex<AppState>>); fn handler<A, E, R>() -> Effect<A, E, R> where R: NeedsAppState + 'static, // … { effect!(|r: &mut R| { let state = ~ AppStateKey; let mut s = state.lock().unwrap(); s.request_count += 1; // … }) } }
Or, for mutable state that needs transactional semantics across fibers, use TRef:
#![allow(unused)] fn main() { // Replace Arc<Mutex<Counter>> with TRef<u64> service_key!(CounterKey: TRef<u64>); fn increment_counter<E, R>() -> Effect<u64, E, R> where R: NeedsCounter + 'static, E: 'static, { effect!(|r: &mut R| { let counter = ~ CounterKey; ~ commit(counter.modify_stm(|n| n + 1)); ~ commit(counter.read_stm()) }) } }
Pattern 5: Resource Cleanup
Before — manual drop or relying on Drop impls
#![allow(unused)] fn main() { async fn with_connection<F, T>(pool: &Pool, f: F) -> Result<T, DbError> where F: AsyncFnOnce(&Connection) -> Result<T, DbError> { let conn = pool.get().await?; let result = f(&conn).await; // conn is dropped here — relies on Drop result } }
After — explicit Scope
#![allow(unused)] fn main() { fn with_connection<A, E, R, F>(f: F) -> Effect<A, E, R> where F: FnOnce(&Connection) -> Effect<A, E, R> + 'static, R: NeedsPool + 'static, E: From<DbError> + 'static, A: 'static, { effect!(|r: &mut R| { let pool = ~ PoolKey; ~ scope.acquire( pool.get(), // acquire |conn| pool.release(conn), // release (always runs) |conn| f(conn), // use ) }) } }
The Scope finalizer runs whether the inner effect succeeds, fails, or is cancelled. Drop doesn't give you that guarantee for async code.
Migration Strategy
Migrate gradually, one module at a time:
- Start with leaf functions (those with no id_effect dependencies yet) — convert them first.
- Move up the call graph. Functions that call converted leaf functions become easy to convert.
- Push the
run_blockingcall tomainor the request handler entry point. - Convert tests last — once business logic is effect-based, tests become simple layer swaps.
You can mix old-style async functions and effect functions during the transition: wrap async functions with from_async and call effect functions with run_blocking in async contexts when needed.