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

Introduction

krucyfiks is a small toolkit for writing Rust applications where the core application stays ordinary Rust.

The app owns its model. It receives events. It updates state. It exposes a view model. When it needs the outside world, it calls async traits:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Random: Send + Sync {
    async fn get_number(&self) -> i64;
}

#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Render: Send + Sync {
    async fn render(&self);
}
}

That is the main rule. Application code should describe capabilities it needs, not the transport that will provide them.

The same app can then run in different environments:

  • a unit test can receive effects through channels and answer them manually;
  • a Ratatui runtime can implement one direct effect handler;
  • a UniFFI boundary can expose plain data enums to foreign code;
  • a debugger can log or replay protocol values.

Those are adapters around the app. They are not the app architecture itself.

Mental Model

There are two layers:

  • Capabilities are small async traits such as Random, Render, Logger, Http, or Clock.
  • Protocols are plain enums that represent effect requests and outputs at a boundary.

Most application code should only see capabilities. Boundary code can choose the protocol shape that is useful for tests, tracing, FFI, or UI integration.

The macros exist to remove glue:

  • #[krucyfiks::effect] turns a capability trait into request/output enums and adapters;
  • #[derive(krucyfiks::Effect)] composes smaller effect enums into a larger protocol enum.

The result is intentionally flexible. You can write a simple app without a runtime, wrap it in The Elm Architecture when you want inspectable effects, or export it through UniFFI without changing the domain model.

Model an App

Start with the domain. Do not start with the runtime.

The example app in this repository has an account module and a counter module. Each module follows the same small shape:

  • an Event enum for inputs;
  • private model state;
  • a ViewModel for rendering;
  • an async update;
  • a synchronous view.

The counter is the smallest example:

#![allow(unused)]
fn main() {
pub enum Event {
    Increase,
    Decrease,
    Random,
}

pub struct ViewModel {
    pub counter: String,
}
}

The implementation holds state and the capabilities it needs:

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

The update function is plain async Rust:

#![allow(unused)]
fn main() {
pub async fn update(&self, event: Event) {
    match event {
        Event::Increase => {
            self.model.write().counter += 1;
        }
        Event::Decrease => {
            self.model.write().counter -= 1;
        }
        Event::Random => {
            let num = self.random.get_number().await;
            self.model.write().counter += num;
        }
    }

    self.logger
        .log(format!("counter={}", self.view().counter))
        .await;
    self.render.render().await;
}
}

There is no effect queue here, no effect ID, and no runtime callback. The app calls random.get_number().await because it needs a random number. The object behind Random decides whether that call is served by a test channel, a TUI handler, an FFI host, or a real implementation.

Compose Apps Directly

The root app composes child apps directly:

#![allow(unused)]
fn main() {
pub enum Event {
    Account(account::Event),
    Counter(counter::Event),
}
}

It can route events, block events, or call another capability without a special hook:

#![allow(unused)]
fn main() {
pub async fn update(&self, event: Event) {
    match event {
        Event::Account(event) => self.account.update(event).await,
        Event::Counter(event) => {
            if self.account.is_logged_in() {
                self.counter.update(event).await;
            } else {
                self.logger
                    .log("counter=blocked_until_login".to_owned())
                    .await;
                self.blocked_render.render().await;
            }
        }
    }
}
}

This is one of the important differences from frameworks where effects are the only way to communicate. Here the root app is just Rust. If one handler needs to trigger behavior that another handler also uses, it can call the same capability or route to the same child app.

Keep the View Boring

The view model is data for the outside world:

#![allow(unused)]
fn main() {
pub enum ViewModel {
    LoggedOut {
        account: account::ViewModel,
    },
    LoggedIn {
        account: account::ViewModel,
        counter: counter::ViewModel,
    },
}
}

A UI can render it, map user input back into Event, and call update.

Why Not Crux?

Crux is useful prior art: put application logic in Rust, expose a protocol to the host, and let native shells render the UI. krucyfiks keeps that general goal, but changes where the protocol lives.

In Crux, the protocol tends to become the way the app itself thinks. In krucyfiks, the app thinks in ordinary Rust capabilities, and protocol values are introduced only where they are useful.

Effect IDs

If the host receives an effect and later sends a response back, the system needs some way to match the response to the suspended computation.

A common answer is an effect ID. That ID is bookkeeping. It is not domain data, but it leaks into the architecture because every pending effect must be tracked and completed correctly.

krucyfiks uses two different shapes depending on the boundary:

  • EffectChannel puts a response sender inside each PendingEffect;
  • direct handlers use async fn handle(effect) -> output.

In both cases, the response is causally tied to the request. No global effect ID is needed.

Causality

Nested protocols need more than “this is a render effect”.

In the example app, account and counter can both request RenderEffect. Completing account render must produce:

#![allow(unused)]
fn main() {
AppEffectOutput::Account(AccountEffectOutput::Render(...))
}

Completing counter render must produce:

#![allow(unused)]
fn main() {
AppEffectOutput::Counter(CounterEffectOutput::Render(...))
}

The leaf effect type alone cannot tell those apart. krucyfiks carries a typed path or scope through protocol extraction and completion, so the output goes back through the same branch that produced the request.

Effects Triggering Effects

In a protocol-first architecture, “one effect causes another effect” often turns into a runtime or hook problem. The app emits an effect, the host handles it, then something must feed the next event or effect back through the system.

In krucyfiks, app logic is async Rust. If an update needs a random number and then a render, it writes that sequence directly:

#![allow(unused)]
fn main() {
let num = self.random.get_number().await;
self.model.write().counter += num;
self.render.render().await;
}

The boundary can still observe both effects when it wants to. The core app does not need to encode ordinary control flow as a runtime convention.

In a way Crux has pull based model. It’s hosts responsibilty to “ask app whether there is any effect to be resolved, and then call resolve(EffectOutput) to feed the app”.

Meanwhile krucyfiks inverts that process - its apps responsibility to “call host and tell it that there is an effect and wait for it”. Because of this invertion of control, no effect id is needed. Runtime is simple, trivial even. Krucyfiks takes natural mechanisms of rust language instead of fighting its way thru it.

Middleware Pressure

Crux-style middleware can become the composition mechanism for root apps, cross-cutting behavior, and effect routing.

krucyfiks keeps those jobs separate:

  • root app composition is ordinary Rust;
  • effect handlers adapt protocols at boundaries;
  • hooks are optional instrumentation around events or effect handlers.

The Ratatui example uses tracing hooks, but not because the app requires hooks to work. They are there to observe events and effects.

In Crux middleware exists because “sad reality is 99% of the Rust ecosystem does not follow crux way”. It needs middleware to bridge between TEA (The elm architecture) and the rest of the world. If you need to write a database handler directly in rust (using diesel for example) - in crux you are forced to use middleware.

In that context krucyfiks does another inversion - the model directly is just a plain rust with normal traits. Something that is more compatible with ecosystem and does not enforce any TEA. One can take app written with krucyfiks, reject app_tea or app_tea_ffi and write native app_ffi or use app to write its own ratatui UI with no notion of AppEffect.

krucyfiks in that context is a toolkit, not enforcing framework. We want you to use tea when it suits you. Not because someone else decided by you.

Boundary Pressure

Advanced Crux examples may still need to step outside their usual convention and export foreign traits directly, for example using uniffi::export(with_foreign) in the counter hook example.

krucyfiks treats that shape as normal boundary code. A UniFFI host can expose one foreign async handler:

#![allow(unused)]
fn main() {
#[uniffi::export(with_foreign)]
#[async_trait::async_trait]
pub trait FfiEffectHandler: Send + Sync {
    async fn handle(&self, effect: FfiEffect) -> FfiEffectOutput;
}
}

The app still depends on its Rust capability traits. Only the FFI layer needs FFI-safe enums.

Boilerplate and Typegen

If effects are represented by traits, hand-writing the data protocol and adapter glue is repetitive. If the protocol is generated by a broad typegen system, the generated code can become fragile or hard to review.

krucyfiks chooses small, explicit code generation:

  • capability traits are written by hand;
  • request and output enums are generated from those traits;
  • composed protocol enums are written by hand;
  • branch routing and scoped adapters are generated from those enums;
  • FFI-safe enums are explicit at the FFI boundary.

This keeps generated code narrow. Reordering Rust declarations should not become an architectural event, and foreign bindings do not have to dictate the app model.

