Schema Design¶
Event sourcing puts an unusual amount of weight on the database schema. In a CRUD system the tables are an implementation detail that can be reshaped at will; here they are the system of record, the ordering authority, the concurrency arbiter, and the contract that every replay, projection rebuild and tenant migration depends on. A column added carelessly is inconvenient. A primary key chosen carelessly is a rewrite.
This page explains why the schemas shipped in eventsource.adapters.sql.schemas look
the way they do — why the events table carries two identities rather than one,
why optimistic locking is a unique constraint instead of application logic, why
the payload is opaque JSON, and what each of those choices costs. It is not a
setup guide and not a column-by-column reference; it is the reasoning behind the
DDL, so that when you extend it, partition it, or port it to another backend you
know which properties are load-bearing and which are conveniences.
Throughout, the schemas under discussion are the ones you get from
get_schema() and get_all_schemas(): events, events_partitioned,
outbox, checkpoints, dlq, snapshots, and migration, in their
PostgreSQL and SQLite forms.
Why this page exists¶
The library has carried an in-tree design note at
src/eventsource/adapters/sql/schemas/SCHEMA_DESIGN.md since the schemas were first
written. It sat next to the SQL, which made it easy to update while writing DDL
and easy to forget afterwards — and it drifted. It still describes the events
table as having event_id for a primary key, while
src/eventsource/adapters/sql/schemas/schemas/events.sql has used
global_position BIGSERIAL PRIMARY KEY with event_id UUID NOT NULL UNIQUE
for some time. That is not a cosmetic difference: it is the difference between
a store that can be replayed in a total order and one that cannot. A design
document that is wrong about the primary key is worse than no document, because
readers trust it.
This page is the published counterpart of that note, and the one that is kept
current. The in-tree file becomes a pointer here. Keeping the prose in docs/
means it is built, reviewed and cross-linked alongside the rest of the
documentation rather than reviewed only by whoever happens to be editing SQL.
The split of responsibilities is deliberate:
- The SQL files in
src/eventsource/adapters/sql/schemas/schemas/(andtemplates/,updates/) are the authority on what the schema is. If this page and a.sqlfile disagree, the.sqlfile is right and this page is a bug. - The reference documentation covers the API surface —
get_schema(),get_all_schemas(),get_alembic_template()— and the per-backend store implementations. - This page covers why: the constraints that are load-bearing, the ones that are convenience, and the costs each choice imposes on operations.
Read it before you change the DDL, before you add an index, and before you assume a constraint is safe to relax. Most of what follows exists because some property of the system — total replay order, optimistic locking, at-least-once delivery — depends on it in a way that is not visible from the DDL alone.
Scope¶
This page covers the SQL that ships inside the library — the seven schema names
exposed by eventsource.adapters.sql.schemas and nothing else:
| Schema name | Objects it creates | Backends |
|---|---|---|
events |
events (non-partitioned) plus its indexes |
postgresql, sqlite |
events_partitioned |
events declaratively partitioned by timestamp, the events_global_position_seq sequence, monthly partitions and lifecycle helpers |
postgresql |
outbox |
event_outbox |
postgresql, sqlite |
checkpoints |
projection_checkpoints |
postgresql, sqlite |
dlq |
dead_letter_queue |
postgresql, sqlite |
snapshots |
snapshots |
postgresql, sqlite |
migration |
tenant_migrations, tenant_routing, migration_position_mappings, migration_audit_log |
postgresql |
These names are not documentation labels; they are the values of the
SchemaName literal in src/eventsource/adapters/sql/schemas/__init__.py, and they are
what you pass to get_schema(name, backend=...). There is one further name,
"all", which is not a table but a pre-combined file — and its contents are
narrower than the list above, which is a distinction worth holding onto:
schemas/all.sql bundles the non-partitioned events, event_outbox,
projection_checkpoints, dead_letter_queue and snapshots. It does not
include events_partitioned (which defines a conflicting events table) and
it does not include the tenant-migration tables (which most deployments never
need). get_all_schemas() is the quick path to a working store, not a
superset.
Two backends are in scope, because two backends are supported:
postgresql and sqlite. PostgreSQL is the production target and carries the
full set — partitioning, partial indexes, JSONB, triggers, the migration
tables. SQLite exists for tests and small single-process deployments and
carries five of the seven schemas; events_partitioned and migration have
no SQLite form at all. That gap is expressed in the layout rather than in a
compatibility shim: PostgreSQL templates live directly in
src/eventsource/adapters/sql/schemas/templates/, SQLite templates in
templates/sqlite/, and a schema simply has no file under the backend that
does not support it. Asking for one you do not get a degraded substitute; you
get a ValueError naming what is available.
Also in scope, though it sits slightly outside the get_schema() surface, is
src/eventsource/adapters/sql/schemas/updates/ — currently the pair of
001_add_global_position files that retrofit position-based resume onto an
existing projection_checkpoints table. New installs get that column from the
shipped template already; the update files exist for databases created before
it landed. They explain the append-only convention discussed later on this
page, so they are discussed here too.
Deliberately out of scope:
- Your tables. Read-model and projection tables are yours to design. The
library tracks where a projection is (
projection_checkpoints) and what broke (dead_letter_queue); it has no opinion about the shape of what the projection writes. - Non-SQL backends. The in-memory and Redis implementations have no DDL. Nothing on this page constrains them, though the invariants — total order, per-aggregate versioning, event identity — are the same ones their conformance tests assert.
- Advisory locks. Distributed locking uses PostgreSQL advisory locks, which are a runtime facility, not a table. There is no schema to describe.
- Runtime behaviour. How the outbox poller drains rows, how subscriptions retry, how a tenant migration cuts over — those belong to the components that use these tables. This page stops at the storage shape and the invariants the DDL enforces.
Design goals¶
Four properties shape every table on this page. None of them is fully achievable by DDL alone, and being precise about where the schema's responsibility ends is the point of this section: a constraint the database enforces is a guarantee, a convention the application upholds is a risk, and the two are easy to confuse when you are reading SQL.
Immutability¶
Events are facts. Once events has a row, that row is not meant to change and
not meant to disappear — replay must produce the same aggregate state today
that it produced last month, and an audit trail that can be edited is not an
audit trail.
The schema leans into that shape. events has no updated_at, no soft-delete
flag, no status column, nothing that implies a lifecycle. Contrast it with
event_outbox, projection_checkpoints and dead_letter_queue, which all
carry mutable status or counters and, in the checkpoints case, a
BEFORE UPDATE trigger to maintain updated_at. The absence of those columns
in events is the design statement. The library reinforces it in Python:
DomainEvent sets model_config = ConfigDict(frozen=True), so an event
instance cannot be mutated after construction.
What the schema does not do is enforce it. There is no rule, no trigger, and
no REVOKE in templates/events.sql that stops UPDATE events SET payload =
... or a DELETE. Immutability here is a discipline backed by the absence of
any API that would violate it, not a database-level prohibition. If you need it
enforced — and in a regulated environment you probably do — that is a
deployment-level concern: grant the application role SELECT, INSERT on
events and withhold UPDATE, DELETE. The schema is compatible with that
grant; it does not apply it for you.
The one place this collides with reality is data protection, since "never delete" and "erase this subject's data on request" are direct opposites. That tension is real, has no clean schema-level answer, and is taken up later on this page.
Ordered replay¶
Replay only means something if it has an order, and the order has to be one the
database can hand back deterministically. That requirement is why events
carries two identities rather than one. global_position BIGSERIAL PRIMARY KEY
is the total order across every stream; version INTEGER under
UNIQUE (aggregate_id, aggregate_type, version) is the per-aggregate order.
The first is what a subscription resumes from; the second is what an aggregate
replays along.
Neither of those is timestamp. The timestamp column is domain time — when
the event happened in the business sense — and domain time is supplied by the
writer, can be backdated, can arrive out of order under clock skew, and can
collide exactly between two events. It is indexed and useful for queries and
auditing. It is not an ordering authority, and a checkpoint stored as a
timestamp would silently skip events written by a lagging clock. This is why
projection_checkpoints has a global_position BIGINT column at all, and why
resume is expressed as "everything after position N".
The per-aggregate half of the ordering is where the write side gets its
guarantee, and the design decision worth noticing is that the guarantee is
delegated to the database. The unique constraint on
(aggregate_id, aggregate_type, version) means two concurrent writers who both
believe an aggregate is at version 7 cannot both write version 8; one of them
gets a constraint violation. PostgreSQLEventStore.append catches the
IntegrityError, classifies it via _classify_integrity_error (which reads
constraint_name off the underlying asyncpg.exceptions.UniqueViolationError,
falling back to a substring match only if that attribute is unavailable),
re-reads MAX(version) for the aggregate and raises OptimisticLockError with
the actual version. The SQLite store does the same against
aiosqlite.IntegrityError. The application never has to hold a lock or
serialize writers to be correct — the concurrency check is the constraint,
and correctness survives multiple processes, restarts and races it never sees.
event_id UUID NOT NULL UNIQUE is the third identity, and it is separate from
both orders. It is client-generated, travels with the event through the bus and
into the outbox, DLQ and checkpoints, and gives consumers a stable key to
deduplicate on. Note what its uniqueness does and does not do on the write
path: re-appending an already-stored event raises rather than silently
succeeding — the store has no ON CONFLICT (event_id) DO NOTHING. It is a
guard against double-writes, not an upsert.
At-least-once delivery with idempotent handlers¶
The delivery guarantee is at-least-once, deliberately, and everything downstream of the event store is designed on the assumption that handlers will see duplicates.
Exactly-once across a database and a message broker requires distributed
transactions, which is a cost most systems should not pay. The library takes
the standard trade instead: event_outbox is written in the same transaction
as the event, so a state change and the intent to publish it commit or fail
together. A background poller then drains pending rows —
get_pending_outbox_events() selects WHERE status = 'pending' ordered by
created_at with FOR UPDATE SKIP LOCKED, so concurrent workers do not
collide — and marks them published afterwards. If the process dies between
publishing to the broker and marking the row published, the row is still
pending and the event is published again. That is not a bug in the design; it
is the design. retry_count and last_error on the outbox row exist because
publishing is expected to fail sometimes and be retried.
The consequence is a contract with your code: event handlers must be
idempotent. Processing the same event twice must produce the same result as
processing it once. The schema helps where it can — event_id is a stable
deduplication key, projection_checkpoints records where a projection got to,
and dead_letter_queue has UNIQUE (event_id, projection_name) so a repeated
failure updates one row rather than accumulating many — but no arrangement of
tables can make a non-idempotent handler safe. "Exactly-once processing" is a
property of idempotent handlers plus checkpoints, never of the schema alone.
The at-least-once choice also explains two operational obligations that appear
later: event_outbox and dead_letter_queue are working tables that grow
without bound unless something prunes them, which is why the shipped SQL
includes cleanup_published_outbox_events() and cleanup_resolved_dlq()
rather than leaving cleanup entirely to you.
Optional multi-tenancy¶
One schema serves both single-tenant and multi-tenant deployments. There is no
events_multitenant.sql, no build flag, no separate template. The mechanism is
a nullable column: tenant_id UUID on events and on event_outbox, left
NULL when you have no tenants and populated when you do.
Nullable rather than required, because requiring it would tax every
single-tenant install with a column it must invent a value for, and because
NULL is an honest representation of "this deployment has no tenant concept".
actor_id VARCHAR(255) is nullable for the same reason — some events are
caused by a system or a scheduler, not a user.
The cost is paid in indexing, and the schema is explicit about it. Multi-tenant
lookups use a partial index, idx_events_tenant_id ... WHERE tenant_id IS NOT
NULL, so a single-tenant install with a hundred million all-NULL rows does
not carry a hundred million useless index entries. The same partial-index
pattern appears on event_outbox.tenant_id. That is the general principle
behind "optional" here: optional features should be free when unused, and the
place that shows up is index size and write amplification rather than the table
definition.
Where the DDL stops is filtering. Nothing in these schemas prevents a query
from reading across tenants — there is no row-level security policy, no
mandatory predicate, no foreign key to a tenants table. Tenant isolation is an
application obligation, upheld at runtime by the contextvars-based tenant
context, TenantDomainEvent (which makes tenant_id required rather than
optional at the event level), and the tenant-scoped repository, which validates
that every uncommitted event carries the expected tenant_id before it saves
and raises TenantMismatchError otherwise. The column makes isolation
possible and makes it auditable after the fact; the runtime layer is what
makes it happen.
The events table¶
Everything else in this library is derived data. Projections can be rebuilt,
snapshots discarded, checkpoints reset, outbox rows drained and deleted. The
events table cannot be regenerated from anything, so its shape is the one
place where a wrong decision is permanent. Eleven columns, one primary key, one
unique constraint, and one additional unique index carry the whole design.
Two identities, two jobs¶
The table has two independent identifiers, and confusing them is the most common way to get event sourcing wrong:
global_position is assigned by the database at insert time, from a
sequence, in insertion order. It answers "where am I in the stream of everything?" — it is a
monotonic cursor over the whole store, and it is the primary key because that
is also the order the table is most usefully clustered and scanned in.
Subscriptions resume from it, current_position() reports the current high
water mark as an opaque Position, and append returns an AppendResult
whose position field is the position of the first event it wrote so a caller
knows where its own writes landed.
event_id is assigned by the client, before the write — it is
uuid4() by default on DomainEvent. It answers "which event is this?" and it
is the identity that survives leaving the database: it travels through the bus,
into event_outbox, into dead_letter_queue, and into whatever deduplication
table a downstream consumer keeps. It is UNIQUE but not the primary key,
because a UUID makes a poor ordering key and no ordering at all.
That separation is what lets the store detect a duplicate append without being
lossy on order. There is no pre-check SELECT for event_id on the write
path — PostgreSQLEventStore.append inserts unconditionally and relies
entirely on the UNIQUE constraint to catch a repeat. When the insert
violates it, _classify_integrity_error recognizes the events_event_id_key
constraint and the store raises DuplicateEventError rather than silently
skipping the row. A retried append with the same event_id is therefore
rejected, not absorbed — callers that want at-least-once retry semantics on
top of this need to catch DuplicateEventError themselves and treat it as
"already applied."
A single identifier cannot do both jobs. A UUID primary key gives identity but
no replay order. A bare sequence gives order but no identity that means
anything outside this one database — restore into a new database and the
positions change, while the event_id values do not.
Why global_position exists at all¶
The obvious alternative to a sequence is timestamp, and it does not work.
timestamp in this schema is domain time: append writes
event.occurred_at into it, which is a value the application supplies. It can
be backdated deliberately when importing history. It can go backwards under
clock skew between two writers. Two events can share it exactly. None of that
is a defect — domain time is supposed to describe when something happened in
the business, not when a row was inserted — but every one of those properties
breaks a cursor.
Consider a projection that has processed everything up to 12:00:00 and stores
that timestamp. A writer with a slightly slow clock commits an event stamped
11:59:59 a moment later. The projection resumes "after 12:00:00" and never
sees it. Nothing errors; the read model is just quietly wrong, and the only way
to discover it is to notice the missing data. With global_position the same
event gets a higher position than everything already committed, and "everything
after position N" cannot skip it.
This is why projection_checkpoints grew a global_position BIGINT column,
and why resume is expressed in positions throughout. The checkpoint repository
exposes exactly that pair — save_position(name, position) and
get_position(name) — and the subscription machinery uses them: the manager
saves a position after processing, and transition logic reads it back to decide
where a catch-up subscription should start. The store side matches: reading
forward is WHERE global_position > :from_position ... ORDER BY global_position
ASC. Nothing in that loop consults a clock.
The distinction is not that timestamps are useless — it is that they are a
query facility rather than a resume facility. There is no per-event-type
query on the current port at all (the old get_events_by_type() has no
replacement); the closest surviving facility is read_category(), which reads
every stream for one aggregate_type and takes a CategoryReadOptions with a
from_timestamp bound. Even there the bound is filtered and ordered against
created_at (storage time — EventEnvelope.stored_at), not the domain
timestamp column, which is the right shape for "show me everything recorded
for Orders since this point" and still the wrong shape for a resume cursor,
for the same reason as above: it orders on insertion time as a convenience,
not as a total order the way global_position is. Timestamp filters are also
what enable partition pruning on events_partitioned, so they earn their
place — just not as the thing a consumer stores.
The events table does also keep created_at TIMESTAMPTZ NOT NULL DEFAULT
NOW(), which is wall-clock insert time — useful for auditing the gap between
when something happened and when it was recorded, and specifically not used
for ordering either. Two timestamp columns, neither an ordering authority, is a
deliberate arrangement: the moment one of them becomes a cursor, the guarantee
above is gone.
One caveat worth knowing before you build tooling on it: global_position
values are allocated from a sequence, so they are monotonic but not gap-free
(rolled-back transactions burn values), and under concurrent writers a
transaction that grabs a lower position may commit after one with a higher
position. A reader polling the tail can therefore see position 105 before 104
becomes visible. For catch-up reads over committed history — the case the
checkpoints are for — this does not arise.
Why the unique constraint is the optimistic lock¶
This is the single most important line in the schema. It is not a data-quality check; it is the concurrency control mechanism for the entire library. The database — not the application — arbitrates concurrent appends to an aggregate.
The append path starts in Python. append reads
SELECT COALESCE(MAX(version), 0) for the (aggregate_id, aggregate_type)
pair, then passes the result and the caller's expected (an ExpectedVersion)
to _check_expected, which raises OptimisticLockError(aggregate_id,
expected_version, current_version) on mismatch. ExpectedVersion.kind shapes
that comparison: "no_stream" requires the current version to be 0,
"stream_exists" requires it not to be, "exact" requires it to equal
expected.version, and "any" skips the check entirely. It is a useful
check — it fails fast, before any insert, with a clear error — but on its own
it is a textbook read-then-write race. Nothing between the SELECT and the
INSERT locks the aggregate: no SELECT ... FOR UPDATE, no advisory lock, no
serializable isolation requirement. Two processes can both read version 7,
both conclude they may write version 8, and both proceed. Under
ExpectedVersion.any() there is no application-side check at all.
The constraint is what makes that safe. One insert of version 8 commits; the
other violates uq_events_aggregate_version and the driver raises
IntegrityError. append catches it, rolls the transaction back, and hands
the exception to _classify_integrity_error, which confirms the failure was
this constraint by reading constraint_name off the underlying
asyncpg.exceptions.UniqueViolationError (falling back to a substring match
on the stringified exception only if that attribute is missing), re-reads the
actual MAX(version), and raises OptimisticLockError with the true current
version. An IntegrityError from anything else — a duplicate event_id,
say — is re-raised untouched.
The result is that both paths converge on the same exception. A caller cannot tell whether its conflict was caught by the pre-check or by the constraint, and does not need to: it reloads the aggregate and retries either way. The pre-check is an optimisation that avoids a doomed insert in the common uncontended case; the constraint is the guarantee.
SQLiteEventStore.append follows the same structure against
aiosqlite.IntegrityError, with one difference worth knowing: SQLite gives no
constraint_name-style attribute to introspect the way asyncpg does, so the
store always classifies by matching the lowercased exception text — checking
for "event_id" for the identity conflict, and "unique" together with
"aggregate_id" or "version" for the version conflict, which is what
SQLite's UNIQUE constraint failed: events.aggregate_id, events.aggregate_type,
events.version produces. It is a string heuristic over an error message
across the board on this backend (PostgreSQL only falls back to one when no
constraint_name is available), and it is broader than the primary
PostgreSQL match — a unique violation on some other column named version
in a future table would be misclassified. Nothing today triggers that, but it
is a reason to prefer PostgreSQL where concurrency actually matters. Any
non-IntegrityError failure mid-batch is also rolled back explicitly on
SQLite, since the store holds one long-lived connection across calls and a
dirty open transaction would otherwise leak into the next append's commit.
The consequence of pushing the check into the database is that correctness
does not depend on the application's deployment topology. There is no lock to
acquire, no leader to elect, no requirement that writers for a given aggregate
be routed to the same process. Deploy twenty replicas across three machines and
the guarantee holds, because the arbiter is the one component all of them
already share. This is why the PostgreSQL advisory locks in
eventsource.adapters.postgresql.locks exist for coordinating operations —
tenant cutover, migration exclusivity — and not for guarding aggregate writes.
Aggregate writes need no lock.
The costs are real and worth stating:
- Callers must handle conflicts.
OptimisticLockErroris a normal outcome, not an exceptional one. The usual response is to reload the aggregate, re-run the command against fresh state, and re-append — retry logic you have to write, and which must be safe to run more than once. - Contention degrades into wasted work. Under heavy concurrent writes to a single aggregate, each loser has done its full command execution before being rejected, and retries pile up. That is a signal the aggregate is too coarse-grained, not a defect in the schema; the fix is smaller aggregates, not a bigger lock.
- Rejection happens late. The losing writer discovers the conflict only at insert time, after serializing every event in the batch. The batch is all-or-nothing: the transaction rolls back, so a partially-written stream is never left behind.
- The constraint name is load-bearing on PostgreSQL. The error mapping
matches on
uq_events_aggregate_versionby name. Rename it in your own migration and version conflicts stop surfacing asOptimisticLockErrorand start surfacing as rawIntegrityError— a silent behavioural regression with no test in your codebase to catch it. If you must rename, change the store's matcher in the same commit.
Why aggregate_type is part of the key¶
The key is (aggregate_id, aggregate_type, version) rather than
(aggregate_id, version), which looks redundant if aggregate IDs are UUIDs —
they are globally unique, so the type adds no distinguishing power.
It is not there for uniqueness. It is there because the store treats an
aggregate stream as (id, type), not id alone: every version query in
postgresql.py filters on both columns. That makes the type part of the stream
address, which allows the same identifier to carry independent event streams for
different aggregate types — the pattern where a User aggregate and a
UserPreferences aggregate share a subject ID and version independently. With
aggregate_id alone they would share a version counter and constantly conflict
over nothing.
Including the type in the constraint also makes the constraint's index directly
usable for the store's actual predicate. And the ordering matters: aggregate_id
leads because it is the selective column, so the index is useful for
WHERE aggregate_id = ? even when the type is not specified — which is exactly
what the store's fallback lookup, SELECT aggregate_type, MAX(version) ... WHERE
aggregate_id = :aggregate_id, needs.
Why version is a per-aggregate counter¶
version starts at 1 for each aggregate and increments by one. It is not a
global sequence, and it is not shared between aggregate types.
Per-aggregate numbering is what makes the version meaningful to the domain. "Order 123 is at version 7" is a statement a command handler can act on: load the aggregate, assert it is still at 7, append 8. A global sequence would give each event a number, but "this order is at global number 4,812,003" tells a writer nothing about whether another writer has touched the same order, so it could not serve as the concurrency check at all.
The dense, gap-free property is what makes partial replay work. Loading an
aggregate from a snapshot is WHERE aggregate_id = ? AND version > ? — with a
per-aggregate counter that is a contiguous range read; with a global sequence
it would be a sparse scatter across the table.
Note that the column is assigned by the store, not taken from the event: the
append loop computes new_version = current_version + 1 and writes that.
DomainEvent.aggregate_version is the event's own copy of the same idea and is
serialized into the payload, but the column is the authority for locking and
ordering. Starting at 1 rather than 0 leaves 0 free to mean "this stream does
not exist", which is exactly how COALESCE(MAX(version), 0) and
ExpectedVersion.NO_STREAM use it.
Why payload is JSONB (or TEXT)¶
The event's data lives in one opaque column — JSONB NOT NULL on PostgreSQL,
TEXT NOT NULL on SQLite — rather than in typed columns per event type.
The reason is that schema evolution belongs in the pydantic event classes,
not in DDL. A store holds every version of every event type that has ever
been written, forever. Typed columns would mean a migration for each new event
type, a nullable column for each new field, and a table whose column count
grows without bound — and no way at all to have two versions of the same event
type coexist. DomainEvent carries event_version precisely so that evolution
is a Python concern: add an optional field with a default and old rows validate
fine; make a breaking change and bump event_version and branch in your
upcasting logic. Neither requires touching the database.
What is written is the whole event: _serialize_event is
event.model_dump(mode="json"), so payload contains event_id,
aggregate_id, timestamp, metadata and every domain field. The typed
columns alongside it are denormalized copies extracted for indexing. Read back,
_deserialize_event looks up the class by event_type in the EventRegistry
and calls model_validate on the payload alone — the columns are not consulted
to rebuild the event. The columns exist so the database can filter and order;
the payload exists so Python can reconstruct. That is also why the columns
must not be edited independently of the payload: they would diverge silently,
with the payload winning on read.
The costs: the database cannot validate payload structure, so a malformed write
is only caught by pydantic on read, potentially long after the fact.
Type-correctness depends entirely on the event class still being importable and
registered — deleting an event class makes those rows undeserializable, which
is why EventTypeNotFoundError exists. JSONB is more verbose on disk than
packed typed columns. And querying inside the payload means JSONB operators on
unindexed data, which is fine for occasional investigation and wrong as a
projection strategy. On SQLite the payload is plain TEXT, so even those
operators are unavailable — read models, not payload queries, are the supported
way to ask questions of event data.
Nullable tenant_id and actor_id¶
Both are nullable, and both are nullable for the same reason: one schema has to serve deployments that use the feature and deployments that do not.
For tenant_id, the alternative designs are worse. A separate multi-tenant
schema would double the DDL, double the store implementations, and force an
irreversible decision at install time — the single-tenant deployment that later
needs tenants would face a migration rather than a configuration change. A
NOT NULL column with a sentinel tenant would tax every single-tenant install
with a fictional value to invent, propagate, and filter on. NULL is the
honest encoding of "this deployment has no tenant concept", and the shape
matches DomainEvent.tenant_id: UUID | None. Deployments that do want the
column mandatory get that from TenantDomainEvent, which makes tenant_id
required at the event level — the strictness is opt-in at the type layer rather
than baked into DDL everyone shares.
actor_id is nullable because plenty of events have no user behind them:
scheduler ticks, system reconciliation, imports, events emitted by other
events. It is VARCHAR(255) rather than UUID deliberately — an actor may be
a user ID, a service name, or a job identifier, and constraining it to a UUID
would exclude the latter two.
The cost of nullability is index efficiency, and the schema pays it explicitly:
idx_events_tenant_id is declared WHERE tenant_id IS NOT NULL, so a
single-tenant store with a hundred million all-NULL rows carries an empty
index rather than a hundred million useless entries. The same pattern appears
on event_outbox. That is the general principle behind "optional" in this
schema — optional features should cost nothing when unused, and the place the
cost would otherwise show up is index size and write amplification. The other
cost is enforcement: nothing in the DDL stops a query reading across tenants,
so isolation remains an application obligation, discussed at the end of this
page.