0033. Events, Handlers, and Internal Ring Migration¶
events/, handlers/, and _internal/ were the last top-level packages
.claude/rules/architecture.md's ring map still carried on a "during
transition" list — events/ under entities (ring 1), handlers/ under use
cases (ring 2), and _internal/ as an unranked shared-utility package with
no ring assignment at all. This ADR dissolves all three: their seven modules
land on domain/, application/, and adapters/_bus/ by the same
Dependency Rule test ADR 0025, ADR 0026, ADR 0029, ADR 0030, ADR 0031, and
ADR 0032 have already applied to every other pre-ring package, and
eventsource/events/, eventsource/handlers/, and eventsource/_internal/
are deleted outright.
Status¶
Accepted. Implemented in src/eventsource/domain/event.py,
src/eventsource/domain/event_registry.py, src/eventsource/domain/decorators.py,
src/eventsource/domain/exceptions.py (three merged exceptions),
src/eventsource/application/projections/handlers.py,
src/eventsource/application/background_tasks.py, and
src/eventsource/adapters/_bus/handler_adapter.py. src/eventsource/events/,
src/eventsource/handlers/, and src/eventsource/_internal/ no longer
exist; import eventsource.events, import eventsource.handlers, and
import eventsource._internal all raise ModuleNotFoundError. No shim, no
deprecation window — the standing rule ADR 0025/0026/0030 already applied to
every prior retirement. The top-level eventsource/__init__.py barrel's
__all__ is byte-identical to before this migration.
Decision Table¶
| Old module | New home | Ring | Rationale |
|---|---|---|---|
events/base.py (DomainEvent) |
domain/event.py |
domain | Pure pydantic entity — the same "frozen model, no I/O" shape every other domain-ring type already has. |
events/registry.py (EventRegistry, register_event, default_registry, get_event_class, get_event_class_or_none, is_event_registered, list_registered_events) |
domain/event_registry.py |
domain | Pure stdlib (threading.RLock) plus domain types — no I/O, no adapter contact. |
EventTypeNotFoundError, DuplicateEventTypeError |
domain/exceptions.py |
domain | Full-hierarchy rule (ADR 0030): every core exception lives in one place. Rebased onto EventSourceError, KeyError and ValueError mixins retained respectively — widening-only, the same pattern ADR 0029 and ADR 0032 already applied. |
handlers/decorators.py (@handles, get_handled_event_type, is_event_handler) |
domain/decorators.py |
domain | DeclarativeAggregate.__init_subclass__ (domain/aggregate.py) discovers the _handles_event_type attribute directly — the attribute's contract is owned by its innermost consumer, not by a package shared with the projection-facing registry. |
handlers/registry.py (HandlerRegistry, HandlerInfo, UnregisteredEventHandling) |
application/projections/handlers.py |
application | Extracted from DeclarativeProjection under ADR 0013; its sole src/ importer is application/projections/base.py. |
HandlerSignatureError |
domain/exceptions.py |
domain | Full-hierarchy rule; ValueError mixin retained, widening-only. |
handlers/adapter.py (HandlerAdapter, AsyncHandlerFunc, get_handler_name) |
adapters/_bus/handler_adapter.py |
adapters | Every importer is a bus adapter (adapters/_bus/base.py, adapters/_bus/registry.py, and the Kafka/RabbitMQ/Redis/memory bus backends) — HandlerAdapter normalizes a caller-supplied handler to await adapter.handle(event) for dispatch, which is bus plumbing, not use-case orchestration. |
_internal/background_tasks.py (BackgroundTaskManager) |
application/background_tasks.py |
application | See below. |
Context¶
docs/core-surface.md and .claude/rules/architecture.md's ring map
carried events/ under "Entities (domain/ — during transition also
events/)" and handlers/ under "Use cases (application/ — during
transition migration/, handlers/)" since the rings were first drawn.
Both were the last modules on their respective transitional lists once ADR
0032 retired subscriptions/ — application/'s list shrank to migration/
and handlers/, and domain/'s shrank to events/ alone. _internal/
never appeared on the ring map at all: it shipped as a standalone top-level
package (ADR 0030's own Context section names it, in passing, as the
reason events/base.py sits beneath ports/handlers.py as a hard import
floor) holding one module, background_tasks.py, with no ring assignment
and no import-linter contract of its own.
events/base.py's DomainEvent is the load-bearing extraction floor
docs/core-surface.md already documents: ports/handlers.py,
handlers/decorators.py, and (pre-ADR-0031) bus/interface.py all carry a
plain module-level from eventsource.events.base import DomainEvent, not
guarded by TYPE_CHECKING, so every one of those modules pulls in pydantic
transitively. That floor does not move with this ADR — domain/event.py is
exactly as load-bearing a floor as events/base.py was, just renamed.
events/registry.py's only eventsource import was already
TYPE_CHECKING-only (docs/core-surface.md notes this as the one module
that breaks the module-level-coupling pattern), so its move introduces no
new dependency-rule question.
handlers/decorators.py was the one module in the "use cases" transitional
list whose sole consumer lived in domain/, not application/:
DeclarativeAggregate.__init_subclass__ (domain/aggregate.py:716) scans
dir(cls) for the _handles_event_type marker @handles sets, entirely
independent of HandlerRegistry. handlers/registry.py, by contrast, is
used by nothing but application/projections/base.py — it is the
HandlerRegistry/HandlerInfo pair ADR 0013 extracted out of
DeclarativeProjection as a collaborator. Leaving @handles and
HandlerRegistry in one package obscured that they have different
consuming rings; splitting them onto domain/decorators.py and
application/projections/handlers.py makes each module's ring assignment
match its actual importer instead of an accident of original packaging.
handlers/adapter.py's HandlerAdapter had a third, disjoint consumer set:
every one of adapters/_bus/base.py, adapters/_bus/registry.py, and the
Kafka/RabbitMQ/Redis/memory bus backends imports it to normalize a
caller-supplied handler (an object with .handle(), or a plain sync/async
callable) into one awaitable shape before dispatch. Nothing in
application/ imports it. Its old home also carried a compatibility
re-export block pointing back at ports/handlers.py for AsyncEventHandler
and SyncEventHandler — a shim this ADR drops per the no-shims rule; those
two names' canonical home was already ports/handlers.py since ADR 0030,
and the re-export bought nothing but a second import path for the same
names.
_internal/background_tasks.py's BackgroundTaskManager had two
consumers on opposite sides of the ring boundary: application/aggregates/
uses it for background snapshot scheduling (one of the two concrete
SnapshotScheduler implementations ADR 0021 introduced), and
adapters/_bus/base.py uses it to track and drain in-flight background
publish tasks on shutdown. Neither consumer depends on the other, and
BackgroundTaskManager itself is pure asyncio task-set bookkeeping with
no store, bus, or driver contact of its own — it does not need a Protocol
in front of it, only a ring assignment.
Why BackgroundTaskManager lands in application/, not a shared package¶
The Dependency Rule is directional, not symmetric: an outer ring
(adapters/) may depend inward on an inner ring (application/), but never
the reverse. A utility two rings both use therefore has exactly one
dependency-rule-compatible home — the innermost of its consumers — because
placing it at the outer ring would force the inner ring to import outward
to reach it, and placing it in a third, ring-less package (what _internal/
already was) recreates the "unranked shared utility" problem this ADR
exists to close. application/ is BackgroundTaskManager's innermost
consumer, so application/background_tasks.py is the only placement that
lets both application/aggregates/ (an intra-ring import) and
adapters/_bus/base.py (an outward-to-inward import, which is exactly the
direction adapters are permitted to depend) reach it without a contract
exception. No code inside BackgroundTaskManager changed — placement was
the entire fix.
Consequences¶
Positive¶
- The ring-1 "during transition" list in
.claude/rules/architecture.mdlosesevents/; the ring-2 list loseshandlers/and keeps onlymigration/._internal/— which never had a ring assignment to lose — stops existing as an unranked exception to the ring map entirely. domain/decorators.pyandapplication/projections/handlers.pyeach now sit in the ring their actual (and only) consumer occupies, rather than sharing a package whose name matched neither.import-linter's contracts gain explicit coverage foreventsource.adapters._busimportinghandler_adapter— previouslyhandlers/adapter.pysat outside any ring-boundary contract at all.- Tier 0 (stdlib + pydantic only) is unchanged in membership, just renamed:
domain/event.pyanddomain/event_registry.pyreplaceevents/base.pyandevents/registry.pyas the floor beneathports/handlers.py.
Negative¶
import eventsource.events,import eventsource.handlers, andimport eventsource._internalnow raiseModuleNotFoundErrorwith no transition period. Every internal importer (src/,tests/,bench/,docs/examples) needed the sweep this migration's implementation slices carried out — roughly fortysrc/importers plus inline test imports.- The
handlers/adapter.pycompatibility re-export ofAsyncEventHandler/SyncEventHandlerfromports/handlers.pyis gone. Any code that imported those two names fromeventsource.handlers.adapter(rather than fromeventsource.handlersoreventsource.ports.handlers, both of which still work / now resolve to the same names) must repoint toeventsource.ports.handlersdirectly. handlers/registry.py'sUnregisteredEventHandlingtype alias was never re-exported from theeventsource.handlerspackage (only from the submodule); it keeps that same asymmetry at its new address,eventsource.application.projections.handlers.
Alternatives Considered¶
Keep _internal/ as a rings-exempt shared package, the way it already
existed. Rejected: _internal/ was the one top-level package with no
ring assignment and no import-linter contract, which is exactly the
"transitional exception" shape this campaign of ADRs (0025, 0026, 0029,
0030, 0031, 0032) has been closing out one package at a time. A
rings-exempt shared package is also underspecified as a rule — nothing
stops a second, unrelated utility from landing there next, and the
inward-dependency argument above shows a ring-correct home always exists
for genuinely shared code. There is no second module in the same shape
today to justify keeping the exemption open.
Put HandlerAdapter in application/, alongside HandlerRegistry.
Rejected: every actual importer of HandlerAdapter is a bus adapter, not a
use case. application/ code never imports it. Moving it to
application/ would recreate exactly the "package named for one thing,
consumed by another" problem this ADR splits handlers/ apart to fix —
the same reasoning that separates domain/decorators.py from
application/projections/handlers.py in the first place. HandlerAdapter
also has no Protocol counterpart in ports/; it is pure normalization
logic that belongs with the adapters that call it, matching the
adapters/_bus/base.py / adapters/_bus/registry.py collaborators it
already sits beside.
Give BackgroundTaskManager its own ports/ Protocol plus per-ring
implementations. Rejected: there is exactly one implementation and no
second one is anticipated — BackgroundTaskManager is asyncio.Task
bookkeeping, not a capability that varies by backend technology the way
EventBus or EventStore do. A Protocol split would add ports-ring
ceremony with no second adapter to justify it, the same "don't invent an
interface for a single implementation" judgment ADR 0009 applied to
InMemoryLeaderElector before Kubernetes/Redis/Consul electors existed to
motivate a Protocol.
References¶
src/eventsource/domain/event.py,src/eventsource/domain/event_registry.py,src/eventsource/domain/decorators.py,src/eventsource/domain/exceptions.pysrc/eventsource/application/projections/handlers.py,src/eventsource/application/background_tasks.pysrc/eventsource/adapters/_bus/handler_adapter.py- ADR 0013 — the original
HandlerRegistry/HandlerAdaptercomposition decision this ADR relocates without changing - ADR 0025, ADR 0026, ADR 0029, ADR 0030, ADR 0031, ADR 0032 — the same no-shim, ring-map-completion pattern this ADR applies to the last three top-level packages
Related¶
docs/api/events.md,docs/api/handlers.md,docs/api/exceptions.md— API reference updated for the new module layout.claude/rules/architecture.md— ring map updated to markdomain/event.py,domain/event_registry.py,domain/decorators.py,application/projections/handlers.py,application/background_tasks.py, andadapters/_bus/handler_adapter.pyas settled