Meanwhile Crux uses facet-typegen maintained by them. It is unfortunately quite unstable and fragile. Choosing to use native uniffi codegen capabilities with derive macros is better.

Yes - it means more boilerplate at the FFI level with some extra From and Into but it is proven, battle-tested on a production and… let’s be honest. LLM’s are pretty good at working with boilerplate.

Testing with Channels

EffectChannel is the test-oriented adapter.

It gives the app an implementation of a capability trait and gives the test a handler that can receive pending requests:

#![allow(unused)]
fn main() {
let (random, random_handler) = EffectChannel::unbounded();
let (render, render_handler) = EffectChannel::unbounded();
let logger = EffectSink::unbounded();

let app = App::new(random, render, logger);
}

EffectChannel::unbounded() and EffectSink::unbounded() make the queue policy explicit. Unbounded queues are fine for tests, scripted hosts, tools, and small apps where pending effects are naturally limited. If a host needs backpressure, use EffectChannel::bounded(capacity) or EffectSink::bounded(capacity). A capacity of 0 creates a rendezvous channel where the app-side effect call and handler-side receive must meet.

When the app awaits an effect, the future stays pending until the test answers it:

#![allow(unused)]
fn main() {
let mut update = Box::pin(app.update(Event::Counter(counter::Event::Random)));

assert_pending!(&mut update);

random_handler.handle_get_number(async || 42).await.unwrap();

assert_pending!(&mut update);

render_handler.handle_render(async || {
    assert_eq!(app.view().counter, "42");
}).await.unwrap();

assert_ready!(&mut update);
}

This is more precise than a traditional mock. The test controls when the effect is observed, what it returns, and what state is visible before the original update resumes.

Script Scenarios

At the same time usually mocks are more static, or require to create sequences of network responses beforehand. Things like “first we will return 500 and then return 200 to simulate temporary network failure” are way more natural with the channel approach. We do control timeline and can test advanced scenarios.

The handler receives real pending requests. That means a test can script a sequence:

#![allow(unused)]
fn main() {
let mut status_codes = vec![200, 500].into_iter();

network_handler
    .handle_request(async |request| {
        assert_eq!(request.path, "/profile");
        HttpResponse {
            status: status_codes.next().unwrap(),
            body: String::new(),
        }
    })
    .await
    .unwrap();
}

The first request can return success and the second can return failure. The app does not know it is talking to a mock. It is awaiting the same capability trait it would use in production.

Out-of-Order Completion

Each PendingEffect contains its own response sender:

#![allow(unused)]
fn main() {
let first = handler.next().await;
let second = handler.next().await;

second.respond_async(second_output).await.unwrap();
first.respond_async(first_output).await.unwrap();
}

The response resumes the exact call that produced that pending effect. There is no effect ID map for the test to maintain.

Ignoring Effects

Sometimes a test cares about one capability and wants to ignore another. EffectSink is useful for unit-returning effects such as logging:

#![allow(unused)]
fn main() {
let logger = EffectSink::unbounded();
}

Unit effects can be discarded because they do not feed a value back into app computation. Value-returning effects still need a channel or a real implementation because the app is waiting for a result. If you intentionally use EffectSink for a mixed effect, use sink.handler() to handle value-returning requests.

Failure And Cancellation

EffectChannel exposes shutdown as typed errors through its fallible methods. The fail-fast generated trait adapters still panic if those errors reach them, so production code that needs to recover should use the lower-level fallible APIs.

If the app future waiting on an effect is dropped, the pending request remains visible to the handler, but answering it returns ChannelError::ResponseReceiverDropped:

#![allow(unused)]
fn main() {
let pending = handler.next().await.unwrap();
drop(update);

assert_eq!(
    pending.respond(output).unwrap_err(),
    ChannelError::ResponseReceiverDropped,
);
}

If a PendingEffect is dropped without a response, the app-side try_recv(...).await returns ChannelError::ResponseSenderDropped. If every handler receiver is dropped, app-side send returns ChannelError::RequestReceiverDropped. If every app-side sender is dropped, handler-side receive returns ChannelError::HandlerQueueClosed.

The Elm Architecture

The plain app can be wrapped in a TEA-style boundary when you want one inspectable protocol for effects.

The app still receives events and exposes a view:

#![allow(unused)]
fn main() {
pub async fn update(&self, event: Event) {
    self.app.update(event).await;
}

pub fn view(&self) -> ViewModel {
    self.app.view()
}
}

The difference is how effects are provided. Instead of passing separate capability implementations, the TEA wrapper receives one root effect handler:

#![allow(unused)]
fn main() {
pub type AppEffectHandler = dyn EffectHandler<AppEffect> + Send + Sync;
}

POD Effects and Outputs

The protocol is made from plain data enums:

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

#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum CounterEffect {
    Random(RandomEffect),
    Render(RenderEffect),
}

#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum AppEffect {
    Account(AccountEffect),
    Counter(CounterEffect),
}
}

Note that RenderEffect and RandomEffect is generated automatically from the trait marked with krucyfiks::effect attribute macro.

*Effect takes a shape of all parameters of the method - one variant per method. while *EffectOutput takes a shape of returning type of each method. Also one variant per method.

This gives a boundary one value to log, inspect, serialize, or pass across FFI: AppEffect.

Example:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Random: Send + Sync {
    async fn get_number(&self) -> i64;
}

#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Logger: Send + Sync {
    async fn log(&self, message: String);
}
}

generates this:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub enum RandomEffect {
    GetNumber,
}
#[derive(Debug, Clone, PartialEq)]
pub enum RandomEffectOutput {
    GetNumberDone(i64),
}
impl ::krucyfiks::Effect for RandomEffect {
    type Output = RandomEffectOutput;
}

#[derive(Debug, Clone, PartialEq)]
pub enum LoggerEffect {
    Log { message: String },
}
#[derive(Debug, Clone, PartialEq)]
pub enum LoggerEffectOutput {
    LogDone,
}
impl ::krucyfiks::Effect for LoggerEffect {
    type Output = LoggerEffectOutput;
}
}

and some additional helper macros.

Scoped Handlers

The inner app does not want an AppEffectHandler. It wants the small traits it was written against:

#![allow(unused)]
fn main() {
let account_effects = Arc::new(AppEffect::account_scope(handler.clone()));
let counter_effects = Arc::new(AppEffect::counter_scope(handler));

let random: Arc<dyn Random> = counter_effects.clone();
let account_render: Arc<dyn Render> = account_effects;
let counter_render: Arc<dyn Render> = counter_effects;
}

Scopes say which branch a leaf effect belongs to. This matters when multiple branches contain the same leaf effect. Account render and counter render are both RenderEffect, but they produce different AppEffectOutput branches.

The generated scope adapter injects a leaf request through the chosen branch, awaits the root output, and projects the matching leaf output back out. If the wrong output branch comes back, it panics because the handler violated the protocol contract.

Why POD Effects Matter

POD effects are useful even when the app itself stays trait-oriented:

  • logs can include exact effect requests and outputs;
  • time-travel tools can record events, effects, and responses;
  • replay tools can answer effects from a saved transcript;
  • FFI hosts can switch on enums instead of understanding Rust trait objects;
  • tests can assert which branch produced an effect.

This keeps the boundary inspectable without forcing the domain model to become a protocol interpreter.

TEA Test Doubles

In TEA mode, the app boundary sees one AppEffect -> AppEffectOutput handler. That handler can be a real runtime, a foreign host, or a test double.

For many tests, EffectChannel<AppEffect> is enough:

