The EventBus in src/cluster_erp/core/event_bus.py is the only channel through which Cluster ERP modules call each other. This page explains its two patterns, events and queries, how handlers run, and the rules around it. The list of every contract is on the contracts reference.
In short¶
- An
Eventis a frozen, keyword-only dataclass withsource_moduleandtimestamp; aQueryis a subclass ofEvent(src/cluster_erp/core/event_bus.py:24-32). emit(event)runs every handler subscribed to the exact event type, in priority order, and returns their results as a list (src/cluster_erp/core/event_bus.py:83-96).query(query)runs the one handler registered for the exact query type and returns its result (src/cluster_erp/core/event_bus.py:98-102).- A second query handler for the same type raises
DuplicateQueryHandlerError; a query without a handler raisesQueryHandlerNotFoundError(src/cluster_erp/core/event_bus.py:73-81,src/cluster_erp/core/event_bus.py:98-101). - The bus is in-process: one instance is created in the server lifespan and handed to every module (
src/cluster_erp/server/app.py:62,src/cluster_erp/server/app.py:140).
How it works¶
Events and queries¶
| Pattern | Call | Handlers | Result | Error behaviour | Source |
|---|---|---|---|---|---|
| Event (fan-out) | await bus.emit(event) |
Zero or more, registered with subscribe |
A list with one entry per handler that returned | A handler exception is logged and skipped; other handlers still run | src/cluster_erp/core/event_bus.py:55-65, src/cluster_erp/core/event_bus.py:83-96 |
| Query (point to point) | await bus.query(query) |
Exactly one, registered with register_query_handler |
The handler's return value | No handler raises QueryHandlerNotFoundError; a handler exception propagates to the caller |
src/cluster_erp/core/event_bus.py:73-81, src/cluster_erp/core/event_bus.py:98-102 |
Handlers are looked up by type(event) (src/cluster_erp/core/event_bus.py:84, src/cluster_erp/core/event_bus.py:99). A handler subscribed to a base class does not receive events of a subclass.
The sequence diagram shows one event and one query.
sequenceDiagram participant P as Producer module participant B as EventBus participant H1 as Handler A participant H2 as Handler B participant Q as Query handler P->>B: emit(PartyUpdated) B->>H1: await handler(event) H1-->>B: result or exception (logged) B->>H2: await handler(event) H2-->>B: result B-->>P: list of results P->>B: query(GetPartyById) B->>Q: await handler(query) Q-->>B: result B-->>P: result
In prose: emit awaits each subscribed handler one after the other and collects the results; an exception in one handler is logged and does not stop the next. query awaits the single registered handler and returns its value to the caller.
Priorities¶
EventPriority has four values: LOW (0), NORMAL (1), HIGH (2), and CRITICAL (3) (src/cluster_erp/core/event_bus.py:17-21). subscribe defaults to NORMAL and keeps each handler list sorted from the highest value to the lowest (src/cluster_erp/core/event_bus.py:55-64). No module passes a non-default priority (searched with grep -rn "EventPriority\.\(HIGH\|LOW\|CRITICAL\)" over src/cluster_erp).
Events used to collect answers¶
Several contracts are events although the caller wants answers, because more than one module must answer. The design note in src/cluster_erp/core/contracts.py:2043-2056 states the reason: query() allows a single owner, while emit() returns every handler's return value. Examples are DescribeAnalyticsFacts, CollectSubjectData, DescribeDisplaySwitches, and ResolveEntityEmails (src/cluster_erp/core/contracts.py:2060, src/cluster_erp/core/contracts.py:2385, src/cluster_erp/core/contracts.py:2441, src/cluster_erp/core/contracts.py:352). GET /api/v1/_switches merges the dictionaries returned by DescribeDisplaySwitches handlers (src/cluster_erp/server/app.py:1558-1572).
Sessions inside handlers¶
A handler that runs inside a request joins the request's transaction through core/handler_session.py, which yields the request session when one is published, and otherwise opens and commits its own session (src/cluster_erp/core/handler_session.py:25-37). A handler that needs its own session uses ModuleBase.request_database() so that it reaches the request's tenant database (src/cluster_erp/core/module_base.py:40-59). See database and sessions.
Events after commit¶
Some events must fire only after the data is durable. The change-history hook queues TenantFieldValuesChanged in session.info, and the session dependency emits the queue after the commit (src/cluster_erp/core/change_history.py:357-386, src/cluster_erp/server/dependencies.py:203-222). A failing post-commit handler is logged and does not change the response.
Why it is built this way¶
The project's written engineering rules forbid direct communication between modules and require the EventBus for it. The rules add that modules never import each other and that events are fire-and-forget with many handlers while queries are request-response with one handler. The EventBus docstring states the same two patterns (src/cluster_erp/core/event_bus.py:44-49).
Rules and boundaries¶
- A module imports no other module, with one exception:
cluster_erp.modules.auth.guards.tests/test_architecture_boundaries.py:46-57enforces this and names the exceptionDOOR(tests/test_architecture_boundaries.py:24). - Contracts live in
src/cluster_erp/core/contracts.py, whose docstring says that new modules define their contracts there (src/cluster_erp/core/contracts.py:1-6).
Interaction with other parts¶
- Every module wires its handlers in
register_events(src/cluster_erp/core/module_base.py:67-68). See module lifecycle. - The server itself subscribes one handler at start-up, to
AnonymiseSubject(src/cluster_erp/server/app.py:90). - The contracts reference lists the producers and consumers of every contract.
Limits and open points¶
emitruns handlers sequentially in the caller's task (src/cluster_erp/core/event_bus.py:89-95). There is no queue, retry, or persistence. An event emitted while no handler is subscribed is dropped with a debug log line (src/cluster_erp/core/event_bus.py:85-87).- The bus does not cross process boundaries. With several worker processes, each process has its own bus (
src/cluster_erp/server/app.py:62).
Terms introduced¶
| Term | Code identifier | Definition | Turkish term | Source |
|---|---|---|---|---|
| event bus | EventBus |
The in-process mediator that delivers events and queries between modules. | olay veri yolu | src/cluster_erp/core/event_bus.py:43 |
| event | Event |
A fire-and-forget message delivered to every subscribed handler. | olay | src/cluster_erp/core/event_bus.py:24-27 |
| query | Query |
A request delivered to exactly one handler, which returns the answer. | sorgu | src/cluster_erp/core/event_bus.py:30-32 |
| event priority | EventPriority |
The order in which handlers of one event run, highest first. | olay önceliği | src/cluster_erp/core/event_bus.py:17-21 |
Related pages¶
- Platform core
- Contracts reference
- Module lifecycle
- Database and sessions
- Architecture boundaries
- EventBus-only communication between modules
Verified against commit 92f8051 on 2026-11-13.