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, orClock. - 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
Eventenum for inputs; - private model state;
- a
ViewModelfor 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:
EffectChannelputs a response sender inside eachPendingEffect;- 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
Randombut leaveRenderpending; - 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::RefreshselectsRefreshTarget::CurrentLocation;Event::ClearselectsRefreshTarget::Disabled;Event::BackgroundeddispatchesBackgroundEvent::Suspend;Event::ForegroundeddispatchesBackgroundEvent::Resume;Event::Openeddoes 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
whereclauses are rejected; - trait
whereclauses 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
updatefuture; - the
EffectChannelHandlerwaiting 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_appowns business logic;counter_app_teaowns the krucyfiks protocol;counter_app_cruxowns 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