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.