The bus
Coming from Go? Topics are like channels, but typed by a declaration instead of by
chan T, and many-to-many instead of point-to-point. You don’t pass a channel into a goroutine; a locus declares which topics it subscribes to and publishes, and the runtime wires the delivery. No channel plumbing threaded through constructors.
You met the bus implicitly in logging: emitters publish, sinks subscribe, neither references the other. Here you declare and use it directly.
Topics are typed declarations
A topic names a channel and the type that flows on it:
type Order { id: String; amount: Decimal; }
topic OrderPlaced { payload: Order; }
topic OrderShipped { payload: Order; }
A topic is a top-level declaration, like type or locus. It’s
referenced by name — never a magic string — so the payload type
is checked at every publish and every handler, and renaming the
topic moves every use with it.
Subscribe and publish
A locus declares its bus interface in a bus { } block:
locus Warehouse {
bus {
subscribe OrderPlaced as on_order; // inbound
publish OrderShipped; // outbound
}
fn on_order(o: Order) {
// ... pick and pack ...
OrderShipped <- o; // the send
}
}
subscribe TOPIC as HANDLER;wires inbound messages to a handler method. The handler must exist with the matching signature —fn on_order(o: Order)— and the compiler checks it.publish TOPIC;authorizes this locus to send on the topic. Without it, a send is a compile error.TOPIC <- value;is the send. It’s a statement, not an expression — it produces no value, like Erlang’sPid ! Msg.
Subscribing is declarative — there’s no subscribe() call at
runtime. Registration happens when the locus is constructed, and
unsubscribe happens automatically at dissolve.
One ordering rule
A subscriber must be born before a publisher sends, or the
message has nowhere to land. In practice: instantiate your
subscribers first in main. (This is the same rule you saw with
the log sink.)
Why this doesn’t break the tower
In the parent/child model, flow is strictly vertical — a locus only talks up to its parent and down to its children. The bus seems to let unrelated loci talk sideways. It doesn’t, really: publishers and subscribers don’t see each other, they see the topic, which lives at the runtime root — structurally above everyone. Every send goes up to the bus; every delivery comes down to a subscriber. It’s vertical flow through a shared root, which is why two loci on opposite branches of a deep tree can coordinate with no shared pointer and no registry lookup.
This is the productive shape for events: many-to-many flow without back-channels. A topic can have any number of publishers and subscribers.
You won’t always pay for it
If a topic is only ever used inside a single locus type — the same locus both publishes and subscribes, with no external binding — the compiler can prove every send routes back to a handler on the same instance, and rewrites the send into a direct method call. The bus is elided entirely. So you can use topics freely for a locus’s own internal event flow without paying dispatch cost; if the topic later grows a second subscriber or a deployment binding, the real bus path comes back automatically, and your code doesn’t change.
As of v0.9.0 the static-dispatch devirtualization is broader than that intra-locus-type case: any quiet, flat-payload, same-thread handler on a closed-world local subject lowers to a direct synchronous call — even when the publisher and subscriber are distinct locus types.
Routing keys: one topic, sharded by a field
By default every subscriber to a topic sees every message. When you have many subscribers that each care about one slice of the traffic — one connection, one symbol, one tenant — fanning every message to all of them and filtering in each handler is wasteful. A routing key moves that filter into the bus: a subscriber declares which key it wants, and the runtime only delivers matching messages.
Name a payload field as the key on the topic, then filter on it at the subscribe site:
type Tick { symbol_id: Int; price: Decimal; }
topic Quote { payload: Tick; keyed_by symbol_id; }
locus Feed {
params { symbol_id: Int = 0; }
bus {
subscribe Quote as on_quote where key == self.symbol_id;
}
fn on_quote(t: Tick) {
// only ticks whose symbol_id matches this Feed arrive
}
}
A publish carries its key in the payload, so the send is
unchanged — Quote <- Tick { symbol_id: 7, price: 100.0d };
reaches only the Feed instances that subscribed with
where key == 7.
keyed_by FIELDon the topic picks the routing field. It must be a field of the payload, and its type must be one the bus can hash to a fixed-width key:Int,Bool,Time,Duration, a no-payload enum, orDecimal. (Need a compound key like(symbol, venue)? Pack it into oneDecimalfield yourself.)where key == EXPRon a subscribe filters that subscriber.EXPRcan be a literal, aconst, orself.<field>— the common case, one instance per shard.- The key is captured by value when the locus is constructed.
Reassigning
self.symbol_idlater does not re-route the subscription; to change shards, dissolve the locus and instantiate a fresh one.
When nothing matches
A keyed publish whose key matches no subscriber is governed by the
topic’s on_unmatched: policy:
topic Quote { payload: Tick; keyed_by symbol_id; on_unmatched: fallback; }
swallow(the default) — the message is dropped silently. Run withLOTUS_BUS_LOG_UNMATCHED=1to log drops while debugging.fail— the publish becomes fallible; every send site must dispose of it:Quote <- t or raise;panics on an unmatched key,Quote <- t or discard;swallows it. Use this when an unrouted message is a bug, not an expected case.fallback— an unmatched message is delivered to a catch-all subscriber that opts in withwhere key == _. At least one such subscriber must exist program-wide, or the topic is rejected at compile time.
Next: where loci actually run — Concurrency & placement.