Every Binions daemon is a thin program over one shared framework, so writing a new one is mostly a matter of declaring handlers and wiring them up. The framework crate — common-microservice — provides the consumer loop, the outbox worker, idempotency, the health server, metrics, tracing, and graceful shutdown. You supply the business logic as a few pure functions. This page walks through the whole shape of a daemon.
What you write vs. what you get. You write handlers (pure functions) and a short
mainthat registers them. You get, for free: stream consumption, at-least-once delivery with effectively-once processing, transactional emits,/health+/metrics, OpenTelemetry tracing, and systemd integration.
A daemon reacts to events by implementing one or more handler traits. Each declares a strongly-typed payload, the event type it handles, and an async method. Note the verbs: an action is handled, a fact is reacted to, a control op is applied.
#[async_trait]
pub trait ActionHandler {
type Action: DeserializeOwned + JsonSchema + Send + Sync;
fn event_type() -> &'static str; // e.g. "Order.Place"
fn event_version() -> u32 { 1 }
async fn handle(&self, ctx: &HandlerContext, action: Self::Action,
envelope: &EventEnvelope<Value>) -> Result<HandlerOutput>;
}
#[async_trait]
pub trait FactHandler {
type Fact: DeserializeOwned + JsonSchema + Send + Sync;
fn event_type() -> &'static str; // e.g. "Mail.Received"
async fn react(&self, ctx: &HandlerContext, fact: Self::Fact,
envelope: &EventEnvelope<Value>) -> Result<HandlerOutput>;
}
#[async_trait]
pub trait ControlHandler {
type Control: DeserializeOwned + Send + Sync;
fn event_type() -> &'static str; // e.g. "Orders.SetLogLevel"
async fn apply(&self, ctx: &HandlerContext, control: Self::Control) -> Result<()>;
}
Handlers are pure. A handler never touches Redis. It reads from the context if needed and returns a
HandlerOutputthat describes what should change and what should be emitted. The framework applies that description and publishes the emits through the outbox. This is what makes handlers trivial to unit-test.
Every handler receives a HandlerContext, the daemon's toolbox:
ctx.state | Per-service state store (hset, hgetall, del, hincrby, exists) scoped to svc:<service>:*. |
ctx.clock | A wall-clock abstraction (real in production, mockable in tests). |
ctx.ids | A UUID v7 generator (mockable for deterministic tests). |
ctx.config | Hot-reloadable runtime config — swap it from a Control handler. |
ctx.tracing | The current correlation / trace context. |
HandlerOutput is a builder. You compose the state changes and the events to emit, and return it:
HandlerOutput::ok()
.state_set(ctx.state.keys().state("order", &a.order_id), fields)
.fact("Order.Placed", OrderPlaced { order_id: a.order_id.clone(),
total_pennies: a.total }, &a.order_id)
.action("billing-service", "Billing.Charge", Charge { order_id: a.order_id })
.log_info("order accepted", json!({ "order": a.order_id }))
It carries four lists: state changes (Set / Delete / Incr), facts to broadcast, actions to dispatch to other daemons, and logs. The framework applies state changes, then pushes every emit into the outbox before acknowledging the triggering event.
The MicroserviceBuilder assembles the runtime: point it at this daemon's Redis, declare which streams it consumes, register the handlers, and run. The builder always adds the daemon's own control:<service> consumer for you.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
common_microservice::init_tracing(OtelConfig::from_env())?;
Microservice::builder("orders-service", env!("CARGO_PKG_VERSION"))
.own_redis(env!("REDIS_URL")) // this daemon's Redis (config-driven)
.own_actions("orders") // consume actions:orders
.react_to_fact("payments-service", PaymentSettledHandler) // react to a peer's facts
.register_action(PlaceOrderHandler) // handle Action Order.Place
.register_control(SetLogLevelHandler) // handle Control ops
.build().await?
.run().await?; // runs until SIGTERM
Ok(())
}
Useful builder options: .peer_redis(service, url) (reach a peer's Redis for cross-service facts/actions), .publisher_only() (no consumers — for emit-only daemons), .healthcheck_addr(addr), and .idempotency_ttl_secs(n).
Calling .run() performs the standard boot sequence shared by all daemons:
/health/live, /health/ready, /metrics).READY=1 is signalled and a watchdog keep-alive begins.SIGTERM/SIGINT, in-flight work is drained before exit.Concurrent dispatch, per-resource ordering. The consumer tasks above describe the stream reader topology — one reader per stream. Handler invocation is a separate layer: the framework dispatches handlers concurrently via a bounded worker pool, so a slow handler (for example, one that makes a network call) does not block other incoming actions on the same daemon. Actions targeting the same resource are still serialised in arrival order; independent actions run in parallel. This design eliminates head-of-line blocking without sacrificing correctness, and your handler code does not need to think about it — the framework manages the pool and the per-resource queues for you.
Registering two handlers for the same (kind, event_type) is rejected at build time, so conflicts surface immediately rather than as silent shadowing.
You inherit the guarantees. Because emits go through the outbox and dispatch is idempotency-guarded, your daemon is crash-safe and duplicate-safe without writing any of that plumbing yourself.