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.