#![allow(unused)]
fn main() {
let (effects, handler) = EffectChannel::unbounded();
let app = TeaApp::new(effects);

let mut update = Box::pin(app.update(Event::Account(account::Event::StartLogin {
    email: "demo@example.com".to_owned(),
    password: "password".to_owned(),
})));

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

The generated handler helpers let the test answer a leaf capability even though the channel carries the composed root protocol.

Why Use Test Doubles Here?

TEA effects are plain data, so it can be tempting to write a broad fake handler that returns outputs immediately. That is useful for simple tests, but it hides timing.

Channel-backed doubles let the test pause at each effect boundary:

  • assert the view before an effect is answered;
  • answer Random but leave Render pending;
  • return different values for repeated requests;
  • verify that account effects and counter effects use different branches.

That makes these doubles better than ordinary mocks for async app logic. They model the boundary and the suspended computation, not only a method call count.

Native Effects Can Stay Native

Not every capability has to enter the TEA protocol.

The example TEA wrapper keeps Logger native in one constructor:

#![allow(unused)]
fn main() {
pub fn new_with_logger(handler: Arc<AppEffectHandler>, logger: Arc<dyn Logger>) -> Self
}

That is useful when logging should be handled locally while UI-visible effects go through the protocol. krucyfiks does not require an all-or-nothing choice.

Ratatui

The Ratatui integration is a host loop around the TEA app. It dispatches events into a worker, listens for render requests, and decides when to read view().

At startup, create a direct effect handler and pass it to the TEA wrapper:

#![allow(unused)]
fn main() {
let render = RenderSignal::new();
let counter = CounterEffect::handler()
    .with_render(render.clone())
    .with_random(RandomHandler)
    .build()?;
let account = AccountEffect::handler()
    .with_render(render.clone())
    .build()?;
let effects = AppEffect::handler()
    .with_account(account)
    .with_counter(counter)
    .build()?;
let effect_handler = HookedEffectHandler::new(effects, TracingSpanHook);
let app = Arc::new(app_tea::TeaApp::new(Arc::new(effect_handler)));
}

The terminal loop does not call block_on(update). It keeps the current view, redraws when the render signal is dirty, and dispatches input events without waiting for the update to finish:

#![allow(unused)]
fn main() {
let dispatcher = Dispatcher::unbounded(&runtime, Arc::clone(&app), TracingEventHook);
let mut view = app.view();

loop {
    if render.take_dirty() {
        view = app.view();
        terminal.draw(|frame| ui::draw(frame, view.clone()))?;
    }

    if event::poll(Duration::from_millis(200))? {
        let TerminalEvent::Key(key) = event::read()? else {
            continue;
        };

        match input::map_key(key.code, &view) {
            Input::Quit => break,
            Input::Update(event) => dispatcher.dispatch(event),
            Input::Ignore => {}
        }
    }
}
}

dispatch sends the event over a flume channel to one async worker. The worker preserves event ordering by awaiting each update before receiving the next event:

#![allow(unused)]
fn main() {
while let Ok(event) = rx.recv_async().await {
    hook
        .process(event, |event| {
            let app = Arc::clone(&app);
            async move {
                app.update(event).await;
            }
        })
        .await;
}
}

This is a deliberate queue. If the user quickly presses r, +, and -, the random update starts first and the later counter events wait behind it. The terminal still accepts input, but app state changes remain serialized.

That is not the only valid choice. An app could spawn every update independently, and then + and - might finish before a slow random effect. The user could see the counter move to 1, then 0, then finally 7 when random completes. That may be right for some applications, but it means input order and state update order can diverge. The TUI examples choose serialization because it keeps the app state machine predictable.

A terminal can keep handlers small. RandomHandler implements the original Random capability, and the generated builder adapts it into the composed AppEffect protocol:

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
impl Random for RandomHandler {
    async fn get_number(&self) -> i64 {
        tokio::time::sleep(Duration::from_secs(5)).await;
        7
    }
}
}

The five-second delay is intentional. Pressing random should not freeze terminal input or redraw scheduling; it should only leave that app update pending in the worker.

You may think - but hey why do we need to jump thru those hoops if we go back to the square one? We do Random trait -> AppEffect -> Random trait all for nothing? And the honest question is - to some degree you can think of it - yes.

But that’s also the point of krucyfiks. If you don’t need TEA. Then… You are free to skip it! See how we have app_tea_tui but also app_native_tui? You can pick your tool based on a job you need to do.

RenderSignal wires RenderEffect through the normal with_render(...) builder method, but it does not draw directly. It marks the view dirty. The terminal loop owns actual drawing through terminal.draw(...), so it can coalesce or debounce frequent render requests and call view() separately.

Tracing hooks are optional. They wrap the boundary so effects and events can be observed, but it is not responsible for composing the app.

The next chapter adds one more layer: terminal-local TEA state for editable forms, focus, and render invalidation that is separate from the domain app.

Layered TUI TEA

The domain app should not know that a terminal has focus, tabs, backspace, or a cursor. Those are host concerns. A browser input can manage focus by itself, an iOS text field has its own editing model, and a Ratatui form needs terminal state that does not belong in app::Event.

The TUI example adds a second TEA layer above the domain app:

#![allow(unused)]
fn main() {
pub enum UiEvent {
    FocusNext,
    FocusPrevious,
    EditFocused(Edit),
    Submit,
    Logout,
    IncreaseCounter,
    DecreaseCounter,
    RandomCounter,
}
}

Raw crossterm keys are translated into these normalized TUI events. The TUI app owns draft form text and focus in screen-specific modules:

#![allow(unused)]
fn main() {
struct LoginForm {
    email: String,
    password: String,
    focused: LoginField,
}
}

Counter keys are also TUI events first. input.rs does not construct app::Event; the TUI app decides how a terminal command maps to the domain.

Submitting is the boundary between UI interaction and domain intent. On the login screen, UiEvent::Submit becomes:

#![allow(unused)]
fn main() {
app::Event::Account(account::Event::StartLogin {
    email,
    password,
})
}

On the second-factor screen, it becomes:

#![allow(unused)]
fn main() {
app::Event::Account(account::Event::SubmitSecondFactor { code })
}

Focus changes and text edits do not touch the domain app. They only update the TUI model and request a TUI render.

Composition Direction

There are two valid ways to compose this example.

For simple hosts, compose the domain first:

account::App + counter::App -> app::App -> host adapter

For richer UI hosts, compose around screens:

AuthScreen(account::App + auth UI state)
CounterScreen(counter::App + counter UI state)
-> TuiApp

This chapter keeps app::App as the domain composition root. That means AuthScreen does not own account::App; it receives account::ViewModel from the domain root and translates local UI events into account::Event.

That is slightly awkward, but intentional. It keeps domain composition logic, including the “counter requires login” guard, in app::App. FFI and TUI hosts therefore share the same domain behavior.

A larger Ratatui app might choose the other direction. AuthScreen could own account::App, CounterScreen could own counter::App, and TuiApp could compose screens directly. That gives each screen a cleaner update/view API, but the root must also own cross-screen domain policy such as login guards.

As a rule of thumb: if the host mostly forwards domain events, compose the domain first. If the host has rich screen-local state and workflows, composing by screen may be more natural.

Again, it all depends on application - krucyfiks does not enforce one way as the only one available. Pragmatism over dogma.

Render Translation

The TUI layer has its own render effect:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait TuiRender: Send + Sync {
    async fn render(&self);
}
}

The composed TUI protocol contains both local TUI effects and domain effects:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum TuiEffect {
    Render(TuiRenderEffect),
}

#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum TuiAppEffect {
    Tui(TuiEffect),
    Domain(AppEffect),
}
}

The runtime builds one composed TUI handler with the same generated builder API used by app_tea:

#![allow(unused)]
fn main() {
let tui = TuiEffect::handler()
    .with_render(render.clone())
    .build()?;
let account = AccountEffect::handler()
    .with_render(render.clone())
    .build()?;
let counter = CounterEffect::handler()
    .with_render(render.clone())
    .with_random(RandomHandler)
    .build()?;
let domain_effects = AppEffect::handler()
    .with_account(account)
    .with_counter(counter)
    .build()?;
let root = TuiAppEffect::handler()
    .with_tui(tui)
    .with_domain(domain_effects)
    .build()?;
}

The nested domain app still expects an EffectHandler<AppEffect>, while the TUI app wants a local TuiRender. Generated branch and scope adapters hide both casts:

#![allow(unused)]
fn main() {
let root = Arc::new(root);
let domain = TeaApp::new(Arc::new(TuiAppEffect::domain_handler(root.clone())));
let tui_render: Arc<dyn TuiRender> = Arc::new(TuiAppEffect::tui_scope(root));
}

RenderSignal implements both app::Render and TuiRender, so account render, counter render, and local TUI render all mark the same dirty flag without a hand-written root router. That keeps the meanings separate:

  • domain render means the domain view changed;
  • TUI render means the composed terminal view changed;
  • the terminal host still decides when to call view() and draw.

This is the same idea as the dispatch queue in the Ratatui chapter. Effects are not just forwarded outward. An adapter can translate, batch, suppress, or enrich effects before they reach the host.

Native Contrast

app_native_tui uses the same real form fields, focus, and submit translation, but it coordinates them manually next to the direct app calls. That is a valid choice for small programs. The TEA TUI version gives the terminal concerns a named event/model/effect boundary, which becomes easier to reason about as the form grows.

