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:

  1. Remove dependency parameters (db, mailer)
  2. Declare service types (struct Counter(u32); or type Database = Arc<dyn DbClient>;)
  3. Use Effect<_, _, caps!(K1, K2)> (or where Env: Needs<K> + … when generic)
  4. Replace async move { … } with effect!(|r: &mut caps!(…)| { … })
  5. Replace .await? with ~ prefix
  6. Use require!(K) for each capability inside effect!
  7. 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

BeforeArc<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:

  1. id_effect_platformHttpClientService + ReqwestHttpClientProvider + execute for portable requests.
  2. id_effect_platform::http::reqwestreqwest::Client keyed in Env, 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:

  1. Start with leaf functions (no effect dependencies yet).
  2. Move up the call graph.
  3. Push run_with / run_blocking to main or the request handler.
  4. 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…6caps!(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::provideProviderSpec + run_with
IntoBindNeeds<K> + require!(K)
config ambientEnv::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 supertraitsNeeds<K>
~ Service in effect!require!(K)
req!(K: V | …)caps!(…) + Needs<K> bounds
Layer / Stack / layer_serviceProviderSpec + 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

  1. Declare service types (structs or Arc<dyn Trait> aliases).
  2. Change NeedsX / Get<K> bounds to Needs<K> or caps!(…).
  3. Replace ~ K with require!(K) (use |r: &mut caps!(…)| on effect! when needed).
  4. Replace layer stacks with ProviderSpec impls + provide!(…).
  5. Replace .provide() / .provide_layer() with run_with or manual Env + run_blocking.
  6. 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.