Skip to content

Aggregate Repository

aggregate_repository

Aggregate RepositoryPort implementation.

Provides a repository specifically designed for AggregateRoot persistence
with event sourcing support.

AggregateRepository

Bases: InMemoryRepository[TAggregateRoot, TId]

RepositoryPort for persisting event-sourced aggregates.
Coordinates event store writes with in-memory snapshot caching
for AggregateRoot subtypes.

Class Type Parameters:

Name Bound or Constraints Description Default
EventPayloadType

The event payload type tracked by the event store.
Flows through the public generic interface.

required
TAggregateRoot AggregateRoot[UUID, Any]

An AggregateRoot subtype with UUID identity whose event
payload type must match EventPayloadType at each call site. The
bound uses Any as the second type argument — not because the
event type is untyped, but because PEP 695 (and the underlying type
system) forbids one TypeVar from appearing inside another TypeVar's
bound. The cast in save is the explicit, localized bridge
across this gap. The invariant is enforced by construction.

required
TId UUID

The aggregate identity type, bounded by UUID.

required
Example
class Event[T]:
    pass


class AggregateRoot[TId, TPayload]:
    def __init__(self, aggregate_id: TId) -> None:
        self.id = aggregate_id


class InMemoryEventStore[T]:
    def __init__(self) -> None: ...

    async def append_events(
        self, aggregate_id: object, events: list[object], expected_version: int
    ) -> None: ...
    async def get_events(self, aggregate_id: object) -> list[object]: ...
    async def get_current_version(self, aggregate_id: object) -> int: ...


class MyAggregate(AggregateRoot[UUID, str]):
    def __init__(self, aggregate_id: UUID) -> None:
        super().__init__(aggregate_id)

    def _handle(self, event: Event[str]) -> None:
        pass


event_store = InMemoryEventStore[str]()
aggregate_id = UUID("00000000-0000-0000-0000-000000000001")
repo = AggregateRepository[str, MyAggregate, UUID](
    event_store=event_store,
    aggregate_type=MyAggregate,
)


async def main() -> None:
    aggregate = MyAggregate(aggregate_id)
    await repo.save(aggregate)
    retrieved = await repo.get_by_id(aggregate_id)
