Core Surface Boundary¶
The core surface is the set of modules that depend only on stdlib + pydantic -- no sqlalchemy, redis, or other infrastructure libraries. These modules define the contracts (protocols, ABCs, base classes, types) that the rest of the library implements. They are candidates for future extraction into a standalone eventsource-core package (Tier 0) that downstream libraries can depend on without pulling in database drivers.
Why this matters¶
- Lighter dependency tree: Consumers who only need the event/aggregate contracts (e.g., shared domain libraries) should not need sqlalchemy.
- Cleaner layering: Making the boundary explicit prevents accidental infrastructure leakage into core contracts.
- Future extraction: When the time comes to split the package, this document defines exactly what moves.
What a Tier 0 module looks like¶
Correction (2026-07-28): this section previously used
serialization/as the reference case for the strictest form of Tier 0 -- "imports cleanly with nothing but the standard library present." That stopped being true the momentorjsonbecame a core dependency ofserialization/json.py(a user decision: performance over Tier 0 purity for that module). orjson is a compiled third-party extension, not stdlib and not pydantic, soserialization/no longer qualifies as Tier 0 under this document's own definition -- see its new entry in "Modules NOT in Tier 0" below.exceptions.pyreplaces it as the reference case; the rest of this section is otherwise unchanged in substance.
exceptions.py is the cleanest example in the codebase, and the easiest one to reason about.
Its entire import block, at src/eventsource/exceptions.py, is:
That is the whole dependency surface. No pydantic. No sqlalchemy. Not even a transitive eventsource import -- exceptions.py never reaches back into the rest of the library, so there is no chain of internal modules that could quietly drag infrastructure in behind it.
The module is just as small: one file, defining EventSourceError and its subclasses. Everything you need to understand it fits in one screen, which is what makes it a useful yardstick: if a candidate Tier 0 module is harder to audit than this, the boundary is doing work you should look at closely.
The practical test for extraction-readiness follows directly from that import block. In a bare virtualenv with no third-party packages installed, this succeeds:
Any module that passes that test -- imports cleanly with nothing but the standard library present -- is unambiguously extractable. Most Tier 0 modules will not pass it, because pydantic is an allowed Tier 0 dependency (see the rules below); they pass the weaker "stdlib + pydantic only" version of the same check. exceptions.py passing the strict form is what makes it the reference case.
Tier 0 modules¶
| Module | Dependencies (beyond stdlib) | Purpose |
|---|---|---|
domain/event.py |
pydantic | DomainEvent base class |
domain/event_registry.py |
(none beyond domain/event) | EventRegistry auto-registration |
domain/aggregate.py |
pydantic (via domain/event, types) | AggregateRoot, DeclarativeAggregate |
domain/stream_id.py |
(none -- stdlib only) | StreamId value object |
protocols.py |
from eventsource.domain.event import DomainEvent (module level, so pydantic transitively) -- not standalone |
EventHandler, SyncEventHandler, EventSubscriber protocols/ABCs |
bus/interface.py |
(none beyond domain/event, protocols) | EventBus ABC |
ports/snapshots.py |
(none -- stdlib only) | SnapshotStore ABC, Snapshot dataclass |
ports/positions.py |
(none beyond exceptions) | Position, ExpectedVersion value objects |
ports/envelopes.py |
pydantic (via domain, events) | EventEnvelope, read-option value objects, AppendResult, ReadDirection |
ports/store.py |
(none beyond domain, events, ports siblings) | EventAppender, StreamReader, CategoryQuery, EventLookup, GlobalEventFeed, FullEventStore port Protocols |
ports/bus.py |
(none beyond events/base) | EventPublisher publishing port |
ports/checkpoints.py |
(none -- stdlib only) | ProjectionCheckpoints, SubscriptionPositions, composed CheckpointRepository Protocols; CheckpointData, LagMetrics dataclasses. Replaced the Protocol/dataclass half of the old repositories/checkpoint.py (see boundary finding 2 and ADR 0024). |
ports/dlq.py |
(none -- stdlib only) | DLQRepository Protocol; DLQEntry, DLQStats, ProjectionFailureCount dataclasses. Replaced the Protocol/dataclass half of the old repositories/dlq.py (see boundary finding 2 and ADR 0024). |
ports/migration/ |
(none -- stdlib + pydantic only) | models.py (Migration, MigrationConfig, TenantRouting, PositionMapping, MigrationAuditEntry, and their enums) plus repositories.py (MigrationRepository, TenantRoutingRepository, PositionMappingRepository, MigrationAuditLogRepository Protocols). Cut out of the sqlalchemy-backed migration/repositories/ modules by ADR 0034 -- the Protocol classes were already pure in shape, just colocated with their implementations. |
ports/lifecycle.py |
(none -- stdlib only) | SupportsClose Protocol -- optional resource-release capability (ADR 0037). |
application/projections/base.py, checkpoints.py, dlq.py, coordinator.py, retry.py |
(none beyond ports/checkpoints, ports/dlq, events/base, handlers/registry, observability, protocols, application/subscriptions/retry) |
Replaces the old projections/ package (dissolved by ADR 0024). Projection, EventHandlerBase, DeclarativeProjection, checkpoint/DLQ recording helpers, RetryConfig. This is the module the rest of this document now describes as Tier-0-clean where projections/base.py, projections/checkpoint_manager.py, and projections/dlq_manager.py used to be blocked (see boundary finding 1). |
application/aggregates/snapshotting.py |
_internal/background_tasks, domain/aggregate, ports/snapshots |
SnapshotPolicy, SnapshotScheduler, EveryNEvents, Never, ImmediateScheduler, BackgroundScheduler, take_snapshot(), read_valid_snapshot() |
handlers/decorators.py |
pydantic (via events/base) | @handles decorator |
handlers/registry.py |
(none beyond handlers/decorators, events, exceptions) | HandlerRegistry |
handlers/adapter.py |
(none beyond protocols, events) | Sync/async handler adapter |
exceptions.py |
(none) | All exception types |
config.py |
(none -- empty placeholder module, defines zero configuration classes) | Nothing yet; see "Empty and placeholder modules" below |
types.py |
(none) | AggregateId, EventId, TenantId, CorrelationId, CausationId (the aggregate state parameter is now an inline PEP 695 type parameter on AggregateRoot, not a module-level type) |
observability/ |
(none -- opentelemetry is optional, guarded) | Tracer, attribute constants |
bus/memory.py |
(none beyond bus/interface, bus/base, events, handlers/adapter, observability) | InMemoryEventBus |
adapters/memory/store.py |
(none beyond domain, events, exceptions, ports) | InMemoryEventStore (ring in-memory store) |
adapters/memory/snapshots.py |
observability, ports/snapshots |
InMemorySnapshotStore |
adapters/memory/checkpoints.py |
observability, ports/checkpoints |
InMemoryCheckpointRepository -- the in-memory half of the old repositories/checkpoint.py split (ADR 0024). Tier 0 only when imported by its own submodule path (eventsource.adapters.memory.checkpoints); importing the eventsource.adapters.memory package pulls its sibling dlq.py, which is not Tier 0 -- see the next table and boundary finding 2. |
adapters/_sql/positions.py |
(none beyond exceptions, ports/positions) | IntPositionCodec |
testing/builder.py |
pydantic (via events/base) | EventBuilder fluent test-event builder |
testing/assertions.py |
pydantic (via events/base) | EventAssertions domain-specific test assertions |
testing/conformance.py |
(none beyond events/base, bus/interface) | EventBusConformanceSuite; store conformance now lives in testing/conformance_ports/ (see below) |
testing/recording.py |
(none beyond bus/interface, events/base, protocols) | RecordingEventBus |
testing/partitioned_memory.py |
(none beyond domain, events, exceptions, ports) | Partitioned in-memory store for feed/ordering tests |
testing/sync_facade.py |
(none beyond domain, events, ports) | SyncStoreFacade synchronous wrapper over the ports surface |
application/aggregates/repository.py |
application/aggregates/snapshotting, domain/aggregate, exceptions, observability, ports/bus, ports/envelopes, ports/positions, ports/store (ports/snapshots types imported under TYPE_CHECKING) |
AggregateRepository load/save orchestration, composing a SnapshotPolicy and SnapshotScheduler |
sync/adapter.py |
stdlib threading + concurrent.futures.ThreadPoolExecutor, plus domain, events/base, and ports |
SyncEventStoreAdapter wrapping an async store for sync callers |
multitenancy/context.py |
stdlib contextvars only, plus multitenancy/exceptions |
Tenant context get/set, get_required_tenant |
multitenancy/events.py |
pydantic (Field), plus events/base and multitenancy/context |
TenantDomainEvent |
multitenancy/exceptions.py |
(none beyond eventsource.exceptions) |
TenantContextNotSetError, TenantMismatchError |
multitenancy/repository.py |
(none beyond application/aggregates/repository, domain/aggregate, multitenancy context + exceptions) | Tenant-scoped aggregate repository |
readmodels/base.py |
pydantic (BaseModel, ConfigDict, Field) |
ReadModel base class |
readmodels/query.py |
(none -- stdlib only) | Query, Filter, operators |
readmodels/schema.py |
pydantic (FieldInfo), stdlib types/decimal, plus readmodels/base |
Read model schema derivation from pydantic fields |
readmodels/repository.py |
(none beyond readmodels/base; Query imported under TYPE_CHECKING) |
ReadModelRepository interface |
readmodels/in_memory.py |
(none beyond readmodels base/query/exceptions and observability) |
InMemoryReadModelRepository |
readmodels/exceptions.py |
(none) | ReadModelNotFoundError, ReadModelVersionConflictError |
domain/, ports/, and application/ are Tier 0 in full, package __init__s included: every module under them resolves to stdlib, pydantic, and other Tier 0 in-library modules. This now includes ports/checkpoints.py and ports/dlq.py, and all five modules of application/projections/ -- the pure and application-ring halves of the old checkpoint/DLQ Tier-0 blocker (see boundary finding 2, resolved by ADR 0024).
adapters/memory/ is not Tier 0 in full, unlike the three ring packages above -- it is tiered per module, the same way testing/ and readmodels/ are. store.py, snapshots.py, and checkpoints.py are individually Tier 0; dlq.py is not, because it imports eventsource.serialization for json_dumps, and serialization/ carries orjson as a core dependency (see "Modules NOT in Tier 0"). Because adapters/memory/__init__.py eagerly re-exports all four submodules, importing the package -- from eventsource.adapters.memory import ... -- always executes dlq.py and therefore always pulls orjson, even for callers who only want InMemoryCheckpointRepository. Its sibling backend directories (adapters/postgresql/, adapters/sqlite/, adapters/sql/, adapters/_sql/dialect.py) are sqlalchemy-backed and not Tier 0 by design; see the next table. ports/bus.py owns the EventPublisher definition outright; there is no longer any stores/ module for it to have been transitionally coupled to (see boundary finding 12).
testing/ is tiered per module, not as a package. The modules above import only stdlib plus in-library Tier 0 contracts -- builder.py and assertions.py reach for eventsource.domain.event and nothing else; conformance.py (now bus-only) adds eventsource.ports.bus; recording.py, partitioned_memory.py, and sync_facade.py build on ports.bus, ports.handlers, domain, and ports, all of which are themselves Tier 0. None of them touch a repository, a harness, or a backend implementation.
Their sibling modules in the same package are not Tier 0, and the package __init__.py is not either -- see the next table. testing/conformance_ports/ is a third category: it carries no driver dependency, but every suite module in it imports pytest at module level (and stateful.py imports hypothesis), so it does not meet the "stdlib + pydantic only" bar either -- see boundary finding 6.
multitenancy/ is Tier 0 in full: all five modules resolve to stdlib (contextvars), pydantic, and other Tier 0 in-library modules -- repository.py now sits on application/aggregates/repository.py, which is itself Tier 0. Nothing under it reaches a store or repository implementation.
application/subscriptions/ is Tier 0 in full as well, which is a change from earlier revisions of this document: no module under it imports a repository, a store backend, or a driver. Its only out-of-package imports are ports and observability. See boundary finding 13.
readmodels/ is tiered per module in the same way testing/ is. The contract and in-memory half -- base.py, query.py, schema.py, repository.py, in_memory.py, exceptions.py -- is Tier 0; the backend half (postgresql.py, sqlite.py, projection.py) is not. See the next table.
config.py remains a seven-line placeholder with no imports and no configuration classes defined.
Module-level import coupling within Tier 0¶
Tier 0 membership is a statement about which third-party packages a module pulls in. It is not a statement about independence. A module can be perfectly Tier 0 and still be impossible to ship on its own, because it imports other eventsource modules at import time.
protocols.py is the clearest case. It advertises itself as the canonical type-contract module, but src/eventsource/protocols.py:35 is:
That import is at module level -- not inside if TYPE_CHECKING:, not deferred into a function body -- so importing eventsource.protocols executes eventsource.domain.event, which in turn imports pydantic. domain/event.py is therefore an extraction floor: it sits beneath the protocols module and has to move with it. Nothing above the floor can be published without it.
The same shape repeats across the other contract modules:
| Module | Module-level eventsource import |
Floor it sits on |
|---|---|---|
protocols.py |
events.base |
events/base.py |
bus/interface.py |
events.base, protocols |
events/base.py (+ protocols.py) |
handlers/decorators.py |
events.base |
events/base.py |
handlers/registry.py |
events.base, exceptions, handlers.decorators |
events/base.py, exceptions.py |
handlers/adapter.py |
events.base, protocols |
events/base.py (+ protocols.py) |
domain/aggregate.py |
events.base, exceptions, types |
events/base.py, exceptions.py, types.py |
ports/bus.py |
events.base |
events/base.py |
Two modules are worth calling out as exceptions, because they behave differently from what the table's shape suggests:
ports/snapshots.pyhas noeventsourceimports at all. Its import block is stdlib only (abc,dataclasses,datetime,typing,uuid). It does not sit downstream ofevents/base.py, and unlike the modules above it could in principle be lifted on its own.events/registry.pyimportsevents.baseonly underTYPE_CHECKING(src/eventsource/events/registry.py:43-44). At runtime it is stdlib only. It still ships withevents/in practice, but the coupling is a typing-time one, not an import-time one.
types.py is a floor too, but for domain/aggregate.py, not for protocols.py -- protocols.py does not import it. Both floors are stdlib + pydantic, so neither drags infrastructure in; the cost is purely that the package boundary has to be drawn wide enough to contain them.
Consequence for extraction. An eventsource-core package cannot ship protocols.py without events/. The two are a single unit: events/base.py, events/registry.py, and events/__init__.py (which re-exports both) move together with protocols.py, and exceptions.py and types.py come along for the aggregate and handler contracts. Splitting "protocols" and "events" into separate distributions would produce a protocols package that fails to import. ports/bus.py defines EventPublisher outright now; there is no stores/ module left for a reverse binding to have pointed at.
Empty and placeholder modules on the advertised surface¶
One module qualifies as Tier 0 for a reason worth stating out loud: it contains no code.
src/eventsource/config.py is seven lines long in full -- a five-line module docstring, a blank line, and a single trailing comment:
"""Configuration dataclasses for the eventsource library.
This module will contain configuration classes for various components.
Placeholder for future tasks.
"""
# Configuration dataclasses will be added as needed by various tasks
There are no imports, no classes, no functions, and no __all__. It defines zero configuration classes. Its Tier 0 status is therefore vacuous: a module with no import statements cannot import sqlalchemy, so it passes the boundary test without asserting anything about the library's layering.
Nothing imports it. There is no from eventsource.config import ... or from .config import ... anywhere under src/, and nothing under tests/ reaches for it either. The only places it appears are two lines of documentation -- docs/api/index.md:69 lists eventsource.config among the public subsystems that ship without top-level re-export, and docs/api/index.md:797 describes it in the module table as "Configuration helpers." Both descriptions overstate what is there. (eventsource/application/subscriptions/config.py is a different file, is fully populated, and is unrelated to this one; do not conflate them when grepping.)
Why this matters for extraction. A module listed on the advertised API surface and described as providing "configuration helpers" reads, to anyone drawing the eventsource-core boundary, like a contract that core consumers depend on. It is not one. Moving it into a core package would ship an empty file; leaving it behind would break nothing.
There are three ways to resolve it, and the choice should be made deliberately rather than by default:
- Populate it. If the library wants a real configuration surface -- store connection settings, retry defaults, serialization options -- this is the module for it. That decision also determines its tier: pydantic-based settings keep it Tier 0, anything reaching for a driver does not.
- Drop it. Delete the file and remove both
docs/api/index.mdreferences. Nothing imports it, so removal is a no-op for behavior. This is the lowest-cost option if no configuration surface is planned. - Leave it and flag it here. Keep the placeholder as a marker of intent, but treat this section as the authoritative statement that it is empty, so the extraction boundary is drawn with that knowledge rather than around a name.
Until one of the first two happens, option three is what is in effect, and this section is the flag.
Modules NOT in Tier 0 (require sqlalchemy, redis, or another core-but-non-stdlib dependency)¶
| Module | Infrastructure dep | Reason |
|---|---|---|
serialization/ (src/eventsource/serialization/README.md) |
orjson (core dependency, not stdlib/pydantic) |
Moved out of Tier 0 on 2026-07-28: orjson became a core dependency of json.py (performance over Tier 0 purity, a user decision). It still has no eventsource-internal dependency and no sqlalchemy/redis -- it is the lightest module in this table by far, and would be Tier 0 again the moment orjson were made optional -- but it no longer meets this document's "stdlib + pydantic only" bar as written. See docs/reference/serialization-limits.md for what the dependency buys (native UUID/datetime serialization, non-finite-float and integer-range rejection, non-\u-escaped output). |
adapters/_sql/engine.py |
sqlalchemy | Shared async engine factory (create_async_engine). Module-level from sqlalchemy import event and sqlalchemy.ext.asyncio imports; exists to apply the SQLite transaction-control and PRAGMA setup the library's guarantees depend on. |
adapters/postgresql/store.py |
sqlalchemy (lines 29-31); asyncpg availability-checked in a guard (line 52); orjson via serialization |
PostgreSQL event store adapter |
adapters/postgresql/snapshots.py |
sqlalchemy (lines 15-16) | PostgreSQL snapshot store |
adapters/sqlite/store.py |
orjson via serialization (line 46); aiosqlite guarded (line 49) |
SQLite event store adapter. Per boundary rule 2's spirit, the guard does not exempt it -- the runtime contract requires aiosqlite -- and the unguarded serialization import disqualifies it on its own. |
adapters/sqlite/snapshots.py |
aiosqlite (guarded, line 33) | SQLite snapshot store. Clean import block otherwise, but a backend module by runtime contract (boundary rule 2's worked example, readmodels/sqlite.py, applies verbatim). |
adapters/_sql/dialect.py |
sqlalchemy (line 19), orjson via serialization (line 21) |
Shared SQL dialect helpers (Dialect, json_param, uuid_param, ...) used by adapters/sql/checkpoints.py and adapters/sql/dlq.py. The repositories/_dialect.py transition shim that used to re-export this module has been removed; nothing under repositories/ imports it any more. |
adapters/sql/checkpoints.py |
sqlalchemy (text, AsyncConnection, AsyncEngine); adapters/_sql/dialect (sqlalchemy + orjson) |
SQLCheckpointRepository, dialect-parameterized for PostgreSQL and SQLite. The SQL-backed half of the old repositories/checkpoint.py split (ADR 0024, resolving boundary finding 2). |
adapters/sql/dlq.py |
sqlalchemy (text, AsyncConnection, AsyncEngine); adapters/_sql/dialect (sqlalchemy + orjson) |
SQLDLQRepository, dialect-parameterized for PostgreSQL and SQLite. The SQL-backed half of the old repositories/dlq.py split (ADR 0024, resolving boundary finding 2). |
adapters/memory/dlq.py |
orjson via eventsource.serialization (json_dumps) |
InMemoryDLQRepository. No sqlalchemy of its own, unlike its pre-ADR-0024 predecessor -- but it is not stdlib-only either, so it stays out of Tier 0 and is the reason importing the eventsource.adapters.memory package (rather than adapters.memory.checkpoints directly) still costs a non-stdlib dependency. Its sibling adapters/memory/checkpoints.py has no such import and is Tier 0; see the previous table. |
bus/redis.py |
redis (guarded, lines 64-67) | Redis event bus -- backend module by runtime contract |
bus/kafka/ |
aiokafka (guarded, across bus.py, connection.py, dlq.py) |
Kafka event bus package |
bus/rabbitmq/ |
aio-pika (guarded, across its modules) | RabbitMQ event bus package |
(repositories/checkpoint.py and repositories/dlq.py -- deleted) |
-- | These two files, which used to mix a Protocol, dataclasses, an in-memory implementation, and a sqlalchemy-backed SQL implementation in one module each, no longer exist. ADR 0024 split each into a Tier 0 ports/ module (ports/checkpoints.py, ports/dlq.py), a Tier 0 in-memory adapter (adapters/memory/checkpoints.py; adapters/memory/dlq.py is not Tier 0 for an unrelated orjson reason, see above), and a non-Tier-0 SQL adapter (adapters/sql/checkpoints.py, adapters/sql/dlq.py). See boundary finding 2. |
(repositories/outbox.py and repositories/_connection.py -- deleted) |
-- | ADR 0026 completed the split ADR 0024 made for checkpoints and DLQ: the Tier 0 half (OutboxRepository Protocol, OutboxEntry/OutboxStats, outbox_event_data()) now lives in ports/outbox.py; the backend half is three per-technology modules, not one dialect-parameterized module (see adapters/postgresql/outbox.py below and its sibling adapters/sqlite/outbox.py, which is aiosqlite-guarded and appears in the TYPE_CHECKING table). execute_with_connection is gone with _connection.py; every former caller now uses sql_connection from adapters/_sql/connection.py. The whole repositories/ package, including its eager JSON-utility re-exports, is deleted. See boundary finding 2. |
adapters/postgresql/outbox.py |
sqlalchemy (sql_connection, text) |
PostgreSQLOutboxRepository. Same shape as adapters/postgresql/store.py and adapters/postgresql/snapshots.py -- a genuine SQL adapter, not Tier 0 by design. |
adapters/postgresql/readmodels.py |
sqlalchemy, asyncpg | PostgreSQL read model repository. Module-level from sqlalchemy import text and from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, plus adapters/_sql/connection (sql_connection), also sqlalchemy-backed. The accepted debt ADR 0026 §4 recorded against readmodels/postgresql.py -- a non-adapters/ module reaching into adapters/ for a connection helper -- is now resolved: the module itself moved under adapters/ (ADR 0029 §2.3), so there is no longer a boundary crossing to record here. |
adapters/sqlite/readmodels.py |
aiosqlite | SQLite read model repository. No sqlalchemy: it drives aiosqlite connections directly, and the import aiosqlite sits under TYPE_CHECKING. Per boundary rule 2, a TYPE_CHECKING guard does not exempt a module -- the runtime contract is an aiosqlite.Connection, so this is a backend module, not a contract module. |
adapters/sql/readmodel_projection.py |
sqlalchemy (transitive via adapters/sql/projection.py) |
ReadModelProjection. Its own imports are otherwise Tier 0 -- application/projections/base.py, ports/checkpoints.py, and ports/dlq.py are gone from this file's blocker list, since ADR 0024 split them clean. The remaining, different cause is module-level from eventsource.adapters.sql.projection import DatabaseProjection, and adapters/sql/projection.py is itself a sqlalchemy-backed SQL adapter. AsyncConnection/AsyncSession imports are TYPE_CHECKING-only and are not the cause. |
(projections/base.py, projections/checkpoint_manager.py, projections/dlq_manager.py -- deleted) |
-- | The whole projections/ package no longer exists. ADR 0024 dissolved it into application/projections/ -- base.py, checkpoints.py, dlq.py, coordinator.py, retry.py -- which imports only ports/checkpoints.py and ports/dlq.py for the checkpoint/DLQ contracts, and is Tier 0 in full (see the Tier 0 table and boundary finding 1). |
testing/harness.py |
orjson (transitive via adapters/memory/dlq.py, reached through the eventsource.adapters.memory package __init__) |
InMemoryTestHarness. The sqlalchemy blocker is gone -- ADR 0024's split means from eventsource.adapters.memory import InMemoryCheckpointRepository, InMemoryDLQRepository (line 28) no longer touches a repository file that imports sqlalchemy. It still is not Tier 0, for an unrelated reason: importing the adapters.memory package (rather than adapters.memory.checkpoints directly) runs adapters/memory/dlq.py, which imports eventsource.serialization for orjson. Every other import in the file (bus/memory, events/base, adapters/memory/store.py) is Tier 0. |
testing/bdd.py |
sqlalchemy (transitive via testing/harness.py) |
Given-When-Then helpers. Module-level from eventsource.testing.harness import InMemoryTestHarness (line 49); its other imports (domain/aggregate, events/base) are Tier 0. It carries no infrastructure dependency of its own -- it inherits the harness's. |
testing/__init__.py |
sqlalchemy (transitively) | Eagerly re-exports InMemoryTestHarness from harness.py and the BDD helpers from bdd.py alongside the Tier 0 names, so the whole package namespace is tainted: from eventsource.testing import EventBuilder imports sqlalchemy even though builder.py itself is Tier 0. |
testing/conformance_ports/ |
pytest (module level in every suite module), hypothesis (stateful.py) |
Port-level conformance suites. No driver dependency anywhere in the package -- but it is not importable with stdlib + pydantic alone, so it does not meet this document's Tier 0 bar. See finding 6. |
(migration/ -- dissolved) |
-- | ADR 0034 split the package three ways: application/migration/ (orchestration -- coordinator.py, cutover.py, and the rest, still importing sqlalchemy transitively through adapters/sql/migration/), adapters/sql/migration/ (the four repository implementations, sqlalchemy plus adapters/_sql/connection's sql_connection, exactly the migration/repositories/ blocker this row used to describe, same shape, new address), and a new ports/migration/ subpackage (models.py plus the four repository Protocols, cut out of the adapter modules -- Tier 0 in full, no sqlalchemy anywhere). cutover.py and coordinator.py still type-hint PostgreSQLLockManager TYPE_CHECKING-only, unchanged by the split. |
adapters/postgresql/locks.py |
sqlalchemy | PostgreSQLLockManager -- the only production lock manager. eventsource.ports.locks (the DistributedLock/LockRegistry Protocols and LockInfo/migration_lock_key) and eventsource.adapters.memory.locks (InMemoryLockManager) are Tier 0 -- see the Tier 0 table. ADR 0029. |
The readmodels/ split this paragraph used to describe -- "splits down the middle," contract and in-memory halves Tier 0, backend halves not -- has been performed (ADR 0029 §2.2-§2.3). There is no longer a readmodels/ package to split: the contract half (ReadModel, Query/Filter, the ReadModelRepository Protocol, the exception family) is eventsource.ports.readmodels/, a genuine Tier 0 subpackage; the in-memory adapter is adapters/memory/readmodels.py, also Tier 0; the PostgreSQL and SQLite adapters and ReadModelProjection are the three rows above, still not Tier 0 for the reasons stated there. eventsource.readmodels is now a deprecated re-export shim, lazily resolving each name with a warning, removed in 0.8.0.
testing/ still splits down the middle as described: the contract and in-memory halves are Tier 0 (see the previous table), while the backend halves land here. adapters/ splits along directory lines instead: adapters/memory/store.py, snapshots.py, checkpoints.py, locks.py, and readmodels.py are Tier 0, adapters/memory/dlq.py is not (orjson, not sqlalchemy), and the technology directories (adapters/postgresql/, adapters/sqlite/, adapters/sql/) are not.
Boundary findings¶
-
Resolved by ADR 0024: three of the four core
projections/modules were NOT Tier 0, not justbase.py. (Historical.)projections/base.pyused to importrepositories/checkpoint.pyandrepositories/dlq.py, both of which imported sqlalchemy at module level, and the same pull happened twice more independently throughprojections/checkpoint_manager.pyandprojections/dlq_manager.py--base.pyimported both managers as well, so the dependency was redundant rather than layered, and removing the repository imports frombase.pyalone would not have made it Tier 0. ADR 0024 resolved this by dissolving all three modules:projections/no longer exists. Its replacement,application/projections/(base.py,checkpoints.py,dlq.py,coordinator.py,retry.py), imports onlyports/checkpoints.pyandports/dlq.pyfor the checkpoint/DLQ contracts, both of which are Tier 0, and the package is Tier 0 in full -- see the Tier 0 table. The old projection protocols module (projections/protocols.py) was already clean before the split; its re-export ofAsyncEventHandlernow lives directly onprotocols.py, which callers should use instead. -
Resolved by ADR 0024 for checkpoint/DLQ, and by ADR 0026 for outbox. (Historical.) All three repository modules used to mix a Protocol, dataclasses, an in-memory implementation, and a sqlalchemy-backed SQL implementation in one file each -- the uniform shape of
repositories/, and the Tier 0 blocker this document flagged as a known violation in the import-linter section ofpyproject.toml.checkpoint.pyanddlq.pywere split first; ADR 0024 moved each into a pureports/module (ports/checkpoints.py,ports/dlq.py-- the Protocol and dataclasses), an in-memory adapter (adapters/memory/checkpoints.py,adapters/memory/dlq.py), and a dialect-parameterized SQL adapter (adapters/sql/checkpoints.py,adapters/sql/dlq.py).adapters/memory/dlq.pyis not itself Tier 0 -- it importseventsource.serializationfor orjson, an unrelated and much smaller problem than the sqlalchemy one it replaced (see "Modules NOT in Tier 0").
repositories/outbox.py completed the set: ADR 0026 moved the OutboxRepository Protocol, the OutboxEntry/OutboxStats dataclasses, and outbox_event_data() to ports/outbox.py, and split the backend half into three per-technology modules -- adapters/memory/outbox.py, adapters/postgresql/outbox.py, adapters/sqlite/outbox.py -- rather than one dialect-parameterized module like checkpoints and DLQ, because SQLiteOutboxRepository is written against a raw aiosqlite.Connection and unifying it onto sqlalchemy would have rewritten a working adapter's driver layer for directory-naming symmetry alone. The whole repositories/ package is now deleted; the import-linter comment block that used to record this as a known violation now records it as resolved for all three modules.
The projection side and the testing side used to reach into checkpoint.py and dlq.py for different names -- the managers wanted the Protocols and dataclasses, testing/harness.py wanted only the in-memory classes -- and both got sqlalchemy anyway, because all four names lived in files whose first two non-stdlib imports were the sqlalchemy pair above. Splitting checkpoint.py and dlq.py cleared the blocker for projections/ (now application/projections/), for testing/harness.py and testing/bdd.py and the testing/__init__.py namespace behind them, all at once. readmodels/projection.py also lost that blocker, but it picked up a different, narrower one in its place: it now imports adapters/sql/projection.py directly, which is itself a SQL adapter (see finding 5 and "Modules NOT in Tier 0").
-
testing/is not one tier -- it splits three ways, and the package__init__sits on the wrong side of the split. Read per module rather than per package: -
testing/harness.pyno longer has the sqlalchemy blocker described in earlier revisions of this finding:from eventsource.repositories.checkpoint import InMemoryCheckpointRepositoryandfrom eventsource.repositories.dlq import InMemoryDLQRepositoryare gone, replaced byfrom eventsource.adapters.memory import InMemoryCheckpointRepository, InMemoryDLQRepository(line 28). It is still not Tier 0, for a different and smaller reason: that import touches theeventsource.adapters.memorypackage, whose__init__.pyeagerly re-exportsadapters/memory/dlq.py, which importseventsource.serializationfor orjson. Everything else it imports --bus/memory,domain/event,adapters/memory/store.py-- is Tier 0. testing/bdd.pyhas no infrastructure import at all. Its single non-Tier-0 line isfrom eventsource.testing.harness import InMemoryTestHarness; its other in-library imports aredomain/aggregateanddomain/event. It is out of Tier 0 purely by inheritance from the harness.testing/builder.py,testing/assertions.py,testing/conformance.py,testing/recording.py,testing/partitioned_memory.py, andtesting/sync_facade.pyare Tier 0 as they stand today, with no pending change.builder.pyandassertions.pyeach import exactly one in-library name,eventsource.domain.event.DomainEvent.conformance.py(bus-only now) imports two, both contracts:bus/interface,domain/event. The three newer modules reach onlybus/interface,protocols,domain,exceptions, andports. None of the six names a repository, a harness, or a backend.
The distinction matters because the fix is not uniform. The harness needs its checkpoint/DLQ import re-pointed at the submodule paths (adapters.memory.checkpoints, and an orjson-free DLQ source once one exists) rather than the package; bdd.py needs nothing; the other six already comply. Treating testing/ as a single "almost Tier 0" unit hides the fact that most of it is already clean.
Why this matters to users specifically¶
This is the one boundary finding whose cost lands on library consumers rather than on maintainers. The shipped test toolkit is precisely the surface people want without a database: EventBuilder constructs event instances, EventAssertions inspects them, and the conformance suites exercise a FullEventStore or EventBus implementation through its interface. All of these are pure-Python and all are Tier 0 on their own. A downstream team writing domain tests against in-memory infrastructure has a legitimate expectation that importing them costs nothing beyond pydantic.
It does not. testing/__init__.py eagerly imports every re-exported submodule -- assertions, bdd, builder, conformance, harness, and recording -- so from eventsource.testing import EventBuilder executes harness.py. Before ADR 0024 that meant executing repositories/checkpoint.py and repositories/dlq.py, which imported sqlalchemy; today it means executing eventsource.adapters.memory (for InMemoryCheckpointRepository/InMemoryDLQRepository), whose package __init__ in turn executes adapters/memory/dlq.py and pulls orjson. The dependency changed from sqlalchemy to orjson, and shrank considerably, but the shape of the defect did not: the name being imported is Tier 0; the import statement is not. Nothing in the EventBuilder call path touches a repository or a driver, and the user has no way to tell from the import line that it will cost anything at all.
That is why finding 2 was worth prioritizing on user-facing grounds and not only architectural ones: the harness taint was a two-line defect in one file, but its blast radius was every import of the public testing namespace. The sqlalchemy instance of it is fixed; the smaller orjson instance described above is what is left.
-
observability/is Tier 0. It guards opentelemetry behind optional imports with no-op fallbacks. No sqlalchemy or redis. -
Resolved by ADR 0029:
readmodels/split along the seam this finding predicted. (Historical.) This finding used to observe thatreadmodels/already had its Tier 0 and non-Tier-0 halves in separate files (base.py,query.py,schema.py,repository.py,in_memory.py,exceptions.pyTier 0;postgresql.py,sqlite.py,projection.pynot) and needed only a package split, not a file-level refactor, to extract. ADR 0029 §2.2-§2.3 performed exactly that: the Tier 0 half moved toeventsource.ports.readmodels/(a genuine subpackage, not a flat module --model.py,query.py,repository.py,exceptions.py), the in-memory adapter toadapters/memory/readmodels.py(also Tier 0), and the two SQL/aiosqlite-backed pieces toadapters/{postgresql,sqlite}/readmodels.pyandadapters/sql/{readmodel_schema,readmodel_projection}.py(not Tier 0, per the table above).readmodels/projection.py's blocker -- its own module-level import ofadapters/sql/projection.py-- travelled with it unchanged toadapters/sql/readmodel_projection.py.eventsource.readmodelsis now a deprecated lazy re-export shim over all of the above, removed in 0.8.0. -
testing/conformance.pyis Tier 0 today, and it is bus-only -- the store conformance ABC that used to live here is gone. Its import block isabc,typing,uuid, and two Tier 0 in-library modules --bus/interfaceandevents/base. It reaches no repository, no harness, and no backend. The retiredEventStoreConformanceSuitehas no replacement in this module; store implementers now use the per-port suites intesting/conformance_ports/(AppenderConformance,StreamReaderConformance,EventLookupConformance,GlobalFeedConformance,CategoryQueryConformance), which are not Tier 0 themselves (see below).EventBusConformanceSuiteis the ABC an out-of-tree bus subclasses to prove it satisfies theEventBuscontract, so it must travel with the contract it tests. Keeping this module Tier 0 is a constraint on future edits to it, not just an observation: any import added here that pulls sqlalchemy would break the extraction story for every downstream bus. Note that the module is Tier 0 only when imported directly --from eventsource.testing import ...still pulls sqlalchemy through the package__init__(see the previous table).
The newer port-level suites in testing/conformance_ports/ are deliberately not in the same category. They carry no driver imports, but every suite module imports pytest at module level (e.g., appender.py:13) and stateful.py imports hypothesis -- both dev-tier dependencies, neither stdlib nor pydantic. If the ports conformance suites are meant to travel with an extracted core the way conformance.py is, either the extraction accepts pytest as a dependency of a testing extra, or the suites need restructuring so the pytest integration is separable. That is a decision to make at extraction time; this document only records that the bar is currently not met.
- Resolved by ADR 0026:
repositories/_connection.pyis deleted, and its consumers now shareadapters/_sql/connection.py:sql_connection. (Historical.) The module used to be 65 lines hostingexecute_with_connection, an@asynccontextmanagerthat normalized anAsyncConnection | AsyncEngineargument down to a connection, with an unguarded module-levelfrom sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine. It taints nothing now, because it no longer exists:PostgreSQLOutboxRepository's seven call sites, and the five importers outsiderepositories/this finding used to flag as the complication (readmodels/postgresql.py,migration/repositories/audit_log.py,position_mapping.py,migration.py, androuting.py), all now callsql_connection(conn, *, write=...)fromadapters/_sql/connection.py-- the same helper ADR 0024 already introduced for checkpoints and DLQ, so there is now exactly one connection-normalization helper in the codebase instead of two.
ADR 0026 §4 recorded this as accepted debt -- five non-adapters/ modules reaching into adapters/ for a connection helper -- resolved when those modules themselves moved under adapters/. One of the five is now resolved: readmodels/postgresql.py's 16 sql_connection call sites now sit under adapters/postgresql/readmodels.py (ADR 0029 §2.3), so that import no longer crosses a ring boundary. The remaining four -- migration/repositories/{audit_log,position_mapping,migration,routing}.py -- are still open: they remain non-adapters/ modules importing from adapters/_sql/connection.py, tracked in BACKLOG.md's "Move migration/repositories onto the adapters ring" entry.
tests/unit/test_connection_helper.py, which used to pin execute_with_connection's isinstance-patch behavior at eight call sites, is deleted rather than moved -- the helper it existed to test no longer exists.
-
exceptions.pyis the only Tier 0 module with no eventsource-internal dependency at all, which is why the rules are calibrated against it. (Updated 2026-07-28: this finding previously namedserialization/as the package meeting this bar.orjsonbecoming a core dependency ofserialization/json.pymoved that package out of Tier 0 entirely -- see "Modules NOT in Tier 0."exceptions.pyis the replacement reference case, and it is a single module rather than a package, sinceserialization/no longer has a Tier 0 sibling to stand in for it.) Every other Tier 0 entry in the table above reaches somewhere -- most commonly todomain/event, and through it to pydantic.exceptions.pydoes not: it importsuuidand nothing else. A few other individual modules match that on their own --config.py(no imports at all, still a placeholder),readmodels/query.py(dataclasses,typing),domain/stream_id.py(re,dataclasses,uuid), andports/snapshots.py(stdlib only) -- but each is one file inside a top-level namespace or a package whose extraction unit is larger, so none of them is an extractable unit by itself in the same clean sense.exceptions.pyis, and that is what makes it the reference case: when a rule below has to say what "Tier 0" means at its strictest, this is the module it is describing, and the pydantic allowance in rule 1 exists precisely because most Tier 0 modules cannot meet the barexceptions.pysets. -
protocols.pyis Tier 0 but not standalone --domain/event.pyis a hard floor beneath it. The module advertises itself as the canonical type-contract home, which invites the assumption that the protocols can be lifted on their own. They cannot.src/eventsource/protocols.py:35is a plain module-levelfrom eventsource.domain.event import DomainEvent-- not underif TYPE_CHECKING:, not deferred into a function -- so importingeventsource.protocolsexecuteseventsource.domain.eventand, through it, pydantic. The same shape holds fordomain/decorators.py:20andbus/interface.py:16-17(which importsdomain.eventandprotocols). Every one of these is Tier 0 by the dependency test and non-extractable by itself. The consequence for aneventsource-coresplit is concrete:domain/event.pyandprotocols.pyare a single shipping unit, andexceptions.pyandtypes.pycome with them for the aggregate and handler contracts. A distribution containingprotocols.pywithoutdomain/event.pywould fail to import. See "Module-level import coupling within Tier 0" above for the full floor-by-module table, including the two modules that break the pattern (ports/snapshots.py, which has noeventsourceimports at all, anddomain/event_registry.py, whose coupling isTYPE_CHECKING-only). -
config.pyis an empty placeholder that the documentation describes as a real subsystem. The file is seven lines total: a five-line docstring saying "This module will contain configuration classes for various components. Placeholder for future tasks.", a blank line, and one trailing comment. Zero imports, zero classes, zero functions, no__all__. Nothing undersrc/ortests/imports it. Its Tier 0 status is vacuous -- a file with no import statements passes the boundary test without asserting anything about layering -- so it should not be counted as evidence that the core surface is in good shape. What makes it a finding rather than a curiosity is the gap between the file and its documentation:docs/api/index.md:69listseventsource.configamong the public subsystems that ship without top-level re-export, anddocs/api/index.md:797describes it in the module table as "Configuration helpers." Anyone drawing the extraction boundary from the API docs would treat it as a contract core consumers depend on. It is not one, and moving it into a core package would ship an empty file. (Do not confuse it witheventsource/application/subscriptions/config.py, which is a real, populated module.) Resolve it -- populate or delete -- before extraction; see "Empty and placeholder modules on the advertised surface" above for the options and the trade-offs. -
Resolved by ADR 0026:
repositories/__init__.pyno longer exists. (Historical.) The package__init__used to be 97 lines, and every line of executable code in it was an eagerfrom ... importat module scope:checkpoint,dlq,outbox, andeventsource.serialization, followed by a flat 23-name__all__-- no__getattr__, noTYPE_CHECKINGguard, no deferred import anywhere. Splittingcheckpoint.py,dlq.py, andoutbox.pyinto interface + in-memory and SQL halves (finding 2) did not by itself unblock this file; the__init__itself had to stop eagerly re-exporting the SQL-backed names. It never got narrowed or made lazy, because the whole package was deleted instead once outbox landed, which is a stronger fix than either option this finding used to weigh: there is no longer a namespace whose mere touch runs three module bodies and imports sqlalchemy, and the JSON-utility re-export (EventSourceJSONEncoder,json_dumps,json_loads) is gone with it -- callers reacheventsource.serializationdirectly, at the cost of orjson alone.The top-level package's own cost, which this finding used to trace up from
repositories/__init__.py, no longer exists as of ADR 0035:src/eventsource/__init__.pyno longer imports fromeventsource.repositoriesat all (that package is gone), and it no longer importseventsource.adapters._sql.engineoreventsource.adapters.postgresqlat module level either -- both moved behind a PEP 562__getattr__that resolves and caches each name on first access.import eventsourceno longer requires sqlalchemy at all; deletingrepositories/removed one route to that cost, and the lazy front door removed the cost itself. See finding 12 and cleanup step 5, both updated accordingly. -
Resolved by ADR 0035: the ring packages --
domain/,ports/,application/,adapters/memory/-- are Tier 0 in full, and the top-level front door no longer defeats that. Per module, the new hexagonal core is exactly what this document asks for: every file under those four packages,__init__s included, resolves to stdlib, pydantic, and Tier 0 in-library modules. The legacystores/package is gone entirely, so there is no longer any transitional re-export seam to describe:EventPublisheris defined inports/bus.pyand nothing else claims it. The complete import closure of theportssubpackage, package__init__s included, isdomain/,events/,exceptions.py, andtypes.py; verified by importing everyportssubmodule under a stubbed top-level package and asserting sqlalchemy absent fromsys.modules. The Tier 0 import-linter contract now listseventsource.portsas a whole (not justports/snapshots), so a reintroduced outward import would break the build.The caveat this finding used to record -- that
import eventsource.<anything>executes the top-leveleventsource/__init__.pyfirst, and that front-door initializer eagerly reachedadapters/postgresql/snapshots.py, so a bare runtimeimport eventsource.portsstill loaded sqlalchemy regardless of which ring-Tier-0 name was actually wanted -- is closed.src/eventsource/__init__.pyis now a PEP 562 lazy front door (ADR 0035): every public name is imported and cached on first__getattr__access rather than at module load, and aTYPE_CHECKINGblock carries the same import statements for static analysis.import eventsource, sys; assert "sqlalchemy" not in sys.modulesnow holds for a bare import, and the "tempting automated check" the verification section below used to say "currently fails for every module in the package" now succeeds for anything reached exclusively through the four ring packages, at last making the runtimesys.modulescheck this document asked for directly possible instead of only the staticast-based approximationtests/unit/ports/test_readmodels_port_surface.pyhad to fall back on. -
application/subscriptions/no longer belongs in the non-Tier-0 table. Earlier revisions of this document listed it as sqlalchemy-tainted "via stores, repositories." That is no longer true: no module underapplication/subscriptions/imports a repository, a store backend, or a driver, at module level or otherwise. The package's only out-of-package imports areportsandobservability-- all Tier 0 -- and its runtime contracts are the store/bus ports, satisfiable by the in-memory adapters. The package__init__eagerly re-exports its own submodules only, so the namespace is as clean as the modules. It is a large surface (25+ modules) rather than a small contract set, so whether it belongs in an extracted core is a scoping question, not a dependency one.
Boundary rules for Tier 0¶
- Allowed dependencies: stdlib, pydantic, typing-extensions. (Pydantic is permitted, not required -- a Tier 0 module that needs none of it should import none of it.
exceptions.pyis the pydantic-free case: its whole import surface isuuid, and it stays that way.serialization/was this document's previous example of the pydantic-free case; it no longer qualifies, having taken onorjsonas a core dependency -- see "Modules NOT in Tier 0.") - No sqlalchemy imports -- not even behind
TYPE_CHECKING. Tier 0 modules must be importable without sqlalchemy installed. - Optional deps must be guarded: opentelemetry is acceptable if behind
try/except ImportErrorwith no-op fallback. - In-memory implementations belong in Tier 0: They implement the interface contracts using only stdlib. They are essential for testing without infrastructure.
- Interface + implementation separation: Files that define both a Protocol/ABC and a sqlalchemy-backed implementation must be split before the interface can move to Tier 0.
- Tier 0 membership travels with the module's own transitive Tier 0 imports. Passing the dependency test is not the same as being extractable. If a module imports another
eventsourcemodule at import time, that module is part of its extraction unit and must move with it -- there is no partial ship.protocols.pyis the worked example:src/eventsource/protocols.py:35is a plain module-levelfrom eventsource.domain.event import DomainEvent, so a distribution containingprotocols.pywithoutdomain/event.pyfails to import. When claiming a module for Tier 0, close its module-leveleventsourceimport set transitively and confirm every member is also Tier 0 and also in scope for the move. Imports underif TYPE_CHECKING:do not create this obligation (they are typing-time only, as indomain/event_registry.py:43-44) -- but note this is the opposite of rule 2, where aTYPE_CHECKINGguard does not excuse a sqlalchemy import. The two rules ask different questions: rule 2 is about what must not be installed, this rule is about what must ship together. - Placeholder and empty modules do not count as satisfied surface. A module qualifies for Tier 0 only by asserting something about layering, not by containing too little code to violate it.
src/eventsource/config.pyis seven lines of docstring and comment with zero imports, zero classes, and no importer anywhere insrc/ortests/; it passes rules 1-3 vacuously. Do not treat such a module as a satisfied contract, do not cite it as evidence the core surface is healthy, and do not carry it into an extracted package on the strength of its name or its API-doc description. Resolve it first -- populate it, or delete it and its documentation references -- so the boundary is drawn around code that exists.
Recommended pre-extraction cleanup¶
These are tracked items; steps 1 and 2 have landed since the last revision of this document, the rest have not. Steps 1-3 were originally the same operation applied three times to the three repository modules; steps 4 and 5 are the parts that are easy to miss, and skipping either of them leaves the boundary where it started.
-
Split
repositories/checkpoint.pyinto an interface + in-memory module and a SQL backend module. Done (ADR 0024). The Tier 0 half -- theCheckpointRepositoryProtocol and its composed pieces, theCheckpointData/LagMetricsdataclasses -- now lives inports/checkpoints.py, plusInMemoryCheckpointRepositoryinadapters/memory/checkpoints.py. The backend half,SQLCheckpointRepository(dialect-parameterized for PostgreSQL and SQLite), now lives inadapters/sql/checkpoints.py.repositories/checkpoint.pyitself is deleted. Importing the Protocol or the in-memory class no longer requires sqlalchemy, which is what boundary rule 5 asked for. -
Split
repositories/dlq.pythe same way. Done (ADR 0024). Interface + dataclasses (DLQRepository,DLQEntry,DLQStats,ProjectionFailureCount) now live inports/dlq.py; the in-memory implementation isadapters/memory/dlq.py; the SQL-backedSQLDLQRepositoryisadapters/sql/dlq.py.repositories/dlq.pyitself is deleted. One caveat carried over rather than fully resolved:adapters/memory/dlq.pyimportseventsource.serializationfor orjson, so it is not itself Tier 0 -- a smaller, unrelated problem than the sqlalchemy one this step removed (see boundary finding 2 and "Modules NOT in Tier 0"). -
Split
repositories/outbox.pythe same way. Done (ADR 0026). The Tier 0 half -- theOutboxRepositoryProtocol, theOutboxEntry/OutboxStatsdataclasses, andoutbox_event_data()-- now lives inports/outbox.py. The backend half is three per-technology modules rather than one dialect-parameterized module like checkpoints and DLQ:adapters/memory/outbox.py,adapters/postgresql/outbox.py, andadapters/sqlite/outbox.py. Unlike checkpoints and DLQ, the outbox could not land dialect-parameterized --SQLiteOutboxRepositoryis written against a rawaiosqlite.Connection, not a sqlalchemy engine or session, and unifying it would mean rewriting a working adapter's driver layer for no functional gain (ADR 0026 §2, rejected alternative).repositories/outbox.pyitself is deleted along with the rest of therepositories/package. -
Confine
_connection.pyto the backend side. Done (ADR 0026), by deletion rather than relocation.repositories/_connection.pyand itsexecute_with_connectionhelper are gone. Every call site -- the seven insidePostgreSQLOutboxRepository, plus the five importers outsiderepositories/this step originally flagged as the constraint (readmodels/postgresql.py, andmigration/repositories/audit_log.py,migration/repositories/position_mapping.py,migration/repositories/migration.py,migration/repositories/routing.py) -- now callssql_connection(conn, *, write=...)fromadapters/_sql/connection.py, the same helper ADR 0024 introduced for checkpoints and DLQ.tests/unit/test_connection_helper.pyis deleted rather than moved: it existed to pinexecute_with_connection's isinstance-patch behavior, and that helper no longer exists to pin.
Accepted debt, since resolved for the migration importers (ADR 0034), still open for readmodels. The four migration/repositories/*.py importers (audit_log.py, position_mapping.py, migration.py, routing.py) named above as "outside adapters/ reaching into it for a connection helper" moved into adapters/sql/migration/ themselves when eventsource.migration was dissolved into the rings -- the naming smell this step recorded as debt is closed for those four, not just relocated. readmodels/postgresql.py's successor (adapters/postgresql/readmodels.py) had already resolved the same way earlier, per ADR 0029 §2.3 (noted in the Tier 0 table above). No importer of adapters/_sql/connection.py's sql_connection now lives outside adapters/.
- Reduce the eager re-exports in
repositories/__init__.py. Moot -- the package is deleted.repositories/__init__.pyno longer exists, so there is nothing left to narrow or make lazy; the "two viable shapes" this step used to weigh (narrow the eager surface, or make it lazy via__getattr__) were never built, because the package that would have hosted either shape was removed outright in the same slice that would have applied them.
The re-export cost this step traced up to the top-level package was not resolved by the deletion at the time -- worth stating plainly rather than letting the step's closure imply otherwise, since it took a separate slice (ADR 0035) to actually close it. src/eventsource/__init__.py no longer imports from eventsource.repositories -- that route to sqlalchemy was gone even then -- but it still imported eventsource.adapters._sql.engine and eventsource.adapters.postgresql at module level for some time after, both sqlalchemy at import time in their own right. That cost is now closed too: ADR 0035 made the top-level __init__ a PEP 562 lazy front door, and import eventsource no longer requires sqlalchemy for any caller who doesn't touch a sqlalchemy-backed name. See finding 12 and the verification section below for the full story.
- The payoff: with the split,
application/projections/(the successor toprojections/base.py) is Tier 0, andtesting/harness.pyhas shrunk to a single, smaller open item. Realized forapplication/projections/. The table below records what the oldprojections/modules imported and where those names live now --projections/base.pyitself no longer exists, dissolved intoapplication/projections/base.pyand its siblings.
| Old module (deleted) | Old import to re-point | Names it wanted | Where they live now |
|---|---|---|---|
projections/base.py |
repositories.checkpoint, repositories.dlq |
CheckpointRepository, InMemoryCheckpointRepository, DLQRepository, InMemoryDLQRepository |
application/projections/base.py imports the Protocols from ports/checkpoints.py and ports/dlq.py; the in-memory classes are reached via adapters/memory where needed, not by application/projections/ itself |
projections/checkpoint_manager.py |
repositories.checkpoint |
CheckpointRepository, InMemoryCheckpointRepository |
Dissolved into application/projections/checkpoints.py, which imports ProjectionCheckpoints from ports/checkpoints.py |
projections/dlq_manager.py |
repositories.dlq |
DLQEntry, DLQRepository, InMemoryDLQRepository |
Dissolved into application/projections/dlq.py, which imports DLQEntry, DLQRepository from ports/dlq.py |
Not fully realized for testing/harness.py. The re-point happened -- it now imports InMemoryCheckpointRepository, InMemoryDLQRepository from eventsource.adapters.memory (lines 28) -- but that import targets the package rather than adapters.memory.checkpoints directly, and the package __init__ eagerly pulls in adapters/memory/dlq.py, which is not itself Tier 0 (orjson via eventsource.serialization). Pointing testing/harness.py at eventsource.adapters.memory.checkpoints directly, and at an orjson-free source for InMemoryDLQRepository (or accepting orjson as the harness's one remaining non-stdlib cost), is what closes this out; see boundary finding 3. Because both names now target the package rather than a single defining module, step 5 is not a prerequisite for this one either -- the constraint is the package __init__'s own eagerness, not repositories/__init__.py. Everything else testing/harness.py imports -- bus/memory, events/base, adapters/memory/store.py -- is already Tier 0. Closing this out also unblocks testing/bdd.py and the testing/__init__.py namespace, both of which inherit the harness's dependency.
Verifying the boundary¶
The tables above are a snapshot. Every claim in them was produced by the checks below, and those checks are how you re-derive a module's tier after any change -- do not trust the table over a fresh grep.
The check: module-level infrastructure imports¶
A module is disqualified from Tier 0 by an import that runs at import time. The direct check is a grep anchored at column zero, which is what "module level" means in an import block:
grep -rlE '^(from|import) (sqlalchemy|redis|asyncpg|aiosqlite|aiokafka|aio_pika)' \
src/eventsource/ --include='*.py' | sort
Run against the tree today, this returns fourteen files -- down from fifteen after ADR 0026 deleted repositories/_connection.py and repositories/outbox.py and added adapters/postgresql/outbox.py in their place (net one file fewer). The legacy stores/ package (stores/postgresql.py, stores/sqlite.py) is gone entirely -- deleted along with the rest of stores/ in favor of ports/ + adapters/ -- which drops two further rows a prior revision of this document carried:
src/eventsource/adapters/_sql/connection.py
src/eventsource/adapters/_sql/dialect.py
src/eventsource/adapters/postgresql/outbox.py
src/eventsource/adapters/postgresql/snapshots.py
src/eventsource/adapters/postgresql/store.py
src/eventsource/adapters/sql/checkpoints.py
src/eventsource/adapters/sql/dlq.py
src/eventsource/adapters/_sql/engine.py
src/eventsource/locks/postgresql.py
src/eventsource/migration/repositories/audit_log.py
src/eventsource/migration/repositories/migration.py
src/eventsource/migration/repositories/position_mapping.py
src/eventsource/migration/repositories/routing.py
src/eventsource/readmodels/postgresql.py
Every other non-Tier-0 module in this document is disqualified transitively -- it imports one of these fifteen, or something that does (or, in the serialization/ and conformance_ports/ cases, a non-driver dependency the grep does not cover: orjson, pytest, hypothesis). So the grep is a first pass, not the whole answer: a clean result means "not directly disqualified," and you still have to walk the module's own in-library imports and confirm each one is Tier 0.
TYPE_CHECKING guards do not exempt a module¶
Boundary rule 2 says no sqlalchemy imports, not even behind TYPE_CHECKING. This is the rule people get wrong most often, because a guarded import genuinely does not execute at runtime and the module will import fine with the dependency absent. It is still not Tier 0.
The reason is that the guard hides the dependency without removing it. If a method signature is annotated connection: AsyncConnection, the runtime contract is a sqlalchemy object regardless of when the name is resolved -- the module is a backend adapter that happens to defer a symbol lookup. A Tier 0 module has to be usable, not merely importable, without the infrastructure library.
Two consequences for how you run the check:
- Do not relax the anchor to
^\s*. A leading-whitespace regex sweeps in guarded and function-local imports, and its output mixes genuine module-level violations with indented ones. Run the anchored grep for disqualification, then run the indented variant separately as a review list, not as a verdict:
grep -rnE '^[ \t]+(from|import) (sqlalchemy|redis|asyncpg|aiosqlite|aiokafka|aio_pika)' \
src/eventsource/ --include='*.py'
Today that surfaces the guarded driver imports across bus/redis.py (lines 64-67), the bus/kafka/ and bus/rabbitmq/ package modules, adapters/postgresql/store.py:52 (asyncpg), adapters/sqlite/store.py:46 and adapters/sqlite/snapshots.py:33 (aiosqlite), adapters/sqlite/outbox.py:31 (aiosqlite -- ADR 0026's replacement for the old repositories/outbox.py:38 guard), adapters/sql/projection.py:355,359 (sqlalchemy), readmodels/sqlite.py:37, and readmodels/projection.py:26 and :231. None of those lines runs at import time; none of those modules is Tier 0 either.
readmodels/sqlite.pyis the worked example. It does not appear in the anchored grep at all -- it uses no sqlalchemy and importsaiosqliteonly underTYPE_CHECKING. It is still a backend module, because its public methods take anaiosqlite.Connection. Judge by what the module requires of its caller, not by what its import block executes.
Why an import-time test used to not work, and now does (ADR 0035)¶
(Historical, through the top-level front door specifically -- see below for what changed.) The tempting automated check -- install a sys.meta_path finder that raises on sqlalchemy, then importlib.import_module the target -- used to fail for every module in the package reached through import eventsource, including ones this document lists as Tier 0, because the chain was in the package initializers, not the modules. Deleting stores/ shortened the chain: it no longer routed through a stores/__init__ that pulled in a PostgreSQL backend as a side effect of touching a Tier 0 interface module. But the front door still reached sqlalchemy directly through the top-level __init__'s own eager import of adapters/postgresql/:
eventsource/__init__.py:37 (from eventsource.adapters.postgresql import ASYNCPG_AVAILABLE, PostgreSQLEventStore)
-> adapters/postgresql/__init__.py:3 (from eventsource.adapters.postgresql.snapshots import PostgreSQLSnapshotStore)
-> adapters/postgresql/snapshots.py:15 (from sqlalchemy import text)
That three-hop chain -- down from the five-hop one this document recorded before stores/ was deleted -- entered entirely through the top-level eventsource/__init__.py eagerly importing the adapters.postgresql package, fired unconditionally before any caller-specific import was ever reached. Importing eventsource.testing.conformance, or anything else, ran eventsource/__init__.py first, so the blocker fired before the target module was ever reached, regardless of how Tier 0 that target module's own import list was.
ADR 0035 closes this. eventsource/__init__.py is now a PEP 562 lazy front door: the module-level from eventsource.adapters.postgresql import ... and its siblings are gone, replaced by a _LAZY: dict[str, str] mapping resolved on first __getattr__ access to each name, with a TYPE_CHECKING block carrying the same import statements verbatim for static analysis. The three-hop chain above no longer fires on a bare import eventsource -- it fires only when a caller actually touches eventsource.PostgreSQLEventStore (or another sqlalchemy-backed name), exactly the on-demand behavior this section used to say was unavailable. import eventsource, sys; assert "sqlalchemy" not in sys.modules now passes; tests/unit/test_lazy_import.py pins it with a subprocess test (a fresh interpreter is required -- the pytest process itself has already imported half the world by the time any in-process test runs).
The package-taint pattern this section documented for repositories/__init__.py, testing/__init__.py, readmodels/, and adapters/postgresql/__init__.py alike -- an eager __init__ reaching a backend before the caller asked for one -- still applies inside those packages; ADR 0035 only lazy-loads the top-level front door, not every intermediate package __init__ it might route through once a caller does ask for a name. from eventsource.adapters.postgresql import PostgreSQLEventStore still runs adapters/postgresql/__init__.py in full, including snapshots.py, exactly as before -- the fix is that nothing forces that import to happen just because a caller imported eventsource and wanted a completely unrelated name.
So: tier is a property of a module, not of the package that contains it, and for anything reached exclusively through the four ring packages (domain/, ports/, application/, adapters/memory/), an import-time sys.modules check is now the authoritative verification, not just static grep plus manual import-walking. For adapter packages with their own eager internal re-exports (adapters/postgresql/, adapters/testing/, etc.), the same package-taint caveat as always applies once a caller does import something from them.
That check now runs in CI. tests/unit/test_core_surface_purity.py imports each core-surface module in a fresh subprocess and fails if sqlalchemy, redis, asyncpg, aiosqlite, aiokafka, or aio_pika lands in sys.modules. It is not redundant with the import-linter contract, which reads import statements and therefore cannot see an importlib.import_module, a driver a third-party package registers on import, or a name the lazy front door resolves; the runtime check covers those and the static contract covers by-name dependencies that never execute. The contract's forbidden_modules was widened from sqlalchemy alone to the same six drivers at the same time -- redis had been an extra since the 0.5.0 demotion with nothing enforcing its absence from Tier 0.
Checklist for a single module¶
- Run the anchored grep on the file. Any hit: not Tier 0, stop.
- Read its import block. For each
from eventsource.X import ...at module level, confirmXis Tier 0 per the tables above -- recursing if you do not already know. Watch for package imports (from eventsource.adapters.postgresql import ...), which pull that package's__init__.pyand everything it re-exports, not just the name you asked for. - Check the
TYPE_CHECKINGblock and the method signatures. If a public signature's runtime contract is an infrastructure object, the module is a backend adapter regardless of step 1. - Optional deps are acceptable only in the
try/except ImportErrorwith no-op fallback shape thatobservability/uses (boundary rule 3). A bare guarded import is not that shape.