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

Realistic Fallible Capability

The app_profile crate is a small app-shaped example rather than a toy counter. It loads and saves a user profile through a fallible capability:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait ProfileStore: Send + Sync {
    async fn load_profile(&self, user_id: UserId) -> Result<Profile, ProfileError>;
    async fn save_profile(&self, profile: Profile) -> Result<(), ProfileError>;
}
}

The app still depends on ordinary async Rust traits:

#![allow(unused)]
fn main() {
pub struct App {
    model: RwLock<Model>,
    store: Arc<dyn ProfileStore>,
    render: Arc<dyn Render>,
}
}

There is no effect queue in the domain model. Loading a profile sets local state, awaits the store, records either the profile or the error, and renders:

#![allow(unused)]
fn main() {
let result = self.store.load_profile(user_id).await;

match result {
    Ok(profile) => {
        model.status = Status::Ready;
        model.profile = Some(profile);
    }
    Err(error) => {
        model.status = Status::Error;
        model.error = Some(error);
    }
}
}

Test The Timeline

Tests use EffectChannel to observe and answer each external request exactly where the app awaits it:

#![allow(unused)]
fn main() {
let (store, store_handler) = EffectChannel::unbounded();
let (render, render_handler) = EffectChannel::unbounded();
let app = App::new(store, render);

let mut update = Box::pin(app.update(Event::LoadProfile { user_id }));
assert_pending!(&mut update);

store_handler
    .handle_load_profile(async |_| Ok(profile))
    .await
    .unwrap();

assert_pending!(&mut update);
render_handler.handle_render(async || {}).await.unwrap();
assert_ready!(&mut update);
}

That middle assert_pending! is important: after the test answers load_profile, the app future must be polled again so it can advance to the next awaited effect, render.

The same shape scripts failures. A failed save keeps the edited profile in the model, marks it dirty, and records the error returned by the capability.

Compose The Boundary Protocol

The app can still expose one inspectable boundary protocol:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum AppEffect {
    ProfileStore(ProfileStoreEffect),
    Render(RenderEffect),
}
}

Generated scopes let a root handler speak AppEffect while app-local code keeps using the smaller ProfileStore and Render traits. This is the same boundary shape used by tests, TUIs, debuggers, replay tools, or FFI hosts.

FFI-Style Conversion

The example also includes UniFFI-shaped records and enums that mirror the generated protocol:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, uniffi::Record)]
pub struct FfiProfile {
    pub user_id: FfiUserId,
    pub display_name: String,
}

#[derive(Debug, Clone, uniffi::Enum)]
pub enum ProfileStoreFfiEffect {
    LoadProfile { user_id: FfiUserId },
    SaveProfile { profile: FfiProfile },
}
}

The conversion code is deliberately explicit. It shows the intended FFI style: generated protocol enums stay inside Rust, while a host boundary can expose reviewable request and output enums with uniffi derives. Fallible outputs are represented as explicit success and failure variants at the boundary, then converted back into Rust Result values for the generated protocol.

When This Is Enough

This pattern is a good fit for smaller applications whose hard part is testing async interactions with storage, HTTP, clocks, auth, or platform APIs. You get normal Rust app code and precise test control over the timeline.

It is not a reason to rewrite a flagship app by itself. Event ordering, scheduling, retries, persistence, and host lifecycle are still host policy. The toolkit makes effect boundaries explicit; it does not replace application architecture decisions around them.