UniFFI

The UniFFI layer is a boundary adapter.

Rust app types do not have to be FFI-safe. The FFI crate defines explicit foreign-facing event, view, effect, and output enums, then converts between those enums and the Rust protocol.

Events and Views

Events are plain UniFFI enums:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, uniffi::Enum)]
pub enum AccountFfiEvent {
    StartLogin { email: String, password: String },
    SubmitSecondFactor { code: String },
    Logout,
}

#[derive(Debug, Clone, uniffi::Enum)]
pub enum FfiEvent {
    Account(AccountFfiEvent),
    Counter(CounterFfiEvent),
}
}

Views are also plain UniFFI records and enums:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, uniffi::Enum)]
pub enum FfiViewModel {
    LoggedOut {
        account: AccountFfiViewModel,
    },
    LoggedIn {
        account: AccountFfiViewModel,
        counter: CounterFfiViewModel,
    },
}
}

The exported object keeps the same app shape:

#![allow(unused)]
fn main() {
#[uniffi::export]
impl TeaApp {
    #[uniffi::constructor]
    pub fn new(handler: Arc<dyn FfiEffectHandler>) -> Arc<Self> {
        Arc::new(Self {
            app: app_tea::TeaApp::new(Arc::new(ForeignEffectHandler { inner: handler })),
        })
    }

    pub async fn update(&self, event: FfiEvent) {
        self.app.update(event.into()).await;
    }

    pub fn view(&self) -> FfiViewModel {
        self.app.view().into()
    }
}
}

Foreign Effect Handler

The foreign host implements one async handler:

#![allow(unused)]
fn main() {
#[uniffi::export(with_foreign)]
#[async_trait::async_trait]
pub trait FfiEffectHandler: Send + Sync {
    async fn handle(&self, effect: FfiEffect) -> FfiEffectOutput;
}
}

The adapter converts Rust AppEffect into FfiEffect, awaits the host, and converts FfiEffectOutput back into AppEffectOutput:

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
impl EffectHandler<AppEffect> for ForeignEffectHandler {
    async fn handle(&self, effect: AppEffect) -> AppEffectOutput {
        self.inner.handle(effect.into()).await.into()
    }
}
}

This is a direct request/response boundary. The host does not poll for pending effects and does not send a later response tagged with an effect ID. The awaited return value is the response for that effect call.

Explicit FFI Protocols

The FFI protocol is intentionally explicit:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, uniffi::Enum)]
pub enum FfiEffect {
    Account(AccountFfiEffect),
    Counter(CounterFfiEffect),
}

#[derive(Debug, Clone, uniffi::Enum)]
pub enum FfiEffectOutput {
    Account(AccountFfiEffectOutput),
    Counter(CounterFfiEffectOutput),
}
}

That costs a small amount of conversion code, but it keeps the generated foreign API reviewable and stable. The Rust app model is not forced to become whatever a type generator can infer.

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.

Background Tasks

Background work should enter the app the same way user input does: by dispatching typed events. The background worker may run on another task, sleep, poll a server, watch a database, or receive socket input, but it should not directly mutate the foreground app state.

The weather example uses a periodic refresh to demonstrate the pattern. It has two app-like components:

weather_app::App
  handles Event and owns business state

weather_app::background::BackgroundApp
  handles BackgroundEvent and owns refresh scheduling state

Both components have ordinary async update methods. The host owns queues, timers, and task spawning.

Background Events

The background app has its own event type:

#![allow(unused)]
fn main() {
pub enum BackgroundEvent {
    Started,
    RefreshTick { generation: u64 },
    Resume,
    Suspend,
    TargetChanged(RefreshTarget),
    Stopped,
}
}

Resume and Suspend describe the background worker itself. They are not named after the UI lifecycle. The foreground app still has Event::Foregrounded and Event::Backgrounded, but it translates those into background intent.

The refresh target is also background-local state:

#![allow(unused)]
fn main() {
pub enum RefreshTarget {
    CurrentLocation,
    ManualLocation(String),
    Disabled,
}
}

That keeps periodic refresh behavior explicit and testable. A manual search can change the future refresh target without directly touching the background app.

Capabilities

The background module defines two narrow capabilities:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait AppEventSink: Send + Sync {
    async fn dispatch(&self, event: Event);
}

#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait BackgroundEventSink: Send + Sync {
    async fn dispatch(&self, event: BackgroundEvent);
    async fn dispatch_after(&self, delay: Duration, event: BackgroundEvent);
}
}

AppEventSink lets the background app request a foreground app event. In the weather example, a tick dispatches either Event::Refresh or Event::Search { location }.

BackgroundEventSink lets any app request background events. The foreground app uses immediate dispatch to change the target or suspend/resume work. The background app uses dispatch_after to schedule its next tick.

The return value is only an acknowledgement that the host accepted the request. It is not a promise that the event has already been handled.

The Foreground App Owns Policy

The weather foreground app owns the policy that turns business events into background intent:

#![allow(unused)]
fn main() {
Event::Search { location } => {
    let target = location.trim().to_owned();
    if !target.is_empty() {
        background_events
            .dispatch(BackgroundEvent::TargetChanged(
                RefreshTarget::ManualLocation(target),
            ))
            .await;
    }
    self.search(location).await;
}
}

Other events follow the same rule:

  • Event::Refresh selects RefreshTarget::CurrentLocation;
  • Event::Clear selects RefreshTarget::Disabled;
  • Event::Backgrounded dispatches BackgroundEvent::Suspend;
  • Event::Foregrounded dispatches BackgroundEvent::Resume;
  • Event::Opened does not need a target change because the default target is current location.

This is domain behavior. A TUI, FFI host, or test should not need to duplicate the rule that a manual search changes future automatic refreshes.

No Inner Loops

The background app does not run an infinite loop:

#![allow(unused)]
fn main() {
loop {
    sleep(interval).await;
    refresh().await;
}
}

Instead, it handles one event at a time:

#![allow(unused)]
fn main() {
BackgroundEvent::RefreshTick { generation } => {
    if let Some(target) = self.current_target_for_tick(generation) {
        self.dispatch_target(target).await;
        self.schedule_tick(generation).await;
    }
}
}

Intervals are represented as self-scheduling one-shot events. The host owns sleeping. The background app owns the meaning of a tick.

This shape is easier to test because tests can inject RefreshTick directly. They do not wait for real time to pass.

Stale Ticks

The weather background app uses an active flag and a generation counter. Every start, resume, suspend, or stop increments the generation. A scheduled tick contains the generation it was created with:

#![allow(unused)]
fn main() {
RefreshTick { generation }
}

When an old tick arrives after suspend/resume, it is ignored because its generation no longer matches. This is a small cancellation mechanism without task handles.

This plumbing is not business logic. It could eventually move into a helper crate, but keeping it in the example makes the pattern visible.

Host Runtime

The host wires queues and dispatchers. In the TUI example the host has:

EventQueue<UiEvent>
EventQueue<weather_app::Event>
EventQueue<BackgroundEvent>

The foreground dispatcher serializes local UI events and domain app events. The background dispatcher serializes background events:

ui queue          -> TuiApp::update(UiEvent)
app queue         -> TuiApp::update_domain(weather_app::Event)
background queue  -> BackgroundApp::update(BackgroundEvent)

Delayed scheduling is a host adapter over the background queue:

#![allow(unused)]
fn main() {
async fn dispatch_after(&self, delay: Duration, event: BackgroundEvent) {
    let queue = self.queue.clone();
    self.handle.spawn(async move {
        tokio::time::sleep(delay).await;
        queue.dispatch(event);
    });
}
}

A production host might use priority queues, cancellation handles, bounded channels, or a work-stealing executor. The app does not depend on those choices.

Testing

Tests should not sleep. They should inject events and record requested effects.

The background app tests drive BackgroundEvent directly:

#![allow(unused)]
fn main() {
app.update(BackgroundEvent::Started).await;
app.update(BackgroundEvent::RefreshTick { generation: 1 }).await;

assert_eq!(*app_events.events.lock(), vec![Event::Refresh]);
}

Foreground app tests record background requests:

#![allow(unused)]
fn main() {
app.update(Event::Search {
    location: "  Wroclaw  ".to_owned(),
})
.await;

assert_eq!(
    *background.events.lock(),
    vec![BackgroundEvent::TargetChanged(
        RefreshTarget::ManualLocation("Wroclaw".to_owned()),
    )]
);
}

