Status: Accepted, stated in the project's written engineering rules. This record covers the rule that a module of Cluster ERP never imports another module and reaches other modules only through the EventBus in src/cluster_erp/core/event_bus.py, with contracts from src/cluster_erp/core/contracts.py. The mechanism itself is explained on event bus.
Context¶
Cluster ERP is split into modules under src/cluster_erp/modules/, 28 of them counted as package folders. Each module is loaded on its own by the registry and may be enabled or not (src/cluster_erp/core/module_registry.py:58-72). The project's written engineering rules forbid direct communication between modules and call it a mandatory architecture rule. The boundary test states the intent: the rules are "held by a test instead of by memory" (tests/test_architecture_boundaries.py:1-11). Rationale beyond keeping modules independent is not determined from code.
Decision¶
- A module never imports another module. The single sanctioned import is
cluster_erp.modules.auth.guards, which supplies the user type and the permission dependencies (tests/test_architecture_boundaries.py:3-7,tests/test_architecture_boundaries.py:24). - Everything else goes through the
EventBus: anEventis fire-and-forget with any number of handlers; aQueryis request and response with exactly one handler (src/cluster_erp/core/event_bus.py:43-49). - Contracts are frozen dataclasses in
src/cluster_erp/core/contracts.py. - The application layer in
src/cluster_erp/server/routes/is not a module. It may orchestrate several module services in one request (src/cluster_erp/server/routes/party_composite.py:4-9).
The sequence diagram shows the two patterns.
sequenceDiagram participant A as Module A service participant Bus as EventBus participant B as Module B handler participant C as Module C handler A->>Bus: emit(Event) Bus->>B: handler(event), by priority Bus->>C: handler(event), by priority A->>Bus: query(Query) Bus->>B: the single registered handler B-->>A: result
In prose: emit calls every subscribed handler of the event's type, ordered by EventPriority, and returns their results (src/cluster_erp/core/event_bus.py:55-65, src/cluster_erp/core/event_bus.py:83-96). query calls the one handler registered for the query's type and returns its answer (src/cluster_erp/core/event_bus.py:98-102).
Consequences¶
src/cluster_erp/core/contracts.pyholds 146 classes that subclassEventand 118 that subclassQuery(counted withgrep -c).- An exception in an event handler is logged and does not reach the sender; the other handlers still run (
src/cluster_erp/core/event_bus.py:91-95). - A query without a handler raises
QueryHandlerNotFoundError(src/cluster_erp/core/event_bus.py:98-101). A second handler for the same query raisesDuplicateQueryHandlerErrorat registration (src/cluster_erp/core/event_bus.py:78-79). - Handlers that run inside a request use
request_database()so they read the current tenant's database (src/cluster_erp/core/module_base.py:40-59). - Validation that spans modules is a query. Example:
ensure_product_breakdownsendsValidateProductBreakdown, whichproducts_and_servicesanswers (src/cluster_erp/core/product_breakdown.py:11-13,src/cluster_erp/modules/products_and_services/module.py:131). - The desktop client follows the same idea with its own signal bus; see signal bus and watch_entity.
Where the code enforces it¶
| Mechanism | What it checks | Source |
|---|---|---|
test_no_module_imports_another_module_except_through_the_guards_door |
Every import in every module file | tests/test_architecture_boundaries.py:46-57 |
test_modules_exist |
The scan covers at least 20 modules | tests/test_architecture_boundaries.py:42-43 |
EventBus.register_query_handler |
One handler per query type | src/cluster_erp/core/event_bus.py:73-81 |
CI test job |
Runs the boundary test on every push | .github/workflows/ci.yml:124 |
Related pages¶
Verified against commit 92f8051 on 2026-11-13.