Aggregate Metadata
Date: 2026-03-14
Status
Accepted
Context
Currently Repository.aggregate() yields only the aggregate instance. The caller has no access to stream-level information such as:
- What is the current version of the aggregate?
- Is this a new aggregate (no prior events in the stream)?
- When was the stream created or last modified?
- What stream identity was used?
This information is essential for many use cases: - Detecting whether an aggregate is being created for the first time (e.g. to enforce idempotent creation) - Knowing the version for external optimistic concurrency checks or ETags - Accessing timestamps for auditing or display purposes
Other event sourcing frameworks consistently expose this kind of metadata:
- Marten returns StreamState with Version, Created, LastTimestamp, AggregateType via FetchForWriting<T>
- Eventide returns (entity, version) tuple, with :no_stream sentinel for non-existent streams
- Axon exposes @AggregateVersion and AggregateLifecycle.isLive()
- Ecotone enriches events with _aggregate_version, _aggregate_type headers
Decisions
D1: Wrapper pattern — WrappedAggregate
Following the established pattern of WrappedEvent which wraps Event with metadata, the repository will return a WrappedAggregate that wraps the aggregate instance with stream metadata. The aggregate itself remains a pure domain object with no knowledge of infrastructure concerns.
Rationale: This is consistent with WrappedEvent[TEvent] which carries event, version, uuid, created_at, and context alongside the event itself. WrappedAggregate[TAggregate] follows the same convention — the wrapper holds metadata, the inner object stays pure.
Migration: This is a breaking change. Existing callers using as aggregate will need to change to as wrapped and access wrapped.aggregate. Given the library is pre-1.0, this is acceptable.
D2: Fields on WrappedAggregate
| Field | Type | Source |
|---|---|---|
aggregate |
TAggregate |
The aggregate instance |
version |
int (computed) |
Stored version + count of pending changes in aggregate |
is_new |
bool (computed) |
True when stored version is 0 (no events existed before load) |
stream_id |
StreamId |
Built from UUID + aggregate category |
created_at |
datetime \| None |
Timestamp of the first event in the stream |
updated_at |
datetime \| None |
Timestamp of the last event in the stream |
context |
Context \| None |
The context passed to the repository for this operation |
version is computed dynamically. It reflects the stored version plus any uncommitted changes the aggregate has emitted. This means:
- After loading an aggregate with 5 events: version == 5
- After emitting 2 more events: version == 7
- After persisting (context exit): version reflects the final stored state
This is achieved by reading the aggregate's __changes__ property and computing stored_version + len(aggregate.__changes__) on access.
is_new is based on stored version only, not including pending changes. An aggregate that was just created and has emitted its first event is still is_new == True — it didn't exist before this session. This matches Eventide's :no_stream semantics.
D3: Class naming and location
The class will be called WrappedAggregate. It is a generic dataclass WrappedAggregate[TAggregate], mirroring WrappedEvent[TEvent]. It lives in the event_sourcery.event_sourcing module alongside Aggregate and Repository.
Rationale: WrappedAggregate directly mirrors WrappedEvent — both are generic wrappers that pair a domain object with infrastructure metadata. The naming convention is already established in the project. A dataclass is used instead of a Pydantic model because WrappedAggregate is a simple data holder — it does not need serialization, validation, or schema generation. A dataclass keeps the dependency footprint minimal and the implementation straightforward.
Solution Proposal
New class: WrappedAggregate
# event_sourcery/event_sourcing/aggregate.py
TAggregate = TypeVar("TAggregate", bound=Aggregate)
@dataclass
class WrappedAggregate(Generic[TAggregate]):
aggregate: TAggregate
stream_id: StreamId
context: Context | None
created_at: datetime | None
updated_at: datetime | None
stored_version: int
@property
def version(self) -> int:
return self.stored_version + len(
getattr(self.aggregate, "__changes__", [])
)
@property
def is_new(self) -> bool:
return self.stored_version == 0
Changes to Repository
# event_sourcery/event_sourcing/repository.py
@contextmanager
def aggregate(
self,
uuid: StreamUUID,
aggregate: TAggregate,
context: Context | None = None,
) -> Iterator[WrappedAggregate[TAggregate]]:
stream_id = StreamId(uuid=uuid, name=uuid.name, category=aggregate.category)
stored_version, created_at, updated_at = self._load(stream_id, aggregate)
wrapped = WrappedAggregate(
aggregate=aggregate,
stream_id=stream_id,
context=context,
created_at=created_at,
updated_at=updated_at,
stored_version=stored_version,
)
yield wrapped
self._save(aggregate, stored_version, stream_id, context)
The _load method will be updated to extract created_at from the first event's timestamp and updated_at from the last event's timestamp during replay.
Public API exports
WrappedAggregate will be exported from event_sourcery.event_sourcing package __init__.py.
Consequences
- Breaking change in
Repository.aggregate()yield type — all callers must update fromas aggregatetoas wrappedand accesswrapped.aggregate. - Consistent with the existing
WrappedEventpattern — both domain objects (Event,Aggregate) are wrapped with metadata by infrastructure, never polluted directly. - The aggregate remains a pure domain object — no infrastructure leakage.
versionproperty provides a live view of the aggregate's version including pending changes, useful for logging/debugging.is_newenables idempotent aggregate creation patterns without checking version manually.created_at/updated_atare derived from event timestamps — no additional storage or queries needed.contextreference enables callers to inspect or pass along the context used for the current operation.versionreadsaggregate.__changes__dynamically, so it updates automatically as events are emitted.