Source code in src/forging_blocks/infrastructure/repositories/aggregate_repository.py
class AggregateRepository[
    EventPayloadType,
    TAggregateRoot: AggregateRoot[UUID, Any],
    TId: UUID,
](InMemoryRepository[TAggregateRoot, TId]):
    """RepositoryPort for persisting event-sourced aggregates.
    Coordinates event store writes with in-memory snapshot caching
    for AggregateRoot subtypes.

    Type Parameters:
        EventPayloadType: The event payload type tracked by the event store.
            Flows through the public generic interface.
        TAggregateRoot: An AggregateRoot subtype with UUID identity whose event
            payload type must match ``EventPayloadType`` at each call site. The
            bound uses ``Any`` as the second type argument — not because the
            event type is untyped, but because PEP 695 (and the underlying type
            system) forbids one TypeVar from appearing inside another TypeVar's
            bound. The ``cast`` in `save` is the explicit, localized bridge
            across this gap. The invariant is enforced by construction.
        TId: The aggregate identity type, bounded by ``UUID``.

    Example:
        ```python
        class Event[T]:
            pass


        class AggregateRoot[TId, TPayload]:
            def __init__(self, aggregate_id: TId) -> None:
                self.id = aggregate_id


        class InMemoryEventStore[T]:
            def __init__(self) -> None: ...

            async def append_events(
                self, aggregate_id: object, events: list[object], expected_version: int
            ) -> None: ...
            async def get_events(self, aggregate_id: object) -> list[object]: ...
            async def get_current_version(self, aggregate_id: object) -> int: ...


        class MyAggregate(AggregateRoot[UUID, str]):
            def __init__(self, aggregate_id: UUID) -> None:
                super().__init__(aggregate_id)

            def _handle(self, event: Event[str]) -> None:
                pass


        event_store = InMemoryEventStore[str]()
        aggregate_id = UUID("00000000-0000-0000-0000-000000000001")
        repo = AggregateRepository[str, MyAggregate, UUID](
            event_store=event_store,
            aggregate_type=MyAggregate,
        )


        async def main() -> None:
            aggregate = MyAggregate(aggregate_id)
            await repo.save(aggregate)
            retrieved = await repo.get_by_id(aggregate_id)
        ```
    """

    _event_store: EventStoreBase[EventPayloadType]

    def __init__(
        self,
        event_store: EventStoreBase[EventPayloadType],
        aggregate_type: type[TAggregateRoot],
        storage: dict[TId, TAggregateRoot] | None = None,
    ) -> None:
        """Initialize the aggregate repository.

        Args:
            event_store: The event store for persisting domain events.
            aggregate_type: The aggregate root class. Used via
                its ``reconstitute`` classmethod when an aggregate
                must be rebuilt from stored events.
            storage: Optional in-memory storage for aggregate snapshots.

        """
        super().__init__(storage)
        self._event_store = event_store
        self._aggregate_type = aggregate_type

    async def save(self, aggregate: TAggregateRoot) -> None:
        """Save an aggregate and its uncommitted events.

        Writes events to the event store first, then persists the aggregate
        snapshot. If the event store write fails, the error is raised so the
        Unit of Work can rollback and the aggregate retains its uncommitted
        events.

        The ``cast`` on ``uncommitted_changes`` bridges the gap between
        ``TAggregateRoot``'s bound (``AggregateRoot[UUID, Any]``) and the
        repository's ``EventPayloadType`` generic. The types are guaranteed to
        match at runtime by construction; the type system cannot express this
        cross-TypeVar-bound relationship (see PEP 695, pyright
        ``reportGeneralTypeIssues``).

        Args:
            aggregate: The aggregate to save.

        Raises:
            EventStoreError: If the event store write fails (e.g., concurrency
                conflict, I/O error).

        """
        events = cast(list[Event[EventPayloadType]], aggregate.uncommitted_changes)
        aggregate_id: UUID | None = aggregate.id
        if events and aggregate_id is not None:
            version = aggregate.version.value - len(events)
            result = await self._event_store.append_events(
                aggregate_id, events, expected_version=version
            )
            if not result.is_ok:
                raise result.error
        await super().save(aggregate)

    async def get_by_id(self, entity_id: TId) -> TAggregateRoot | None:
        """Retrieve an aggregate by ID and replay its events.

        Checks the in-memory cache first; if not cached, replays the
        aggregate from the event store and caches the result so subsequent
        reads avoid a full replay.

        Args:
            entity_id: Unique identifier of the aggregate.

        Returns:
            The retrieved aggregate or None if not found.

        Raises:
            EventStoreError: If the event store read fails. Callers can
                distinguish infrastructure failures from "not found" (None).

        """
        aggregate = await super().get_by_id(entity_id)
        if aggregate is not None:
            return aggregate

        result = await self._event_store.get_events(cast(UUID, entity_id))

        if not result.is_ok:
            raise result.error

        events = result.value

        if not events:
            return None

        aggregate = self._aggregate_type.reconstitute(entity_id, events)
        await super().save(aggregate)

        return aggregate

__init__(event_store: EventStoreBase[EventPayloadType], aggregate_type: type[TAggregateRoot], storage: dict[TId, TAggregateRoot] | None = None) -> None

Initialize the aggregate repository.

Parameters:

Name Type Description Default
event_store EventStoreBase[EventPayloadType]

The event store for persisting domain events.

required
aggregate_type type[TAggregateRoot]

The aggregate root class. Used via
its reconstitute classmethod when an aggregate
must be rebuilt from stored events.

required
storage dict[TId, TAggregateRoot] | None

Optional in-memory storage for aggregate snapshots.