This gives deterministic tests for both directions:

  • foreground app -> background event requests;
  • background app -> foreground event requests;
  • background app -> delayed self-scheduling requests.

The executor, timer, and queue implementation can be tested separately.

Same Rule For Other Inputs

This pattern is not only for timers. Database watchers, websocket clients, job queue consumers, and HTTP socket listeners can all use the same rule:

external activity -> typed event -> serialized update

If the external activity has its own state, model it as a small background app. If it is just an adapter, let the host enqueue a foreground event directly.

The important boundary stays the same: app state changes only by handling typed events, and time/concurrency remain host concerns.

Generic Effects

Generics are useful when a capability belongs in a shared utility crate, but the application chooses the concrete type. For example, a utility crate might expose Foo<T>, while one app uses Foo<Value> everywhere.

krucyfiks supports this pattern for type parameters. The generated protocol keeps request and output generics separate where it can, so output-only generic capabilities do not force generic request enums.

Output-Only Generics

If a generic parameter appears only in method outputs, the generated request enum does not need that parameter:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub struct Value(String);

#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Foo<T = Value>: Send + Sync {
    async fn get(&self) -> T;
}
}

This generates a non-generic request enum and a generic output enum:

#![allow(unused)]
fn main() {
pub enum FooEffect {
    Get,
}

pub enum FooEffectOutput<T = Value> {
    GetDone(T),
}
}

The request is plain data. The output carries the type parameter because that is where T is actually used.

Composing Output-Only Generics

A composed effect can also keep the request enum non-generic:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
#[krucyfiks(output_param(T = Value))]
pub enum AppEffect {
    #[krucyfiks(ctx = T)]
    Foo(FooEffect),
}
}

output_param(T = Value) declares AppEffectOutput<T = Value>.

ctx = T tells the derive macro that this branch uses FooEffect as Effect<T>, not Effect<()>. That matters because FooEffect is not generic, but its output is selected through the Effect<Ctx> context parameter:

#![allow(unused)]
fn main() {
impl<T> krucyfiks::Effect<T> for FooEffect {
    type Output = FooEffectOutput<T>;
}
}

Without ctx = T, the composed output would try to use the child effect with the default context ().

Request Generics

If a type parameter appears in a method argument, the request enum must be generic:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Cache<T = Value>: Send + Sync {
    async fn put(&self, value: T);
}
}

This generates:

#![allow(unused)]
fn main() {
pub enum CacheEffect<T = Value> {
    Put { value: T },
}

pub enum CacheEffectOutput {
    PutDone,
}
}

When composing this effect, the app enum must also carry the request type if it stores CacheEffect<T>:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum AppEffect<T = Value> {
    #[krucyfiks(ctx = T)]
    Cache(CacheEffect<T>),
}
}

This is the shape where the core app can stay generic and a host crate, such as a TUI, locks in the concrete type.

Request And Output Generics

A capability can use generic parameters in both directions:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Store<T, U>: Send + Sync {
    async fn put(&self, value: T);
    async fn get(&self) -> U;
}
}

The request enum uses T; the output enum uses U. A composed app can carry the request generic and declare the output generic:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
#[krucyfiks(output_param(U))]
pub enum AppEffect<T> {
    #[krucyfiks(ctx = U)]
    Store(StoreEffect<T>),
}
}

Use a tuple context when the child effect uses more than one output context parameter:

#![allow(unused)]
fn main() {
#[krucyfiks(ctx = (T, U))]
Foo(FooEffect<T, U>)
}

Bounds And Defaults

Type parameters may have bounds and defaults:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Foo<T: Clone = Value>: Send + Sync {
    async fn get(&self) -> T;
}
}

The same applies to composed enums:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum AppEffect<T: Clone = Value> {
    #[krucyfiks(ctx = T)]
    Foo(FooEffect<T>),
}
}

For output-only parameters, use output_param:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
#[krucyfiks(output_param(T: Clone = Value))]
pub enum AppEffect {
    #[krucyfiks(ctx = T)]
    Foo(FooEffect),
}
}

Multiple output parameters can be written as separate options or grouped:

#![allow(unused)]
fn main() {
#[krucyfiks(output_param(T: Clone), output_param(U: std::fmt::Debug))]
#[krucyfiks(output_param(T: Clone, U: std::fmt::Debug))]
}

Both forms mean the same thing. Duplicate names are rejected; combine bounds for one parameter in one declaration:

#![allow(unused)]
fn main() {
#[krucyfiks(output_param(T: Clone + std::fmt::Debug))]
}

Current Limits

krucyfiks still keeps the generic surface intentionally narrow:

  • only type parameters are supported;
  • lifetimes are rejected;
  • const generics are rejected;
  • generic methods are rejected;
  • associated types and associated constants are rejected;
  • method where clauses are rejected;
  • trait where clauses must constrain type parameters;
  • composed variants must be single-field tuple variants with plain child effect paths;
  • UniFFI may reject generic protocols even when core Rust code accepts them.

These limits keep generated requests and outputs owned, inspectable, and usable across async and host boundaries.

Crux Bridge

krucyfiks apps can run inside a Crux App without moving business logic into Crux. The bridge is useful when an existing shell or organization already uses Crux, but new domain code wants to stay in ordinary async Rust with krucyfiks capabilities.

The counter example has a small proof of concept in counter_app_crux. It keeps the business logic in counter_app, reuses the composed protocol from counter_app_tea, and only adds a Crux-facing adapter crate.

Two Different Effect Types

The most important detail is that Crux has two effect-shaped values:

#![allow(unused)]
fn main() {
AppEffect
}

is the krucyfiks protocol. It is the operation payload sent to the shell. It implements:

#![allow(unused)]
fn main() {
krucyfiks::Effect
crux_core::capability::Operation<Output = AppEffectOutput>
}

The Crux app still needs a shell effect type:

#![allow(unused)]
fn main() {
#[crux_core::macros::effect]
pub enum Effect {
    App(AppEffect),
}
}

After Crux expands this enum, the actual variant stores a crux_core::Request<AppEffect>, not a plain AppEffect. That request contains the resolve handle the shell later passes to Core::resolve.

This is why the wrapper enum is not just ceremony. AppEffect is the operation data. Effect is the Crux shell envelope that remembers how to resume the suspended command.

A single-operation app could write an equivalent newtype manually, but the Crux macro is the conventional spelling and also gives the usual Crux test helpers.

Opting In

Add the crux option to the composed krucyfiks protocol:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
#[krucyfiks(crux)]
pub enum AppEffect {
    Account(AccountEffect),
    Counter(CounterEffect),
}
}

This generates the Crux operation impl:

#![allow(unused)]
fn main() {
impl crux_core::capability::Operation for AppEffect {
    type Output = AppEffectOutput;
}
}

The crate using this option must depend on krucyfiks_crux. The core krucyfiks crate does not depend on Crux.

If Crux support should be optional, gate the attribute the same way you would gate any other derive-helper attribute:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
#[cfg_attr(feature = "crux", krucyfiks(crux))]
pub enum AppEffect {
    Account(AccountEffect),
    Counter(CounterEffect),
}
}

Then make krucyfiks_crux an optional dependency and expose a matching feature:

[features]
crux = ["dep:krucyfiks_crux"]

[dependencies]
krucyfiks_crux = { path = "../../krucyfiks_crux", optional = true }

With the feature enabled, AppEffect implements Crux Operation. Without it, the protocol remains a plain krucyfiks protocol with no Crux dependency.

#[krucyfiks(crux)] works when the output type is determined by the effect type itself. Output-only generic contexts are rejected because Crux Operation::Output cannot depend on type parameters that do not appear in the operation type.

The Crux Model

The bridge model owns the krucyfiks app and the channel that receives pending effects:

#![allow(unused)]
fn main() {
pub struct Model {
    app: Arc<TeaApp>,
    effects: EffectChannelHandler<AppEffect>,
}
}

The default model wires the app with an EffectChannel:

#![allow(unused)]
fn main() {
let (effects, handler) = EffectChannel::unbounded();

Self {
    app: Arc::new(TeaApp::new(effects)),
    effects: handler,
}
}

The Crux model is mostly an adapter. The domain state still lives inside the krucyfiks app.

Updating

Crux update is synchronous, but it returns a Command. The command is where the async krucyfiks update runs:

