event_bus_arch 3.0.1
event_bus_arch: ^3.0.1 copied to clipboard
This package is a part of Event-driven architecture providing sending, listening, processing and receiving the events. This package additional contain Finite State Machine and Layers in Isolate what [...]
EventBus Arch #
event_bus_arch is an event-driven architecture toolkit for Dart and Flutter. Components talk to
each other through events addressed by a topic — a type plus a path — so they stay decoupled
and can be moved between isolates without changing their logic.
On top of the bus the package ships a finite state machine, an isolate bridge, multi-isolate "layers", and a scope/command wrapper for stateful values.
- Detailed documentation: doc/
- Full API reference: doc/06-api-reference.md
- Upgrading from 2.x: doc/07-caveats.md
- Load benchmark: benchmark/README.md
Features #
- Two publish modes:
fire(one-way, listeners only) andrequest(one handler, returns a result) — see Two modes:fireandrequest - Send and receive events between components (
listen/subscribe) - Handlers that return a result to
request - Two bus flavours: common (
EventBus()) and model (EventBus(isModelBus: true)) — the model bus keeps the last value per topic even when nobody is listening - Topic-based routing: event type +
paths, plusargumentsas call parameters EventBusIsolate— the same API, but the events are processed in a worker isolateLayers— chains of isolates:main ↔ isolate₂ ↔ isolate₁ ↔ isolate₀FSM— finite state machine driven by bus eventsScope/Command— stateful value with call historyEventBusLogger— trace every event that crosses the bus- Type-safe handlers, no code generation
Installation #
dependencies:
event_bus_arch: ^3.0.0
dart pub get # or: flutter pub get
Dart SDK >=3.6.0 <4.0.0. The package re-exports rxdart, so BehaviorSubject,
debounceTime, doOnCancel and friends are available without an extra import.
Quick start #
import 'package:event_bus_arch/event_bus_arch.dart';
Future<void> main() async {
final bus = EventBus();
// Receive: every event of that topic, current and future
final sub = bus.subscribe<String>(onData: (event) => print('got: $event'));
// Handle: the handler returns the response; request waits for it.
// lastData is the PREVIOUS value of this topic, dto.data is the current one.
bus.handlers.setHandler<String>(handler: (dto, lastData) async {
return 'processed: ${dto.data}';
});
final result = await bus.request<String>('hello');
print(result); // processed: hello
// by now the listener has already received the value: request() is a sync point
await sub.cancel();
await bus.dispose();
}
Two modes: fire and request #
There are exactly two ways to publish an event, and they have different contracts:
fire<T>(...) |
request<T>(...) |
|
|---|---|---|
| Purpose | one-way event | ask a handler for a result |
Invokes setHandler responders |
no — listeners only | yes — exactly one handler |
| Return value | bool — was the event accepted |
Future<dynamic> — the handler's result |
| No listener / no handler | false, not an error |
throws NoHandlerException |
| Waits | no, returns immediately | yes, until the handler returns; optional timeout |
| Call arguments | arguments reach listeners in dto.arguments |
the same, plus the handler |
| Wildcard paths | rejected — the call names a concrete topic | rejected — the call names a concrete topic |
final bus = EventBus();
// Listeners can be registered on a concrete topic or on a pattern.
bus.subscribe<int>(paths: ['sensor', '*'], onData: (v) => print('event: $v'));
// A handler is a request responder; fire never calls it.
bus.handlers.setHandler<int>(
paths: ['sensor', 'temp'],
handler: (dto, lastData) async => dto.data * 2,
);
// fire: one-way, returns whether the event was accepted.
final delivered = bus.fire<int>(21, paths: ['sensor', 'temp']); // true
// the wildcard listener printed "event: 21" (asynchronously)
// request: waits for the handler and returns its result.
final doubled = await bus.request<int>(21, paths: ['sensor', 'temp']); // 42
// the wildcard listener also saw the request: "event: 21"
fire and request both notify listeners; the difference is the handler call and the result
channel. A request without a handler is an error, not a silently ignored event.
Topics #
A topic is the static type argument of the call plus an optional path:
OrderPlaced.warehouse.in
│ │
│ └── paths: ['warehouse', 'in']
└─────────────── type: the type parameter T of the call
bus.fire<Order>(order, paths: ['warehouse', 'in'], arguments: {'priority': '1'});
bus.subscribe<Order>(paths: ['warehouse', 'in'], onData: (order) => print(order));
paths selects the topic; arguments are delivered to the handler as dto.arguments but do not
affect routing. Because the type is part of the address, request<int> and a handler
registered with setHandler<num> are different topics — always be explicit with type
arguments.
A path token must not contain the separator (. by default): paths: ['a.b'] and
paths: ['a', 'b'] build the same address. Pass separator: '/' to address such a topic,
or change the fallback for a whole bus:
final bus = EventBusImpl(false)..defaultTopicPathSeparator = '/';
Wildcards #
listen, subscribe and setHandler accept wildcard path tokens:
*matches exactly one path token:int.sensor.*;>matches one or more trailing tokens and must be the last token:int.sensor.>.
fire through a wildcard #
fire always names a concrete topic; a wildcard subscription receives every matching event:
final sub = bus.subscribe<int>(
paths: ['sensor', '*'], // one path token after "sensor"
onData: (value) => print('sensor: $value'),
);
// Concrete events; no wildcard in the fire() call itself.
bus.fire<int>(21, paths: ['sensor', 'temp']); // true → sensor: 21
bus.fire<int>(22, paths: ['sensor', 'humidity']); // true → sensor: 22
bus.fire<int>(23, paths: ['sensor', 'room', 'x']); // false — '*' matches one token, not two
// '>' matches one or more trailing tokens.
bus.subscribe<int>(paths: ['sensor', '>'], onData: (v) => print('subtree: $v'));
bus.fire<int>(24, paths: ['sensor', 'room', 'temp']); // true → subtree: 24
The same pattern can answer requests when it is registered as a handler:
bus.handlers.setHandler<int>(paths: ['sensor', '*'],
handler: (dto, lastData) async => dto.data * 2);
await bus.request<int>(21, paths: ['sensor', 'temp']); // 42
A fire or request call itself must name a concrete topic: wildcard paths
throw. A request may be answered by a handler registered on a pattern (exact
handler first, then the most specific pattern). fire delivers to the exact
node and to every matching pattern node. currentData(pattern) returns the
last matching value; currentData(concrete) reads only the exact node.
Model bus vs common bus #
EventBus() |
EventBus(isModelBus: true) |
|
|---|---|---|
Creates a node on fire to an unknown topic |
no | yes |
Keeps currentData after the last listener unsubscribes |
no | yes |
fire to a topic with no node |
returns false, event goes to allEventStream with flag false |
returns true, node is created |
request without a handler |
throws NoHandlerException |
throws NoHandlerException |
A handler registered with setHandler is a request responder: request invokes it, while
fire only notifies listeners.
Use the model bus when the bus is a store of current state, and the common bus when it is a message pipe.
Isolates #
import 'dart:isolate';
import 'package:event_bus_arch/event_bus_arch.dart';
Future<void> main() async {
final iso = EventBusIsolate(onInit: _worker, initialData: 'worker');
await iso.waitInit; // true when onInit has completed
iso.subscribe<String>(onData: (e) => print('from worker: $e'));
final doubled = await iso.request<int>(21, arguments: {'multiplier': '2'});
print(doubled); // 42
await iso.dispose();
}
// runs inside the worker isolate — must be a top-level or static function
Future<void> _worker(EventBus bus, Object? initialData) async {
await _heavyInit(); // async init is fine
bus.handlers.setHandler<int>(handler: (dto, lastData) async {
final m = int.tryParse(dto.arguments?['multiplier'] ?? '1') ?? 1;
return dto.data * m;
});
}
EventBusIsolate behaves like a normal bus on both sides: events sent from the worker reach
main-isolate listeners, and request from main returns the worker's handler result. Paths, separator
and arguments cross the boundary with the event.
Good to know:
- Only sendable objects can cross the isolate boundary.
waitInitcompletes whenonInithas finished (including itsawaits), so events sent right after it are never lost. Errors fromonInitsurface as anEventBusExceptionfromwaitInit.- Every request is answered exactly once and correlated by request id, so concurrent requests of
the same type each get their own result. A topic the worker has no handler for fails with
NoHandlerException; a handler error is rethrown at the sender; an in-flight request fails withEventBusExceptionif the isolate is disposed. ping()only checks liveness ("is the worker alive"), not readiness.
Layers #
final layer = await Layers.createLayers([
(_initMiddle, null, true), // (init function, initial data, isModelBus)
(_initBottom, null, true),
]);
// main ↔ isolate[middle] ↔ isolate[bottom]
// createLayers completes only when every layer in the chain is initialized,
// so events can be sent right away
layer.bottom!.subscribe<Ping>(onData: (e) => print(e.count));
layer.bottom!.fire(Ping(0));
await layer.close();
Inside a layer, layer.top sends to the layer above, layer.bottom to the layer below, and
layer.current is the layer's own bus. Init functions may be async and may live in any layer;
Layer.close() works from any layer and is safe to call twice. See
doc/03-isolate-and-layers.md
and example/event_bus_layers_example.dart.
Finite state machine #
enum State { idle, loading, ready }
class Loader with FSM<State> {
Loader(EventBus bus) {
initFSM(bus, State.idle);
addFSMTransition<Start>(State.idle, State.loading,
eventPaths: ['api', 'items'], // the transition listens on Start.api.items
filter: (current, last, event) => event.url.isNotEmpty,
handler: (current, last, event, ignored) async {
print('$last -> $current ($event)');
return null;
});
}
}
bus.fire(Start('/api/items'), paths: ['api', 'items']); // State.idle -> State.loading
Events arriving while a transition handler is running are collected into the ignoredEvents
argument and are not replayed afterwards. FSM.dispose() unsubscribes the machine from the bus.
Scope and Command #
final scope = Scope<int>();
await scope.initScope(
bus,
paths: ['counter'],
initialData: 0,
maxLenForUndo: 10, // history depth; undo needs at least 2 entries
onUpdate: (value) => print('value: $value'),
);
await scope.call(1); // goes through the bus
print(scope.lastData); // 1 (filled by the bus subscription)
await scope.undo(); // re-sends the PREVIOUS value from the history
scope.disposeScope();
Command keeps the call history (maxLen) and undo() sends the previous value back through the
bus. undo() returns null when there is nothing to roll back to.
Documentation #
Detailed guides live in doc/:
| Document | Content |
|---|---|
| 01-architecture.md | Topics, DTOs, nodes, the full send/receive flow |
| 02-event-bus.md | Practical bus usage, handlers, addressing, recipes |
| 03-isolate-and-layers.md | EventBusIsolate, Layers, exchange limits |
| 04-fsm.md | State machine on top of the bus |
| 05-scope-and-command.md | State with history, command pattern |
| 06-api-reference.md | Every public type and member |
| 07-caveats.md | What 3.0.0 changed, what remains by design, checklist |
| 08-transports.md | Running the bus over NATS / Redis / NanoMQ: analysis |
| 09-event-bus-nats.md | The implemented NATS transport: usage, architecture, limitations |
| 10-event-bus-mqtt.md | The implemented MQTT 5 transport: usage, architecture, limitations |
Version 3.0.0 changes #
The address model changed; this is a breaking release.
Topicis no longer URI-like:scheme,targetandfragmentare gonepath: '/in'becamepaths: ['in']— a list of tokens joined by a configurableseparator(.by default,EventBus.defaultTopicPathSeparator)argumentsmoved fromTopictoEventDTO:dto.topic.argumentsis nowdto.arguments, both locally and across an isolatesendis split intofireandrequest:fireis one-way and returnsbool;requestinvokes a handler and waits for its result (orNoHandlerException)- Handlers return their response;
EventDTO.completerwas removed, andfireno longer invokes handlers lastDatawas renamedcurrentData(bus.currentData<T>(),EventNode.currentData)- Local wildcards:
*matches one path token,>one or more trailing tokens;listen/subscribe/setHandleraccept patterns,fire/requestrequire concrete topics - The type in the address is
'$T'normalised to a subject-safe token — no lowercasing, no percent-encoding (List<int>→List(int)) topic.topicis the single routing key (fullTopic/topicWithFragmentare gone)packages/event_bus_arch_natsadds a NATS transport as a separate package; the core stays broker-free (see doc/09-event-bus-nats.md)packages/event_bus_arch_transportholds the broker-agnostic connection, mapping, bus and node shared by the NATS and MQTT 5 transportspackages/event_bus_arch_mqttadds an MQTT 5 transport (see doc/10-event-bus-mqtt.md)- The deprecated
queueLenght,initalData,initialDataNeedExecuteandshemaaliases were removed
The full migration table is in doc/07-caveats.md.
Version 2 changes #
EventBusIsolate,Layers,FSM,Scope/Command,subscribe,disposewere added- Reduced API surface; the bus always returns a result or
null(the oldcallmethod is gone) EventBusMasterwas removed — buses no longer need names- Handlers return values through
EventDTO.completer(removed in 3.0.0 — handlers return the value directly)
Version 2.3.0 changes #
EventBusnow exposeshandlers(no morebus as EventBusHandlers) and an idempotentdispose()- Handler failures reach the sender instead of hanging it; listeners receive values before
await request(...)returns EventBusIsolatecorrelates responses by request id,waitInitmeans "onInit completed", andping()checks liveness; in-flight requests fail instead of hanging on disposeLayers.createLayerscompletes only when the whole chain is initialized;Layer.close()works from any layerScope/Commandundo()sends the previous value;Scope.maxLenForUndodefaults to 10EventDTO.toJsonno longer encodes data;EventBusExceptionis exported- Typo'd members got correct names (
queueLength,initialData,scheme,initialDataNeedExecute) — those aliases were removed again in 3.0.0
The full list with reproductions is in doc/07-caveats.md.
See CHANGELOG.md for the full history.
License #
MIT — see LICENSE.