eventsourcing

repository·9.6·Indexed 23 days ago

https://github.com/pyeventsourcing/eventsourcing

A comprehensive Python library for implementing the event sourcing design pattern. It provides abstractions for defining domain aggregates using the Aggregate class and @event decorator, and managing them via the Application class. The library includes a repository for retrieving aggregates by ID or version, a notification log implementing the outbox pattern, and support for various persistence modules including SQLite, PostgreSQL, MySQL, Cassandra, and Redis.

Tokens
76.9K
Snippets
129
Records
337
Agent score
77%

What's inside eventsourcing

  1. Overview of pyeventsourcing features

    9.6

    pyeventsourcing provides a comprehensive suite of tools for building robust event-sourced systems:

    • Flexible Event Store: Extensible persistence using mappers and recorders. Supports custom transcoders and various database backends via environment variables.
    • Security & Efficiency: Built-in application-level encryption (for GDPR/PII compliance) and compression (typically reducing event size by 25-50%).
    • Performance: Snapshotting to reduce access time for aggregates with large event streams.
    • Evolution: Versioning for both domain events and aggregate classes, allowing for upcasting of old data to new models.
    • Reliability: Optimistic concurrency control for distributed environments and hash-chaining for data integrity verification.
    • Observability: Correlation and causation IDs for tracing stories through multiple applications.
    • CQRS Support: Notifications and projections for building materialized views and decoupled event-driven systems.
  2. Event Sourcing in Python with pyeventsourcing

    9.6
    The pyeventsourcing library is a comprehensive Python framework for implementing the event sourcing design pattern. In this pattern, all changes to application state are captured and stored as a sequence of immutable events rather than just storing the current state. This library provides the necessary abstractions to build reliable and performant event-sourced applications.
  3. What is a domain event?

    9.6

    In the context of this library and event sourcing, a domain event is a specific kind of event: an individual decision originated by the domain model of a software application.

    Key characteristics of domain events in this framework:

    • They are encapsulated by software objects.
    • They are stored as database records in an append-only log.
    • This log of events serves as the source of truth used to determine the current state of the application.
  4. What is event sourcing?

    9.6

    Event sourcing is a persistence mechanism where domain event objects are used as the primary source of truth in a software application.

    Instead of only persisting the current state of domain objects, the application records the sequence of decisions (events) that led to that state. This approach is often used in conjunction with Domain-Driven Design (DDD).

  5. Save multiple aggregates in an atomic transaction

    9.6

    The persistence modules in this library support the atomic recording of events from multiple aggregate sequences in a single transaction. This is useful when one action affects multiple aggregates (e.g., creating a Page and its corresponding Index).

    Note that while the library supports this, not all databases or library extensions support atomic recording across multiple aggregate sequences. If a collision occurs during an atomic save (e.g., a uniqueness constraint violation in an index), the entire transaction will fail, and a eventsourcing.persistence.RecordConflictError will be raised.

    # Example of atomic save in a Wiki application
    class Wiki(Application):
        def create_page(self, name: str, body: str) -> None:
            page = Page.create(name, body)
            index = Index.create(page)
            self.save(page, index)  # Both page and index are saved atomically
  6. Core persistence abstractions: StoredEvent, Recorder, and Transcoder

    9.6

    The persistence layer is composed of several key abstractions:

    • StoredEvent: A universal, frozen data class representing any domain event in a format suitable for storage.
    • Recorder: Responsible for inserting StoredEvent objects into a database and selecting them for retrieval.
    • Transcoder: Handles the serialization and deserialization of the domain event state into bytes.
    • Compressor: (Optional) Compresses/decompresses the serialized state.
    • Cipher: (Optional) Encrypts/decrypts the serialized state.
    • Mapper: Converts domain events to StoredEvent objects and vice versa.
    • Event Store: The high-level component that uses a Mapper and a Recorder to manage domain events.
    • Infrastructure Factory: A common interface used to construct and configure persistence infrastructure objects.
  7. How aggregate events evolve state using mutate and apply

    9.6

    In pyeventsourcing, aggregate state is reconstructed by applying a sequence of events to an aggregate object. This is achieved through two primary methods on event classes:

    1. mutate(self, aggregate: Any) -> Any: This method is responsible for the orchestration of state evolution. For 'subsequent' events, it validates the event against the current aggregate (checking originator_id and originator_version), calls apply(), increments the aggregate's version, and updates modified_on.
    2. apply(self, aggregate: Any) -> None: This is the method you should override in your event subclasses to define the actual logic of how an event modifies the aggregate's attributes.

    By iterating through a sequence of events and calling mutate() on each, you can implement an 'aggregate projector' to reconstruct the current state from history.

    from typing import Any
    
    class MyEvent(AggregateEvent):
        full_name: str
    
        def apply(self, aggregate: Any) -> None:
            aggregate.full_name = self.full_name
    
    # Usage:
    # a = my_event.mutate(a)
    # assert a.full_name == "Eric Idle"
  8. Use Namespaced IDs (UUIDv5) for indexing

    9.6

    Since aggregate IDs cannot be changed once created, you can use Version-5 UUIDs to create 'Index Aggregates'. This allows you to identify an aggregate by a mutable attribute (like a name) even if the primary ID is a random UUID.

    Pattern:

    1. Generate a deterministic UUIDv5 based on a namespace and a name (e.g., /pages/{name}).
    2. Create an Index aggregate using this deterministic ID.
    3. Store the actual Page aggregate's UUID inside the Index aggregate.
    4. To find a page by name: Recreate the UUIDv5 $\rightarrow$ Retrieve the Index aggregate $\rightarrow$ Get the Page ID $\rightarrow$ Retrieve the Page aggregate.
    from uuid import NAMESPACE_URL, uuid5
    from eventsourcing.domain import Aggregate
    
    class Index(Aggregate):
        def __init__(self, name: str, ref: UUID):
            self.name = name
            self.ref = ref
    
        @classmethod
        def create(cls, name: str, ref: UUID) -> Self:
            return cls._create(
                event_class=cls.Created,
                id=cls.create_id(name),  # Deterministic ID
                name=name,
                ref=ref
            )
    
        @staticmethod
        def create_id(name: str) -> UUID:
            return uuid5(NAMESPACE_URL, f"/pages/{name}")
  9. Implement an immutable aggregate base class

    9.6

    To use immutable aggregates, you can define a custom base class using Python's frozen dataclasses. This pattern requires that aggregate command methods do not mutate the instance in place, but instead return the events they trigger.

    Key components for an immutable aggregate pattern include:

    • DomainEvent: A frozen dataclass representing state changes.
    • Aggregate: A frozen dataclass that provides a trigger_event method to construct events with incremented version numbers and timestamps, and a projector class method to reconstruct the aggregate by iterating over events and calling a mutate method.
    • Snapshot: A special DomainEvent used to capture and restore the aggregate's state.
    • mutate method: A method responsible for evolving the aggregate state by returning a new instance of the aggregate class based on the provided event.
  10. Use an Interface layer to decouple clients from the Domain Model

    9.6

    An interface layer (e.g., BookingService) acts as a bridge between clients (like Web UIs or test suites) and the application/domain logic.

    Key responsibilities of the interface include:

    • Simplification: Presenting simple, easily serializable/deserializable object types to the client.
    • Abstraction: Hiding the complexity of the domain model and custom value objects.
    • Interaction: Translating client requests into calls to the application layer using domain-specific types.
  11. Implement aggregate projectors using singledispatchmethod

    9.6

    An alternative style for implementing aggregate projectors (the logic that updates the aggregate's state when an event is applied) is to use a single apply method decorated with @singledispatchmethod.

    To implement this pattern:

    1. Define a base Event class common to all events in the aggregate.
    2. Ensure the base event calls the aggregate's apply method.
    3. Use @singledispatchmethod on the apply method to register specific handlers for each event type.
  12. Perform cross-cutting decisions with Group

    9.6

    A Group extends Perspective and allows for decision-making that affects multiple EnduringObjects. The group's consistency boundary is the union of the boundaries of its members.

    When a Group calls trigger_event(), the resulting decision is tagged with the continuity IDs of all members in the group. This allows a single event to evolve the state of multiple objects simultaneously (the 'one fact magic').

    from eventsourcing.dcb.domain import Group
    
    class StudentAndCourse(Group[Decision]):
        def __init__(self, student: Student | None, course: Course | None) -> None:
            self.student = student
            self.course = course
    
        def student_joins_course(self) -> None:
            # Business rules
            assert len(self.student.course_ids) < self.student.max_courses
            assert len(self.course.student_ids) < self.course.max_students
            
            # One event affects both members
            self.trigger_event(
                StudentJoinedCourse,
                student_id=self.student.id,
                course_id=self.course.id,
            )
    
    # Usage
    group = StudentAndCourse(student, course)
    group.student_joins_course()