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.