#![allow(unused)]
fn main() {
fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
    let app = model.app.clone();
    let effects = model.effects.clone();

    Command::drive_krucyfiks(async move { app.update(event).await }, effects)
}
}

Command::drive_krucyfiks comes from krucyfiks_crux::KrucyfiksCommandExt. It runs two things together:

  • the krucyfiks update future;
  • the EffectChannelHandler waiting for pending krucyfiks effects.

Whenever the app awaits a capability call, the helper forwards that pending request to Crux:

#![allow(unused)]
fn main() {
ctx.request_from_shell(pending.request.clone()).await
}

The Crux shell resolves the request with AppEffectOutput. The helper then responds to the original PendingEffect, which lets the krucyfiks update continue.

No block_on, manual polling, or synthetic PollMe effect is needed. Crux already knows how to drive async commands.

Viewing

The Crux view simply delegates:

#![allow(unused)]
fn main() {
fn view(&self, model: &Model) -> ViewModel {
    model.app.view()
}
}

This means Crux shells can keep their normal Core::view() workflow while the domain app remains a krucyfiks app.

Testing

The Crux tests handle the same protocol a normal krucyfiks boundary would see. For example, starting login emits account render:

#![allow(unused)]
fn main() {
let effects = core.process_event(start_login());

assert!(matches!(
    effects.as_slice(),
    [Effect::App(request)]
        if matches!(
            request.operation,
            AppEffect::Account(AccountEffect::Render(RenderEffect::Render))
        )
));
}

Resolving the Crux request resumes the krucyfiks update:

#![allow(unused)]
fn main() {
match effect {
    Effect::App(mut request) => {
        core.resolve(&mut request, account_render_done()).unwrap();
    }
}
}

The random counter flow demonstrates a longer sequence: Crux receives Counter(Random(GetNumber)), resolves it with 42, then receives Counter(Render).

What This Bridge Does Not Do

The bridge does not make krucyfiks depend on Crux. It also does not require Crux middleware, generated typegen, or changes to the domain app.

The boundary remains explicit:

  • counter_app owns business logic;
  • counter_app_tea owns the krucyfiks protocol;
  • counter_app_crux owns the Crux shell adapter.

That separation is the point. A team can keep existing Crux shells running while new or extracted domain code uses krucyfiks directly.

Advanced

These chapters describe the lower-level contracts behind the examples.

Generated API Reference

krucyfiks has two code-generation entry points:

  • #[krucyfiks::effect] turns one async capability trait into a leaf protocol.
  • #[derive(krucyfiks::Effect)] turns a single-field tuple enum into a composed protocol.

Generated item visibility follows the input item. A pub trait Random produces public generated enums and traits. A private trait produces private generated items. The only generated item that is always private is the trait-adapter struct named like RandomEffectTraitHandler<T>.

Supported Macro Input

#[krucyfiks::effect] supports async traits whose methods use &self receivers and owned request arguments. Return values may be owned values, (), tuples, or Result<T, E>.

Trait type parameters are supported, including bounds, defaults, and where clauses that constrain type parameters. The macro intentionally rejects method generics, lifetimes, associated types, associated constants, const generics, method where clauses, receivers other than &self, and borrowed request data.

Generated code refers to the runtime crate as ::krucyfiks by default. If a downstream crate renames the dependency or uses a re-exported macro, pass the runtime path explicitly:

#![allow(unused)]
fn main() {
#[kx::effect(crate = "::kx")]
#[async_trait::async_trait]
pub trait Logger: Send + Sync {
    async fn log(&self, message: String);
}

#[derive(Debug, Clone, PartialEq, kx::Effect)]
#[krucyfiks(crate = "::kx")]
pub enum AppEffect {
    Logger(LoggerEffect),
}
}

Leaf Effects

Start with a capability trait:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Random: Send + Sync {
    async fn get_number(&self, from: i32, to: i32) -> i32;
    async fn reset(&self);
}
}

The macro keeps the trait and generates the items below.

RandomEffect

Generated shape:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub enum RandomEffect {
    GetNumber { from: i32, to: i32 },
    Reset,
}
}

Use it as the request protocol at boundaries. Tests, debuggers, FFI layers, and direct handlers can inspect this enum instead of trying to call a Rust trait object.

#![allow(unused)]
fn main() {
let pending = random_handler.next().await?;

assert_eq!(
    pending.request,
    RandomEffect::GetNumber { from: 1, to: 10 },
);
}

Each async method becomes one PascalCase variant. Identifier arguments become owned struct fields. Borrowed arguments are rejected because effects may cross async, thread, FFI, or debugger boundaries.

RandomEffectOutput

Generated shape:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub enum RandomEffectOutput {
    GetNumberDone(i32),
    ResetDone,
}
}

Use it as the response protocol for RandomEffect.

#![allow(unused)]
fn main() {
pending
    .respond_async(RandomEffectOutput::GetNumberDone(42))
    .await?;
}

Return values become payloads on MethodDone variants. Methods returning () or omitting a return type get a unit-like MethodDone variant.

impl Effect for RandomEffect

Generated shape:

#![allow(unused)]
fn main() {
impl krucyfiks::Effect for RandomEffect {
    type Output = RandomEffectOutput;
}
}

This links the request enum to the output enum. Core adapters such as EffectChannel<RandomEffect>, EffectHandler<RandomEffect>, and composed protocols depend on this association.

impl Protocol and impl ProtocolLeaf

Generated shape:

#![allow(unused)]
fn main() {
impl krucyfiks::protocol::Protocol for RandomEffect {
    type Path<Leaf: krucyfiks::Effect + 'static> =
        krucyfiks::protocol::SelfPath<Leaf>;
    /* extract and complete */
}

impl<Leaf> krucyfiks::protocol::ProtocolLeaf<Leaf> for RandomEffect
where
    Leaf: krucyfiks::Effect + 'static,
{
    /* inject_leaf and project_leaf */
}
}

Use these indirectly. They are the base case that lets a composed protocol eventually extract RandomEffect from a larger AppEffect, then complete the matching RandomEffectOutput back through the same path.

#![allow(unused)]
fn main() {
handler
    .handle_get_number(async |from, to| from + to)
    .await?;
}

In that example handler may be an EffectChannelHandler<AppEffect>. The generated channel helper uses Protocol::extract::<RandomEffect> before calling the closure and Protocol::complete::<RandomEffect> after it returns.

Generated EffectSink Adapter

Use EffectSink when a test or runtime wants a capability implementation that can ignore unit-returning effects such as logging or rendering.

#![allow(unused)]
fn main() {
let logger = krucyfiks::effect_sink::EffectSink::unbounded();
logger.log("ignored in this test".to_owned()).await;
}

EffectSink can also be used with mixed capabilities. Unit-returning methods are acknowledged immediately, while value-returning methods are routed through the sink’s internal channel. Keep sink.handler() and answer those requests with the generated handle_* methods.

#![allow(unused)]
fn main() {
let random = krucyfiks::effect_sink::EffectSink::unbounded();
let random_handler = random.handler();

let mut call = Box::pin(random.get_number(20, 22));
krucyfiks::assert_pending!(&mut call);

random_handler
    .handle_get_number(async |from, to| from + to)
    .await?;
}

RandomHandler

Generated shape:

#![allow(unused)]
fn main() {
#[allow(async_fn_in_trait)]
pub trait RandomHandler {
    async fn handle_get_number(
        &self,
        f: impl AsyncFnMut(i32, i32) -> i32,
    ) -> Result<(), krucyfiks::GeneratedHandlerError>;

    async fn handle_reset(
        &self,
        f: impl AsyncFnMut(),
    ) -> Result<(), krucyfiks::GeneratedHandlerError>;
}
}

Generated impl:

#![allow(unused)]
fn main() {
impl<E> RandomHandler for krucyfiks::effect_channel::EffectChannelHandler<E>
where
    E: krucyfiks::protocol::Protocol + Send + 'static,
    E::Output: Send + 'static,
{
    /* receives, extracts, handles, completes */
}
}

Use it in tests and scripted Rust hosts when you want to handle the next queued effect by method name.

#![allow(unused)]
fn main() {
let (random, random_handler) = krucyfiks::effect_channel::EffectChannel::unbounded();

let mut call = Box::pin(random.get_number(20, 22));
krucyfiks::assert_pending!(&mut call);

random_handler
    .handle_get_number(async |from, to| from + to)
    .await?;

assert_eq!(call.await, 42);
}

