ADR-0014: Aggregate metadata via decorator parameters, not a Meta class
Status: Accepted
Date: June 2026
Context
Protean needs a place to declare aggregate-level metadata that is neither a field nor a method. This metadata says which provider persists the aggregate, whether it is event-sourced, whether fact events are emitted, and now which indexes the persistence layer should create (see issue #944).
Historically, many DDD and ORM frameworks collect this metadata in an inner
class Meta: block. Protean used to allow class Meta: on aggregates but
removed it some time ago in favor of decorator parameters. The current
convention is:
@domain.aggregate(part_of=..., is_event_sourced=True, fact_events=True)
class Order:
...
Adding indexes=[...] forced the question explicitly: should index
declarations reintroduce class Meta: (indexes are arguably the most
"configuration-like" of the options), or extend the decorator-parameter
convention?
Two forces are in tension. Decorator parameters keep all aggregate metadata in
one place and consistent with the options that already exist. A Meta block
scales better organizationally once an element accumulates many declarative
concerns, because a long decorator signature becomes hard to read.
Decision
Aggregate-level metadata is expressed as decorator parameters, and
indexes=[...] follows that convention:
from protean import Index, Q
@domain.aggregate(indexes=[
Index("status", "priority", desc=("priority",)),
Index("message_id", unique=True),
Index("status", where=Q(status__in=["pending", "failed"]), name="ix_active"),
])
class Outbox:
...
We will not reintroduce class Meta: for this. The portable Index API cannot
model some storage-specific index tuning (GIN/BRIN, expression indexes). That
tuning belongs on @domain.model via Index.from_sql(dialect=..., ddl=...),
which keeps the aggregate clean and the storage concern in the adapter layer.
We revisit the convention only if four or more orthogonal aggregate-level
metadata concerns accumulate (today there are a handful: provider,
is_event_sourced, fact_events, indexes, plus internal options). At that
point a Meta block may be worth adding. That change would migrate all
options to the Meta block at once, so metadata is never split across two
locations.
Consequences
- Consistency: All aggregate metadata is declared in one place, the decorator. A reader does not have to look in two locations to understand how an aggregate is configured.
- No split-home migration pressure: Introducing
class Meta:for one new concern would put half the metadata on the decorator and half onMeta, and create pressure to later migrate the existing options (a Tier-1 break under ADR-0004). Avoiding it keeps the surface stable. - Signature length: The decorator signature grows as options are added.
With many indexes the
indexes=[...]list can be long. This is the main cost, and the trigger documented above for revisiting the decision. - Clean domain/infrastructure split: Portable indexes live on the aggregate; dialect-specific DDL lives on the model. This maps cleanly to the ports-and-adapters architecture.
Alternatives Considered
class Meta: indexes = [...]: Rejected. This option puts aggregate metadata in two locations. It also reverses a convention the framework already adopted. The organizational benefit does not yet outweigh the inconsistency.Field(index=True)only, no composite support: Rejected. Single-column indexes cannot express the composite ordering the outbox polling path needs (status, priority DESC). Protean must support composite indexes.- Indexes only on
@domain.model: Considered. Indexes are a property of the persistence representation, so model-level placement is defensible. Rejected because the most common indexes (uniqueness on identifiers, composite query ordering) encode business invariants and query patterns. Those invariants and query patterns belong to the domain. Storage-specific indexes still belong on the model viaIndex.from_sql.