Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Migrating from async fn to effects

This appendix shows the current migration shape for effectful: return Effect, keep dependencies in R, and run at the boundary with an explicit environment.

Mental Model Shift

In ordinary async Rust, calling an async fn creates a Future; awaiting it runs the work.

async fn get_user(id: u64, db: &DbClient) -> Result<User, DbError> {
    db.query_one(id).await
}

In effectful, a function returns an Effect description. The runner receives the environment later.

fn get_user(id: u64) -> Effect<User, DbError, DbClient> {
    effect!(|db: &mut DbClient| {
        bind* db.query_one(id)
    })
}

let user = run_blocking(get_user(42), db_client)?;

Pattern 1: async fn to Effect

Before

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

#[derive(Clone)]
struct AppEnv {
    db: DbClient,
    mailer: MailClient,
}

pub fn process_order(order_id: OrderId) -> Effect<Receipt, AppError, AppEnv> {
    effect!(|env: &mut AppEnv| {
        let order = bind* env.db.get_order(order_id).map_error(AppError::Db);
        let receipt = bind* env.db.complete_order(order).map_error(AppError::Db);
        bind* env.mailer.send_receipt(&receipt).map_error(AppError::Mail);
        receipt
    })
}

Migration steps:

  1. Change async fn to fn returning Effect<A, E, R>.
  2. Move dependencies into an environment type or service context.
  3. Replace .await? on effectful operations with bind*.
  4. Return the success value as the block tail.
  5. Call run_blocking(effect, env) or run_async(effect, env) at the boundary.

Pattern 2: Wrapping Third-Party Async

Third-party libraries return futures, not effects. Wrap them with from_async.

fn fetch_price(symbol: String) -> Effect<f64, reqwest::Error, ()> {
    from_async(move |_r: &mut ()| async move {
        let response = reqwest::get(format!("https://api.example.com/price/{symbol}"))
            .await?;
        let body = response.json::<PriceResponse>().await?;
        Ok(body.price)
    })
}

Inside the async closure, use normal .await. Outside, compose the result as an Effect.

Pattern 3: Error Types

Map infrastructure errors into your application error at composition points.

#[derive(Debug)]
enum AppError {
    Db(DbError),
    Mail(MailError),
}

let effect = db_call().map_error(AppError::Db);

Use catch to recover with another effect, and catch_all to turn typed errors into fallback success values.

Pattern 4: Services

For application dependency injection, prefer #[derive(Service)] plus ServiceContext.

#[derive(Clone, Service)]
struct AppState {
    request_count: Arc<AtomicU64>,
}

fn handler() -> Effect<Response, AppError, ServiceContext> {
    AppState::use_sync(|state| {
        state.request_count.fetch_add(1, Ordering::Relaxed);
        Response::ok()
    })
}

let env = AppState::new().to_context();
let response = run_blocking(handler(), env)?;

For tagged HList contexts, use service_key!(pub struct Key);, service_env::<Key, _>(value), and Context::get::<Key>().

Pattern 5: Transactional State

Use TRef when state updates must compose transactionally.

let counter = run_blocking(commit(TRef::make(0_u64)), ())?;

fn increment(counter: TRef<u64>) -> Effect<u64, (), ()> {
    effect! {
        bind* commit(counter.update_stm(|n| n + 1));
        bind* commit(counter.read_stm())
    }
}

There is no stm! macro in the current API; compose transactions with flat_map, map, and helpers like update_stm.

Pattern 6: Resource Cleanup

Use Scope when cleanup must run at an explicit lifetime boundary.

fn with_connection<A, E, F>(pool: Pool<Connection, DbError>, f: F) -> Effect<A, E, ()>
where
    F: FnOnce(Connection) -> Effect<A, E, ()> + 'static,
    A: 'static,
    E: From<DbError> + 'static,
{
    scope_with(move |scope| {
        effect! {
            let conn = bind* pool.get().provide_env(scope.clone()).map_error(E::from);
            let close_conn = conn.clone();
            scope.add_finalizer(Box::new(move |_| close_conn.close()));
            bind* f(conn)
        }
    })
}

For pooled resources, Pool::get() registers return-to-pool cleanup on the provided Scope.

Migration Strategy

  1. Convert leaf async wrappers first with from_async.
  2. Introduce explicit environment structs or ServiceContext.
  3. Move run_blocking / run_async to program edges.
  4. Convert tests to pass test environments or test layers.
  5. Replace stale helper assumptions with current names: run_collect, run_fold, retry(|| ..., schedule), TRef::make, run_test(effect, env).

You can mix old async code with effects during migration. Wrap async futures at the edge and keep new domain workflows as Effect values.