None
Source code in src/forging_blocks/infrastructure/repositories/aggregate_repository.py
def __init__(
    self,
    event_store: EventStoreBase[EventPayloadType],
    aggregate_type: type[TAggregateRoot],
    storage: dict[TId, TAggregateRoot] | None = None,
) -> None:
    """Initialize the aggregate repository.

    Args:
        event_store: The event store for persisting domain events.
        aggregate_type: The aggregate root class. Used via
            its ``reconstitute`` classmethod when an aggregate
            must be rebuilt from stored events.
        storage: Optional in-memory storage for aggregate snapshots.

    """
    super().__init__(storage)
    self._event_store = event_store
    self._aggregate_type = aggregate_type

save(aggregate: TAggregateRoot) -> None async

Save an aggregate and its uncommitted events.

Writes events to the event store first, then persists the aggregate
snapshot. If the event store write fails, the error is raised so the
Unit of Work can rollback and the aggregate retains its uncommitted
events.

The cast on uncommitted_changes bridges the gap between
TAggregateRoot's bound (AggregateRoot[UUID, Any]) and the
repository's EventPayloadType generic. The types are guaranteed to
match at runtime by construction; the type system cannot express this
cross-TypeVar-bound relationship (see PEP 695, pyright
reportGeneralTypeIssues).

Parameters:

Name Type Description Default
aggregate TAggregateRoot

The aggregate to save.

required

Raises:

Type Description
EventStoreError

If the event store write fails (e.g., concurrency
conflict, I/O error).

Source code in src/forging_blocks/infrastructure/repositories/aggregate_repository.py
async def save(self, aggregate: TAggregateRoot) -> None:
    """Save an aggregate and its uncommitted events.

    Writes events to the event store first, then persists the aggregate
    snapshot. If the event store write fails, the error is raised so the
    Unit of Work can rollback and the aggregate retains its uncommitted
    events.

    The ``cast`` on ``uncommitted_changes`` bridges the gap between
    ``TAggregateRoot``'s bound (``AggregateRoot[UUID, Any]``) and the
    repository's ``EventPayloadType`` generic. The types are guaranteed to
    match at runtime by construction; the type system cannot express this
    cross-TypeVar-bound relationship (see PEP 695, pyright
    ``reportGeneralTypeIssues``).

    Args:
        aggregate: The aggregate to save.

    Raises:
        EventStoreError: If the event store write fails (e.g., concurrency
            conflict, I/O error).

    """
    events = cast(list[Event[EventPayloadType]], aggregate.uncommitted_changes)
    aggregate_id: UUID | None = aggregate.id
    if events and aggregate_id is not None:
        version = aggregate.version.value - len(events)
        result = await self._event_store.append_events(
            aggregate_id, events, expected_version=version
        )
        if not result.is_ok:
            raise result.error
    await super().save(aggregate)

get_by_id(entity_id: TId) -> TAggregateRoot | None async

Retrieve an aggregate by ID and replay its events.

Checks the in-memory cache first; if not cached, replays the
aggregate from the event store and caches the result so subsequent
reads avoid a full replay.

Parameters:

Name Type Description Default
entity_id TId

Unique identifier of the aggregate.

required

Returns:

Type Description
TAggregateRoot | None

The retrieved aggregate or None if not found.

Raises:

Type Description
EventStoreError

If the event store read fails. Callers can
distinguish infrastructure failures from "not found" (None).

Source code in src/forging_blocks/infrastructure/repositories/aggregate_repository.py
async def get_by_id(self, entity_id: TId) -> TAggregateRoot | None:
    """Retrieve an aggregate by ID and replay its events.

    Checks the in-memory cache first; if not cached, replays the
    aggregate from the event store and caches the result so subsequent
    reads avoid a full replay.

    Args:
        entity_id: Unique identifier of the aggregate.

    Returns:
        The retrieved aggregate or None if not found.

    Raises:
        EventStoreError: If the event store read fails. Callers can
            distinguish infrastructure failures from "not found" (None).

    """
    aggregate = await super().get_by_id(entity_id)
    if aggregate is not None:
        return aggregate

    result = await self._event_store.get_events(cast(UUID, entity_id))

    if not result.is_ok:
        raise result.error

    events = result.value

    if not events:
        return None

    aggregate = self._aggregate_type.reconstitute(entity_id, events)
    await super().save(aggregate)

    return aggregate