Migrating from async fn to effects
2.x → 3.0: See Migrating 2.x → 3.0 for the DI maturity breaking changes.
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.
1.x DI: If you are migrating from id_effect 1.x tag/HList DI (
service_key!,ctx!,Layer/Stack), skip to Migrating 1.x DI to id_effect 3.0 first.
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, domain functions return an Effect — a description you run later with an environment:
#![allow(unused)] fn main() { struct Database; fn get_user(id: u64) -> Effect<User, DbError, caps!(Database)> { effect!(|r: &mut caps!(Database)| { let db = require!(Database); let user = ~ db.get_user(id); user }) } }
The database client is no longer a function parameter. It is declared via caps!(Database) and retrieved with require!. The business logic is identical; what changes is how dependencies are supplied at run_with / main.
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() { struct Database; struct Mailer; pub fn process_order(order_id: OrderId) -> Effect<Receipt, AppError, caps!(Database, Mailer)> { effect!(|r: &mut caps!(Database, Mailer)| { let db = require!(Database); let mailer = require!(Mailer); let order = ~ db.get_order(order_id); let receipt = ~ db.complete_order(order); ~ mailer.send_receipt(&receipt); receipt }) } }
Migration steps:
- Remove dependency parameters (
db,mailer) - Declare service types (
struct Counter(u32);ortype Database = Arc<dyn DbClient>;) - Use
Effect<_, _, caps!(K1, K2)>(orwhere Env: Needs<K> + …when generic) - Replace
async move { … }witheffect!(|r: &mut caps!(…)| { … }) - Replace
.await?with~prefix - Use
require!(K)for each capability insideeffect! - Wire providers at the edge with
run_with([provide!(…), …], effect)
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(symbol: String) -> Effect<f64, reqwest::Error, ()> { from_async(move |_env| async move { reqwest::get(format!("https://api.example.com/price/{symbol}")) .await? .json::<PriceResponse>() .await .map(|r| r.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 E
#![allow(unused)] fn main() { #[derive(Debug)] struct NotFoundError(String); struct Database; fn get_user(id: u64) -> Effect<User, DbError, caps!(Database)> { effect!(|r: &mut caps!(Database)| { let db = require!(Database); ~ db.get_user(id) }) } }
You still need an AppError at the top level (in main or your HTTP handler), but individual functions no longer need 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 as a capability
#![allow(unused)] fn main() { struct AppStateCap; fn handler() -> Effect<Response, AppError, caps!(AppStateCap)> { effect!(|r: &mut caps!(AppStateCap)| { let state = require!(AppStateCap); let mut s = state.lock().unwrap(); s.request_count += 1; // … }) } }
Or, for transactional mutable state across fibers, use TRef + STM (see Part III).
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; result } }
After — explicit Scope
#![allow(unused)] fn main() { struct PoolCap; fn with_connection<F, A, E>(f: F) -> Effect<A, E, caps!(PoolCap)> where F: FnOnce(&Connection) -> Effect<A, E, caps!(PoolCap)> + 'static, E: From<DbError> + 'static, A: 'static, { effect!(|r: &mut caps!(PoolCap)| { let pool = require!(PoolCap); ~ scope.acquire( pool.get(), |conn| pool.release(conn), |conn| f(conn), ) }) } }
The Scope finalizer runs whether the inner effect succeeds, fails, or is cancelled.
HTTP boundaries: raw reqwest → workspace crates
After effects replace bare async fn, move HTTP edges toward typed capabilities:
id_effect_platform—HttpClientService+ReqwestHttpClientProvider+executefor portable requests.id_effect_platform::http::reqwest—reqwest::Clientkeyed inEnv, pools,json_schema— see HTTP via reqwest.
Host either style under Axum with id_effect_axum (Axum host).
Migration Strategy
Migrate gradually, one module at a time:
- Start with leaf functions (no effect dependencies yet).
- Move up the call graph.
- Push
run_with/run_blockingtomainor the request handler. - Convert tests last — swap
provide!(Mock…)implementations.
You can mix async functions and effects during the transition: wrap async with from_async; call effects with run_blocking or run_async at boundaries.
Migrating 2.x → 3.0
| Removed (2.x) | Replacement (3.0) |
|---|---|
CapEnv1…6 | caps!(K0, K1, …) / CapList<(K0, K1, …)> |
caps!(Key, T) (removed) | declare service type T directly |
require!(env, K) | require!(K) in effect! or Needs::<K>::need(env) |
ctx!, req!, service_key! | ``, caps!, build_env |
Layer / Stack / Effect::provide | ProviderSpec + run_with |
IntoBind | Needs<K> + require!(K) |
config ambient | Env::scoped / build_env |
Effect<_, _, Env> (multi-cap public API) | Effect<_, _, caps!(…)> |
Run cargo test -p id_effect --test ui_compile_fail to see compile-fail examples for each removed symbol.
Migrating 1.x DI to id_effect 3.0
id_effect 3.0 replaces the Effect.ts-style tag/HList stack with capability services, Env, and ProviderSpec. 1.x symbols (service_key!, ctx!, req!, Layer/Stack, .provide() on effects) are removed from the public DI path.
Symbol mapping
| 1.x (removed from DI path) | 3.0 |
|---|---|
service_key!(K: V) | service type T / type K = Arc<dyn Trait> |
Tagged<K> / tagged(v) | Env::insert::<Cap<K>>(v) |
Context / Cons / Nil / ctx!(…) | Env (order-independent) |
Get<K> / NeedsX supertraits | Needs<K> |
~ Service in effect! | require!(K) |
req!(K: V | …) | caps!(…) + Needs<K> bounds |
Layer / Stack / layer_service | ProviderSpec + provide!(P) |
effect.provide(ctx) | run_with([provide!(…)], effect) |
LayerGraph (app wiring) | CapabilityGraph (via run_with / build_env) |
Example: service key → capability service
1.x
#![allow(unused)] fn main() { service_key!(UserRepo: Arc<dyn UserRepository>); fn get_user<R: NeedsUserRepo>(id: u64) -> Effect<User, DbError, R> { effect! { let repo = ~ UserRepo; ~ repo.get_user(id) } } run_blocking(get_user(1).provide(ctx!(tagged::<UserRepo>(repo))))?; }
3.0
#![allow(unused)] fn main() { struct UserRepo; fn get_user(id: u64) -> Effect<User, DbError, caps!(UserRepo)> { effect!(|r: &mut caps!(UserRepo)| { let repo = require!(UserRepo); ~ repo.get_user(id) }) } run_with([provide!(UserRepoLive)], get_user(1))?; }
Example: layer stack → provider list
1.x
#![allow(unused)] fn main() { let app_layer = config_layer .stack(db_layer) .stack(user_repo_layer); run_blocking(my_app().provide_layer(app_layer))?; }
3.0
#![allow(unused)] fn main() { run_with( [ provide!(ConfigLive), provide!(DatabaseLive), provide!(UserRepoLive), ], my_app(), )?; }
Each *Live type implements ProviderSpec. Dependencies are declared in requires() and satisfied via deps.get::<Cap<K>>() inside provide(). CapabilityGraph plans build order — no manual stacking.
Test environments
1.x
#![allow(unused)] fn main() { let env = ctx!(tagged::<Database>(mock_db), tagged::<EffectLogger>(mock_log)); run_blocking(effect.provide(env))?; }
3.0
#![allow(unused)] fn main() { let mut env = build_env([provide!(DatabaseLive), provide!(LoggerLive)])?; env.insert::<Cap<Database>>(mock_db); run_blocking(effect, env)?; // or swap providers entirely run_with([provide!(MockDatabase), provide!(MockLogger)], effect)?; }
Migration checklist
- Declare service types (structs or
Arc<dyn Trait>aliases). - Change
NeedsX/Get<K>bounds toNeeds<K>orcaps!(…). - Replace
~ Kwithrequire!(K)(use|r: &mut caps!(…)|oneffect!when needed). - Replace layer stacks with
ProviderSpecimpls +provide!(…). - Replace
.provide()/.provide_layer()withrun_withor manualEnv+run_blocking. - Update
main, tests, and workspace crate providers (id_effect_platform,id_effect_config, etc.) in the same release — 3.0 is a clean break.
See Part II (chapters 4–7) for the full capability DI narrative.