GeneratedHandlerError is HandleError<ProtocolError>. It reports channel shutdown and wrong-branch protocol reads instead of panicking.

RandomRequestHandler

Generated shape:

#![allow(unused)]
fn main() {
#[allow(async_fn_in_trait)]
pub trait RandomRequestHandler {
    async fn handle_get_number(
        self,
        f: impl AsyncFnMut(i32, i32) -> i32,
    ) -> Result<RandomEffectOutput, krucyfiks::ProtocolError>;
}

impl RandomRequestHandler
    for krucyfiks::effect_request::EffectRequestHandler<RandomEffect>
{
    /* checks the request variant and builds RandomEffectOutput */
}
}

Use it when you already own one RandomEffect value and want to handle exactly one expected method without using a channel.

#![allow(unused)]
fn main() {
let output = RandomRequestHandler::handle_get_number(
    krucyfiks::effect_request::EffectRequestHandler::new(
        RandomEffect::GetNumber { from: 20, to: 22 },
    ),
    async |from, to| from + to,
)
.await?;

assert_eq!(output, RandomEffectOutput::GetNumberDone(42));
}

If the request is another variant, this returns ProtocolError::WrongBranch.

RandomEffectHandler

Generated shape:

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait RandomEffectHandler {
    async fn handle_effect(&self, effect: RandomEffect) -> RandomEffectOutput;
}
}

Use it for direct production-style handling of a leaf protocol. The handler takes plain request data and returns the matching plain output data.

#![allow(unused)]
fn main() {
struct FixedRandom;

#[async_trait::async_trait]
impl RandomEffectHandler for FixedRandom {
    async fn handle_effect(&self, effect: RandomEffect) -> RandomEffectOutput {
        match effect {
            RandomEffect::GetNumber { from, to } => {
                RandomEffectOutput::GetNumberDone(from + to)
            }
            RandomEffect::Reset => RandomEffectOutput::ResetDone,
        }
    }
}
}

The composed handler builder also accepts anything that can become an Arc<dyn RandomEffectHandler + Send + Sync>.

RandomEffectHandlerInput

Generated shape:

#![allow(unused)]
fn main() {
pub trait RandomEffectHandlerInput {
    fn into_effect_handler(
        self,
    ) -> std::sync::Arc<dyn RandomEffectHandler + Send + Sync>;
}
}

Use it indirectly through generated builders:

#![allow(unused)]
fn main() {
let counter = CounterEffect::handler()
    .with_random(FixedRandom)
    .with_render(krucyfiks::effect_sink::EffectSink::unbounded())
    .build()?;
}

The macro implements this trait for any concrete T that implements the original capability trait:

#![allow(unused)]
fn main() {
impl<T> RandomEffectHandlerInput for T
where
    T: Random + Send + Sync + 'static,
{
    /* wraps T in RandomEffectTraitHandler<T> */
}
}

This is why with_random(FixedRandom) works even when FixedRandom implements Random, not RandomEffectHandler.

RandomEffectTraitHandler<T>

Generated shape:

#![allow(unused)]
fn main() {
struct RandomEffectTraitHandler<T> {
    inner: T,
}
}

This struct is private generated glue. It adapts a concrete implementation of the original trait into RandomEffectHandler.

You should not name or construct it. Use RandomEffectHandlerInput through with_random(...) or call into_effect_handler() when a direct conversion is really needed.

Generated Handler Implementations

The macro also generates these impls:

#![allow(unused)]
fn main() {
impl RandomEffectHandler for krucyfiks::effect_channel::EffectChannel<RandomEffect> { /* ... */ }
impl RandomEffectHandler for krucyfiks::effect_sink::EffectSink<RandomEffect> { /* ... */ }
impl<T> RandomEffectHandler for std::sync::Arc<T>
where
    T: RandomEffectHandler + Send + Sync + ?Sized,
{
    /* ... */
}
impl<Handler, Scope> RandomEffectHandler
    for krucyfiks::protocol::ScopedEffectHandler<Handler, Scope>
where
    Scope: krucyfiks::protocol::EffectScope<RandomEffect> + Send + Sync,
{
    /* ... */
}
}

These impls make the common adapters interchangeable:

#![allow(unused)]
fn main() {
let channel: std::sync::Arc<dyn Random> =
    krucyfiks::effect_channel::EffectChannel::<RandomEffect>::new().0;

let scoped: std::sync::Arc<dyn Random> =
    std::sync::Arc::new(AppEffect::counter_scope(root_handler));
}

The scoped impl is fail-fast: it panics if the root handler returns an output that cannot be projected back to the requested leaf. Use ScopedEffectHandler::try_handle directly when you want a ProtocolError instead.

Blanket impl Random for T

Generated shape:

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
impl<T> Random for T
where
    T: RandomEffectHandler + Send + Sync,
{
    async fn get_number(&self, from: i32, to: i32) -> i32 {
        let out = self
            .handle_effect(RandomEffect::GetNumber { from, to })
            .await;

        match out {
            RandomEffectOutput::GetNumberDone(result) => result,
            _ => panic!("unexpected output variant"),
        }
    }
}
}

This is the adapter that lets the app keep depending on Arc<dyn Random> while tests and runtimes provide generated effect handlers.

Generic Leaf Effects

For generic traits, krucyfiks only puts type parameters on generated request or output enums that actually need them.

If a parameter appears in a request argument:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Cache<T = String>: Send + Sync {
    async fn put(&self, value: T);
}
}

the request enum is generic:

#![allow(unused)]
fn main() {
pub enum CacheEffect<T = String> {
    Put { value: T },
}
}

If a parameter appears only in an output:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Foo<T = String>: Send + Sync {
    async fn get(&self) -> T;
}
}

the request enum stays non-generic and the output enum is generic:

#![allow(unused)]
fn main() {
pub enum FooEffect {
    Get,
}

pub enum FooEffectOutput<T = String> {
    GetDone(T),
}
}

Output-only generics are represented through the runtime context parameter:

#![allow(unused)]
fn main() {
impl<T> krucyfiks::Effect<T> for FooEffect {
    type Output = FooEffectOutput<T>;
}
}

Generated public declarations preserve type bounds and defaults where Rust allows them. Generated impl and function generics strip defaults because Rust does not allow defaults in those positions.

Composed Effects

Start with a single-field tuple enum whose fields are effect types:

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

#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum AppEffect {
    Account(AccountEffect),
    Counter(CounterEffect),
}
}

The derive macro keeps the enum and generates the items below.

Composed enums may use type parameters with bounds and defaults. If a child effect needs an output-only generic parameter, declare it with #[krucyfiks(output_param(...))]:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
#[krucyfiks(output_param(T: Clone = String))]
pub enum AppEffect {
    #[krucyfiks(ctx = T)]
    Foo(FooEffect),
}
}

output_param(T) adds T to generated output-side items such as AppEffectOutput<T>, handler traits, and handler builders. ctx = T tells the derive macro to use the child branch as Effect<T>. Without ctx, child branches default to Effect<()>.

Multiple output params may be declared separately or grouped:

#![allow(unused)]
fn main() {
#[krucyfiks(output_param(T), output_param(U))]
#[krucyfiks(output_param(T: Clone, U: std::fmt::Debug))]
}

AppEffectOutput

Generated shape:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub enum AppEffectOutput {
    Account(<AccountEffect as krucyfiks::Effect>::Output),
    Counter(<CounterEffect as krucyfiks::Effect>::Output),
}
}

Use it as the response protocol for AppEffect.

#![allow(unused)]
fn main() {
let output = AppEffectOutput::Counter(
    CounterEffectOutput::Random(RandomEffectOutput::GetNumberDone(7)),
);
}

Each output branch mirrors the request branch. A handler for AppEffect::Counter(...) must return AppEffectOutput::Counter(...).

AppEffectPath<Leaf>

Generated shape:

#![allow(unused)]
fn main() {
pub enum AppEffectPath<Leaf>
where
    Leaf: krucyfiks::Effect + 'static,
{
    Account(<AccountEffect as krucyfiks::protocol::Protocol>::Path<Leaf>),
    Counter(<CounterEffect as krucyfiks::protocol::Protocol>::Path<Leaf>),
}
}

Use it indirectly. It is the receipt returned by Protocol::extract and later consumed by Protocol::complete.

#![allow(unused)]
fn main() {
let (leaf, path) =
    <AppEffect as krucyfiks::protocol::Protocol>::extract::<RandomEffect>(effect)?;

let output =
    <AppEffect as krucyfiks::protocol::Protocol>::complete::<RandomEffect>(
        path,
        RandomEffectOutput::GetNumberDone(7),
    );
}

This path is what keeps duplicate leaf effects sound. If both account and counter contain RenderEffect, the path records which branch produced this particular render request.

AppEffectAccountScope

Generated shape for each branch:

#![allow(unused)]
fn main() {
pub struct AppEffectAccountScope;

impl<Leaf> krucyfiks::protocol::EffectScope<Leaf> for AppEffectAccountScope
where
    Leaf: krucyfiks::Effect + 'static,
    AccountEffect: krucyfiks::protocol::ProtocolLeaf<Leaf>,
{
    type Root = AppEffect;
    /* inject and project */
}

impl krucyfiks::protocol::EffectBranch<AccountEffect> for AppEffectAccountScope {
    type Root = AppEffect;
    /* inject_child and project_child */
}
}

Use the generated constructor instead of naming the scope directly:

#![allow(unused)]
fn main() {
let account_effects = std::sync::Arc::new(AppEffect::account_scope(root.clone()));
let account_render: std::sync::Arc<dyn Render> = account_effects;
}

The scope means “route leaf requests through AppEffect::Account.” It matters when the same leaf appears in more than one branch.

Name the struct only in advanced type signatures or when building your own adapter around ScopedEffectHandler or BranchEffectHandler.

impl Effect for AppEffect

Generated shape:

#![allow(unused)]
fn main() {
impl krucyfiks::Effect for AppEffect {
    type Output = AppEffectOutput;
}
}

This makes the composed enum usable anywhere an effect protocol is expected: channels, direct handlers, hooks, and parent composed protocols.

impl Protocol for AppEffect

Generated shape:

#![allow(unused)]
fn main() {
impl krucyfiks::protocol::Protocol for AppEffect {
    type Path<Leaf: krucyfiks::Effect + 'static> = AppEffectPath<Leaf>;

    fn extract<Leaf>(self) -> Result<(Leaf, Self::Path<Leaf>), Self> { /* ... */ }

    fn complete<Leaf>(path: Self::Path<Leaf>, output: Leaf::Output) -> Self::Output {
        /* ... */
    }
}
}

Use it indirectly through generated channel helpers and scoped handlers. Use it directly when writing custom host dispatch code that peels one leaf request out of a root protocol.

impl ProtocolLeaf for AppEffect

Generated shape:

#![allow(unused)]
fn main() {
impl<Leaf> krucyfiks::protocol::ProtocolLeaf<Leaf> for AppEffect
where
    Leaf: krucyfiks::Effect + 'static,
    AccountEffect: krucyfiks::protocol::ProtocolLeaf<Leaf>,
    CounterEffect: krucyfiks::protocol::ProtocolLeaf<Leaf>,
{
    /* inject_leaf and project_leaf */
}
}

Use it indirectly when a parent protocol or a scope needs to inject a leaf through nested composed enums.

#![allow(unused)]
fn main() {
let render = TuiAppEffect::domain_scope(root_handler);
render.render().await;
}

That call can inject RenderEffect through TuiAppEffect::Domain, then through the matching branch inside AppEffect.

AppEffectHandler

Generated shape:

#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait AppEffectHandler {
    async fn handle_effect(&self, effect: AppEffect) -> AppEffectOutput;
}
}

Use it for direct handling of the composed protocol, especially inside generated builders. Most production code can use the core trait krucyfiks::effect_handler::EffectHandler<AppEffect> instead.

AppEffectHandlerInput

Generated shape:

#![allow(unused)]
fn main() {
pub trait AppEffectHandlerInput {
    fn into_effect_handler(
        self,
    ) -> std::sync::Arc<dyn AppEffectHandler + Send + Sync>;
}
}

Use it indirectly through parent builders. It lets a parent branch accept any concrete handler that already implements AppEffectHandler.

AppEffectHandlerBuilder

Generated shape:

#![allow(unused)]
fn main() {
pub struct AppEffectHandlerBuilder {
    account: Option<std::sync::Arc<dyn AccountEffectHandler + Send + Sync>>,
    counter: Option<std::sync::Arc<dyn CounterEffectHandler + Send + Sync>>,
}
}

Fields are private. Use AppEffect::handler() and the generated with_* methods.

#![allow(unused)]
fn main() {
let app_effects = AppEffect::handler()
    .with_account(account_handler)
    .with_counter(counter_handler)
    .build()?;
}

This is the usual production assembly path when each branch already has its own handler.

with_account and with_counter

Generated shape:

#![allow(unused)]
fn main() {
impl AppEffectHandlerBuilder {
    pub fn with_account<Handler>(mut self, handler: Handler) -> Self
    where
        Handler: AccountEffectHandlerInput,
    {
        /* stores Arc<dyn AccountEffectHandler + Send + Sync> */
    }
}
}

Use one with_* call for every branch before build().

#![allow(unused)]
fn main() {
let counter = CounterEffect::handler()
    .with_random(FixedRandom)
    .with_render(krucyfiks::effect_sink::EffectSink::unbounded())
    .build()?;
}

Missing branches are reported as BuildError::MissingBranch.

AppEffectBuiltHandler

Generated shape:

#![allow(unused)]
fn main() {
pub struct AppEffectBuiltHandler {
    account: std::sync::Arc<dyn AccountEffectHandler + Send + Sync>,
    counter: std::sync::Arc<dyn CounterEffectHandler + Send + Sync>,
}

#[async_trait::async_trait]
impl AppEffectHandler for AppEffectBuiltHandler { /* ... */ }

#[async_trait::async_trait]
impl krucyfiks::effect_handler::EffectHandler<AppEffect>
    for AppEffectBuiltHandler
{
    /* ... */
}
}

Use the value returned by build() as the root effect handler:

#![allow(unused)]
fn main() {
let root = std::sync::Arc::new(app_effects);
let app = TeaApp::new(root);
}

It dispatches by request branch and wraps each child output in the matching AppEffectOutput branch.

AppEffect::handler()

Generated shape:

#![allow(unused)]
fn main() {
impl AppEffect {
    pub fn handler() -> AppEffectHandlerBuilder {
        /* empty builder */
    }
}
}

Use it to start composing branch handlers.

AppEffect::account_scope(handler)

Generated shape:

#![allow(unused)]
fn main() {
impl AppEffect {
    pub fn account_scope<Handler>(
        handler: Handler,
    ) -> krucyfiks::protocol::ScopedEffectHandler<
        Handler,
        AppEffectAccountScope,
    > {
        krucyfiks::protocol::ScopedEffectHandler::new(handler)
    }
}
}

Use it when app internals need a leaf capability trait, but the boundary owns a root handler.

#![allow(unused)]
fn main() {
let account_effects = std::sync::Arc::new(AppEffect::account_scope(root.clone()));
let account_render: std::sync::Arc<dyn Render> = account_effects;
}

The returned scoped handler can implement generated leaf capability traits such as Render because #[krucyfiks::effect] generated impl<T> Render for T where T: RenderEffectHandler + Send + Sync.

AppEffect::account_handler(handler)

Generated shape:

#![allow(unused)]
fn main() {
impl AppEffect {
    pub fn account_handler<Handler>(
        handler: Handler,
    ) -> krucyfiks::protocol::BranchEffectHandler<
        Handler,
        AppEffectAccountScope,
    > {
        krucyfiks::protocol::BranchEffectHandler::new(handler)
    }
}
}

Use it when the caller already speaks the direct child protocol.

#![allow(unused)]
fn main() {
let domain = TeaApp::new(std::sync::Arc::new(
    TuiAppEffect::domain_handler(root.clone()),
));
}

Here the nested domain app wants an EffectHandler<AppEffect>, while the outer TUI runtime has an EffectHandler<TuiAppEffect>.

Audit Notes

The currently generated surface has these public generated names:

#[effect] Trait:
  TraitEffect
  TraitEffectOutput
  TraitHandler
  TraitRequestHandler
  TraitEffectHandler
  TraitEffectHandlerInput
  TraitEffectTraitHandler<T>        private

#[derive(Effect)] Enum:
  EnumOutput
  EnumPath<Leaf>
  EnumVariantScope                 one per branch
  EnumHandler
  EnumHandlerInput
  EnumHandlerBuilder
  EnumBuiltHandler