├── setup.py ├── tests ├── __init__.py ├── benchmark │ ├── __init__.py │ ├── benchmark_application.py │ └── benchmark_domain.py ├── dcb_tests │ ├── __init__.py │ ├── test_application.py │ └── test_msgpack.py ├── docs_tests │ └── __init__.py ├── domain_tests │ └── __init__.py ├── interface_tests │ └── __init__.py ├── system_tests │ └── __init__.py ├── utils_tests │ └── __init__.py ├── application_tests │ ├── __init__.py │ ├── test_application_with_popo.py │ ├── test_application_with_postgres.py │ ├── test_application_with_sqlite.py │ └── test_snapshotting.py ├── persistence_tests │ ├── __init__.py │ ├── test_aes_cryptodome_crytography_interoperability.py │ ├── test_transcoder.py │ ├── test_noninterleaving_notification_ids.py │ └── test_eventstore.py ├── projection_tests │ ├── __init__.py │ └── test_application_subscription.py └── mypy_issue.py ├── eventsourcing ├── py.typed ├── __init__.py ├── dcb │ ├── __init__.py │ └── msgpack.py ├── tests │ └── __init__.py └── compressor.py ├── examples ├── __init__.py ├── aggregate1 │ ├── __init__.py │ ├── domainmodel.py │ ├── application.py │ └── test_application.py ├── aggregate10 │ ├── __init__.py │ ├── domainmodel.py │ ├── application.py │ ├── test_snapshotting_intervals.py │ ├── test_compression_and_encryption.py │ └── test_application.py ├── aggregate11 │ ├── __init__.py │ ├── application.py │ └── domainmodel.py ├── aggregate2 │ ├── __init__.py │ ├── domainmodel.py │ ├── application.py │ └── test_application.py ├── aggregate3 │ ├── __init__.py │ ├── application.py │ ├── domainmodel.py │ └── test_application.py ├── aggregate4 │ ├── __init__.py │ ├── application.py │ ├── domainmodel.py │ └── test_application.py ├── aggregate5 │ ├── __init__.py │ ├── application.py │ ├── test_application.py │ └── baseclasses.py ├── aggregate6 │ ├── __init__.py │ ├── application.py │ ├── test_application.py │ ├── baseclasses.py │ └── domainmodel.py ├── aggregate6a │ ├── __init__.py │ ├── application.py │ └── test_application.py ├── aggregate7 │ ├── __init__.py │ ├── application.py │ ├── test_compression_and_encryption.py │ ├── immutablemodel.py │ ├── test_application.py │ ├── orjsonpydantic.py │ ├── domainmodel.py │ └── test_snapshotting_intervals.py ├── aggregate7a │ ├── __init__.py │ ├── test_compression_and_encryption.py │ ├── application.py │ └── test_application.py ├── aggregate8 │ ├── __init__.py │ ├── domainmodel.py │ ├── application.py │ ├── test_snapshotting_intervals.py │ ├── test_compression_and_encryption.py │ ├── test_application.py │ ├── test_mutablemodel.py │ └── mutablemodel.py ├── aggregate9 │ ├── __init__.py │ ├── application.py │ ├── test_compression_and_encryption.py │ ├── immutablemodel.py │ ├── test_application.py │ ├── msgpack.py │ ├── domainmodel.py │ └── test_snapshotting_intervals.py ├── bankaccounts │ ├── __init__.py │ ├── domainmodel.py │ └── application.py ├── ftsprocess │ ├── __init__.py │ ├── sqlite.py │ ├── postgres.py │ ├── system.py │ └── application.py ├── shopvertical │ ├── __init__.py │ ├── slices │ │ ├── __init__.py │ │ ├── clear_cart │ │ │ ├── __init__.py │ │ │ ├── cmd.py │ │ │ └── test.py │ │ ├── submit_cart │ │ │ ├── __init__.py │ │ │ └── cmd.py │ │ ├── add_item_to_cart │ │ │ ├── __init__.py │ │ │ └── cmd.py │ │ ├── get_cart_items │ │ │ ├── __init__.py │ │ │ └── query.py │ │ ├── add_product_to_shop │ │ │ ├── __init__.py │ │ │ ├── cmd.py │ │ │ └── test.py │ │ ├── list_products_in_shop │ │ │ ├── __init__.py │ │ │ └── query.py │ │ ├── remove_item_from_cart │ │ │ ├── __init__.py │ │ │ └── cmd.py │ │ └── adjust_product_inventory │ │ │ ├── __init__.py │ │ │ ├── cmd.py │ │ │ └── test.py │ ├── exceptions.py │ ├── events.py │ └── common.py ├── cargoshipping │ └── __init__.py ├── contentmanagement │ ├── __init__.py │ ├── utils.py │ └── domainmodel.py ├── dcb_enrolment │ ├── __init__.py │ ├── interface.py │ ├── application.py │ ├── test_application.py │ └── domainmodel.py ├── ftsprojection │ └── __init__.py ├── ftscontentmanagement │ ├── __init__.py │ ├── persistence.py │ └── application.py ├── searchabletimestamps │ ├── __init__.py │ ├── persistence.py │ └── application.py ├── dcb_enrolment_with_basic_objects │ ├── __init__.py │ └── test_application.py ├── dcb_enrolment_with_enduring_objects │ ├── __init__.py │ └── test_eventstore.py ├── dcb_enrolment_with_vertical_slices │ └── __init__.py └── shopstandard │ ├── __init__.py │ └── exceptions.py ├── docs ├── .gitignore ├── _templates │ └── footerdonate.html ├── topics │ ├── patterns-map.png │ ├── dcb-speedrun-agg-pg.png │ ├── process-application.png │ ├── dcb-speedrun-dcb-pg-ts.png │ ├── dcb-speedrun-dcb-pg-tt.png │ ├── event-sourcing-in-python-cover.png │ ├── modules.rst │ ├── interface.rst │ ├── tutorial.rst │ ├── examples │ │ ├── dcb-enrolment-with-basic-objects.rst │ │ ├── shop-standard.rst │ │ ├── bank-accounts.rst │ │ ├── aggregate1.rst │ │ ├── aggregate8.rst │ │ ├── aggregate2.rst │ │ ├── aggregate10.rst │ │ └── searchable-timestamps.rst │ ├── examples.rst │ └── support.rst ├── Makefile ├── index.rst └── make.bat ├── .github └── FUNDING.yml ├── images ├── Cupid-foot-686x343.jpeg └── Cupid-foot-original.jpeg ├── AUTHORS ├── dev ├── download_axon_server.sh ├── .env ├── Dockerfile_eventsourcing_requirements ├── release-distribution.py ├── RELEASE_SCRIPT.md ├── docker-compose.yaml ├── MACOS_SETUP_NOTES.md └── test-released-distribution.py ├── .flake8 ├── mypy.ini ├── pytest.ini ├── .editorconfig ├── .gitignore ├── .dockerignore ├── .readthedocs.yaml └── LICENSE /setup.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eventsourcing/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | -------------------------------------------------------------------------------- /eventsourcing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/benchmark/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dcb_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/docs_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_templates/footerdonate.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eventsourcing/dcb/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate10/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate11/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate2/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate3/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate4/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate5/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate6/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate6a/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate7/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate7a/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate8/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/aggregate9/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/bankaccounts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/ftsprocess/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/shopvertical/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/domain_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/interface_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/system_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/cargoshipping/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/contentmanagement/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/dcb_enrolment/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/ftsprojection/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/application_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/persistence_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/projection_tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/ftscontentmanagement/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/searchabletimestamps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: pyeventsourcing 2 | -------------------------------------------------------------------------------- /examples/dcb_enrolment_with_basic_objects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/clear_cart/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/submit_cart/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/dcb_enrolment_with_enduring_objects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/dcb_enrolment_with_vertical_slices/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/add_item_to_cart/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/get_cart_items/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/add_product_to_shop/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/list_products_in_shop/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/remove_item_from_cart/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/adjust_product_inventory/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/topics/patterns-map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyeventsourcing/eventsourcing/HEAD/docs/topics/patterns-map.png -------------------------------------------------------------------------------- /images/Cupid-foot-686x343.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyeventsourcing/eventsourcing/HEAD/images/Cupid-foot-686x343.jpeg -------------------------------------------------------------------------------- /images/Cupid-foot-original.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyeventsourcing/eventsourcing/HEAD/images/Cupid-foot-original.jpeg -------------------------------------------------------------------------------- /docs/topics/dcb-speedrun-agg-pg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyeventsourcing/eventsourcing/HEAD/docs/topics/dcb-speedrun-agg-pg.png -------------------------------------------------------------------------------- /docs/topics/process-application.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyeventsourcing/eventsourcing/HEAD/docs/topics/process-application.png -------------------------------------------------------------------------------- /docs/topics/dcb-speedrun-dcb-pg-ts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyeventsourcing/eventsourcing/HEAD/docs/topics/dcb-speedrun-dcb-pg-ts.png -------------------------------------------------------------------------------- /docs/topics/dcb-speedrun-dcb-pg-tt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyeventsourcing/eventsourcing/HEAD/docs/topics/dcb-speedrun-dcb-pg-tt.png -------------------------------------------------------------------------------- /eventsourcing/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | warnings.resetwarnings() # VS Code unittest runner somehow adds warning filters :-/ 4 | -------------------------------------------------------------------------------- /docs/topics/event-sourcing-in-python-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyeventsourcing/eventsourcing/HEAD/docs/topics/event-sourcing-in-python-cover.png -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | John Bywater 2 | Denis Kyorov 3 | Chris May 4 | Bo Jin 5 | Leon Harris 6 | Julian Pistorius 7 | Lukasz Balcerzak 8 | James Rivett-Carnac 9 | Vladimir Nani 10 | Russ Ferriday 11 | -------------------------------------------------------------------------------- /dev/download_axon_server.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | wget https://download.axoniq.io/axonserver/AxonServer.zip 3 | unzip AxonServer.zip 4 | mv AxonServer-* axonserver 5 | chmod +x axonserver/axonserver.jar 6 | -------------------------------------------------------------------------------- /examples/ftsprocess/sqlite.py: -------------------------------------------------------------------------------- 1 | from eventsourcing.sqlite import SQLiteProcessRecorder 2 | from examples.ftscontentmanagement.sqlite import SQLiteFtsApplicationRecorder 3 | 4 | 5 | class SQLiteFtsProcessRecorder(SQLiteFtsApplicationRecorder, SQLiteProcessRecorder): 6 | pass 7 | -------------------------------------------------------------------------------- /docs/topics/modules.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Modules 3 | ======= 4 | 5 | This library contains several modules that can help with event sourcing in Python. 6 | 7 | 8 | .. toctree:: 9 | :maxdepth: 3 10 | 11 | domain 12 | application 13 | persistence 14 | projection 15 | system 16 | interface 17 | dcb 18 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,__pycache__,.eggs,*.egg,.pip-cache,.poetry,.venv,dist,*_pb2.py,*_pb2_grpc.py 3 | max-line-length = 88 4 | select = C,E,F,W,B,B950 5 | ignore = C101, E203, E266, E501, W503, B027, E704, E231, E702, 6 | 7 | 8 | # ignore = E203,E266,E501,W503,B907,E231 9 | # max-complexity = 18 10 | # select = B,C,E,F,W,T4,B9 11 | 12 | -------------------------------------------------------------------------------- /examples/ftsprocess/postgres.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from eventsourcing.postgres import PostgresProcessRecorder 4 | from examples.ftscontentmanagement.postgres import PostgresFtsApplicationRecorder 5 | 6 | 7 | class PostgresFtsProcessRecorder( 8 | PostgresFtsApplicationRecorder, PostgresProcessRecorder 9 | ): 10 | pass 11 | -------------------------------------------------------------------------------- /examples/shopstandard/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Shop Standard Example 3 | 4 | This example demonstrates an event-sourced application using the standard approach 5 | with a subclass of the library's Application class, a Cart aggregate class that 6 | subclasses the library's Aggregate class, and a Product aggregate class that also 7 | subclasses the library's Aggregate class. 8 | """ 9 | -------------------------------------------------------------------------------- /examples/ftsprocess/system.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from eventsourcing.system import System 4 | from examples.contentmanagement.application import ContentManagement 5 | from examples.ftsprocess.application import FtsProcess 6 | 7 | 8 | class ContentManagementSystem(System): 9 | def __init__(self) -> None: 10 | super().__init__(pipes=[[ContentManagement, FtsProcess]]) 11 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.10 3 | check_untyped_defs = True 4 | no_implicit_reexport = True 5 | 6 | ignore_missing_imports = True 7 | incremental = True 8 | follow_imports = normal 9 | warn_redundant_casts = True 10 | warn_unused_ignores = True 11 | strict_optional = True 12 | no_implicit_optional = True 13 | disallow_untyped_defs = True 14 | disallow_any_generics = True 15 | plugins = pydantic.mypy 16 | -------------------------------------------------------------------------------- /examples/aggregate1/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from eventsourcing.domain import Aggregate, event 4 | 5 | 6 | class Dog(Aggregate): 7 | 8 | @event("Registered") 9 | def __init__(self, name: str) -> None: 10 | self.name = name 11 | self.tricks: list[str] = [] 12 | 13 | @event("TrickAdded") 14 | def add_trick(self, trick: str) -> None: 15 | self.tricks.append(trick) 16 | -------------------------------------------------------------------------------- /dev/.env: -------------------------------------------------------------------------------- 1 | COMPOSE_FILE=dev/docker-compose.yaml 2 | COMPOSE_PROJECT_NAME=eventsourcing 3 | 4 | CASSANDRA_HOSTS=127.0.0.1 5 | 6 | MYSQL_HOST=127.0.0.1 7 | MYSQL_DATABASE=eventsourcing 8 | MYSQL_PASSWORD=eventsourcing 9 | MYSQL_ROOT_PASSWORD=eventsourcing_root 10 | MYSQL_USER=eventsourcing 11 | 12 | POSTGRES_HOST=127.0.0.1 13 | POSTGRES_PORT=5432 14 | POSTGRES_PASSWORD=eventsourcing 15 | POSTGRES_USER=eventsourcing 16 | 17 | REDIS_HOST=127.0.0.1 18 | -------------------------------------------------------------------------------- /dev/Dockerfile_eventsourcing_requirements: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | WORKDIR /app 4 | 5 | # Copy enough to install the eventsourcing requirements. 6 | COPY setup.py /app/setup.py 7 | RUN mkdir eventsourcing 8 | COPY eventsourcing/ /app/eventsourcing/ 9 | 10 | # Install the requirements. 11 | RUN pip install -e .[testing] 12 | 13 | # Remove the package source files. 14 | RUN pip uninstall eventsourcing --yes 15 | RUN rm -rf /app/eventsourcing 16 | -------------------------------------------------------------------------------- /examples/shopstandard/exceptions.py: -------------------------------------------------------------------------------- 1 | class ProductNotFoundInShopError(Exception): 2 | pass 3 | 4 | 5 | class ProductAlreadyInShopError(Exception): 6 | pass 7 | 8 | 9 | class CartFullError(Exception): 10 | pass 11 | 12 | 13 | class ProductNotInCartError(Exception): 14 | pass 15 | 16 | 17 | class InsufficientInventoryError(Exception): 18 | pass 19 | 20 | 21 | class CartAlreadySubmittedError(Exception): 22 | pass 23 | -------------------------------------------------------------------------------- /examples/shopvertical/exceptions.py: -------------------------------------------------------------------------------- 1 | class ProductNotFoundInShopError(Exception): 2 | pass 3 | 4 | 5 | class ProductAlreadyInShopError(Exception): 6 | pass 7 | 8 | 9 | class CartFullError(Exception): 10 | pass 11 | 12 | 13 | class ProductNotInCartError(Exception): 14 | pass 15 | 16 | 17 | class InsufficientInventoryError(Exception): 18 | pass 19 | 20 | 21 | class CartAlreadySubmittedError(Exception): 22 | pass 23 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | # --tb not given Produces reams of output, with full source code included in tracebacks 3 | # --tb=no Just shows location of failure in the test file: no use for tracking down errors 4 | # --tb=short Just shows vanilla traceback: very useful, but file names are incomplete and relative 5 | # --tb=native Slightly more info than short: still works very well. The full paths may be useful for CI 6 | addopts = --tb=native 7 | -------------------------------------------------------------------------------- /tests/benchmark/benchmark_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from eventsourcing.application import Application 8 | 9 | if TYPE_CHECKING: 10 | from pytest_benchmark.fixture import BenchmarkFixture 11 | 12 | 13 | @pytest.mark.benchmark(group="construct-application") 14 | def test_construct_application(benchmark: BenchmarkFixture) -> None: 15 | benchmark(Application) 16 | -------------------------------------------------------------------------------- /eventsourcing/compressor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import zlib 4 | 5 | from eventsourcing.persistence import Compressor 6 | 7 | 8 | class ZlibCompressor(Compressor): 9 | def compress(self, data: bytes) -> bytes: 10 | """Compress bytes using zlib.""" 11 | return zlib.compress(data) 12 | 13 | def decompress(self, data: bytes) -> bytes: 14 | """Decompress bytes using zlib.""" 15 | return zlib.decompress(data) 16 | -------------------------------------------------------------------------------- /docs/topics/interface.rst: -------------------------------------------------------------------------------- 1 | ============================================= 2 | :mod:`~eventsourcing.interface` --- Interface 3 | ============================================= 4 | 5 | *this page is under development --- please check back soon* 6 | 7 | Classes 8 | ======= 9 | 10 | .. automodule:: eventsourcing.interface 11 | :show-inheritance: 12 | :member-order: bysource 13 | :members: 14 | :special-members: 15 | :exclude-members: __weakref__, __dict__ 16 | -------------------------------------------------------------------------------- /tests/application_tests/test_application_with_popo.py: -------------------------------------------------------------------------------- 1 | from eventsourcing.tests.application import ( 2 | ApplicationTestCase, 3 | ExampleApplicationTestCase, 4 | ) 5 | 6 | 7 | class TestApplicationWithPOPO(ApplicationTestCase): 8 | pass 9 | 10 | 11 | class TestExampleApplicationWithPOPO(ExampleApplicationTestCase): 12 | expected_factory_topic = "eventsourcing.popo:POPOFactory" 13 | 14 | 15 | del ApplicationTestCase 16 | del ExampleApplicationTestCase 17 | -------------------------------------------------------------------------------- /docs/topics/tutorial.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Tutorial 3 | ======== 4 | 5 | A tutorial for event sourcing in Python. 6 | 7 | This tutorial shows how to write an event-sourced application in Python. 8 | It expands and explains the :ref:`Synopsis `. It prepares 9 | new users of the library for reading the :doc:`Modules ` 10 | documentation. 11 | 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | 16 | tutorial/part1 17 | tutorial/part2 18 | tutorial/part3 19 | tutorial/part4 20 | tutorial/part5 21 | -------------------------------------------------------------------------------- /examples/aggregate2/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from eventsourcing.domain import Aggregate, event 4 | 5 | 6 | class Dog(Aggregate): 7 | class Registered(Aggregate.Created): 8 | name: str 9 | 10 | class TrickAdded(Aggregate.Event): 11 | trick: str 12 | 13 | @event(Registered) 14 | def __init__(self, name: str) -> None: 15 | self.name = name 16 | self.tricks: list[str] = [] 17 | 18 | @event(TrickAdded) 19 | def add_trick(self, trick: str) -> None: 20 | self.tricks.append(trick) 21 | -------------------------------------------------------------------------------- /examples/searchabletimestamps/persistence.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import abstractmethod 4 | from typing import TYPE_CHECKING 5 | 6 | from eventsourcing.persistence import ApplicationRecorder 7 | 8 | if TYPE_CHECKING: 9 | from datetime import datetime 10 | from uuid import UUID 11 | 12 | 13 | class SearchableTimestampsRecorder(ApplicationRecorder): 14 | @abstractmethod 15 | def get_version_at_timestamp( 16 | self, originator_id: UUID, timestamp: datetime 17 | ) -> int | None: 18 | """Returns originator version at timestamp for given originator ID.""" 19 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | POETRY ?= poetry 5 | 6 | # You can set these variables from the command line, and also 7 | # from the environment for the first two. 8 | SPHINXOPTS ?= 9 | SPHINXBUILD ?= sphinx-build 10 | SOURCEDIR = . 11 | BUILDDIR = _build 12 | 13 | # Put it first so that "make" without argument is like "make help". 14 | help: 15 | $(POETRY) run $(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 16 | 17 | .PHONY: help Makefile 18 | 19 | # Catch-all target: route all unknown targets to Sphinx using the new 20 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 21 | %: Makefile 22 | $(POETRY) run $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 23 | -------------------------------------------------------------------------------- /tests/dcb_tests/test_application.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from eventsourcing.dcb.application import DCBApplication 4 | 5 | 6 | class TestDCBApplication(TestCase): 7 | def test_as_context_manager(self) -> None: 8 | with DCBApplication(): 9 | pass 10 | 11 | def test_construct_with_env(self) -> None: 12 | with DCBApplication({"NAME": "value"}) as app: 13 | self.assertIn("NAME", app.env) 14 | 15 | def test_can_subclass(self) -> None: 16 | 17 | class MyApp1(DCBApplication): 18 | pass 19 | 20 | app1 = MyApp1() 21 | self.assertEqual("MyApp1", app1.name) 22 | 23 | class MyApp2(DCBApplication): 24 | name = "name1" 25 | 26 | app2 = MyApp2() 27 | self.assertEqual("name1", app2.name) 28 | -------------------------------------------------------------------------------- /examples/aggregate8/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from eventsourcing.domain import event 4 | from examples.aggregate7.immutablemodel import Immutable 5 | from examples.aggregate8.mutablemodel import Aggregate, AggregateSnapshot, SnapshotState 6 | 7 | 8 | class Trick(Immutable): 9 | name: str 10 | 11 | 12 | class DogSnapshotState(SnapshotState): 13 | name: str 14 | tricks: list[Trick] 15 | 16 | 17 | class Dog(Aggregate): 18 | class Snapshot(AggregateSnapshot): 19 | state: DogSnapshotState 20 | 21 | @event("Registered") 22 | def __init__(self, name: str) -> None: 23 | self.name = name 24 | self.tricks: list[Trick] = [] 25 | 26 | @event("TrickAdded") 27 | def add_trick(self, trick: Trick) -> None: 28 | self.tricks.append(trick) 29 | -------------------------------------------------------------------------------- /examples/aggregate1/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from uuid import UUID 5 | 6 | from eventsourcing.application import Application 7 | from examples.aggregate1.domainmodel import Dog 8 | 9 | 10 | class DogSchool(Application[UUID]): 11 | is_snapshotting_enabled = True 12 | 13 | def register_dog(self, name: str) -> UUID: 14 | dog = Dog(name) 15 | self.save(dog) 16 | return dog.id 17 | 18 | def add_trick(self, dog_id: UUID, trick: str) -> None: 19 | dog: Dog = self.repository.get(dog_id) 20 | dog.add_trick(trick) 21 | self.save(dog) 22 | 23 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 24 | dog: Dog = self.repository.get(dog_id) 25 | return {"name": dog.name, "tricks": tuple(dog.tricks)} 26 | -------------------------------------------------------------------------------- /examples/aggregate2/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from uuid import UUID 5 | 6 | from eventsourcing.application import Application 7 | from examples.aggregate2.domainmodel import Dog 8 | 9 | 10 | class DogSchool(Application[UUID]): 11 | is_snapshotting_enabled = True 12 | 13 | def register_dog(self, name: str) -> UUID: 14 | dog = Dog(name) 15 | self.save(dog) 16 | return dog.id 17 | 18 | def add_trick(self, dog_id: UUID, trick: str) -> None: 19 | dog: Dog = self.repository.get(dog_id) 20 | dog.add_trick(trick) 21 | self.save(dog) 22 | 23 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 24 | dog: Dog = self.repository.get(dog_id) 25 | return {"name": dog.name, "tricks": tuple(dog.tricks)} 26 | -------------------------------------------------------------------------------- /examples/aggregate11/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from eventsourcing.application import Application 6 | from examples.aggregate11.domainmodel import Dog, Snapshot 7 | 8 | 9 | class DogSchool(Application[str]): 10 | is_snapshotting_enabled = True 11 | snapshot_class = Snapshot 12 | 13 | def register_dog(self, name: str) -> str: 14 | dog = Dog(name) 15 | self.save(dog) 16 | return dog.id 17 | 18 | def add_trick(self, dog_id: str, trick: str) -> None: 19 | dog: Dog = self.repository.get(dog_id) 20 | dog.add_trick(trick) 21 | self.save(dog) 22 | 23 | def get_dog(self, dog_id: str) -> dict[str, Any]: 24 | dog: Dog = self.repository.get(dog_id) 25 | return {"name": dog.name, "tricks": tuple(dog.tricks)} 26 | -------------------------------------------------------------------------------- /examples/aggregate3/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from uuid import UUID 5 | 6 | from eventsourcing.application import Application 7 | from examples.aggregate3.domainmodel import Dog 8 | 9 | 10 | class DogSchool(Application[UUID]): 11 | is_snapshotting_enabled = True 12 | 13 | def register_dog(self, name: str) -> UUID: 14 | dog = Dog.register(name=name) 15 | self.save(dog) 16 | return dog.id 17 | 18 | def add_trick(self, dog_id: UUID, trick: str) -> None: 19 | dog: Dog = self.repository.get(dog_id) 20 | dog.add_trick(trick) 21 | self.save(dog) 22 | 23 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 24 | dog: Dog = self.repository.get(dog_id) 25 | return {"name": dog.name, "tricks": tuple(dog.tricks)} 26 | -------------------------------------------------------------------------------- /examples/contentmanagement/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | from tempfile import TemporaryDirectory 6 | 7 | 8 | def create_diff(old: str, new: str) -> str: 9 | return run("diff %s %s > %s", old, new) 10 | 11 | 12 | def apply_diff(old: str, diff: str) -> str: 13 | return run("patch -s %s %s -o %s", old, diff) 14 | 15 | 16 | def run(cmd: str, a: str, b: str) -> str: 17 | with TemporaryDirectory() as td: 18 | a_path = Path(td) / "a" 19 | b_path = Path(td) / "b" 20 | c_path = Path(td) / "c" 21 | with a_path.open("w") as a_file: 22 | a_file.write(a) 23 | with b_path.open("w") as b_file: 24 | b_file.write(b) 25 | os.system(cmd % (a_path, b_path, c_path)) # noqa: S605 26 | with c_path.open() as c_file: 27 | return c_file.read() 28 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======================== 2 | Event Sourcing in Python 3 | ======================== 4 | 5 | This project is a comprehensive Python library for implementing event sourcing, a design pattern where all 6 | changes to application state are stored as a sequence of events. This library provides a solid foundation 7 | for building event-sourced applications in Python, with a focus on reliability, performance, and developer 8 | experience. This project is `hosted on GitHub `_. 9 | 10 | Contents 11 | ======== 12 | 13 | .. toctree:: 14 | :maxdepth: 3 15 | 16 | topics/installing 17 | topics/support 18 | topics/introduction 19 | topics/tutorial 20 | topics/modules 21 | topics/examples 22 | topics/release_notes 23 | 24 | 25 | Modules Reference 26 | ================= 27 | 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | -------------------------------------------------------------------------------- /examples/aggregate10/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from eventsourcing.domain import event 4 | from examples.aggregate9.immutablemodel import Immutable 5 | from examples.aggregate10.mutablemodel import ( 6 | Aggregate, 7 | AggregateSnapshot, 8 | SnapshotState, 9 | ) 10 | 11 | 12 | class Trick(Immutable, frozen=True): 13 | name: str 14 | 15 | 16 | class DogSnapshotState(SnapshotState, frozen=True): 17 | name: str 18 | tricks: list[Trick] 19 | 20 | 21 | class Dog(Aggregate): 22 | class Snapshot(AggregateSnapshot, frozen=True): 23 | state: DogSnapshotState 24 | 25 | @event("Registered") 26 | def __init__(self, name: str) -> None: 27 | self.name = name 28 | self.tricks: list[Trick] = [] 29 | 30 | @event("TrickAdded") 31 | def add_trick(self, trick: Trick) -> None: 32 | self.tricks.append(trick) 33 | -------------------------------------------------------------------------------- /examples/aggregate5/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from uuid import UUID 5 | 6 | from eventsourcing.application import Application 7 | from examples.aggregate5.domainmodel import Dog 8 | 9 | 10 | class DogSchool(Application[UUID]): 11 | is_snapshotting_enabled = True 12 | 13 | def register_dog(self, name: str) -> UUID: 14 | dog, event = Dog.register(name) 15 | self.save(event) 16 | return dog.id 17 | 18 | def add_trick(self, dog_id: UUID, trick: str) -> None: 19 | dog = self.repository.get(dog_id, projector_func=Dog.projector) 20 | dog, event = dog.add_trick(trick) 21 | self.save(event) 22 | 23 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 24 | dog = self.repository.get(dog_id, projector_func=Dog.projector) 25 | return {"name": dog.name, "tricks": dog.tricks} 26 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | charset = utf-8 12 | 13 | # Docstrings and comments use max_line_length = 79 14 | [*.py] 15 | max_line_length = 88 16 | 17 | # Use 2 spaces for the HTML files 18 | [*.html] 19 | indent_size = 2 20 | 21 | # The JSON files contain newlines inconsistently 22 | [*.json] 23 | indent_size = 2 24 | insert_final_newline = ignore 25 | 26 | [**/admin/js/vendor/**] 27 | indent_style = ignore 28 | indent_size = ignore 29 | 30 | # Minified JavaScript files shouldn't be changed 31 | [**.min.js] 32 | indent_style = ignore 33 | insert_final_newline = ignore 34 | 35 | # Makefiles always use tabs for indentation 36 | [Makefile] 37 | indent_style = tab 38 | 39 | # Batch files use tabs for indentation 40 | [*.bat] 41 | indent_style = tab 42 | 43 | [*.{yml, yaml}] 44 | indent_size = 2 45 | -------------------------------------------------------------------------------- /examples/aggregate6/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from uuid import UUID 5 | 6 | from eventsourcing.application import Application 7 | from examples.aggregate6.baseclasses import Snapshot 8 | from examples.aggregate6.domainmodel import add_trick, project_dog, register_dog 9 | 10 | 11 | class DogSchool(Application[UUID]): 12 | is_snapshotting_enabled = True 13 | snapshot_class = Snapshot 14 | 15 | def register_dog(self, name: str) -> UUID: 16 | event = register_dog(name) 17 | self.save(event) 18 | return event.originator_id 19 | 20 | def add_trick(self, dog_id: UUID, trick: str) -> None: 21 | dog = self.repository.get(dog_id, projector_func=project_dog) 22 | self.save(add_trick(dog, trick)) 23 | 24 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 25 | dog = self.repository.get(dog_id, projector_func=project_dog) 26 | return {"name": dog.name, "tricks": dog.tricks} 27 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/adjust_product_inventory/cmd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from uuid import UUID # noqa: TC003 4 | 5 | from examples.shopvertical.common import Command, get_events, put_events 6 | from examples.shopvertical.events import AdjustedProductInventory, DomainEvents 7 | from examples.shopvertical.exceptions import ProductNotFoundInShopError 8 | 9 | 10 | class AdjustProductInventory(Command): 11 | product_id: UUID 12 | adjustment: int 13 | 14 | def handle(self, events: DomainEvents) -> DomainEvents: 15 | if not events: 16 | raise ProductNotFoundInShopError 17 | return ( 18 | AdjustedProductInventory( 19 | originator_id=self.product_id, 20 | originator_version=len(events) + 1, 21 | adjustment=self.adjustment, 22 | ), 23 | ) 24 | 25 | def execute(self) -> int | None: 26 | return put_events(self.handle(get_events(self.product_id))) 27 | -------------------------------------------------------------------------------- /tests/dcb_tests/test_msgpack.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from threading import Thread 3 | from unittest import TestCase 4 | 5 | from eventsourcing.dcb.msgpack import Decision 6 | 7 | 8 | class MyDecision(Decision): 9 | a: str 10 | 11 | 12 | class Test(TestCase): 13 | def test(self) -> None: 14 | # Trying to isolate segmentation violation in Python3.13 with 15 | # projection using DCB application with ImMemoryDCBRecorder and 16 | # eventsourcing.dcb.msgpack.Decision. One suspect is deepcopy of 17 | # msgspec.Struct subclasses, perhaps when crossing threads. This 18 | # test tries to replicate what InMemoryDCBRecorder does with a 19 | # subscription (deepcopy on a different thread). However, no segv. 20 | m = MyDecision(a="a") 21 | self.assertEqual(deepcopy(m), m) 22 | 23 | def f() -> None: 24 | self.assertEqual(deepcopy(m), m) 25 | 26 | t = Thread(target=f) 27 | t.start() 28 | t.join() 29 | -------------------------------------------------------------------------------- /examples/aggregate4/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from uuid import UUID 5 | 6 | from eventsourcing.application import Application 7 | from examples.aggregate4.domainmodel import Dog 8 | 9 | 10 | class DogSchool(Application[UUID]): 11 | is_snapshotting_enabled = True 12 | 13 | def register_dog(self, name: str) -> UUID: 14 | dog = Dog.register(name) 15 | self.save(dog) 16 | return dog.id 17 | 18 | def add_trick(self, dog_id: UUID, trick: str) -> None: 19 | dog: Dog = self.repository.get(dog_id, projector_func=Dog.project_events) 20 | dog.add_trick(trick) 21 | self.save(dog) 22 | 23 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 24 | dog: Dog = self.repository.get(dog_id, projector_func=Dog.project_events) 25 | return { 26 | "name": dog.name, 27 | "tricks": tuple(dog.tricks), 28 | "created_on": dog.created_on, 29 | "modified_on": dog.modified_on, 30 | } 31 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/clear_cart/cmd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from uuid import UUID # noqa: TC003 4 | 5 | from examples.shopvertical.common import Command, get_events, put_events 6 | from examples.shopvertical.events import ClearedCart, DomainEvents, SubmittedCart 7 | from examples.shopvertical.exceptions import CartAlreadySubmittedError 8 | 9 | 10 | class ClearCart(Command): 11 | cart_id: UUID 12 | 13 | def handle(self, events: DomainEvents) -> DomainEvents: 14 | is_submitted = False 15 | for event in events: 16 | if isinstance(event, SubmittedCart): 17 | is_submitted = True 18 | 19 | if is_submitted: 20 | raise CartAlreadySubmittedError 21 | 22 | return ( 23 | ClearedCart( 24 | originator_id=self.cart_id, 25 | originator_version=len(events) + 1, 26 | ), 27 | ) 28 | 29 | def execute(self) -> int | None: 30 | return put_events(self.handle(get_events(self.cart_id))) 31 | -------------------------------------------------------------------------------- /examples/aggregate10/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from examples.aggregate9.msgpack import MsgspecApplication 6 | from examples.aggregate10.domainmodel import Dog, Trick 7 | 8 | if TYPE_CHECKING: 9 | from uuid import UUID 10 | 11 | 12 | class DogSchool(MsgspecApplication): 13 | is_snapshotting_enabled = True 14 | 15 | def register_dog(self, name: str) -> UUID: 16 | dog = Dog(name) 17 | self.save(dog) 18 | return dog.id 19 | 20 | def add_trick(self, dog_id: UUID, trick: str) -> None: 21 | dog: Dog = self.repository.get(dog_id) 22 | dog.add_trick(Trick(name=trick)) 23 | self.save(dog) 24 | 25 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 26 | dog: Dog = self.repository.get(dog_id) 27 | return { 28 | "name": dog.name, 29 | "tricks": tuple([t.name for t in dog.tricks]), 30 | "created_on": dog.created_on, 31 | "modified_on": dog.modified_on, 32 | } 33 | -------------------------------------------------------------------------------- /examples/aggregate8/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from examples.aggregate7.orjsonpydantic import PydanticApplication 6 | from examples.aggregate8.domainmodel import Dog, Trick 7 | 8 | if TYPE_CHECKING: 9 | from uuid import UUID 10 | 11 | 12 | class DogSchool(PydanticApplication): 13 | is_snapshotting_enabled = True 14 | 15 | def register_dog(self, name: str) -> UUID: 16 | dog = Dog(name) 17 | self.save(dog) 18 | return dog.id 19 | 20 | def add_trick(self, dog_id: UUID, trick: str) -> None: 21 | dog: Dog = self.repository.get(dog_id) 22 | dog.add_trick(Trick(name=trick)) 23 | self.save(dog) 24 | 25 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 26 | dog: Dog = self.repository.get(dog_id) 27 | return { 28 | "name": dog.name, 29 | "tricks": tuple([t.name for t in dog.tricks]), 30 | "created_on": dog.created_on, 31 | "modified_on": dog.modified_on, 32 | } 33 | -------------------------------------------------------------------------------- /tests/persistence_tests/test_aes_cryptodome_crytography_interoperability.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | import eventsourcing.cipher as pycryptodome 4 | from eventsourcing import cryptography 5 | from eventsourcing.utils import Environment 6 | 7 | 8 | class TestAesCipherInteroperability(TestCase): 9 | def test(self) -> None: 10 | environment = Environment() 11 | key = pycryptodome.AESCipher.create_key(16) 12 | environment["CIPHER_KEY"] = key 13 | 14 | aes_pycryptodome = pycryptodome.AESCipher(environment) 15 | aes_cryptography = cryptography.AESCipher(environment) 16 | 17 | plain_text = b"some text" 18 | encrypted_text = aes_pycryptodome.encrypt(plain_text) 19 | recovered_text = aes_cryptography.decrypt(encrypted_text) 20 | self.assertEqual(plain_text, recovered_text) 21 | 22 | plain_text = b"some text" 23 | encrypted_text = aes_cryptography.encrypt(plain_text) 24 | recovered_text = aes_pycryptodome.decrypt(encrypted_text) 25 | self.assertEqual(plain_text, recovered_text) 26 | -------------------------------------------------------------------------------- /examples/shopvertical/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Sequence 4 | from decimal import Decimal # noqa: TC003 5 | from typing import TYPE_CHECKING 6 | from uuid import UUID # noqa: TC003 7 | 8 | from examples.aggregate7.immutablemodel import Immutable 9 | 10 | if TYPE_CHECKING: 11 | from typing import TypeAlias 12 | 13 | 14 | class DomainEvent(Immutable): 15 | originator_id: UUID 16 | originator_version: int 17 | 18 | 19 | DomainEvents: TypeAlias = Sequence[DomainEvent] 20 | 21 | 22 | class AddedProductToShop(DomainEvent): 23 | name: str 24 | description: str 25 | price: Decimal 26 | 27 | 28 | class AdjustedProductInventory(DomainEvent): 29 | adjustment: int 30 | 31 | 32 | class AddedItemToCart(DomainEvent): 33 | product_id: UUID 34 | name: str 35 | description: str 36 | price: Decimal 37 | 38 | 39 | class RemovedItemFromCart(DomainEvent): 40 | product_id: UUID 41 | 42 | 43 | class ClearedCart(DomainEvent): 44 | pass 45 | 46 | 47 | class SubmittedCart(DomainEvent): 48 | pass 49 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/add_product_to_shop/cmd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from decimal import Decimal # noqa: TC003 4 | from uuid import UUID # noqa: TC003 5 | 6 | from examples.shopvertical.common import Command, get_events, put_events 7 | from examples.shopvertical.events import AddedProductToShop, DomainEvents 8 | from examples.shopvertical.exceptions import ProductAlreadyInShopError 9 | 10 | 11 | class AddProductToShop(Command): 12 | product_id: UUID 13 | name: str 14 | description: str 15 | price: Decimal 16 | 17 | def handle(self, events: DomainEvents) -> DomainEvents: 18 | if len(events): 19 | raise ProductAlreadyInShopError 20 | return ( 21 | AddedProductToShop( 22 | originator_id=self.product_id, 23 | originator_version=1, 24 | name=self.name, 25 | description=self.description, 26 | price=self.price, 27 | ), 28 | ) 29 | 30 | def execute(self) -> int | None: 31 | return put_events(self.handle(get_events(self.product_id))) 32 | -------------------------------------------------------------------------------- /examples/aggregate3/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from eventsourcing.dispatch import singledispatchmethod 4 | from eventsourcing.domain import Aggregate 5 | 6 | 7 | class Dog(Aggregate): 8 | class Event(Aggregate.Event): 9 | def apply(self, aggregate: Dog) -> None: 10 | aggregate.apply(self) 11 | 12 | class Registered(Event, Aggregate.Created): 13 | name: str 14 | 15 | class TrickAdded(Event): 16 | trick: str 17 | 18 | @classmethod 19 | def register(cls, name: str) -> Dog: 20 | return cls._create(cls.Registered, name=name) 21 | 22 | def add_trick(self, trick: str) -> None: 23 | self.trigger_event(self.TrickAdded, trick=trick) 24 | 25 | @singledispatchmethod 26 | def apply(self, event: Event) -> None: 27 | """Applies event to aggregate.""" 28 | 29 | @apply.register 30 | def _(self, event: Dog.Registered) -> None: 31 | self.name = event.name 32 | self.tricks: list[str] = [] 33 | 34 | @apply.register 35 | def _(self, event: Dog.TrickAdded) -> None: 36 | self.tricks.append(event.trick) 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | *dog-school* 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | .idea/ 62 | eventsourcing/tests/djangoproject/db.sqlite3 63 | .mypy_cache/ 64 | .dmypy.json 65 | 66 | # Virtualenv 67 | venv 68 | 69 | #Eclipse IDE 70 | .project 71 | .pydevproject 72 | .settings 73 | .dbeaver 74 | 75 | #VS Code IDE 76 | .vscode 77 | 78 | -------------------------------------------------------------------------------- /examples/aggregate7/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from examples.aggregate7.domainmodel import Trick, add_trick, project_dog, register_dog 6 | from examples.aggregate7.immutablemodel import Snapshot 7 | from examples.aggregate7.orjsonpydantic import PydanticApplication 8 | 9 | if TYPE_CHECKING: 10 | from uuid import UUID 11 | 12 | 13 | class DogSchool(PydanticApplication): 14 | is_snapshotting_enabled = True 15 | snapshot_class = Snapshot 16 | 17 | def register_dog(self, name: str) -> UUID: 18 | event = register_dog(name) 19 | self.save(event) 20 | return event.originator_id 21 | 22 | def add_trick(self, dog_id: UUID, trick: str) -> None: 23 | dog = self.repository.get(dog_id, projector_func=project_dog) 24 | self.save(add_trick(dog, Trick(name=trick))) 25 | 26 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 27 | dog = self.repository.get(dog_id, projector_func=project_dog) 28 | return { 29 | "name": dog.name, 30 | "tricks": tuple([t.name for t in dog.tricks]), 31 | "created_on": dog.created_on, 32 | "modified_on": dog.modified_on, 33 | } 34 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .coveragerc 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # Other ignores 61 | .github/ 62 | .idea/ 63 | .mypy_cache/ 64 | .pytest_cache/ 65 | .editorconfig 66 | .env 67 | .travis.yml 68 | AUTHORS 69 | CODE_OF_CONDUCT.md 70 | dev 71 | eventsourcing/tests/djangoproject/db.sqlite3 72 | README.md 73 | README_example_with_axon.md 74 | -------------------------------------------------------------------------------- /docs/topics/examples/dcb-enrolment-with-basic-objects.rst: -------------------------------------------------------------------------------- 1 | .. _DCB example 2: 2 | 3 | DCB 2 - Basic DCB Objects 4 | ========================= 5 | 6 | Here we meet the :doc:`course subscriptions challenge ` 7 | directly with DCB. 8 | 9 | Application 10 | ----------- 11 | 12 | The :class:`~examples.dcb_enrolment_with_basic_objects.application.EnrolmentWithDCB` application implements 13 | :ref:`the enrolment interface ` introduced on the previous page, using the 14 | basic :ref:`DCB objects ` included in this library, and the :ref:`DCB application ` 15 | class. 16 | 17 | Whilst the code is relatively verbose, the DCB approach can be understood directly 18 | without any extra abstractions. 19 | 20 | .. literalinclude:: ../../../examples/dcb_enrolment_with_basic_objects/application.py 21 | :pyobject: EnrolmentWithDCB 22 | 23 | 24 | Test case 25 | --------- 26 | 27 | The ::ref:`enrolment test case ` is extended for 28 | :class:`~examples.dcb_enrolment_with_basic_objects.application.EnrolmentWithDCB`. 29 | 30 | .. literalinclude:: ../../../examples/dcb_enrolment_with_basic_objects/test_application.py 31 | :pyobject: TestEnrolmentWithDCB 32 | 33 | 34 | -------------------------------------------------------------------------------- /examples/aggregate9/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from examples.aggregate9.domainmodel import Trick, add_trick, project_dog, register_dog 6 | from examples.aggregate9.immutablemodel import Snapshot 7 | from examples.aggregate9.msgpack import MsgspecApplication 8 | 9 | if TYPE_CHECKING: 10 | from uuid import UUID 11 | 12 | 13 | class DogSchool(MsgspecApplication): 14 | is_snapshotting_enabled = True 15 | snapshot_class = Snapshot 16 | 17 | def register_dog(self, name: str) -> UUID: 18 | event = register_dog(name) 19 | self.save(event) 20 | return event.originator_id 21 | 22 | def add_trick(self, dog_id: UUID, trick: str) -> None: 23 | dog = self.repository.get(dog_id, projector_func=project_dog) 24 | self.save(add_trick(dog, Trick(name=trick))) 25 | 26 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 27 | dog = self.repository.get(dog_id, projector_func=project_dog) 28 | return { 29 | "id": dog.id, 30 | "name": dog.name, 31 | "tricks": tuple([t.name for t in dog.tricks]), 32 | "created_on": dog.created_on, 33 | "modified_on": dog.modified_on, 34 | } 35 | -------------------------------------------------------------------------------- /eventsourcing/dcb/msgpack.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, TypeVar 4 | 5 | import msgspec 6 | 7 | from eventsourcing.dcb import api, domain, persistence 8 | from eventsourcing.utils import get_topic, resolve_topic 9 | 10 | 11 | class Decision(msgspec.Struct, domain.Decision): 12 | def as_dict(self) -> dict[str, Any]: 13 | return {key: getattr(self, key) for key in self.__struct_fields__} 14 | 15 | 16 | TDecision = TypeVar("TDecision", bound=Decision) 17 | 18 | 19 | class MessagePackMapper(persistence.DCBMapper[Decision]): 20 | def to_dcb_event(self, event: domain.Tagged[TDecision]) -> api.DCBEvent: 21 | return api.DCBEvent( 22 | type=get_topic(type(event.decision)), 23 | data=msgspec.msgpack.encode(event.decision), 24 | tags=event.tags, 25 | ) 26 | 27 | def to_domain_event(self, event: api.DCBEvent) -> domain.Tagged[Decision]: 28 | return domain.Tagged( 29 | tags=event.tags, 30 | decision=msgspec.msgpack.decode( 31 | event.data, 32 | type=resolve_topic(event.type), 33 | ), 34 | ) 35 | 36 | 37 | class InitialDecision(Decision, domain.InitialDecision): 38 | originator_topic: str 39 | -------------------------------------------------------------------------------- /examples/dcb_enrolment_with_enduring_objects/test_eventstore.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from eventsourcing.dcb.domain import Tagged 4 | from eventsourcing.dcb.msgpack import Decision, MessagePackMapper 5 | 6 | # TODO: Actually test the event store independently of the example application. 7 | 8 | 9 | class StudentRegistered(Decision): 10 | name: str 11 | max_courses: int 12 | 13 | 14 | class TestMapper(TestCase): 15 | def test_mapper(self) -> None: 16 | mapper = MessagePackMapper() 17 | 18 | event = Tagged[StudentRegistered]( 19 | tags=["student-1"], 20 | decision=StudentRegistered( 21 | name="Sara", 22 | max_courses=2, 23 | ), 24 | ) 25 | 26 | dcb_event = mapper.to_dcb_event(event) 27 | 28 | self.assertTrue(dcb_event.type.endswith("StudentRegistered"), dcb_event.type) 29 | self.assertTrue(dcb_event.tags, dcb_event.type) 30 | 31 | copy = mapper.to_domain_event(dcb_event) 32 | assert isinstance(copy, Tagged) # for mypy 33 | assert isinstance(copy.decision, StudentRegistered) # for mypy 34 | 35 | self.assertEqual(copy.tags, event.tags) 36 | self.assertEqual(copy.decision.name, event.decision.name) 37 | self.assertEqual(copy.decision.max_courses, event.decision.max_courses) 38 | -------------------------------------------------------------------------------- /examples/aggregate2/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from examples.aggregate2.application import DogSchool 6 | 7 | 8 | class TestDogSchool(TestCase): 9 | def test_dog_school(self) -> None: 10 | # Construct application object. 11 | school = DogSchool() 12 | 13 | # Evolve application state. 14 | dog_id = school.register_dog("Fido") 15 | school.add_trick(dog_id, "roll over") 16 | school.add_trick(dog_id, "play dead") 17 | 18 | # Query application state. 19 | dog = school.get_dog(dog_id) 20 | assert dog["name"] == "Fido" 21 | assert dog["tricks"] == ("roll over", "play dead") 22 | 23 | # Select notifications. 24 | notifications = school.notification_log.select(start=1, limit=10) 25 | assert len(notifications) == 3 26 | 27 | # Take snapshot. 28 | school.take_snapshot(dog_id, version=3) 29 | dog = school.get_dog(dog_id) 30 | assert dog["name"] == "Fido" 31 | assert dog["tricks"] == ("roll over", "play dead") 32 | 33 | # Continue with snapshotted aggregate. 34 | school.add_trick(dog_id, "fetch ball") 35 | dog = school.get_dog(dog_id) 36 | assert dog["name"] == "Fido" 37 | assert dog["tricks"] == ("roll over", "play dead", "fetch ball") 38 | -------------------------------------------------------------------------------- /examples/aggregate3/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from examples.aggregate3.application import DogSchool 6 | 7 | 8 | class TestDogSchool(TestCase): 9 | def test_dog_school(self) -> None: 10 | # Construct application object. 11 | school = DogSchool() 12 | 13 | # Evolve application state. 14 | dog_id = school.register_dog("Fido") 15 | school.add_trick(dog_id, "roll over") 16 | school.add_trick(dog_id, "play dead") 17 | 18 | # Query application state. 19 | dog = school.get_dog(dog_id) 20 | assert dog["name"] == "Fido" 21 | assert dog["tricks"] == ("roll over", "play dead") 22 | 23 | # Select notifications. 24 | notifications = school.notification_log.select(start=1, limit=10) 25 | assert len(notifications) == 3 26 | 27 | # Take snapshot. 28 | school.take_snapshot(dog_id, version=3) 29 | dog = school.get_dog(dog_id) 30 | assert dog["name"] == "Fido" 31 | assert dog["tricks"] == ("roll over", "play dead") 32 | 33 | # Continue with snapshotted aggregate. 34 | school.add_trick(dog_id, "fetch ball") 35 | dog = school.get_dog(dog_id) 36 | assert dog["name"] == "Fido" 37 | assert dog["tricks"] == ("roll over", "play dead", "fetch ball") 38 | -------------------------------------------------------------------------------- /examples/dcb_enrolment_with_basic_objects/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from eventsourcing.tests.postgres_utils import drop_tables 4 | from examples.dcb_enrolment.test_enrolment import EnrolmentTestCase 5 | from examples.dcb_enrolment_with_basic_objects.application import EnrolmentWithDCB 6 | 7 | 8 | class TestEnrolmentWithDCB(EnrolmentTestCase): 9 | def test_enrolment_in_memory(self) -> None: 10 | self.assert_implementation(EnrolmentWithDCB()) 11 | 12 | def test_enrolment_with_postgres(self) -> None: 13 | env = { 14 | "PERSISTENCE_MODULE": ( 15 | "examples.dcb_enrolment_with_basic_objects.postgres_ts" 16 | ), 17 | "POSTGRES_DBNAME": "eventsourcing", 18 | "POSTGRES_HOST": "127.0.0.1", 19 | "POSTGRES_PORT": "5432", 20 | "POSTGRES_USER": "eventsourcing", 21 | "POSTGRES_PASSWORD": "eventsourcing", 22 | } 23 | try: 24 | self.assert_implementation(EnrolmentWithDCB(env)) 25 | finally: 26 | drop_tables() 27 | 28 | def test_enrolment_with_umadb(self) -> None: 29 | env = { 30 | "PERSISTENCE_MODULE": "eventsourcing_umadb", 31 | "UMADB_URI": "http://127.0.0.1:50051", 32 | } 33 | self.assert_implementation(EnrolmentWithDCB(env)) 34 | 35 | 36 | del EnrolmentTestCase 37 | -------------------------------------------------------------------------------- /examples/ftscontentmanagement/persistence.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING 6 | 7 | from eventsourcing.persistence import Recorder 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Sequence 11 | from uuid import UUID 12 | 13 | 14 | @dataclass(frozen=True) 15 | class PageInfo: 16 | id: UUID 17 | slug: str 18 | title: str 19 | body: str 20 | 21 | 22 | class FtsRecorder(Recorder, ABC): 23 | @abstractmethod 24 | def insert_pages(self, pages: Sequence[PageInfo]) -> None: 25 | """Insert a sequence of pages (id, slug, title, body).""" 26 | 27 | @abstractmethod 28 | def update_pages(self, pages: Sequence[PageInfo]) -> None: 29 | """Update a sequence of pages (id, slug, title, body).""" 30 | 31 | @abstractmethod 32 | def search_pages(self, query: str) -> list[UUID]: 33 | """Returns IDs for pages that match query.""" 34 | 35 | @abstractmethod 36 | def select_page(self, page_id: UUID) -> PageInfo: 37 | """Returns slug, title and body for given ID.""" 38 | 39 | def search(self, query: str) -> Sequence[PageInfo]: 40 | pages = [] 41 | for page_id in self.search_pages(query): 42 | page = self.select_page(page_id) 43 | pages.append(page) 44 | return pages 45 | -------------------------------------------------------------------------------- /examples/aggregate6a/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, ClassVar 4 | from uuid import UUID 5 | 6 | from eventsourcing.application import Application, ProjectorFunction 7 | from eventsourcing.domain import Snapshot 8 | from examples.aggregate6a.domainmodel import Dog, add_trick, project_dog, register_dog 9 | 10 | if TYPE_CHECKING: 11 | from eventsourcing.domain import MutableOrImmutableAggregate 12 | 13 | 14 | class DogSchool(Application[UUID]): 15 | is_snapshotting_enabled = True 16 | snapshotting_intervals: ClassVar[ 17 | dict[type[MutableOrImmutableAggregate[UUID]], int] 18 | ] = {Dog: 5} 19 | snapshotting_projectors: ClassVar[ 20 | dict[type[MutableOrImmutableAggregate[UUID]], ProjectorFunction[Any, Any]] 21 | ] = {Dog: project_dog} 22 | snapshot_class = Snapshot 23 | 24 | def register_dog(self, name: str) -> UUID: 25 | dog = register_dog(name) 26 | self.save(dog) 27 | return dog.id 28 | 29 | def add_trick(self, dog_id: UUID, trick: str) -> None: 30 | dog = self.repository.get(dog_id, projector_func=project_dog) 31 | dog = add_trick(dog, trick) 32 | self.save(dog) 33 | 34 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 35 | dog = self.repository.get(dog_id, projector_func=project_dog) 36 | return {"name": dog.name, "tricks": dog.tricks} 37 | -------------------------------------------------------------------------------- /examples/aggregate10/test_snapshotting_intervals.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, ClassVar 4 | from unittest import TestCase 5 | 6 | from examples.aggregate10.application import DogSchool 7 | from examples.aggregate10.domainmodel import Dog 8 | 9 | if TYPE_CHECKING: 10 | from uuid import UUID 11 | 12 | from eventsourcing.domain import MutableOrImmutableAggregate 13 | 14 | 15 | class SubDogSchool(DogSchool): 16 | snapshotting_intervals: ClassVar[ 17 | dict[type[MutableOrImmutableAggregate[UUID]], int] 18 | ] = {Dog: 1} 19 | 20 | 21 | class TestDogSchool(TestCase): 22 | def test_dog_school(self) -> None: 23 | # Construct application object. 24 | school = SubDogSchool() 25 | 26 | # Evolve application state. 27 | dog_id = school.register_dog("Fido") 28 | assert school.snapshots is not None 29 | self.assertEqual(1, len(list(school.snapshots.get(dog_id)))) 30 | 31 | school.add_trick(dog_id, "roll over") 32 | self.assertEqual(2, len(list(school.snapshots.get(dog_id)))) 33 | 34 | school.add_trick(dog_id, "play dead") 35 | self.assertEqual(3, len(list(school.snapshots.get(dog_id)))) 36 | 37 | # Query application state. 38 | dog = school.get_dog(dog_id) 39 | self.assertEqual(dog["name"], "Fido") 40 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 41 | -------------------------------------------------------------------------------- /examples/aggregate8/test_snapshotting_intervals.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, ClassVar 4 | from unittest import TestCase 5 | 6 | from examples.aggregate8.application import DogSchool 7 | from examples.aggregate8.domainmodel import Dog 8 | 9 | if TYPE_CHECKING: 10 | from uuid import UUID 11 | 12 | from eventsourcing.domain import MutableOrImmutableAggregate 13 | 14 | 15 | class SubDogSchool(DogSchool): 16 | snapshotting_intervals: ClassVar[ 17 | dict[type[MutableOrImmutableAggregate[UUID]], int] 18 | ] = {Dog: 1} 19 | 20 | 21 | class TestDogSchool(TestCase): 22 | def test_dog_school(self) -> None: 23 | # Construct application object. 24 | school = SubDogSchool() 25 | 26 | # Evolve application state. 27 | dog_id = school.register_dog("Fido") 28 | assert school.snapshots is not None 29 | self.assertEqual(1, len(list(school.snapshots.get(dog_id)))) 30 | 31 | school.add_trick(dog_id, "roll over") 32 | self.assertEqual(2, len(list(school.snapshots.get(dog_id)))) 33 | 34 | school.add_trick(dog_id, "play dead") 35 | self.assertEqual(3, len(list(school.snapshots.get(dog_id)))) 36 | 37 | # Query application state. 38 | dog = school.get_dog(dog_id) 39 | self.assertEqual(dog["name"], "Fido") 40 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 41 | -------------------------------------------------------------------------------- /examples/aggregate1/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from examples.aggregate1.application import DogSchool 6 | 7 | 8 | class TestDogSchool(TestCase): 9 | def test_dog_school(self) -> None: 10 | # Construct application object. 11 | school = DogSchool() 12 | 13 | # Evolve application state. 14 | dog_id = school.register_dog("Fido") 15 | school.add_trick(dog_id, "roll over") 16 | school.add_trick(dog_id, "play dead") 17 | 18 | # Query application state. 19 | dog = school.get_dog(dog_id) 20 | self.assertEqual("Fido", dog["name"]) 21 | self.assertEqual(("roll over", "play dead"), dog["tricks"]) 22 | 23 | # Select notifications. 24 | notifications = school.notification_log.select(start=1, limit=10) 25 | self.assertEqual(3, len(notifications)) 26 | 27 | # Take snapshot. 28 | school.take_snapshot(dog_id, version=3) 29 | dog = school.get_dog(dog_id) 30 | self.assertEqual("Fido", dog["name"]) 31 | self.assertEqual(("roll over", "play dead"), dog["tricks"]) 32 | 33 | # Continue with snapshotted aggregate. 34 | school.add_trick(dog_id, "fetch ball") 35 | dog = school.get_dog(dog_id) 36 | self.assertEqual("Fido", dog["name"]) 37 | self.assertEqual(("roll over", "play dead", "fetch ball"), dog["tricks"]) 38 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/clear_cart/test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from typing import cast 3 | from uuid import uuid4 4 | 5 | from examples.shopvertical.events import ClearedCart, DomainEvent, SubmittedCart 6 | from examples.shopvertical.exceptions import CartAlreadySubmittedError 7 | from examples.shopvertical.slices.clear_cart.cmd import ( 8 | ClearCart, 9 | ) 10 | 11 | 12 | class TestClearCart(unittest.TestCase): 13 | def test_clear_cart(self) -> None: 14 | cart_id = uuid4() 15 | cmd = ClearCart( 16 | cart_id=cart_id, 17 | ) 18 | events: tuple[DomainEvent, ...] = () 19 | new_events = cmd.handle(events) 20 | self.assertEqual(len(new_events), 1) 21 | self.assertIsInstance(new_events[0], ClearedCart) 22 | new_event = cast(ClearedCart, new_events[0]) 23 | self.assertEqual(new_event.originator_id, cart_id) 24 | self.assertEqual(new_event.originator_version, 1) 25 | 26 | def test_clear_cart_after_submitted_cart(self) -> None: 27 | cart_id = uuid4() 28 | cart_events: tuple[DomainEvent, ...] = ( 29 | SubmittedCart( 30 | originator_id=cart_id, 31 | originator_version=1, 32 | ), 33 | ) 34 | 35 | cmd = ClearCart( 36 | cart_id=cart_id, 37 | ) 38 | 39 | with self.assertRaises(CartAlreadySubmittedError): 40 | cmd.handle(cart_events) 41 | -------------------------------------------------------------------------------- /examples/aggregate5/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from examples.aggregate5.application import DogSchool 6 | from examples.aggregate5.domainmodel import Dog 7 | 8 | 9 | class TestDogSchool(TestCase): 10 | def test_dog_school(self) -> None: 11 | # Construct application object. 12 | school = DogSchool() 13 | 14 | # Evolve application state. 15 | dog_id = school.register_dog("Fido") 16 | school.add_trick(dog_id, "roll over") 17 | school.add_trick(dog_id, "play dead") 18 | 19 | # Query application state. 20 | dog = school.get_dog(dog_id) 21 | assert dog["name"] == "Fido" 22 | assert dog["tricks"] == ("roll over", "play dead") 23 | 24 | # Select notifications. 25 | notifications = school.notification_log.select(start=1, limit=10) 26 | assert len(notifications) == 3 27 | 28 | # Take snapshot. 29 | school.take_snapshot(dog_id, version=3, projector_func=Dog.projector) 30 | dog = school.get_dog(dog_id) 31 | assert dog["name"] == "Fido" 32 | assert dog["tricks"] == ("roll over", "play dead") 33 | 34 | # Continue with snapshotted aggregate. 35 | school.add_trick(dog_id, "fetch ball") 36 | dog = school.get_dog(dog_id) 37 | assert dog["name"] == "Fido" 38 | assert dog["tricks"] == ("roll over", "play dead", "fetch ball") 39 | -------------------------------------------------------------------------------- /examples/aggregate6/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from examples.aggregate6.application import DogSchool 6 | from examples.aggregate6.domainmodel import project_dog 7 | 8 | 9 | class TestDogSchool(TestCase): 10 | def test_dog_school(self) -> None: 11 | # Construct application object. 12 | school = DogSchool() 13 | 14 | # Evolve application state. 15 | dog_id = school.register_dog("Fido") 16 | school.add_trick(dog_id, "roll over") 17 | school.add_trick(dog_id, "play dead") 18 | 19 | # Query application state. 20 | dog = school.get_dog(dog_id) 21 | assert dog["name"] == "Fido" 22 | assert dog["tricks"] == ("roll over", "play dead") 23 | 24 | # Select notifications. 25 | notifications = school.notification_log.select(start=1, limit=10) 26 | assert len(notifications) == 3 27 | 28 | # Take snapshot. 29 | school.take_snapshot(dog_id, version=3, projector_func=project_dog) 30 | dog = school.get_dog(dog_id) 31 | assert dog["name"] == "Fido" 32 | assert dog["tricks"] == ("roll over", "play dead") 33 | 34 | # Continue with snapshotted aggregate. 35 | school.add_trick(dog_id, "fetch ball") 36 | dog = school.get_dog(dog_id) 37 | assert dog["name"] == "Fido" 38 | assert dog["tricks"] == ("roll over", "play dead", "fetch ball") 39 | -------------------------------------------------------------------------------- /dev/release-distribution.py: -------------------------------------------------------------------------------- 1 | # import os 2 | # import subprocess 3 | # import sys 4 | # 5 | # 6 | # def main(): 7 | # # Validate current working dir (should be project root). 8 | # proj_path = os.path.abspath(".") 9 | # readme_path = os.path.join(proj_path, "README.md") 10 | # if os.path.exists(readme_path): 11 | # assert "A library for event sourcing in Python" in open(readme_path).read() 12 | # else: 13 | # raise Exception("Couldn't find project README.md") 14 | # 15 | # # Build and upload to PyPI. 16 | # subprocess.check_call([sys.executable, "setup.py", "clean", "--all"], cwd=proj_path) 17 | # subprocess.check_call([sys.executable, "setup.py", "sdist", "bdist_wheel"], cwd=proj_path) 18 | # 19 | # version_path = os.path.join(proj_path, "eventsourcing", "__init__.py") 20 | # version = open(version_path).readlines()[0].split("=")[-1].strip().strip('"') 21 | # sdist_path = os.path.join( 22 | # proj_path, 'dist', f'eventsourcing-{version}.tar.gz' 23 | # ) 24 | # bdist_path = os.path.join( 25 | # proj_path, 'dist', f'eventsourcing-{version}-py3-none-any.whl' 26 | # ) 27 | # 28 | # subprocess.check_call([sys.executable, "-m", "pip", "install", "-U", "twine"], cwd=proj_path) 29 | # subprocess.check_call( 30 | # [sys.executable, "-m", "twine", "upload", sdist_path, bdist_path], cwd=proj_path 31 | # ) 32 | # 33 | # # twine upload ./dist/eventsourcing-VERSION.tar.gz 34 | # 35 | # 36 | # if __name__ == "__main__": 37 | # main() 38 | -------------------------------------------------------------------------------- /tests/persistence_tests/test_transcoder.py: -------------------------------------------------------------------------------- 1 | from unittest import skip 2 | 3 | from eventsourcing.persistence import JSONTranscoder, Transcoder, UUIDAsHex 4 | from eventsourcing.tests.persistence import ( 5 | CustomType1AsDict, 6 | CustomType2AsDict, 7 | TranscoderTestCase, 8 | ) 9 | 10 | 11 | class TestJSONTranscoder(TranscoderTestCase): 12 | def construct_transcoder(self) -> Transcoder: 13 | transcoder = JSONTranscoder() 14 | transcoder.register(CustomType1AsDict()) 15 | transcoder.register(CustomType2AsDict()) 16 | transcoder.register(UUIDAsHex()) 17 | return transcoder 18 | 19 | @skip("test_tuple(): JSONTranscoder converts tuples to lists") 20 | def test_tuple(self) -> None: 21 | pass 22 | 23 | @skip("test_mixed(): JSONTranscoder converts tuples to lists") 24 | def test_mixed(self) -> None: 25 | pass 26 | 27 | @skip("test_dict_subclass(): JSONTranscoder converts dict subclasses to dict") 28 | def test_dict_subclass(self) -> None: 29 | pass 30 | 31 | @skip("test_list_subclass(): JSONTranscoder converts list subclasses to list") 32 | def test_list_subclass(self) -> None: 33 | pass 34 | 35 | @skip("test_str_subclass(): JSONTranscoder converts str subclasses to str") 36 | def test_str_subclass(self) -> None: 37 | pass 38 | 39 | @skip("test_int_subclass(): JSONTranscoder converts int subclasses to int") 40 | def test_int_subclass(self) -> None: 41 | pass 42 | 43 | 44 | del TranscoderTestCase 45 | -------------------------------------------------------------------------------- /dev/RELEASE_SCRIPT.md: -------------------------------------------------------------------------------- 1 | # How to make a new release 2 | 3 | Steps to make a new release. 4 | 5 | 1. Review versions of all dependencies. 6 | 2. Go to the minor version branch, or create a minor version branch. 7 | 3. Check release notes describe what's new in this release. 8 | 4. Check package version number is correct new release version number. 9 | 5. Check copyright year in LICENSE file and docs conf.py. 10 | 6. Run 'make prepush'. 11 | 7. Run 'make prepare-dist'. 12 | 8. Fix any errors, until built distribution is working. 13 | 9. Push changes to GitHub and wait for CI to pass. 14 | 10. Set date of release in release notes. 15 | 11. Fix the links at the top of the README file (branch should point to branch, main to most recent release branch). 16 | 12. Commit these changes to the docs. 17 | 13. Create a Git tag with the number of the version, prefixed with 'v' and set the message to the same thing. 18 | 14. Push doc changes and tag to GitHub, and wait for docs to build in readthedocs. 19 | 15. In readthedocs, adjust default version to point to new release version of the docs (tagged version). 20 | 16. Run 'make prepare-dist' again, and check it exits OK. 21 | 17. To build and put distributions on PyPI, run 'make release-dist'. 22 | 18. Check PyPI shows new distributions. 23 | 19. Run 'make test-released-distribution' script (from project root directory). 24 | 20. Checkout main branch. 25 | 21. Merge changes into main branch. 26 | 22. Increase version number (on main branch to next minor version + '.0dev0', or on release branch to next point version). 27 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | build: 6 | os: ubuntu-22.04 7 | tools: 8 | python: "3.11" 9 | jobs: 10 | install: 11 | - python --version 12 | - python -m pip install pipx 13 | - python -m pipx ensurepath 14 | - PATH="/home/docs/.local/bin:${PATH}" make install-poetry 15 | - PATH="/home/docs/.local/bin:${PATH}" make install 16 | build: 17 | html: 18 | - echo "Override default build command for html format" 19 | - PATH="/home/docs/.local/bin:${PATH}" make docs 20 | - ls -l ./docs/_build/html/* 21 | - mkdir --parents $READTHEDOCS_OUTPUT/html/ 22 | - cp --recursive ./docs/_build/html/* $READTHEDOCS_OUTPUT/html/ 23 | epub: 24 | - echo "Override default build command for epub format" 25 | - PATH="/home/docs/.local/bin:${PATH}" make docs-epub 26 | - ls -l ./docs/_build/epub/* 27 | - mkdir -p $READTHEDOCS_OUTPUT/epub/ 28 | - cp ./docs/_build/epub/eventsourcing.epub $READTHEDOCS_OUTPUT/epub/ 29 | pdf: 30 | - echo "Override default build command for pdf format" 31 | - PATH="/home/docs/.local/bin:${PATH}" make docs-pdf 32 | - ls -l ./docs/_build/latex/* 33 | - mkdir -p $READTHEDOCS_OUTPUT/pdf/ 34 | - cp ./docs/_build/latex/eventsourcing.pdf $READTHEDOCS_OUTPUT/pdf/ 35 | sphinx: 36 | configuration: docs/conf.py 37 | formats: 38 | - pdf 39 | - epub 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, John Bywater 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /docs/topics/examples/shop-standard.rst: -------------------------------------------------------------------------------- 1 | .. _Shop application example: 2 | 3 | Application 6 - Shopping cart 4 | ============================= 5 | 6 | This example suggests how a shopping cart might be implemented. 7 | 8 | 9 | Application 10 | ----------- 11 | 12 | .. literalinclude:: ../../../examples/shopstandard/application.py 13 | :pyobject: Shop 14 | 15 | .. literalinclude:: ../../../examples/shopstandard/domain.py 16 | :pyobject: ProductDetails 17 | 18 | Domain model 19 | ------------ 20 | 21 | .. literalinclude:: ../../../examples/shopstandard/domain.py 22 | :pyobject: Product 23 | 24 | .. literalinclude:: ../../../examples/shopstandard/domain.py 25 | :pyobject: Cart 26 | 27 | .. literalinclude:: ../../../examples/shopstandard/domain.py 28 | :pyobject: CartItem 29 | 30 | 31 | Exceptions 32 | ---------- 33 | 34 | .. literalinclude:: ../../../examples/shopstandard/exceptions.py 35 | 36 | Test 37 | ---- 38 | 39 | .. literalinclude:: ../../../examples/shopstandard/test.py 40 | :pyobject: TestShop 41 | 42 | Code reference 43 | -------------- 44 | 45 | .. automodule:: examples.shopstandard.application 46 | :show-inheritance: 47 | :member-order: bysource 48 | :members: 49 | :undoc-members: 50 | :special-members: __init__ 51 | 52 | .. automodule:: examples.shopstandard.domain 53 | :show-inheritance: 54 | :member-order: bysource 55 | :members: 56 | :undoc-members: 57 | :special-members: __init__ 58 | 59 | .. automodule:: examples.shopstandard.exceptions 60 | :show-inheritance: 61 | :member-order: bysource 62 | :members: 63 | :undoc-members: 64 | :special-members: __init__ 65 | -------------------------------------------------------------------------------- /examples/searchabletimestamps/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, cast 4 | 5 | from eventsourcing.application import AggregateNotFoundError 6 | from examples.cargoshipping.application import BookingApplication 7 | from examples.cargoshipping.domainmodel import Cargo 8 | 9 | if TYPE_CHECKING: 10 | from datetime import datetime 11 | from uuid import UUID 12 | 13 | from eventsourcing.application import ProcessingEvent 14 | from eventsourcing.persistence import Recording 15 | from examples.searchabletimestamps.persistence import SearchableTimestampsRecorder 16 | 17 | 18 | class CargoNotFoundError(AggregateNotFoundError): 19 | pass 20 | 21 | 22 | class SearchableTimestampsApplication(BookingApplication): 23 | def _record(self, processing_event: ProcessingEvent[UUID]) -> list[Recording[UUID]]: 24 | event_timestamps_data = [ 25 | (e.originator_id, e.timestamp, e.originator_version) 26 | for e in processing_event.events 27 | if isinstance(e, Cargo.Event) 28 | ] 29 | processing_event.saved_kwargs["event_timestamps_data"] = event_timestamps_data 30 | return super()._record(processing_event) 31 | 32 | def get_cargo_at_timestamp(self, tracking_id: UUID, timestamp: datetime) -> Cargo: 33 | recorder = cast("SearchableTimestampsRecorder", self.recorder) 34 | version = recorder.get_version_at_timestamp(tracking_id, timestamp) 35 | if version is None: 36 | raise CargoNotFoundError((tracking_id, timestamp)) 37 | return cast("Cargo", self.repository.get(tracking_id, version=version)) 38 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/get_cart_items/query.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from decimal import Decimal 3 | from uuid import UUID 4 | 5 | from examples.aggregate7.immutablemodel import Immutable 6 | from examples.shopvertical.common import Query, get_events 7 | from examples.shopvertical.events import ( 8 | AddedItemToCart, 9 | ClearedCart, 10 | DomainEvents, 11 | RemovedItemFromCart, 12 | ) 13 | 14 | 15 | class CartItem(Immutable): 16 | product_id: UUID 17 | name: str 18 | description: str 19 | price: Decimal 20 | 21 | 22 | class GetCartItems(Query): 23 | cart_id: UUID 24 | 25 | @staticmethod 26 | def projection(events: DomainEvents) -> Sequence[CartItem]: 27 | cart_items: list[CartItem] = [] 28 | for event in events: 29 | if isinstance(event, AddedItemToCart): 30 | cart_items.append( 31 | CartItem( 32 | product_id=event.product_id, 33 | name=event.name, 34 | description=event.description, 35 | price=event.price, 36 | ) 37 | ) 38 | elif isinstance(event, RemovedItemFromCart): 39 | for i, cart_item in enumerate(cart_items): 40 | if cart_item.product_id == event.product_id: 41 | cart_items.pop(i) 42 | break 43 | elif isinstance(event, ClearedCart): 44 | cart_items.clear() 45 | return tuple(cart_items) 46 | 47 | def execute(self) -> Sequence[CartItem]: 48 | return self.projection(get_events(self.cart_id)) 49 | -------------------------------------------------------------------------------- /examples/aggregate10/test_compression_and_encryption.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from eventsourcing.cipher import AESCipher 6 | from examples.aggregate10.application import DogSchool 7 | 8 | 9 | class TestDogSchool(TestCase): 10 | def test_dog_school(self) -> None: 11 | # Construct application object. 12 | school = DogSchool( 13 | env={ 14 | "COMPRESSOR_TOPIC": "eventsourcing.compressor:ZlibCompressor", 15 | "CIPHER_TOPIC": "eventsourcing.cipher:AESCipher", 16 | "CIPHER_KEY": AESCipher.create_key(num_bytes=32), 17 | } 18 | ) 19 | 20 | # Evolve application state. 21 | dog_id = school.register_dog("Fido") 22 | school.add_trick(dog_id, "roll over") 23 | school.add_trick(dog_id, "play dead") 24 | 25 | # Query application state. 26 | dog = school.get_dog(dog_id) 27 | assert dog["name"] == "Fido" 28 | assert dog["tricks"] == ("roll over", "play dead") 29 | 30 | # Select notifications. 31 | notifications = school.notification_log.select(start=1, limit=10) 32 | assert len(notifications) == 3 33 | 34 | # Take snapshot. 35 | school.take_snapshot(dog_id, version=3) 36 | dog = school.get_dog(dog_id) 37 | assert dog["name"] == "Fido" 38 | assert dog["tricks"] == ("roll over", "play dead") 39 | 40 | # Continue with snapshotted aggregate. 41 | school.add_trick(dog_id, "fetch ball") 42 | dog = school.get_dog(dog_id) 43 | assert dog["name"] == "Fido" 44 | assert dog["tricks"] == ("roll over", "play dead", "fetch ball") 45 | -------------------------------------------------------------------------------- /examples/aggregate8/test_compression_and_encryption.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from eventsourcing.cipher import AESCipher 6 | from examples.aggregate8.application import DogSchool 7 | 8 | 9 | class TestDogSchool(TestCase): 10 | def test_dog_school(self) -> None: 11 | # Construct application object. 12 | school = DogSchool( 13 | env={ 14 | "COMPRESSOR_TOPIC": "eventsourcing.compressor:ZlibCompressor", 15 | "CIPHER_TOPIC": "eventsourcing.cipher:AESCipher", 16 | "CIPHER_KEY": AESCipher.create_key(num_bytes=32), 17 | } 18 | ) 19 | 20 | # Evolve application state. 21 | dog_id = school.register_dog("Fido") 22 | school.add_trick(dog_id, "roll over") 23 | school.add_trick(dog_id, "play dead") 24 | 25 | # Query application state. 26 | dog = school.get_dog(dog_id) 27 | assert dog["name"] == "Fido" 28 | assert dog["tricks"] == ("roll over", "play dead") 29 | 30 | # Select notifications. 31 | notifications = school.notification_log.select(start=1, limit=10) 32 | assert len(notifications) == 3 33 | 34 | # Take snapshot. 35 | school.take_snapshot(dog_id, version=3) 36 | dog = school.get_dog(dog_id) 37 | assert dog["name"] == "Fido" 38 | assert dog["tricks"] == ("roll over", "play dead") 39 | 40 | # Continue with snapshotted aggregate. 41 | school.add_trick(dog_id, "fetch ball") 42 | dog = school.get_dog(dog_id) 43 | assert dog["name"] == "Fido" 44 | assert dog["tricks"] == ("roll over", "play dead", "fetch ball") 45 | -------------------------------------------------------------------------------- /examples/aggregate6/baseclasses.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any, TypeVar 6 | 7 | from eventsourcing.domain import datetime_now_with_tzinfo 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Iterable 11 | from datetime import datetime 12 | from uuid import UUID 13 | 14 | 15 | @dataclass(frozen=True) 16 | class DomainEvent: 17 | originator_id: UUID 18 | originator_version: int 19 | timestamp: datetime 20 | 21 | 22 | @dataclass(frozen=True) 23 | class Aggregate: 24 | id: UUID 25 | version: int 26 | created_on: datetime 27 | modified_on: datetime 28 | 29 | 30 | @dataclass(frozen=True) 31 | class Snapshot(DomainEvent): 32 | state: dict[str, Any] 33 | 34 | @classmethod 35 | def take(cls, aggregate: Aggregate) -> Snapshot: 36 | return Snapshot( 37 | originator_id=aggregate.id, 38 | originator_version=aggregate.version, 39 | timestamp=datetime_now_with_tzinfo(), 40 | state=aggregate.__dict__, 41 | ) 42 | 43 | 44 | TAggregate = TypeVar("TAggregate", bound=Aggregate) 45 | MutatorFunction = Callable[..., TAggregate | None] 46 | 47 | 48 | def aggregate_projector( 49 | mutator: MutatorFunction[TAggregate], 50 | ) -> Callable[[TAggregate | None, Iterable[DomainEvent]], TAggregate | None]: 51 | def project_aggregate( 52 | aggregate: TAggregate | None, events: Iterable[DomainEvent] 53 | ) -> TAggregate | None: 54 | for event in events: 55 | aggregate = mutator(event, aggregate) 56 | return aggregate 57 | 58 | return project_aggregate 59 | -------------------------------------------------------------------------------- /examples/ftscontentmanagement/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, cast 4 | 5 | from examples.contentmanagement.application import ContentManagement, PageDetailsType 6 | from examples.contentmanagement.domainmodel import Page 7 | from examples.ftscontentmanagement.persistence import FtsRecorder, PageInfo 8 | 9 | if TYPE_CHECKING: 10 | from uuid import UUID 11 | 12 | from eventsourcing.domain import DomainEventProtocol, MutableOrImmutableAggregate 13 | from eventsourcing.persistence import Recording 14 | 15 | 16 | class FtsContentManagement(ContentManagement): 17 | def save( 18 | self, 19 | *objs: MutableOrImmutableAggregate[UUID] | DomainEventProtocol[UUID] | None, 20 | **kwargs: Any, 21 | ) -> list[Recording[UUID]]: 22 | insert_pages: list[PageInfo] = [] 23 | update_pages: list[PageInfo] = [] 24 | for obj in objs: 25 | if isinstance(obj, Page): 26 | if obj.version == len(obj.pending_events): 27 | insert_pages.append(PageInfo(obj.id, obj.slug, obj.title, obj.body)) 28 | else: 29 | update_pages.append(PageInfo(obj.id, obj.slug, obj.title, obj.body)) 30 | kwargs["insert_pages"] = insert_pages 31 | kwargs["update_pages"] = update_pages 32 | return super().save(*objs, **kwargs) 33 | 34 | def search(self, query: str) -> list[PageDetailsType]: 35 | pages = [] 36 | recorder = cast("FtsRecorder", self.recorder) 37 | for page_id in recorder.search_pages(query): 38 | page = self.get_page_by_id(page_id) 39 | pages.append(page) 40 | return pages 41 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/remove_item_from_cart/cmd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from uuid import UUID # noqa: TC003 4 | 5 | from examples.shopvertical.common import Command, get_events, put_events 6 | from examples.shopvertical.events import ( 7 | AddedItemToCart, 8 | ClearedCart, 9 | DomainEvents, 10 | RemovedItemFromCart, 11 | SubmittedCart, 12 | ) 13 | from examples.shopvertical.exceptions import ( 14 | CartAlreadySubmittedError, 15 | ProductNotInCartError, 16 | ) 17 | 18 | 19 | class RemoveItemFromCart(Command): 20 | cart_id: UUID 21 | product_id: UUID 22 | 23 | def handle(self, events: DomainEvents) -> DomainEvents: 24 | product_ids = [] 25 | is_submitted = False 26 | 27 | for event in events: 28 | if isinstance(event, AddedItemToCart): 29 | product_ids.append(event.product_id) 30 | elif isinstance(event, RemovedItemFromCart): 31 | product_ids.remove(event.product_id) 32 | elif isinstance(event, ClearedCart): 33 | product_ids.clear() 34 | elif isinstance(event, SubmittedCart): 35 | is_submitted = True 36 | 37 | if is_submitted: 38 | raise CartAlreadySubmittedError 39 | 40 | if self.product_id not in product_ids: 41 | raise ProductNotInCartError 42 | return ( 43 | RemovedItemFromCart( 44 | originator_id=self.cart_id, 45 | originator_version=len(events) + 1, 46 | product_id=self.product_id, 47 | ), 48 | ) 49 | 50 | def execute(self) -> int | None: 51 | return put_events(self.handle(get_events(self.cart_id))) 52 | -------------------------------------------------------------------------------- /tests/application_tests/test_application_with_postgres.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import TestCase 3 | 4 | from eventsourcing.tests.application import ( 5 | ApplicationTestCase, 6 | ExampleApplicationTestCase, 7 | ) 8 | from eventsourcing.tests.postgres_utils import drop_tables 9 | 10 | 11 | class WithPostgres(TestCase): 12 | expected_factory_topic = "eventsourcing.postgres:PostgresFactory" 13 | 14 | def setUp(self) -> None: 15 | super().setUp() 16 | 17 | os.environ["PERSISTENCE_MODULE"] = "eventsourcing.postgres" 18 | os.environ["CREATE_TABLE"] = "y" 19 | os.environ["POSTGRES_DBNAME"] = "eventsourcing" 20 | os.environ["POSTGRES_HOST"] = "127.0.0.1" 21 | os.environ["POSTGRES_PORT"] = "5432" 22 | os.environ["POSTGRES_USER"] = "eventsourcing" 23 | os.environ["POSTGRES_PASSWORD"] = "eventsourcing" # noqa: S105 24 | os.environ["POSTGRES_SCHEMA"] = "public" 25 | drop_tables() 26 | 27 | def tearDown(self) -> None: 28 | drop_tables() 29 | 30 | del os.environ["PERSISTENCE_MODULE"] 31 | del os.environ["CREATE_TABLE"] 32 | del os.environ["POSTGRES_DBNAME"] 33 | del os.environ["POSTGRES_HOST"] 34 | del os.environ["POSTGRES_PORT"] 35 | del os.environ["POSTGRES_USER"] 36 | del os.environ["POSTGRES_PASSWORD"] 37 | del os.environ["POSTGRES_SCHEMA"] 38 | 39 | super().tearDown() 40 | 41 | 42 | class TestApplicationWithPostgres(WithPostgres, ApplicationTestCase): 43 | pass 44 | 45 | 46 | class TestExampleApplicationWithPostgres(WithPostgres, ExampleApplicationTestCase): 47 | pass 48 | 49 | 50 | del ApplicationTestCase 51 | del ExampleApplicationTestCase 52 | del WithPostgres 53 | -------------------------------------------------------------------------------- /dev/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | eventsourcing_requirements: 5 | build: 6 | context: .. 7 | dockerfile: ./dev/Dockerfile_eventsourcing_requirements 8 | image: "eventsourcing_requirements:latest" 9 | volumes: 10 | - .:/app 11 | links: 12 | - cassandra 13 | - mysql 14 | - postgres 15 | - redis 16 | - axon 17 | environment: 18 | CASSANDRA_HOSTS: cassandra 19 | MYSQL_HOST: mysql 20 | MYSQL_USER: eventsourcing 21 | MYSQL_PASSWORD: eventsourcing 22 | POSTGRES_HOST: postgres 23 | POSTGRES_PORT: 5432 24 | POSTGRES_USER: eventsourcing 25 | POSTGRES_PASSWORD: eventsourcing 26 | REDIS_HOST: redis 27 | AXON_HOST: axon 28 | 29 | cassandra: 30 | image: "cassandra:latest" 31 | volumes: 32 | - cassandra_data:/var/lib/cassandra 33 | ports: 34 | - "9042:9042" 35 | 36 | mysql: 37 | image: "mysql:latest" 38 | env_file: 39 | - .env 40 | volumes: 41 | - mysql_data:/var/lib/mysql 42 | ports: 43 | - "3306:3306" 44 | 45 | postgres: 46 | image: "postgres:latest" 47 | env_file: 48 | - .env 49 | volumes: 50 | - postgres_data:/var/lib/postgresql/data 51 | ports: 52 | - "5432:5432" 53 | 54 | redis: 55 | image: "redis:latest" 56 | volumes: 57 | - redis_data:/data 58 | ports: 59 | - "6379:6379" 60 | 61 | axon: 62 | image: "axoniq/axonserver:latest" 63 | volumes: 64 | - axon_data:/data 65 | - axon_eventdata:/eventdata 66 | ports: 67 | - "8024:8024" 68 | - "8124:8124" 69 | 70 | volumes: 71 | cassandra_data: 72 | mysql_data: 73 | postgres_data: 74 | redis_data: 75 | axon_data: 76 | axon_eventdata: 77 | -------------------------------------------------------------------------------- /examples/aggregate11/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from eventsourcing.domain import ( 8 | BaseAggregate, 9 | CanInitAggregate, 10 | CanMutateAggregate, 11 | CanSnapshotAggregate, 12 | MetaDomainEvent, 13 | event, 14 | ) 15 | 16 | if TYPE_CHECKING: 17 | from datetime import datetime 18 | 19 | 20 | @dataclass(frozen=True) 21 | class DomainEvent(metaclass=MetaDomainEvent): 22 | originator_id: str 23 | originator_version: int 24 | timestamp: datetime 25 | 26 | def __post_init__(self) -> None: 27 | if not isinstance(self.originator_id, str): 28 | msg = ( 29 | f"{type(self)} " 30 | f"was initialized with a non-str originator_id: " 31 | f"{self.originator_id!r}" 32 | ) 33 | raise TypeError(msg) 34 | 35 | 36 | @dataclass(frozen=True) 37 | class Snapshot(DomainEvent, CanSnapshotAggregate[str]): 38 | topic: str 39 | state: dict[str, Any] 40 | 41 | 42 | class Aggregate(BaseAggregate[str]): 43 | @dataclass(frozen=True) 44 | class Event(DomainEvent, CanMutateAggregate[str]): 45 | pass 46 | 47 | @dataclass(frozen=True) 48 | class Created(Event, CanInitAggregate[str]): 49 | originator_topic: str 50 | 51 | 52 | class Dog(Aggregate): 53 | INITIAL_VERSION = 0 54 | 55 | @staticmethod 56 | def create_id() -> str: 57 | return "dog-" + str(uuid.uuid4()) 58 | 59 | @event("Registered") 60 | def __init__(self, name: str) -> None: 61 | self.name = name 62 | self.tricks: list[str] = [] 63 | 64 | @event("TrickAdded") 65 | def add_trick(self, trick: str) -> None: 66 | self.tricks.append(trick) 67 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/adjust_product_inventory/test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from decimal import Decimal 3 | from typing import cast 4 | from uuid import uuid4 5 | 6 | from examples.shopvertical.events import AddedProductToShop, AdjustedProductInventory 7 | from examples.shopvertical.exceptions import ProductNotFoundInShopError 8 | from examples.shopvertical.slices.adjust_product_inventory.cmd import ( 9 | AdjustProductInventory, 10 | ) 11 | 12 | 13 | class TestAdjustProductInventory(unittest.TestCase): 14 | def test_adjust_inventory(self) -> None: 15 | product_id = uuid4() 16 | cmd = AdjustProductInventory( 17 | product_id=product_id, 18 | adjustment=2, 19 | ) 20 | product_events = ( 21 | AddedProductToShop( 22 | originator_id=product_id, 23 | originator_version=1, 24 | name="Tea", 25 | description="A very nice tea", 26 | price=Decimal("3.99"), 27 | ), 28 | ) 29 | new_events = cmd.handle(product_events) 30 | assert len(new_events) == 1 31 | self.assertIsInstance(new_events[0], AdjustedProductInventory) 32 | new_event = cast(AdjustedProductInventory, new_events[0]) 33 | self.assertEqual(new_event.originator_id, product_id) 34 | self.assertEqual(new_event.originator_version, 2) 35 | self.assertEqual(new_event.adjustment, 2) 36 | 37 | def test_adjust_inventory_product_not_found(self) -> None: 38 | product_id = uuid4() 39 | cmd = AdjustProductInventory( 40 | product_id=product_id, 41 | adjustment=2, 42 | ) 43 | product_events = () 44 | 45 | with self.assertRaises(ProductNotFoundInShopError): 46 | cmd.handle(product_events) 47 | -------------------------------------------------------------------------------- /examples/aggregate7/test_compression_and_encryption.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from eventsourcing.cipher import AESCipher 6 | from examples.aggregate7.application import DogSchool 7 | from examples.aggregate7.domainmodel import project_dog 8 | 9 | 10 | class TestDogSchool(TestCase): 11 | def test_dog_school(self) -> None: 12 | # Construct application object. 13 | school = DogSchool( 14 | env={ 15 | "COMPRESSOR_TOPIC": "eventsourcing.compressor:ZlibCompressor", 16 | "CIPHER_TOPIC": "eventsourcing.cipher:AESCipher", 17 | "CIPHER_KEY": AESCipher.create_key(num_bytes=32), 18 | } 19 | ) 20 | 21 | # Evolve application state. 22 | dog_id = school.register_dog("Fido") 23 | school.add_trick(dog_id, "roll over") 24 | school.add_trick(dog_id, "play dead") 25 | 26 | # Query application state. 27 | dog = school.get_dog(dog_id) 28 | assert dog["name"] == "Fido" 29 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 30 | 31 | # Select notifications. 32 | notifications = school.notification_log.select(start=1, limit=10) 33 | assert len(notifications) == 3 34 | 35 | # Take snapshot. 36 | school.take_snapshot(dog_id, version=3, projector_func=project_dog) 37 | dog = school.get_dog(dog_id) 38 | assert dog["name"] == "Fido" 39 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 40 | 41 | # Continue with snapshotted aggregate. 42 | school.add_trick(dog_id, "fetch ball") 43 | dog = school.get_dog(dog_id) 44 | assert dog["name"] == "Fido" 45 | self.assertEqual(dog["tricks"], ("roll over", "play dead", "fetch ball")) 46 | -------------------------------------------------------------------------------- /examples/aggregate9/test_compression_and_encryption.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from eventsourcing.cipher import AESCipher 6 | from examples.aggregate9.application import DogSchool 7 | from examples.aggregate9.domainmodel import project_dog 8 | 9 | 10 | class TestDogSchool(TestCase): 11 | def test_dog_school(self) -> None: 12 | # Construct application object. 13 | school = DogSchool( 14 | env={ 15 | "COMPRESSOR_TOPIC": "eventsourcing.compressor:ZlibCompressor", 16 | "CIPHER_TOPIC": "eventsourcing.cipher:AESCipher", 17 | "CIPHER_KEY": AESCipher.create_key(num_bytes=32), 18 | } 19 | ) 20 | 21 | # Evolve application state. 22 | dog_id = school.register_dog("Fido") 23 | school.add_trick(dog_id, "roll over") 24 | school.add_trick(dog_id, "play dead") 25 | 26 | # Query application state. 27 | dog = school.get_dog(dog_id) 28 | assert dog["name"] == "Fido" 29 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 30 | 31 | # Select notifications. 32 | notifications = school.notification_log.select(start=1, limit=10) 33 | assert len(notifications) == 3 34 | 35 | # Take snapshot. 36 | school.take_snapshot(dog_id, version=3, projector_func=project_dog) 37 | dog = school.get_dog(dog_id) 38 | assert dog["name"] == "Fido" 39 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 40 | 41 | # Continue with snapshotted aggregate. 42 | school.add_trick(dog_id, "fetch ball") 43 | dog = school.get_dog(dog_id) 44 | assert dog["name"] == "Fido" 45 | self.assertEqual(dog["tricks"], ("roll over", "play dead", "fetch ball")) 46 | -------------------------------------------------------------------------------- /examples/aggregate7a/test_compression_and_encryption.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from eventsourcing.cipher import AESCipher 6 | from examples.aggregate7a.application import DogSchool 7 | from examples.aggregate7a.domainmodel import project_dog 8 | 9 | 10 | class TestDogSchool(TestCase): 11 | def test_dog_school(self) -> None: 12 | # Construct application object. 13 | school = DogSchool( 14 | env={ 15 | "COMPRESSOR_TOPIC": "eventsourcing.compressor:ZlibCompressor", 16 | "CIPHER_TOPIC": "eventsourcing.cipher:AESCipher", 17 | "CIPHER_KEY": AESCipher.create_key(num_bytes=32), 18 | } 19 | ) 20 | 21 | # Evolve application state. 22 | dog_id = school.register_dog("Fido") 23 | school.add_trick(dog_id, "roll over") 24 | school.add_trick(dog_id, "play dead") 25 | 26 | # Query application state. 27 | dog = school.get_dog(dog_id) 28 | assert dog["name"] == "Fido" 29 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 30 | 31 | # Select notifications. 32 | notifications = school.notification_log.select(start=1, limit=10) 33 | assert len(notifications) == 3 34 | 35 | # Take snapshot. 36 | school.take_snapshot(dog_id, version=3, projector_func=project_dog) 37 | dog = school.get_dog(dog_id) 38 | assert dog["name"] == "Fido" 39 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 40 | 41 | # Continue with snapshotted aggregate. 42 | school.add_trick(dog_id, "fetch ball") 43 | dog = school.get_dog(dog_id) 44 | assert dog["name"] == "Fido" 45 | self.assertEqual(dog["tricks"], ("roll over", "play dead", "fetch ball")) 46 | -------------------------------------------------------------------------------- /examples/bankaccounts/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from decimal import Decimal 4 | 5 | from eventsourcing.domain import Aggregate, event 6 | 7 | 8 | class BankAccount(Aggregate): 9 | @event("Opened") 10 | def __init__(self, full_name: str, email_address: str): 11 | self.full_name = full_name 12 | self.email_address = email_address 13 | self.balance = Decimal("0.00") 14 | self.overdraft_limit = Decimal("0.00") 15 | self.is_closed = False 16 | 17 | @event("Credited") 18 | def credit(self, amount: Decimal) -> None: 19 | self.check_account_is_not_closed() 20 | self.balance += amount 21 | 22 | @event("Debited") 23 | def debit(self, amount: Decimal) -> None: 24 | self.check_account_is_not_closed() 25 | self.check_has_sufficient_funds(amount) 26 | self.balance -= amount 27 | 28 | @event("OverdraftLimitSet") 29 | def set_overdraft_limit(self, overdraft_limit: Decimal) -> None: 30 | assert overdraft_limit > Decimal("0.00") 31 | self.check_account_is_not_closed() 32 | self.overdraft_limit = overdraft_limit 33 | 34 | @event("Closed") 35 | def close(self) -> None: 36 | self.is_closed = True 37 | 38 | def check_account_is_not_closed(self) -> None: 39 | if self.is_closed: 40 | raise AccountClosedError({"account_id": self.id}) 41 | 42 | def check_has_sufficient_funds(self, amount: Decimal) -> None: 43 | if self.balance - amount < -self.overdraft_limit: 44 | raise InsufficientFundsError({"account_id": self.id}) 45 | 46 | 47 | class TransactionError(Exception): 48 | pass 49 | 50 | 51 | class AccountClosedError(TransactionError): 52 | pass 53 | 54 | 55 | class InsufficientFundsError(TransactionError): 56 | pass 57 | -------------------------------------------------------------------------------- /examples/shopvertical/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import TYPE_CHECKING, Any, cast 5 | 6 | from examples.aggregate7.immutablemodel import Immutable 7 | from examples.aggregate7.orjsonpydantic import PydanticApplication 8 | from examples.shopvertical.events import DomainEvents 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import Sequence 12 | from uuid import UUID 13 | 14 | 15 | class Command(Immutable, ABC): 16 | @abstractmethod 17 | def handle(self, events: DomainEvents) -> DomainEvents: 18 | pass # pragma: no cover 19 | 20 | @abstractmethod 21 | def execute(self) -> int | None: 22 | pass # pragma: no cover 23 | 24 | 25 | class Query(Immutable, ABC): 26 | @abstractmethod 27 | def execute(self) -> Any: 28 | pass # pragma: no cover 29 | 30 | 31 | class _Globals: 32 | app = PydanticApplication() 33 | 34 | 35 | def reset_application() -> None: 36 | _Globals.app = PydanticApplication() 37 | 38 | 39 | def get_events(originator_id: UUID) -> DomainEvents: 40 | return cast(DomainEvents, tuple(_Globals.app.events.get(originator_id))) 41 | 42 | 43 | def put_events(events: DomainEvents) -> int | None: 44 | recordings = _Globals.app.events.put(events) 45 | return recordings[-1].notification.id if recordings else None 46 | 47 | 48 | def get_all_events(topics: Sequence[str] = ()) -> DomainEvents: 49 | return cast( 50 | DomainEvents, 51 | tuple( 52 | map( 53 | _Globals.app.mapper.to_domain_event, 54 | _Globals.app.recorder.select_notifications( 55 | start=None, 56 | limit=1000000, 57 | topics=topics, 58 | ), 59 | ) 60 | ), 61 | ) 62 | -------------------------------------------------------------------------------- /examples/aggregate10/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from unittest import TestCase 5 | 6 | from examples.aggregate10.application import DogSchool 7 | 8 | 9 | class TestDogSchool(TestCase): 10 | def test_dog_school(self) -> None: 11 | # Construct application object. 12 | school = DogSchool() 13 | 14 | # Evolve application state. 15 | dog_id = school.register_dog("Fido") 16 | school.add_trick(dog_id, "roll over") 17 | school.add_trick(dog_id, "play dead") 18 | 19 | # Query application state. 20 | dog = school.get_dog(dog_id) 21 | self.assertEqual(dog["name"], "Fido") 22 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 23 | self.assertIsInstance(dog["created_on"], datetime) 24 | self.assertIsInstance(dog["modified_on"], datetime) 25 | 26 | # Select notifications. 27 | notifications = school.notification_log.select(start=1, limit=10) 28 | assert len(notifications) == 3 29 | 30 | # Take snapshot. 31 | school.take_snapshot(dog_id, version=3) 32 | dog = school.get_dog(dog_id) 33 | self.assertEqual(dog["name"], "Fido") 34 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 35 | self.assertIsInstance(dog["created_on"], datetime) 36 | self.assertIsInstance(dog["modified_on"], datetime) 37 | 38 | # Continue with snapshotted aggregate. 39 | school.add_trick(dog_id, "fetch ball") 40 | dog = school.get_dog(dog_id) 41 | self.assertEqual(dog["name"], "Fido") 42 | self.assertEqual(dog["tricks"], ("roll over", "play dead", "fetch ball")) 43 | self.assertIsInstance(dog["created_on"], datetime) 44 | self.assertIsInstance(dog["modified_on"], datetime) 45 | -------------------------------------------------------------------------------- /examples/aggregate8/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from unittest import TestCase 5 | 6 | from examples.aggregate8.application import DogSchool 7 | 8 | 9 | class TestDogSchool(TestCase): 10 | def test_dog_school(self) -> None: 11 | # Construct application object. 12 | school = DogSchool() 13 | 14 | # Evolve application state. 15 | dog_id = school.register_dog("Fido") 16 | school.add_trick(dog_id, "roll over") 17 | school.add_trick(dog_id, "play dead") 18 | 19 | # Query application state. 20 | dog = school.get_dog(dog_id) 21 | self.assertEqual(dog["name"], "Fido") 22 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 23 | self.assertIsInstance(dog["created_on"], datetime) 24 | self.assertIsInstance(dog["modified_on"], datetime) 25 | 26 | # Select notifications. 27 | notifications = school.notification_log.select(start=1, limit=10) 28 | assert len(notifications) == 3 29 | 30 | # Take snapshot. 31 | school.take_snapshot(dog_id, version=3) 32 | dog = school.get_dog(dog_id) 33 | self.assertEqual(dog["name"], "Fido") 34 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 35 | self.assertIsInstance(dog["created_on"], datetime) 36 | self.assertIsInstance(dog["modified_on"], datetime) 37 | 38 | # Continue with snapshotted aggregate. 39 | school.add_trick(dog_id, "fetch ball") 40 | dog = school.get_dog(dog_id) 41 | self.assertEqual(dog["name"], "Fido") 42 | self.assertEqual(dog["tricks"], ("roll over", "play dead", "fetch ball")) 43 | self.assertIsInstance(dog["created_on"], datetime) 44 | self.assertIsInstance(dog["modified_on"], datetime) 45 | -------------------------------------------------------------------------------- /examples/aggregate4/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from uuid import uuid4 5 | 6 | from eventsourcing.dispatch import singledispatchmethod 7 | from eventsourcing.domain import datetime_now_with_tzinfo 8 | from examples.aggregate4.baseclasses import Aggregate, DomainEvent 9 | 10 | 11 | @dataclass 12 | class Dog(Aggregate): 13 | name: str 14 | tricks: list[str] 15 | 16 | @dataclass(frozen=True) 17 | class Registered(DomainEvent): 18 | name: str 19 | 20 | @dataclass(frozen=True) 21 | class TrickAdded(DomainEvent): 22 | trick: str 23 | 24 | @classmethod 25 | def register(cls, name: str) -> Dog: 26 | event = cls.Registered( 27 | originator_id=uuid4(), 28 | originator_version=1, 29 | timestamp=datetime_now_with_tzinfo(), 30 | name=name, 31 | ) 32 | dog = cls.project_events(None, [event]) 33 | dog.append_event(event) 34 | return dog 35 | 36 | def add_trick(self, trick: str) -> None: 37 | self.trigger_event(self.TrickAdded, trick=trick) 38 | 39 | @singledispatchmethod 40 | def apply_event(self, event: DomainEvent) -> None: 41 | super().apply_event(event) 42 | 43 | @apply_event.register(Registered) 44 | def _(self, event: Registered) -> None: 45 | self.id = event.originator_id 46 | self.version = event.originator_version 47 | self.created_on = event.timestamp 48 | self.modified_on = event.timestamp 49 | self.name = event.name 50 | self.tricks = [] 51 | 52 | @apply_event.register(TrickAdded) 53 | def _(self, event: TrickAdded) -> None: 54 | self.tricks.append(event.trick) 55 | self.version = event.originator_version 56 | self.modified_on = event.timestamp 57 | -------------------------------------------------------------------------------- /examples/aggregate6a/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from examples.aggregate6a.application import DogSchool 6 | from examples.aggregate6a.domainmodel import project_dog 7 | 8 | 9 | class TestDogSchool(TestCase): 10 | def test_dog_school(self) -> None: 11 | # Construct application object. 12 | school = DogSchool() 13 | 14 | # Evolve application state. 15 | dog_id = school.register_dog("Fido") 16 | school.add_trick(dog_id, "roll over") 17 | school.add_trick(dog_id, "play dead") 18 | 19 | # Query application state. 20 | dog = school.get_dog(dog_id) 21 | assert dog["name"] == "Fido" 22 | assert dog["tricks"] == ("roll over", "play dead") 23 | 24 | # Select notifications. 25 | notifications = school.notification_log.select(start=1, limit=10) 26 | assert len(notifications) == 3 27 | 28 | # Take snapshot. 29 | assert school.snapshots is not None 30 | assert len(list(school.snapshots.get(dog_id))) == 0 31 | school.take_snapshot(dog_id, version=3, projector_func=project_dog) 32 | assert len(list(school.snapshots.get(dog_id))) == 1 33 | dog = school.get_dog(dog_id) 34 | assert dog["name"] == "Fido" 35 | assert dog["tricks"] == ("roll over", "play dead") 36 | 37 | # Continue with snapshotted aggregate. 38 | school.add_trick(dog_id, "fetch ball") 39 | dog = school.get_dog(dog_id) 40 | assert dog["name"] == "Fido" 41 | assert dog["tricks"] == ("roll over", "play dead", "fetch ball") 42 | 43 | assert len(list(school.snapshots.get(dog_id))) == 1 44 | school.add_trick(dog_id, "jump hoop") # Version 5, autosnapshot. 45 | assert len(list(school.snapshots.get(dog_id))) == 2 46 | -------------------------------------------------------------------------------- /dev/MACOS_SETUP_NOTES.md: -------------------------------------------------------------------------------- 1 | This section describes how to setup PostgreSQL on MacOS so developers can run the test suite: 2 | 3 | - install postgresl with homebrew: 4 | 5 | $ brew install postgresql 6 | 7 | - edit pg_hba.conf so that passwords are required when connecting with TCP/IP: 8 | 9 | $ vim /opt/homebrew/var/postgresql@14/pg_hba.conf 10 | 11 | # TYPE DATABASE USER ADDRESS METHOD 12 | # "local" is for Unix domain socket connections only 13 | local all all trust 14 | # IPv4 local connections: 15 | host all all 127.0.0.1/32 md5 16 | # IPv6 local connections: 17 | host all all ::1/128 md5 18 | 19 | - start PostgreSQL 20 | 21 | $ brew services start postgresql 22 | 23 | - use psql with the postgres database and your user to create roles for postgres and eventsourcing and database for eventsourcing 24 | $ psql postgres 25 | postgres=> CREATE ROLE postgres LOGIN SUPERUSER PASSWORD 'postgres'; 26 | postgres=> CREATE USER eventsourcing WITH PASSWORD 'eventsourcing'; 27 | postgres=> CREATE DATABASE eventsourcing; 28 | postgres=> ALTER DATABASE eventsourcing OWNER TO eventsourcing; 29 | 30 | - use psql with the eventsourcing user to create schema in eventsourcing database 31 | $ psql -U eventsourcing 32 | eventsourcing=> CREATE SCHEMA myschema AUTHORIZATION eventsourcing; 33 | 34 | 35 | To build PDF docs (make docs-pdf), download and install MacTeX from https://www.tug.org/mactex/mactex-download.html 36 | and then make sure latexmk is on your PATH (export PATH="$PATH:/Library/TeX/texbin"). 37 | 38 | To use psycopg without psycopg-c or psycopg-binary (e.g. when testing beta versions of new Python releases 39 | before psycopg-binary has been released), install libpq with homebrew: 40 | 41 | $ brew install libpq 42 | -------------------------------------------------------------------------------- /examples/aggregate4/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from examples.aggregate4.application import DogSchool 6 | from examples.aggregate4.domainmodel import Dog 7 | 8 | 9 | class TestDogSchool(TestCase): 10 | def test_dog_school(self) -> None: 11 | # Construct application object. 12 | school = DogSchool() 13 | 14 | # Evolve application state. 15 | dog_id = school.register_dog("Fido") 16 | 17 | # Query application state. 18 | dog = school.get_dog(dog_id) 19 | assert dog["name"] == "Fido" 20 | assert dog["tricks"] == () 21 | assert dog["created_on"] == dog["modified_on"] 22 | 23 | school.add_trick(dog_id, "roll over") 24 | school.add_trick(dog_id, "play dead") 25 | 26 | # Query application state. 27 | dog = school.get_dog(dog_id) 28 | assert dog["name"] == "Fido" 29 | assert dog["tricks"] == ("roll over", "play dead") 30 | assert dog["created_on"] < dog["modified_on"] 31 | 32 | # Select notifications. 33 | notifications = school.notification_log.select(start=1, limit=10) 34 | assert len(notifications) == 3 35 | 36 | # Take snapshot. 37 | school.take_snapshot(dog_id, version=3, projector_func=Dog.project_events) 38 | dog = school.get_dog(dog_id) 39 | assert dog["name"] == "Fido" 40 | assert dog["tricks"] == ("roll over", "play dead") 41 | assert dog["created_on"] < dog["modified_on"] 42 | 43 | # Continue with snapshotted aggregate. 44 | school.add_trick(dog_id, "fetch ball") 45 | dog = school.get_dog(dog_id) 46 | assert dog["name"] == "Fido" 47 | assert dog["tricks"] == ("roll over", "play dead", "fetch ball") 48 | assert dog["created_on"] < dog["modified_on"] 49 | -------------------------------------------------------------------------------- /examples/aggregate7/immutablemodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from datetime import datetime 5 | from typing import TYPE_CHECKING, Any, TypeVar 6 | from uuid import UUID 7 | 8 | from pydantic import BaseModel, ConfigDict 9 | 10 | from eventsourcing.domain import datetime_now_with_tzinfo 11 | from eventsourcing.utils import get_topic 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Iterable 15 | 16 | 17 | class Immutable(BaseModel): 18 | model_config = ConfigDict(extra="forbid", frozen=True) 19 | 20 | 21 | class DomainEvent(Immutable): 22 | originator_id: UUID 23 | originator_version: int 24 | timestamp: datetime 25 | 26 | 27 | class Aggregate(Immutable): 28 | id: UUID 29 | version: int 30 | created_on: datetime 31 | modified_on: datetime 32 | 33 | 34 | class Snapshot(DomainEvent): 35 | topic: str 36 | state: dict[str, Any] 37 | 38 | @classmethod 39 | def take(cls, aggregate: Aggregate) -> Snapshot: 40 | return Snapshot( 41 | originator_id=aggregate.id, 42 | originator_version=aggregate.version, 43 | timestamp=datetime_now_with_tzinfo(), 44 | topic=get_topic(type(aggregate)), 45 | state=aggregate.model_dump(), 46 | ) 47 | 48 | 49 | TAggregate = TypeVar("TAggregate", bound=Aggregate) 50 | 51 | MutatorFunction = Callable[..., TAggregate | None] 52 | 53 | 54 | def aggregate_projector( 55 | mutator: MutatorFunction[TAggregate], 56 | ) -> Callable[[TAggregate | None, Iterable[DomainEvent]], TAggregate | None]: 57 | def project_aggregate( 58 | aggregate: TAggregate | None, events: Iterable[DomainEvent] 59 | ) -> TAggregate | None: 60 | for event in events: 61 | aggregate = mutator(event, aggregate) 62 | return aggregate 63 | 64 | return project_aggregate 65 | -------------------------------------------------------------------------------- /examples/aggregate9/immutablemodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from datetime import datetime # noqa: TC003 5 | from typing import TYPE_CHECKING, TypeVar 6 | from uuid import UUID # noqa: TC003 7 | 8 | import msgspec 9 | 10 | from eventsourcing.domain import datetime_now_with_tzinfo 11 | from eventsourcing.utils import get_topic 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Iterable 15 | 16 | 17 | class Immutable(msgspec.Struct, frozen=True): 18 | pass 19 | 20 | 21 | class DomainEvent(Immutable, frozen=True): 22 | originator_id: UUID 23 | originator_version: int 24 | timestamp: datetime 25 | 26 | 27 | class Aggregate(Immutable, frozen=True): 28 | id: UUID 29 | version: int 30 | created_on: datetime 31 | modified_on: datetime 32 | 33 | 34 | class Snapshot(DomainEvent, frozen=True): 35 | topic: str 36 | state: bytes 37 | 38 | @classmethod 39 | def take(cls, aggregate: Aggregate) -> Snapshot: 40 | return Snapshot( 41 | originator_id=aggregate.id, 42 | originator_version=aggregate.version, 43 | timestamp=datetime_now_with_tzinfo(), 44 | topic=get_topic(type(aggregate)), 45 | state=msgspec.json.encode(aggregate), 46 | ) 47 | 48 | 49 | TAggregate = TypeVar("TAggregate", bound=Aggregate) 50 | 51 | MutatorFunction = Callable[..., TAggregate | None] 52 | 53 | 54 | def aggregate_projector( 55 | mutator: MutatorFunction[TAggregate], 56 | ) -> Callable[[TAggregate | None, Iterable[DomainEvent]], TAggregate | None]: 57 | def project_aggregate( 58 | aggregate: TAggregate | None, events: Iterable[DomainEvent] 59 | ) -> TAggregate | None: 60 | for event in events: 61 | aggregate = mutator(event, aggregate) 62 | return aggregate 63 | 64 | return project_aggregate 65 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/add_item_to_cart/cmd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from decimal import Decimal # noqa: TC003 4 | from uuid import UUID # noqa: TC003 5 | 6 | from examples.shopvertical.common import Command, get_events, put_events 7 | from examples.shopvertical.events import ( 8 | AddedItemToCart, 9 | ClearedCart, 10 | DomainEvents, 11 | RemovedItemFromCart, 12 | SubmittedCart, 13 | ) 14 | from examples.shopvertical.exceptions import CartAlreadySubmittedError, CartFullError 15 | 16 | 17 | class AddItemToCart(Command): 18 | cart_id: UUID 19 | product_id: UUID 20 | description: str 21 | price: Decimal 22 | name: str 23 | 24 | def handle(self, events: DomainEvents) -> DomainEvents: 25 | product_ids = [] 26 | is_submitted = False 27 | for event in events: 28 | if isinstance(event, AddedItemToCart): 29 | product_ids.append(event.product_id) 30 | elif isinstance(event, RemovedItemFromCart): 31 | product_ids.remove(event.product_id) 32 | elif isinstance(event, ClearedCart): 33 | product_ids.clear() 34 | elif isinstance(event, SubmittedCart): 35 | is_submitted = True 36 | 37 | if is_submitted: 38 | raise CartAlreadySubmittedError 39 | 40 | if len(product_ids) >= 3: 41 | raise CartFullError 42 | 43 | return ( 44 | AddedItemToCart( 45 | originator_id=self.cart_id, 46 | originator_version=len(events) + 1, 47 | product_id=self.product_id, 48 | name=self.name, 49 | description=self.description, 50 | price=self.price, 51 | ), 52 | ) 53 | 54 | def execute(self) -> int | None: 55 | return put_events(self.handle(get_events(self.cart_id))) 56 | -------------------------------------------------------------------------------- /examples/aggregate7a/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, ClassVar 4 | from uuid import UUID 5 | 6 | from eventsourcing.application import Application, ProjectorFunction 7 | from examples.aggregate7.orjsonpydantic import OrjsonTranscoder, PydanticMapper 8 | from examples.aggregate7a.domainmodel import ( 9 | Dog, 10 | Snapshot, 11 | add_trick, 12 | project_dog, 13 | register_dog, 14 | ) 15 | 16 | if TYPE_CHECKING: 17 | 18 | from eventsourcing.domain import MutableOrImmutableAggregate 19 | from eventsourcing.persistence import Mapper, Transcoder 20 | 21 | 22 | class DogSchool(Application[UUID]): 23 | is_snapshotting_enabled = True 24 | snapshot_class = Snapshot 25 | snapshotting_intervals: ClassVar[ 26 | dict[type[MutableOrImmutableAggregate[UUID]], int] 27 | ] = {Dog: 5} 28 | snapshotting_projectors: ClassVar[ 29 | dict[type[MutableOrImmutableAggregate[UUID]], ProjectorFunction[Any, Any]] 30 | ] = {Dog: project_dog} 31 | 32 | def register_dog(self, name: str) -> UUID: 33 | dog = register_dog(name) 34 | self.save(dog) 35 | return dog.id 36 | 37 | def add_trick(self, dog_id: UUID, trick: str) -> None: 38 | dog = self.repository.get(dog_id, projector_func=project_dog) 39 | dog = add_trick(dog, trick) 40 | self.save(dog) 41 | 42 | def get_dog(self, dog_id: UUID) -> dict[str, Any]: 43 | dog = self.repository.get(dog_id, projector_func=project_dog) 44 | return {"name": dog.name, "tricks": tuple([t.name for t in dog.tricks])} 45 | 46 | def construct_mapper(self) -> Mapper[UUID]: 47 | return self.factory.mapper( 48 | transcoder=self.construct_transcoder(), 49 | mapper_class=PydanticMapper, 50 | ) 51 | 52 | def construct_transcoder(self) -> Transcoder: 53 | return OrjsonTranscoder() 54 | -------------------------------------------------------------------------------- /examples/aggregate7a/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | from examples.aggregate7a.application import DogSchool 6 | from examples.aggregate7a.domainmodel import project_dog 7 | 8 | 9 | class TestDogSchool(TestCase): 10 | def test_dog_school(self) -> None: 11 | # Construct application object. 12 | school = DogSchool() 13 | 14 | # Evolve application state. 15 | dog_id = school.register_dog("Fido") 16 | school.add_trick(dog_id, "roll over") 17 | school.add_trick(dog_id, "play dead") 18 | 19 | # Query application state. 20 | dog = school.get_dog(dog_id) 21 | self.assertEqual(dog["name"], "Fido") 22 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 23 | 24 | # Select notifications. 25 | notifications = school.notification_log.select(start=1, limit=10) 26 | assert len(notifications) == 3 27 | 28 | # Take snapshot. 29 | assert school.snapshots is not None 30 | assert len(list(school.snapshots.get(dog_id))) == 0 31 | school.take_snapshot(dog_id, version=3, projector_func=project_dog) 32 | assert len(list(school.snapshots.get(dog_id))) == 1 33 | dog = school.get_dog(dog_id) 34 | self.assertEqual(dog["name"], "Fido") 35 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 36 | 37 | # Continue with snapshotted aggregate. 38 | school.add_trick(dog_id, "fetch ball") 39 | dog = school.get_dog(dog_id) 40 | self.assertEqual(dog["name"], "Fido") 41 | self.assertEqual(dog["tricks"], ("roll over", "play dead", "fetch ball")) 42 | 43 | # Auto-snapshotting at version 5. 44 | assert len(list(school.snapshots.get(dog_id))) == 1 45 | school.add_trick(dog_id, "jump hoop") 46 | assert len(list(school.snapshots.get(dog_id))) == 2 47 | -------------------------------------------------------------------------------- /examples/aggregate7/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from unittest import TestCase 5 | 6 | from examples.aggregate7.application import DogSchool 7 | from examples.aggregate7.domainmodel import project_dog 8 | 9 | 10 | class TestDogSchool(TestCase): 11 | def test_dog_school(self) -> None: 12 | # Construct application object. 13 | school = DogSchool() 14 | 15 | # Evolve application state. 16 | dog_id = school.register_dog("Fido") 17 | school.add_trick(dog_id, "roll over") 18 | school.add_trick(dog_id, "play dead") 19 | 20 | # Query application state. 21 | dog = school.get_dog(dog_id) 22 | self.assertEqual(dog["name"], "Fido") 23 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 24 | self.assertIsInstance(dog["created_on"], datetime) 25 | self.assertIsInstance(dog["modified_on"], datetime) 26 | 27 | # Select notifications. 28 | notifications = school.notification_log.select(start=1, limit=10) 29 | assert len(notifications) == 3 30 | 31 | # Take snapshot. 32 | school.take_snapshot(dog_id, version=3, projector_func=project_dog) 33 | dog = school.get_dog(dog_id) 34 | self.assertEqual(dog["name"], "Fido") 35 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 36 | self.assertIsInstance(dog["created_on"], datetime) 37 | self.assertIsInstance(dog["modified_on"], datetime) 38 | 39 | # Continue with snapshotted aggregate. 40 | school.add_trick(dog_id, "fetch ball") 41 | dog = school.get_dog(dog_id) 42 | self.assertEqual(dog["name"], "Fido") 43 | self.assertEqual(dog["tricks"], ("roll over", "play dead", "fetch ball")) 44 | self.assertIsInstance(dog["created_on"], datetime) 45 | self.assertIsInstance(dog["modified_on"], datetime) 46 | -------------------------------------------------------------------------------- /examples/aggregate8/test_mutablemodel.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from uuid import uuid4 3 | 4 | from pydantic import ValidationError 5 | 6 | from eventsourcing.domain import datetime_now_with_tzinfo 7 | from examples.aggregate8.mutablemodel import AggregateSnapshot, SnapshotState 8 | 9 | 10 | class TestSnapshotState(TestCase): 11 | def test_raises_type_error_if_not_subclass_of_snapshot_state(self) -> None: 12 | # state defined with wrong type - not okay 13 | with self.assertRaises(TypeError) as cm: 14 | 15 | class MyBrokenSnapshot(AggregateSnapshot): 16 | state: int 17 | 18 | self.assertTrue( 19 | str(cm.exception).endswith("got: "), 20 | str(cm.exception), 21 | ) 22 | 23 | class MySnapshotState(SnapshotState): 24 | a: str 25 | 26 | # state not defined - not okay 27 | with self.assertRaises(TypeError) as cm: 28 | 29 | class MyMisspelledSnapshot(AggregateSnapshot): 30 | misspelled: MySnapshotState 31 | 32 | self.assertTrue( 33 | str(cm.exception).endswith("got: typing.Any"), 34 | str(cm.exception), 35 | ) 36 | 37 | # this is okay 38 | class MySnapshot(AggregateSnapshot): 39 | state: MySnapshotState 40 | 41 | snapshot = MySnapshot( 42 | originator_id=uuid4(), 43 | originator_version=1, 44 | timestamp=datetime_now_with_tzinfo(), 45 | topic="", 46 | state=MySnapshotState( 47 | a="a", 48 | _created_on=datetime_now_with_tzinfo(), # pyright: ignore[reportCallIssue] 49 | _modified_on=datetime_now_with_tzinfo(), # pyright: ignore[reportCallIssue] 50 | ), 51 | ) 52 | 53 | with self.assertRaises(ValidationError): 54 | # It's frozen. 55 | snapshot.state.a = "b" # type: ignore[misc] 56 | -------------------------------------------------------------------------------- /examples/aggregate8/mutablemodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from datetime import datetime 5 | from typing import Any 6 | from uuid import UUID, uuid4 7 | 8 | from pydantic import ConfigDict, TypeAdapter 9 | 10 | from eventsourcing.domain import ( 11 | BaseAggregate, 12 | CanInitAggregate, 13 | CanMutateAggregate, 14 | CanSnapshotAggregate, 15 | ) 16 | from examples.aggregate7.immutablemodel import DomainEvent, Immutable 17 | 18 | datetime_adapter = TypeAdapter(datetime) 19 | 20 | 21 | class SnapshotState(Immutable): 22 | model_config = ConfigDict(extra="allow") 23 | 24 | def __init__(self, **kwargs: Any) -> None: 25 | for key in ["_created_on", "_modified_on"]: 26 | kwargs[key] = datetime_adapter.validate_python(kwargs[key]) 27 | super().__init__(**kwargs) 28 | 29 | 30 | class AggregateSnapshot(DomainEvent, CanSnapshotAggregate[UUID]): 31 | topic: str 32 | state: Any 33 | 34 | def __init_subclass__(cls, **kwargs: Any) -> None: 35 | super().__init_subclass__(**kwargs) 36 | type_of_snapshot_state = typing.get_type_hints(cls)["state"] 37 | try: 38 | assert issubclass( 39 | type_of_snapshot_state, SnapshotState 40 | ), type_of_snapshot_state 41 | except (TypeError, AssertionError) as e: 42 | msg = ( 43 | f"Subclass of {SnapshotState}" 44 | f" is required as the annotated type of 'state' on " 45 | f"{cls}, got: {type_of_snapshot_state}" 46 | ) 47 | raise TypeError(msg) from e 48 | 49 | 50 | class Aggregate(BaseAggregate[UUID]): 51 | @staticmethod 52 | def create_id(*_: Any, **__: Any) -> UUID: 53 | """Returns a new aggregate ID.""" 54 | return uuid4() 55 | 56 | class Event(DomainEvent, CanMutateAggregate[UUID]): 57 | pass 58 | 59 | class Created(Event, CanInitAggregate[UUID]): 60 | originator_topic: str 61 | -------------------------------------------------------------------------------- /examples/aggregate9/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from unittest import TestCase 5 | from uuid import UUID 6 | 7 | from examples.aggregate9.application import DogSchool 8 | from examples.aggregate9.domainmodel import project_dog 9 | 10 | 11 | class TestDogSchool(TestCase): 12 | def test_dog_school(self) -> None: 13 | # Construct application object. 14 | school = DogSchool() 15 | 16 | # Evolve application state. 17 | dog_id = school.register_dog("Fido") 18 | school.add_trick(dog_id, "roll over") 19 | school.add_trick(dog_id, "play dead") 20 | 21 | # Query application state. 22 | dog = school.get_dog(dog_id) 23 | self.assertEqual(dog["name"], "Fido") 24 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 25 | self.assertIsInstance(dog["id"], UUID) 26 | self.assertIsInstance(dog["created_on"], datetime) 27 | self.assertIsInstance(dog["modified_on"], datetime) 28 | 29 | # Select notifications. 30 | notifications = school.notification_log.select(start=1, limit=10) 31 | assert len(notifications) == 3 32 | 33 | # Take snapshot. 34 | school.take_snapshot(dog_id, version=3, projector_func=project_dog) 35 | dog = school.get_dog(dog_id) 36 | self.assertEqual(dog["name"], "Fido") 37 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 38 | self.assertIsInstance(dog["created_on"], datetime) 39 | self.assertIsInstance(dog["modified_on"], datetime) 40 | 41 | # Continue with snapshotted aggregate. 42 | school.add_trick(dog_id, "fetch ball") 43 | dog = school.get_dog(dog_id) 44 | self.assertEqual(dog["name"], "Fido") 45 | self.assertEqual(dog["tricks"], ("roll over", "play dead", "fetch ball")) 46 | self.assertIsInstance(dog["created_on"], datetime) 47 | self.assertIsInstance(dog["modified_on"], datetime) 48 | -------------------------------------------------------------------------------- /dev/test-released-distribution.py: -------------------------------------------------------------------------------- 1 | # import os 2 | # import subprocess 3 | # 4 | # 5 | # def main(): 6 | # # Validate current working dir (should be project root). 7 | # proj_path = os.path.abspath(".") 8 | # readme_path = os.path.join(proj_path, "README.md") 9 | # if os.path.exists(readme_path): 10 | # assert "A library for event sourcing in Python" in open(readme_path).read() 11 | # else: 12 | # raise Exception("Couldn't find project README.md") 13 | # 14 | # try: 15 | # del os.environ["PYTHONPATH"] 16 | # except KeyError: 17 | # pass 18 | # 19 | # # Declare temporary working directory variable. 20 | # build_targets = [ 21 | # (os.path.join(proj_path, "tmpve3.7"), "python") 22 | # ] 23 | # for (venv_path, python_bin) in build_targets: 24 | # 25 | # # Remove existing virtualenv. 26 | # if os.path.exists(venv_path): 27 | # remove_virtualenv(proj_path, venv_path) 28 | # 29 | # # Create virtualenv. 30 | # subprocess.check_call(["virtualenv", "-p", python_bin, venv_path], 31 | # cwd=proj_path) 32 | # subprocess.check_call(["bin/pip", "install", "-U", "pip", "wheel"], 33 | # cwd=venv_path) 34 | # 35 | # # Install from PyPI. 36 | # os.environ["CASS_DRIVER_NO_CYTHON"] = "1" 37 | # subprocess.check_call( 38 | # ["bin/pip", "install", "--no-cache-dir", "eventsourcing[dev]"], 39 | # cwd=venv_path, 40 | # ) 41 | # 42 | # # Check installed tests all pass. 43 | # subprocess.check_call( 44 | # ["bin/python", "-m" "unittest", "discover", "eventsourcing.tests"], 45 | # cwd=venv_path 46 | # ) 47 | # 48 | # remove_virtualenv(proj_path, venv_path) 49 | # 50 | # 51 | # def remove_virtualenv(proj_path, venv_path): 52 | # subprocess.check_call(["rm", "-r", venv_path], cwd=proj_path) 53 | # 54 | # 55 | # if __name__ == "__main__": 56 | # main() 57 | -------------------------------------------------------------------------------- /examples/dcb_enrolment/interface.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import NewType 5 | 6 | StudentID = NewType("StudentID", str) 7 | 8 | CourseID = NewType("CourseID", str) 9 | 10 | 11 | class EnrolmentInterface(ABC): 12 | @abstractmethod 13 | def register_student(self, name: str, max_courses: int) -> StudentID: 14 | """ 15 | Register a new student. 16 | """ 17 | 18 | @abstractmethod 19 | def register_course(self, name: str, places: int) -> CourseID: 20 | """ 21 | Register a new course. 22 | """ 23 | 24 | @abstractmethod 25 | def join_course(self, student_id: StudentID, course_id: CourseID) -> None: 26 | """ 27 | Enrol a student on a course. 28 | """ 29 | 30 | @abstractmethod 31 | def list_students_for_course(self, course_id: CourseID) -> list[str]: 32 | """ 33 | List students enrolled on a course. 34 | """ 35 | 36 | @abstractmethod 37 | def list_courses_for_student(self, student_id: StudentID) -> list[str]: 38 | """ 39 | List courses enrolled by a student. 40 | """ 41 | 42 | 43 | class StudentNotFoundError(Exception): 44 | """ 45 | Raised when a student is not registered. 46 | """ 47 | 48 | 49 | class CourseNotFoundError(Exception): 50 | """ 51 | Raised when a course is not registered. 52 | """ 53 | 54 | 55 | class TooManyCoursesError(Exception): 56 | """ 57 | Raised when a student is already enrolled to a maximum number of courses. 58 | """ 59 | 60 | 61 | class FullyBookedError(Exception): 62 | """ 63 | Raised when a course already has a maximum number of enrolled students. 64 | """ 65 | 66 | 67 | class NotAlreadyJoinedError(Exception): 68 | """ 69 | Raised when a student is not already enrolled on a course. 70 | """ 71 | 72 | 73 | class AlreadyJoinedError(Exception): 74 | """ 75 | Raised when a student is already enrolled on a course. 76 | """ 77 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/list_products_in_shop/query.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from decimal import Decimal 3 | from uuid import UUID 4 | 5 | from eventsourcing.utils import get_topic 6 | from examples.aggregate7.immutablemodel import Immutable 7 | from examples.shopvertical.common import Query, get_all_events 8 | from examples.shopvertical.events import ( 9 | AddedProductToShop, 10 | AdjustedProductInventory, 11 | DomainEvents, 12 | ) 13 | 14 | 15 | class ProductDetails(Immutable): 16 | id: UUID 17 | name: str 18 | description: str 19 | price: Decimal 20 | inventory: int = 0 21 | 22 | 23 | class ListProductsInShop(Query): 24 | @staticmethod 25 | def projection(events: DomainEvents) -> Sequence[ProductDetails]: 26 | products: dict[UUID, ProductDetails] = {} 27 | for event in events: 28 | if isinstance(event, AddedProductToShop): 29 | products[event.originator_id] = ProductDetails( 30 | id=event.originator_id, 31 | name=event.name, 32 | description=event.description, 33 | price=event.price, 34 | ) 35 | elif isinstance(event, AdjustedProductInventory): 36 | product = products[event.originator_id] 37 | products[event.originator_id] = ProductDetails( 38 | id=event.originator_id, 39 | name=product.name, 40 | description=product.description, 41 | price=product.price, 42 | inventory=product.inventory + event.adjustment, 43 | ) 44 | return tuple(products.values()) 45 | 46 | def execute(self) -> Sequence[ProductDetails]: 47 | # TODO: Make this a materialised view. 48 | return self.projection( 49 | get_all_events( 50 | topics=( 51 | get_topic(AddedProductToShop), 52 | get_topic(AdjustedProductInventory), 53 | ) 54 | ) 55 | ) 56 | -------------------------------------------------------------------------------- /examples/aggregate9/msgpack.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, ClassVar 4 | from uuid import UUID 5 | 6 | import msgspec 7 | 8 | from eventsourcing.application import Application 9 | from eventsourcing.persistence import Mapper, StoredEvent, Transcoder 10 | from eventsourcing.utils import get_topic, resolve_topic 11 | 12 | if TYPE_CHECKING: 13 | from eventsourcing.domain import DomainEventProtocol 14 | 15 | 16 | class MessagePackMapper(Mapper[UUID]): 17 | def to_stored_event(self, domain_event: DomainEventProtocol[UUID]) -> StoredEvent: 18 | topic = get_topic(domain_event.__class__) 19 | stored_state = msgspec.json.encode(domain_event) 20 | if self.compressor: 21 | stored_state = self.compressor.compress(stored_state) 22 | if self.cipher: 23 | stored_state = self.cipher.encrypt(stored_state) 24 | return StoredEvent( 25 | originator_id=domain_event.originator_id, 26 | originator_version=domain_event.originator_version, 27 | topic=topic, 28 | state=stored_state, 29 | ) 30 | 31 | def to_domain_event(self, stored_event: StoredEvent) -> DomainEventProtocol[UUID]: 32 | stored_state = stored_event.state 33 | if self.cipher: 34 | stored_state = self.cipher.decrypt(stored_state) 35 | if self.compressor: 36 | stored_state = self.compressor.decompress(stored_state) 37 | cls = resolve_topic(stored_event.topic) 38 | return msgspec.json.decode(stored_state, type=cls) 39 | 40 | 41 | class NullTranscoder(Transcoder): 42 | def encode(self, obj: Any) -> bytes: 43 | """Encodes obj as bytes.""" 44 | return b"" 45 | 46 | def decode(self, data: bytes) -> Any: 47 | """Decodes obj from bytes.""" 48 | return None 49 | 50 | 51 | class MsgspecApplication(Application[UUID]): 52 | env: ClassVar[dict[str, str]] = { 53 | "MAPPER_TOPIC": get_topic(MessagePackMapper), 54 | "TRANSCODER_TOPIC": get_topic(NullTranscoder), 55 | } 56 | -------------------------------------------------------------------------------- /docs/topics/examples.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Examples 3 | ======== 4 | 5 | This library contains a few example applications of event sourcing in Python. 6 | 7 | 8 | .. _Example aggregates: 9 | 10 | Example aggregates 11 | ================== 12 | 13 | The aggregate examples show a range of different styles for coding aggregate 14 | classes, from the declarative syntax which provides the most concise style 15 | for expressing business concerns, to a functional style which uses immutable 16 | aggregate objects. All these examples make use of the library's application 17 | and persistence modules. All these examples satisfy the same test case which 18 | involves creating and updating a ``Dog`` aggregate, and taking a snapshot. 19 | 20 | .. toctree:: 21 | :maxdepth: 2 22 | 23 | examples/aggregate1 24 | examples/aggregate2 25 | examples/aggregate3 26 | examples/aggregate4 27 | examples/aggregate5 28 | examples/aggregate6 29 | examples/aggregate7 30 | examples/aggregate8 31 | examples/aggregate9 32 | examples/aggregate10 33 | examples/aggregate11 34 | 35 | .. _Example applications: 36 | 37 | Example applications 38 | ==================== 39 | 40 | .. toctree:: 41 | :maxdepth: 2 42 | 43 | examples/bank-accounts 44 | examples/cargo-shipping 45 | examples/content-management 46 | examples/searchable-timestamps 47 | examples/fts-content-management 48 | examples/shop-standard 49 | examples/shop-vertical 50 | 51 | .. _Example projections: 52 | 53 | Example projections 54 | =================== 55 | 56 | .. toctree:: 57 | :maxdepth: 2 58 | 59 | examples/fts-projection 60 | 61 | .. _Example systems: 62 | 63 | Example systems 64 | =============== 65 | 66 | .. toctree:: 67 | :maxdepth: 2 68 | 69 | examples/fts-process 70 | 71 | .. _Dynamic consistency boundaries: 72 | 73 | DCB examples 74 | ============ 75 | 76 | .. toctree:: 77 | :maxdepth: 2 78 | 79 | examples/dcb-enrolment-introduction 80 | examples/dcb-enrolment-with-basic-objects 81 | examples/dcb-enrolment-with-enduring-objects 82 | examples/dcb-enrolment-with-vertical-slices 83 | examples/dcb-enrolment-speedrun 84 | -------------------------------------------------------------------------------- /tests/application_tests/test_application_with_sqlite.py: -------------------------------------------------------------------------------- 1 | import os 2 | from abc import ABC 3 | from collections.abc import Iterator 4 | from unittest import TestCase 5 | 6 | from eventsourcing.tests.application import ( 7 | ApplicationTestCase, 8 | ExampleApplicationTestCase, 9 | ) 10 | from eventsourcing.tests.persistence import tmpfile_uris 11 | 12 | 13 | class WithSQLite(TestCase, ABC): 14 | expected_factory_topic = "eventsourcing.sqlite:SQLiteFactory" 15 | uris: Iterator[str] = iter(()) 16 | 17 | def setUp(self) -> None: 18 | super().setUp() 19 | os.environ["PERSISTENCE_MODULE"] = "eventsourcing.sqlite" 20 | os.environ["CREATE_TABLE"] = "y" 21 | os.environ["SQLITE_DBNAME"] = next(self.uris) 22 | 23 | def tearDown(self) -> None: 24 | del os.environ["PERSISTENCE_MODULE"] 25 | del os.environ["CREATE_TABLE"] 26 | del os.environ["SQLITE_DBNAME"] 27 | super().tearDown() 28 | 29 | 30 | class WithSQLiteFile(WithSQLite): 31 | uris = tmpfile_uris() 32 | 33 | 34 | def memory_uris() -> Iterator[str]: 35 | db_number = 1 36 | while True: 37 | uri = f"file:db{db_number}?mode=memory&cache=shared" 38 | yield uri 39 | db_number += 1 40 | 41 | 42 | class WithSQLiteInMemory(WithSQLite): 43 | uris = memory_uris() 44 | 45 | 46 | class TestApplicationWithSQLiteFile(WithSQLiteFile, ApplicationTestCase): 47 | def test_catchup_subscription(self) -> None: 48 | self.skipTest("SQLite recorder doesn't support subscriptions") 49 | 50 | 51 | class TestApplicationWithSQLiteInMemory(WithSQLiteInMemory, ApplicationTestCase): 52 | def test_catchup_subscription(self) -> None: 53 | self.skipTest("SQLite recorder doesn't support subscriptions") 54 | 55 | 56 | class TestExampleApplicationWithSQLiteFile(WithSQLiteFile, ExampleApplicationTestCase): 57 | pass 58 | 59 | 60 | class TestExampleApplicationWithSQLiteInMemory( 61 | WithSQLiteInMemory, ExampleApplicationTestCase 62 | ): 63 | pass 64 | 65 | 66 | del ApplicationTestCase 67 | del ExampleApplicationTestCase 68 | del WithSQLiteFile 69 | del WithSQLiteInMemory 70 | -------------------------------------------------------------------------------- /docs/topics/examples/bank-accounts.rst: -------------------------------------------------------------------------------- 1 | .. _Bank accounts example: 2 | 3 | Application 1 - Bank accounts 4 | ============================= 5 | 6 | This example demonstrates a straightforward `event-sourced application <../application.html>`_. 7 | 8 | Application 9 | ----------- 10 | 11 | The ``BankAccounts`` application has command and query methods for interacting 12 | with the domain model. New accounts can be opened. Existing accounts can be 13 | closed. Deposits and withdraws can be made on open accounts. Transfers can be 14 | made between open accounts, if there are sufficient funds on the debited account. 15 | All actions are atomic, including transfers between accounts. 16 | 17 | .. literalinclude:: ../../../examples/bankaccounts/application.py 18 | 19 | 20 | Domain model 21 | ------------ 22 | 23 | The ``BankAccount`` aggregate class is defined using the 24 | :ref:`declarative syntax `. It has a 25 | balance and an overdraft limit. Accounts can be opened and 26 | closed. Accounts can be credited and debited, which affects 27 | the balance. Neither credits nor debits are allowed if 28 | the account has been closed. Debits are not allowed if the 29 | balance would go below the overdraft limit. The overdraft 30 | limit can be adjusted. 31 | 32 | .. literalinclude:: ../../../examples/bankaccounts/domainmodel.py 33 | 34 | 35 | Test case 36 | --------- 37 | 38 | For the purpose of showing how the application object 39 | might be used, the test runs through a scenario that 40 | exercises all the methods of the application in one 41 | test method. 42 | 43 | .. literalinclude:: ../../../examples/bankaccounts/test.py 44 | 45 | 46 | Code reference 47 | -------------- 48 | 49 | .. automodule:: examples.bankaccounts.domainmodel 50 | :show-inheritance: 51 | :member-order: bysource 52 | :members: 53 | :undoc-members: 54 | 55 | .. automodule:: examples.bankaccounts.application 56 | :show-inheritance: 57 | :member-order: bysource 58 | :members: 59 | :undoc-members: 60 | 61 | .. automodule:: examples.bankaccounts.test 62 | :show-inheritance: 63 | :member-order: bysource 64 | :members: 65 | :undoc-members: 66 | 67 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/add_product_to_shop/test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from decimal import Decimal 4 | from typing import cast 5 | from unittest import TestCase 6 | from uuid import uuid4 7 | 8 | from examples.shopvertical.events import AddedProductToShop, DomainEvent 9 | from examples.shopvertical.exceptions import ProductAlreadyInShopError 10 | from examples.shopvertical.slices.add_product_to_shop.cmd import AddProductToShop 11 | 12 | 13 | class TestAddProductToShop(TestCase): 14 | def test_add_product_to_shop(self) -> None: 15 | product_id = uuid4() 16 | 17 | product_events: tuple[DomainEvent, ...] = () 18 | 19 | cmd = AddProductToShop( 20 | product_id=product_id, 21 | name="Coffee", 22 | description="A very nice coffee", 23 | price=Decimal("5.99"), 24 | ) 25 | 26 | new_events = cmd.handle(product_events) 27 | self.assertEqual(len(new_events), 1) 28 | self.assertIsInstance(new_events[0], AddedProductToShop) 29 | new_event = cast(AddedProductToShop, new_events[0]) 30 | self.assertEqual(new_event.originator_id, product_id) 31 | self.assertEqual(new_event.originator_version, 1) 32 | self.assertEqual(new_event.name, "Coffee") 33 | self.assertEqual(new_event.description, "A very nice coffee") 34 | self.assertEqual(new_event.price, Decimal("5.99")) 35 | 36 | def test_already_added_product_to_shop(self) -> None: 37 | product_id = uuid4() 38 | 39 | product_events: tuple[DomainEvent, ...] = ( 40 | AddedProductToShop( 41 | originator_id=product_id, 42 | originator_version=1, 43 | name="Tea", 44 | description="A very nice tea", 45 | price=Decimal("5.99"), 46 | ), 47 | ) 48 | 49 | cmd = AddProductToShop( 50 | product_id=product_id, 51 | name="Coffee", 52 | description="A very nice coffee", 53 | price=Decimal("3.99"), 54 | ) 55 | 56 | with self.assertRaises(ProductAlreadyInShopError): 57 | cmd.handle(product_events) 58 | -------------------------------------------------------------------------------- /examples/aggregate7/orjsonpydantic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, ClassVar, cast 4 | from uuid import UUID 5 | 6 | import orjson 7 | from pydantic import BaseModel 8 | 9 | from eventsourcing.application import Application 10 | from eventsourcing.persistence import Mapper, StoredEvent, Transcoder 11 | from eventsourcing.utils import get_topic, resolve_topic 12 | 13 | if TYPE_CHECKING: 14 | from eventsourcing.domain import DomainEventProtocol 15 | 16 | 17 | class PydanticMapper(Mapper[UUID]): 18 | def to_stored_event(self, domain_event: DomainEventProtocol[UUID]) -> StoredEvent: 19 | topic = get_topic(domain_event.__class__) 20 | event_state = cast(BaseModel, domain_event).model_dump(mode="json") 21 | stored_state = self.transcoder.encode(event_state) 22 | if self.compressor: 23 | stored_state = self.compressor.compress(stored_state) 24 | if self.cipher: 25 | stored_state = self.cipher.encrypt(stored_state) 26 | return StoredEvent( 27 | originator_id=domain_event.originator_id, 28 | originator_version=domain_event.originator_version, 29 | topic=topic, 30 | state=stored_state, 31 | ) 32 | 33 | def to_domain_event(self, stored_event: StoredEvent) -> DomainEventProtocol[UUID]: 34 | stored_state = stored_event.state 35 | if self.cipher: 36 | stored_state = self.cipher.decrypt(stored_state) 37 | if self.compressor: 38 | stored_state = self.compressor.decompress(stored_state) 39 | event_state: dict[str, Any] = self.transcoder.decode(stored_state) 40 | cls = resolve_topic(stored_event.topic) 41 | return cls(**event_state) 42 | 43 | 44 | class OrjsonTranscoder(Transcoder): 45 | def encode(self, obj: Any) -> bytes: 46 | return orjson.dumps(obj) 47 | 48 | def decode(self, data: bytes) -> Any: 49 | return orjson.loads(data) 50 | 51 | 52 | class PydanticApplication(Application[UUID]): 53 | env: ClassVar[dict[str, str]] = { 54 | "TRANSCODER_TOPIC": get_topic(OrjsonTranscoder), 55 | "MAPPER_TOPIC": get_topic(PydanticMapper), 56 | } 57 | -------------------------------------------------------------------------------- /examples/shopvertical/slices/submit_cart/cmd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | from uuid import UUID # noqa: TC003 5 | 6 | from examples.shopvertical.common import Command, get_events, put_events 7 | from examples.shopvertical.events import ( 8 | AddedItemToCart, 9 | AdjustedProductInventory, 10 | ClearedCart, 11 | DomainEvents, 12 | RemovedItemFromCart, 13 | SubmittedCart, 14 | ) 15 | from examples.shopvertical.exceptions import ( 16 | CartAlreadySubmittedError, 17 | InsufficientInventoryError, 18 | ) 19 | 20 | 21 | class SubmitCart(Command): 22 | cart_id: UUID 23 | 24 | def handle(self, events: DomainEvents) -> DomainEvents: 25 | requested_products: dict[UUID, int] = defaultdict(int) 26 | is_submitted = False 27 | 28 | for event in events: 29 | if isinstance(event, AddedItemToCart): 30 | requested_products[event.product_id] += 1 31 | elif isinstance(event, RemovedItemFromCart): 32 | requested_products[event.product_id] -= 1 33 | elif isinstance(event, ClearedCart): 34 | requested_products.clear() 35 | elif isinstance(event, SubmittedCart): 36 | is_submitted = True 37 | 38 | if is_submitted: 39 | raise CartAlreadySubmittedError 40 | 41 | # Check inventory. 42 | for product_id, requested_amount in requested_products.items(): 43 | current_inventory = 0 44 | for product_event in get_events(product_id): 45 | if isinstance(product_event, AdjustedProductInventory): 46 | current_inventory += product_event.adjustment 47 | if current_inventory < requested_amount: 48 | msg = f"Insufficient inventory for product with ID {product_id}" 49 | raise InsufficientInventoryError(msg) 50 | 51 | return ( 52 | SubmittedCart( 53 | originator_id=self.cart_id, 54 | originator_version=len(events) + 1, 55 | ), 56 | ) 57 | 58 | def execute(self) -> int | None: 59 | return put_events(self.handle(get_events(self.cart_id))) 60 | -------------------------------------------------------------------------------- /tests/mypy_issue.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: E501 2 | 3 | # Just wanted to document that mypy (and not pyright) reports errors with the first 4 | # case but not the second, with two constraints rather than a union bound. Not sure 5 | # if there's something I don't understand... probably there is. Anyway, I switched to 6 | # using a union in the code, and that does what I wanted. 7 | 8 | from typing import Generic, Protocol, TypeVar 9 | from uuid import UUID 10 | 11 | # Case 1. 12 | 13 | T_co = TypeVar("T_co", UUID, str, covariant=True) 14 | T = TypeVar("T", UUID, str) 15 | 16 | 17 | class P(Protocol[T_co]): 18 | @property 19 | def id(self) -> T_co: 20 | raise NotImplementedError 21 | 22 | 23 | class A(Generic[T]): 24 | def get_items(self) -> list[P[T]]: 25 | return [] 26 | 27 | def process_items(self, items: P[T]) -> None: 28 | pass 29 | 30 | 31 | class B(A[T]): 32 | def get_items(self) -> list[P[T]]: 33 | # error: Incompatible return value type (got "list[P[T]]", expected "list[P[UUID]]") [return-value] 34 | # error: Incompatible return value type (got "list[P[T]]", expected "list[P[str]]") [return-value] 35 | return super().get_items() # type: ignore[return-value] 36 | 37 | def process_items(self, items: P[T]) -> None: 38 | # error: Argument 1 to "process_items" of "A" has incompatible type "P[UUID]"; expected "P[T]" [arg-type] 39 | # error: Argument 1 to "process_items" of "A" has incompatible type "P[str]"; expected "P[T]" [arg-type] 40 | super().process_items(items) # type: ignore[arg-type] 41 | 42 | 43 | # Case 2. 44 | 45 | S_co = TypeVar("S_co", bound=UUID | str, covariant=True) 46 | S = TypeVar("S", bound=UUID | str) 47 | 48 | 49 | class Q(Protocol[S_co]): 50 | @property 51 | def id(self) -> S_co: 52 | raise NotImplementedError 53 | 54 | 55 | class C(Generic[S]): 56 | def get_items(self) -> list[Q[S]]: 57 | return [] 58 | 59 | def process_items(self, items: Q[S]) -> None: 60 | pass 61 | 62 | 63 | class D(C[S]): 64 | def get_items(self) -> list[Q[S]]: 65 | return super().get_items() 66 | 67 | def process_items(self, items: Q[S]) -> None: 68 | super().process_items(items) 69 | -------------------------------------------------------------------------------- /examples/ftsprocess/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, ClassVar, cast 4 | from uuid import UUID 5 | 6 | from eventsourcing.dispatch import singledispatchmethod 7 | from eventsourcing.system import ProcessApplication 8 | from examples.contentmanagement.domainmodel import Page 9 | from examples.contentmanagement.utils import apply_diff 10 | from examples.ftscontentmanagement.persistence import FtsRecorder, PageInfo 11 | 12 | if TYPE_CHECKING: 13 | from eventsourcing.application import ProcessingEvent 14 | from eventsourcing.domain import DomainEventProtocol 15 | 16 | 17 | class FtsProcess(ProcessApplication[UUID]): 18 | env: ClassVar[dict[str, str]] = { 19 | "COMPRESSOR_TOPIC": "gzip", 20 | } 21 | 22 | @singledispatchmethod 23 | def policy( 24 | self, 25 | domain_event: DomainEventProtocol[UUID], 26 | processing_event: ProcessingEvent[UUID], 27 | ) -> None: 28 | if isinstance(domain_event, Page.Created): 29 | processing_event.collect_events( 30 | insert_pages=[ 31 | PageInfo( 32 | id=domain_event.originator_id, 33 | slug=domain_event.slug, 34 | title=domain_event.title, 35 | body=domain_event.body, 36 | ) 37 | ] 38 | ) 39 | elif isinstance(domain_event, Page.BodyUpdated): 40 | recorder = cast("FtsRecorder", self.recorder) 41 | page_id = domain_event.originator_id 42 | page = recorder.select_page(page_id) 43 | page_body = apply_diff(page.body, domain_event.diff) 44 | processing_event.collect_events( 45 | update_pages=[ 46 | PageInfo( 47 | id=page_id, 48 | slug=page.slug, 49 | title=page.title, 50 | body=page_body, 51 | ) 52 | ] 53 | ) 54 | 55 | def search(self, query: str) -> list[UUID]: 56 | recorder = cast("FtsRecorder", self.recorder) 57 | return recorder.search_pages(query) 58 | -------------------------------------------------------------------------------- /examples/aggregate9/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import singledispatch 4 | from uuid import uuid4 5 | 6 | import msgspec.json 7 | 8 | from eventsourcing.domain import datetime_now_with_tzinfo 9 | from examples.aggregate9.immutablemodel import ( 10 | Aggregate, 11 | DomainEvent, 12 | Immutable, 13 | Snapshot, 14 | aggregate_projector, 15 | ) 16 | 17 | 18 | class Trick(Immutable, frozen=True): 19 | name: str 20 | 21 | 22 | class Dog(Aggregate, frozen=True): 23 | name: str 24 | tricks: tuple[Trick, ...] 25 | 26 | 27 | class DogRegistered(DomainEvent, frozen=True): 28 | name: str 29 | 30 | 31 | class TrickAdded(DomainEvent, frozen=True): 32 | trick: Trick 33 | 34 | 35 | def register_dog(name: str) -> DomainEvent: 36 | return DogRegistered( 37 | originator_id=uuid4(), 38 | originator_version=1, 39 | timestamp=datetime_now_with_tzinfo(), 40 | name=name, 41 | ) 42 | 43 | 44 | def add_trick(dog: Dog, trick: Trick) -> DomainEvent: 45 | return TrickAdded( 46 | originator_id=dog.id, 47 | originator_version=dog.version + 1, 48 | timestamp=datetime_now_with_tzinfo(), 49 | trick=trick, 50 | ) 51 | 52 | 53 | @singledispatch 54 | def mutate_dog(_: DomainEvent, __: Dog | None) -> Dog | None: 55 | """Mutates aggregate with event.""" 56 | 57 | 58 | @mutate_dog.register 59 | def _(event: DogRegistered, _: None) -> Dog: 60 | return Dog( 61 | id=event.originator_id, 62 | version=event.originator_version, 63 | created_on=event.timestamp, 64 | modified_on=event.timestamp, 65 | name=event.name, 66 | tricks=(), 67 | ) 68 | 69 | 70 | @mutate_dog.register 71 | def _(event: TrickAdded, dog: Dog) -> Dog: 72 | return Dog( 73 | id=dog.id, 74 | version=event.originator_version, 75 | created_on=dog.created_on, 76 | modified_on=event.timestamp, 77 | name=dog.name, 78 | tricks=(*dog.tricks, event.trick), 79 | ) 80 | 81 | 82 | @mutate_dog.register 83 | def _(event: Snapshot, _: None) -> Dog: 84 | return msgspec.json.decode(event.state, type=Dog) 85 | 86 | 87 | project_dog = aggregate_projector(mutate_dog) 88 | -------------------------------------------------------------------------------- /docs/topics/support.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Support 3 | ======= 4 | 5 | Thank you for your interest in this library. 6 | Please show your support by 7 | `starring the project on GitHub `_, 8 | and either `sponsor the project `_ 9 | or `make a donation `_. 10 | 11 | If you see any errors or have any issues when using 12 | the library or reading the documentation, please `raise an issue on GitHub 13 | `_, create a 14 | pull request, or start a discussion in the Slack_ channel. 15 | 16 | 17 | Community support 18 | ================= 19 | 20 | The library has a growing community that may be able to help. 21 | 22 | - You can ask questions on the Slack_ channel. 23 | 24 | - You can also register issues and requests on our 25 | `issue tracker `_. 26 | 27 | .. _Slack: https://join.slack.com/t/eventsourcinginpython/shared_invite/zt-3hogb36o-LCvKd4Rz8JMALoLSl_pQ8g 28 | 29 | 30 | Training workshops 31 | ================== 32 | 33 | Training workshops are available to help developers more 34 | quickly learn how to use the library. Workshop participants 35 | will be guided through a series of topics, gradually discovering 36 | what the library is capable of doing, and learning how to use 37 | the library effectively. 38 | 39 | Please contact John Bywater via the Slack_ channel for more information about 40 | training workshops. 41 | 42 | 43 | Professional support 44 | ==================== 45 | 46 | Professional services are available to help developers and managers with 47 | the development and management of event-sourced applications and systems. 48 | 49 | - Training and coaching developers. 50 | - Development of prototype or sample applications. 51 | - Development of applications and systems for production use. 52 | - Overall assessment of your existing implementation, with recommendations for improvement. 53 | - Address specific concerns with how your event-sourced application or system is built and run. 54 | 55 | Please contact John Bywater via the Slack_ channel for more information about professional 56 | support. 57 | 58 | -------------------------------------------------------------------------------- /examples/aggregate5/baseclasses.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import datetime, timezone 5 | from typing import TYPE_CHECKING, Any, TypeVar 6 | 7 | from eventsourcing.dispatch import singledispatchmethod 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Iterable 11 | from uuid import UUID 12 | 13 | from typing_extensions import Self 14 | 15 | 16 | @dataclass(frozen=True) 17 | class DomainEvent: 18 | originator_id: UUID 19 | originator_version: int 20 | timestamp: datetime 21 | 22 | @staticmethod 23 | def create_timestamp() -> datetime: 24 | return datetime.now(tz=timezone.utc) 25 | 26 | 27 | TAggregate = TypeVar("TAggregate", bound="Aggregate") 28 | 29 | 30 | @dataclass(frozen=True) 31 | class Aggregate: 32 | id: UUID 33 | version: int 34 | created_on: datetime 35 | modified_on: datetime 36 | 37 | def trigger_event( 38 | self, 39 | event_class: type[DomainEvent], 40 | **kwargs: Any, 41 | ) -> DomainEvent: 42 | kwargs = kwargs.copy() 43 | kwargs.update( 44 | originator_id=self.id, 45 | originator_version=self.version + 1, 46 | timestamp=event_class.create_timestamp(), 47 | ) 48 | return event_class(**kwargs) 49 | 50 | @classmethod 51 | def projector( 52 | cls, 53 | aggregate: Self | None, 54 | events: Iterable[DomainEvent], 55 | ) -> Self | None: 56 | for event in events: 57 | aggregate = cls.mutate(event, aggregate) 58 | return aggregate 59 | 60 | @singledispatchmethod[Any] 61 | @staticmethod 62 | def mutate(event: DomainEvent, aggregate: TAggregate | None) -> TAggregate | None: 63 | """Mutates aggregate with event.""" 64 | 65 | @dataclass(frozen=True) 66 | class Snapshot(DomainEvent): 67 | state: dict[str, Any] 68 | 69 | @classmethod 70 | def take(cls, aggregate: Aggregate) -> Aggregate.Snapshot: 71 | return Aggregate.Snapshot( 72 | originator_id=aggregate.id, 73 | originator_version=aggregate.version, 74 | timestamp=DomainEvent.create_timestamp(), 75 | state=aggregate.__dict__, 76 | ) 77 | -------------------------------------------------------------------------------- /examples/dcb_enrolment/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ClassVar 4 | 5 | from eventsourcing.application import AggregateNotFoundError, Application 6 | from eventsourcing.utils import get_topic 7 | from examples.aggregate9.msgpack import MessagePackMapper 8 | from examples.dcb_enrolment.domainmodel import Course, Student 9 | from examples.dcb_enrolment.interface import ( 10 | CourseID, 11 | CourseNotFoundError, 12 | EnrolmentInterface, 13 | StudentID, 14 | StudentNotFoundError, 15 | ) 16 | 17 | 18 | class EnrolmentWithAggregates(Application[str], EnrolmentInterface): 19 | env: ClassVar[dict[str, str]] = { 20 | "MAPPER_TOPIC": get_topic(MessagePackMapper), 21 | "ORIGINATOR_ID_TYPE": "text", 22 | } 23 | 24 | def register_student(self, name: str, max_courses: int) -> StudentID: 25 | student = Student(name, max_courses=max_courses) 26 | self.save(student) 27 | return student.id 28 | 29 | def register_course(self, name: str, places: int) -> CourseID: 30 | course = Course(name, places=places) 31 | self.save(course) 32 | return course.id 33 | 34 | def join_course(self, student_id: StudentID, course_id: CourseID) -> None: 35 | course = self.get_course(course_id) 36 | student = self.get_student(student_id) 37 | course.accept_student(student_id) 38 | student.join_course(course_id) 39 | self.save(course, student) 40 | 41 | def list_students_for_course(self, course_id: CourseID) -> list[str]: 42 | course = self.get_course(course_id) 43 | return [self.get_student(s).name for s in course.student_ids] 44 | 45 | def list_courses_for_student(self, student_id: StudentID) -> list[str]: 46 | student = self.get_student(student_id) 47 | return [self.get_course(s).name for s in student.course_ids] 48 | 49 | def get_student(self, student_id: StudentID) -> Student: 50 | try: 51 | return self.repository.get(student_id) 52 | except AggregateNotFoundError: 53 | raise StudentNotFoundError from None 54 | 55 | def get_course(self, course_id: CourseID) -> Course: 56 | try: 57 | return self.repository.get(course_id) 58 | except AggregateNotFoundError: 59 | raise CourseNotFoundError from None 60 | -------------------------------------------------------------------------------- /tests/persistence_tests/test_noninterleaving_notification_ids.py: -------------------------------------------------------------------------------- 1 | from eventsourcing.persistence import ApplicationRecorder 2 | from eventsourcing.popo import POPOApplicationRecorder 3 | from eventsourcing.postgres import PostgresApplicationRecorder, PostgresDatastore 4 | from eventsourcing.sqlite import SQLiteApplicationRecorder, SQLiteDatastore 5 | from eventsourcing.tests.persistence import ( 6 | NonInterleavingNotificationIDsBaseCase, 7 | tmpfile_uris, 8 | ) 9 | from eventsourcing.tests.postgres_utils import drop_tables 10 | 11 | 12 | class TestNonInterleavingPOPO(NonInterleavingNotificationIDsBaseCase): 13 | insert_num = 10000 14 | 15 | def create_recorder(self) -> ApplicationRecorder: 16 | return POPOApplicationRecorder() 17 | 18 | 19 | class TestNonInterleavingSQLiteInMemory(NonInterleavingNotificationIDsBaseCase): 20 | insert_num = 10000 21 | 22 | def create_recorder(self) -> ApplicationRecorder: 23 | recorder = SQLiteApplicationRecorder( 24 | SQLiteDatastore(db_name="file::memory:?cache=shared") 25 | ) 26 | recorder.create_table() 27 | return recorder 28 | 29 | 30 | class TestNonInterleavingSQLiteFileDB(NonInterleavingNotificationIDsBaseCase): 31 | insert_num = 10000 32 | 33 | def create_recorder(self) -> ApplicationRecorder: 34 | self.uris = tmpfile_uris() 35 | self.db_uri = next(self.uris) 36 | 37 | recorder = SQLiteApplicationRecorder(SQLiteDatastore(db_name=self.db_uri)) 38 | recorder.create_table() 39 | return recorder 40 | 41 | 42 | class TestNonInterleavingPostgres(NonInterleavingNotificationIDsBaseCase): 43 | insert_num = 100 44 | 45 | def setUp(self) -> None: 46 | drop_tables() 47 | self.datastore = PostgresDatastore( 48 | "eventsourcing", 49 | "127.0.0.1", 50 | "5432", 51 | "eventsourcing", 52 | "eventsourcing", 53 | ) 54 | 55 | def tearDown(self) -> None: 56 | self.datastore.close() 57 | drop_tables() 58 | 59 | def create_recorder(self) -> ApplicationRecorder: 60 | self.uris = tmpfile_uris() 61 | self.db_uri = next(self.uris) 62 | recorder = PostgresApplicationRecorder(self.datastore) 63 | recorder.create_table() 64 | return recorder 65 | 66 | 67 | del NonInterleavingNotificationIDsBaseCase 68 | -------------------------------------------------------------------------------- /examples/aggregate7/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import singledispatch 4 | from uuid import uuid4 5 | 6 | from eventsourcing.domain import datetime_now_with_tzinfo 7 | from examples.aggregate7.immutablemodel import ( 8 | Aggregate, 9 | DomainEvent, 10 | Immutable, 11 | Snapshot, 12 | aggregate_projector, 13 | ) 14 | 15 | 16 | class Trick(Immutable): 17 | name: str 18 | 19 | 20 | class Dog(Aggregate): 21 | name: str 22 | tricks: tuple[Trick, ...] 23 | 24 | 25 | class DogRegistered(DomainEvent): 26 | name: str 27 | 28 | 29 | class TrickAdded(DomainEvent): 30 | trick: Trick 31 | 32 | 33 | def register_dog(name: str) -> DomainEvent: 34 | return DogRegistered( 35 | originator_id=uuid4(), 36 | originator_version=1, 37 | timestamp=datetime_now_with_tzinfo(), 38 | name=name, 39 | ) 40 | 41 | 42 | def add_trick(dog: Dog, trick: Trick) -> DomainEvent: 43 | return TrickAdded( 44 | originator_id=dog.id, 45 | originator_version=dog.version + 1, 46 | timestamp=datetime_now_with_tzinfo(), 47 | trick=trick, 48 | ) 49 | 50 | 51 | @singledispatch 52 | def mutate_dog(_: DomainEvent, __: Dog | None) -> Dog | None: 53 | """Mutates aggregate with event.""" 54 | 55 | 56 | @mutate_dog.register 57 | def _(event: DogRegistered, _: None) -> Dog: 58 | return Dog( 59 | id=event.originator_id, 60 | version=event.originator_version, 61 | created_on=event.timestamp, 62 | modified_on=event.timestamp, 63 | name=event.name, 64 | tricks=(), 65 | ) 66 | 67 | 68 | @mutate_dog.register 69 | def _(event: TrickAdded, dog: Dog) -> Dog: 70 | return Dog( 71 | id=dog.id, 72 | version=event.originator_version, 73 | created_on=dog.created_on, 74 | modified_on=event.timestamp, 75 | name=dog.name, 76 | tricks=(*dog.tricks, event.trick), 77 | ) 78 | 79 | 80 | @mutate_dog.register 81 | def _(event: Snapshot, _: None) -> Dog: 82 | return Dog( 83 | id=event.state["id"], 84 | version=event.state["version"], 85 | created_on=event.state["created_on"], 86 | modified_on=event.state["modified_on"], 87 | name=event.state["name"], 88 | tricks=event.state["tricks"], 89 | ) 90 | 91 | 92 | project_dog = aggregate_projector(mutate_dog) 93 | -------------------------------------------------------------------------------- /tests/application_tests/test_snapshotting.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from typing import cast 3 | from unittest import TestCase 4 | from uuid import UUID 5 | 6 | from eventsourcing.domain import Snapshot 7 | from eventsourcing.persistence import ( 8 | DatetimeAsISO, 9 | DecimalAsStr, 10 | EventStore, 11 | JSONTranscoder, 12 | Mapper, 13 | UUIDAsHex, 14 | ) 15 | from eventsourcing.sqlite import SQLiteAggregateRecorder, SQLiteDatastore 16 | from eventsourcing.tests.application import EmailAddressAsStr 17 | from eventsourcing.tests.domain import BankAccount 18 | 19 | 20 | class TestSnapshotting(TestCase): 21 | def test(self) -> None: 22 | # Open an account. 23 | account = BankAccount.open( 24 | full_name="Alice", 25 | email_address="alice@example.com", 26 | ) 27 | 28 | # Credit the account. 29 | account.append_transaction(Decimal("10.00")) 30 | account.append_transaction(Decimal("25.00")) 31 | account.append_transaction(Decimal("30.00")) 32 | 33 | transcoder = JSONTranscoder() 34 | transcoder.register(UUIDAsHex()) 35 | transcoder.register(DecimalAsStr()) 36 | transcoder.register(DatetimeAsISO()) 37 | transcoder.register(EmailAddressAsStr()) 38 | 39 | recorder = SQLiteAggregateRecorder( 40 | SQLiteDatastore(":memory:"), 41 | events_table_name="snapshots", 42 | ) 43 | snapshot_store = EventStore[UUID]( 44 | mapper=Mapper(transcoder=transcoder), 45 | recorder=recorder, 46 | ) 47 | recorder.create_table() 48 | 49 | # Clear pending events. 50 | account.collect_events() 51 | 52 | # Take a snapshot. 53 | snapshot = Snapshot.take(account) 54 | 55 | self.assertNotIn("pending_events", snapshot.state) 56 | 57 | # Store snapshot. 58 | snapshot_store.put([snapshot]) 59 | 60 | # Get snapshot. 61 | snapshots = snapshot_store.get(account.id, desc=True, limit=1) 62 | snapshot = cast("Snapshot", next(snapshots)) 63 | assert isinstance(snapshot, Snapshot) 64 | 65 | # Reconstruct the bank account. 66 | copy = snapshot.mutate(None) 67 | assert isinstance(copy, BankAccount) 68 | 69 | # Check copy has correct attribute values. 70 | assert copy.id == account.id 71 | assert copy.balance == Decimal("65.00") 72 | -------------------------------------------------------------------------------- /examples/aggregate6/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from functools import singledispatch 5 | from uuid import uuid4 6 | 7 | from eventsourcing.domain import datetime_now_with_tzinfo 8 | from examples.aggregate6.baseclasses import ( 9 | Aggregate, 10 | DomainEvent, 11 | Snapshot, 12 | aggregate_projector, 13 | ) 14 | 15 | 16 | @dataclass(frozen=True) 17 | class Dog(Aggregate): 18 | name: str 19 | tricks: tuple[str, ...] 20 | 21 | 22 | @dataclass(frozen=True) 23 | class DogRegistered(DomainEvent): 24 | name: str 25 | 26 | 27 | @dataclass(frozen=True) 28 | class TrickAdded(DomainEvent): 29 | trick: str 30 | 31 | 32 | def register_dog(name: str) -> DomainEvent: 33 | return DogRegistered( 34 | originator_id=uuid4(), 35 | originator_version=1, 36 | timestamp=datetime_now_with_tzinfo(), 37 | name=name, 38 | ) 39 | 40 | 41 | def add_trick(dog: Dog, trick: str) -> DomainEvent: 42 | return TrickAdded( 43 | originator_id=dog.id, 44 | originator_version=dog.version + 1, 45 | timestamp=datetime_now_with_tzinfo(), 46 | trick=trick, 47 | ) 48 | 49 | 50 | @singledispatch 51 | def mutate_dog(_: DomainEvent, __: Dog | None) -> Dog | None: 52 | """Mutates aggregate with event.""" 53 | 54 | 55 | @mutate_dog.register 56 | def _(event: DogRegistered, _: None) -> Dog: 57 | return Dog( 58 | id=event.originator_id, 59 | version=event.originator_version, 60 | created_on=event.timestamp, 61 | modified_on=event.timestamp, 62 | name=event.name, 63 | tricks=(), 64 | ) 65 | 66 | 67 | @mutate_dog.register 68 | def _(event: TrickAdded, dog: Dog) -> Dog: 69 | return Dog( 70 | id=dog.id, 71 | version=event.originator_version, 72 | created_on=dog.created_on, 73 | modified_on=event.timestamp, 74 | name=dog.name, 75 | tricks=(*dog.tricks, event.trick), 76 | ) 77 | 78 | 79 | @mutate_dog.register 80 | def _(event: Snapshot, _: None) -> Dog: 81 | return Dog( 82 | id=event.state["id"], 83 | version=event.state["version"], 84 | created_on=event.state["created_on"], 85 | modified_on=event.state["modified_on"], 86 | name=event.state["name"], 87 | tricks=tuple(event.state["tricks"]), # comes back from JSON as a list 88 | ) 89 | 90 | 91 | project_dog = aggregate_projector(mutate_dog) 92 | -------------------------------------------------------------------------------- /examples/aggregate7/test_snapshotting_intervals.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, ClassVar, cast 4 | from unittest import TestCase 5 | 6 | from eventsourcing.application import ProgrammingError 7 | from examples.aggregate7.application import DogSchool 8 | from examples.aggregate7.domainmodel import ( 9 | Dog, 10 | Trick, 11 | add_trick, 12 | project_dog, 13 | register_dog, 14 | ) 15 | 16 | if TYPE_CHECKING: 17 | from uuid import UUID 18 | 19 | from eventsourcing.domain import MutableOrImmutableAggregate 20 | 21 | 22 | class SubDogSchool(DogSchool): 23 | snapshotting_intervals: ClassVar[ 24 | dict[type[MutableOrImmutableAggregate[UUID]], int] 25 | ] = {Dog: 1} 26 | 27 | def register_dog(self, name: str) -> UUID: 28 | event = register_dog(name) 29 | dog = project_dog(None, [event]) 30 | self.save(dog, event) 31 | return event.originator_id 32 | 33 | def add_trick(self, dog_id: UUID, trick: str) -> None: 34 | dog = self.repository.get(dog_id, projector_func=project_dog) 35 | event = add_trick(dog, Trick(name=trick)) 36 | dog = cast("Dog", project_dog(dog, [event])) 37 | self.save(dog, event) 38 | 39 | 40 | class TestDogSchool(TestCase): 41 | def test_dog_school(self) -> None: 42 | # Construct application object. 43 | school = SubDogSchool() 44 | 45 | # Check error when snapshotting_projectors not set. 46 | with self.assertRaises(ProgrammingError) as cm: 47 | school.register_dog("Fido") 48 | 49 | self.assertIn("Cannot take snapshot", cm.exception.args[0]) 50 | 51 | # Set snapshotting_projectors. 52 | SubDogSchool.snapshotting_projectors = {Dog: project_dog} 53 | 54 | # Check snapshotting when snapshotting_projectors is set. 55 | dog_id = school.register_dog("Fido") 56 | 57 | assert school.snapshots is not None 58 | self.assertEqual(1, len(list(school.snapshots.get(dog_id)))) 59 | 60 | school.add_trick(dog_id, "roll over") 61 | self.assertEqual(2, len(list(school.snapshots.get(dog_id)))) 62 | 63 | school.add_trick(dog_id, "play dead") 64 | self.assertEqual(3, len(list(school.snapshots.get(dog_id)))) 65 | 66 | # Query application state. 67 | dog = school.get_dog(dog_id) 68 | self.assertEqual(dog["name"], "Fido") 69 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 70 | -------------------------------------------------------------------------------- /examples/aggregate9/test_snapshotting_intervals.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, ClassVar, cast 4 | from unittest import TestCase 5 | 6 | from eventsourcing.application import ProgrammingError 7 | from examples.aggregate9.application import DogSchool 8 | from examples.aggregate9.domainmodel import ( 9 | Dog, 10 | Trick, 11 | add_trick, 12 | project_dog, 13 | register_dog, 14 | ) 15 | 16 | if TYPE_CHECKING: 17 | from uuid import UUID 18 | 19 | from eventsourcing.domain import MutableOrImmutableAggregate 20 | 21 | 22 | class SubDogSchool(DogSchool): 23 | snapshotting_intervals: ClassVar[ 24 | dict[type[MutableOrImmutableAggregate[UUID]], int] 25 | ] = {Dog: 1} 26 | 27 | def register_dog(self, name: str) -> UUID: 28 | event = register_dog(name) 29 | dog = project_dog(None, [event]) 30 | self.save(dog, event) 31 | return event.originator_id 32 | 33 | def add_trick(self, dog_id: UUID, trick: str) -> None: 34 | dog = self.repository.get(dog_id, projector_func=project_dog) 35 | event = add_trick(dog, Trick(name=trick)) 36 | dog = cast("Dog", project_dog(dog, [event])) 37 | self.save(dog, event) 38 | 39 | 40 | class TestDogSchool(TestCase): 41 | def test_dog_school(self) -> None: 42 | # Construct application object. 43 | school = SubDogSchool() 44 | 45 | # Check error when snapshotting_projectors not set. 46 | with self.assertRaises(ProgrammingError) as cm: 47 | school.register_dog("Fido") 48 | 49 | self.assertIn("Cannot take snapshot", cm.exception.args[0]) 50 | 51 | # Set snapshotting_projectors. 52 | SubDogSchool.snapshotting_projectors = {Dog: project_dog} 53 | 54 | # Check snapshotting when snapshotting_projectors is set. 55 | dog_id = school.register_dog("Fido") 56 | 57 | assert school.snapshots is not None 58 | self.assertEqual(1, len(list(school.snapshots.get(dog_id)))) 59 | 60 | school.add_trick(dog_id, "roll over") 61 | self.assertEqual(2, len(list(school.snapshots.get(dog_id)))) 62 | 63 | school.add_trick(dog_id, "play dead") 64 | self.assertEqual(3, len(list(school.snapshots.get(dog_id)))) 65 | 66 | # Query application state. 67 | dog = school.get_dog(dog_id) 68 | self.assertEqual(dog["name"], "Fido") 69 | self.assertEqual(dog["tricks"], ("roll over", "play dead")) 70 | -------------------------------------------------------------------------------- /examples/bankaccounts/application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from uuid import UUID 5 | 6 | from eventsourcing.application import AggregateNotFoundError, Application 7 | from examples.bankaccounts.domainmodel import BankAccount 8 | 9 | if TYPE_CHECKING: 10 | from decimal import Decimal 11 | 12 | 13 | class BankAccounts(Application[UUID]): 14 | def open_account(self, full_name: str, email_address: str) -> UUID: 15 | account = BankAccount( 16 | full_name=full_name, 17 | email_address=email_address, 18 | ) 19 | self.save(account) 20 | return account.id 21 | 22 | def get_account(self, account_id: UUID) -> BankAccount: 23 | try: 24 | return self.repository.get(account_id) 25 | except AggregateNotFoundError: 26 | raise AccountNotFoundError(account_id) from None 27 | 28 | def get_balance(self, account_id: UUID) -> Decimal: 29 | account = self.get_account(account_id) 30 | return account.balance 31 | 32 | def deposit_funds(self, credit_account_id: UUID, amount: Decimal) -> None: 33 | account = self.get_account(credit_account_id) 34 | account.credit(amount) 35 | self.save(account) 36 | 37 | def withdraw_funds(self, debit_account_id: UUID, amount: Decimal) -> None: 38 | account = self.get_account(debit_account_id) 39 | account.debit(amount) 40 | self.save(account) 41 | 42 | def transfer_funds( 43 | self, 44 | debit_account_id: UUID, 45 | credit_account_id: UUID, 46 | amount: Decimal, 47 | ) -> None: 48 | debit_account = self.get_account(debit_account_id) 49 | credit_account = self.get_account(credit_account_id) 50 | debit_account.debit(amount) 51 | credit_account.credit(amount) 52 | self.save(debit_account, credit_account) 53 | 54 | def set_overdraft_limit(self, account_id: UUID, overdraft_limit: Decimal) -> None: 55 | account = self.get_account(account_id) 56 | account.set_overdraft_limit(overdraft_limit) 57 | self.save(account) 58 | 59 | def get_overdraft_limit(self, account_id: UUID) -> Decimal: 60 | account = self.get_account(account_id) 61 | return account.overdraft_limit 62 | 63 | def close_account(self, account_id: UUID) -> None: 64 | account = self.get_account(account_id) 65 | account.close() 66 | self.save(account) 67 | 68 | 69 | class AccountNotFoundError(Exception): 70 | pass 71 | -------------------------------------------------------------------------------- /examples/contentmanagement/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextvars import ContextVar 4 | from dataclasses import dataclass, field 5 | from typing import cast 6 | from uuid import NAMESPACE_URL, UUID, uuid5 7 | 8 | from eventsourcing.domain import Aggregate, DomainEvent, event 9 | from examples.contentmanagement.utils import apply_diff, create_diff 10 | 11 | user_id_cvar: ContextVar[UUID | None] = ContextVar("user_id", default=None) 12 | """ 13 | Context variable holding a user ID for the current thread. 14 | """ 15 | 16 | 17 | @dataclass 18 | class Page(Aggregate): 19 | title: str 20 | """The title of the page.""" 21 | 22 | slug: str 23 | """The slug of the page - used in URLs.""" 24 | 25 | body: str 26 | """The proper content of the page.""" 27 | 28 | modified_by: UUID | None = field(init=False) 29 | """The ID of the user who last modified the page.""" 30 | 31 | class Event(Aggregate.Event): 32 | user_id: UUID | None = field(default_factory=user_id_cvar.get, init=False) 33 | 34 | def apply(self, aggregate: Aggregate) -> None: 35 | """Sets the aggregate's `modified_by` attribute to the 36 | value of the event's `user_id` attribute. 37 | """ 38 | cast("Page", aggregate).modified_by = self.user_id 39 | 40 | @event("SlugUpdated") 41 | def update_slug(self, slug: str) -> None: 42 | self.slug = slug 43 | 44 | @event("TitleUpdated") 45 | def update_title(self, title: str) -> None: 46 | self.title = title 47 | 48 | def update_body(self, body: str) -> None: 49 | diff = create_diff(old=self.body, new=body) 50 | self._update_body(diff=diff) 51 | 52 | class Created(Aggregate.Created, Event): 53 | title: str 54 | slug: str 55 | body: str 56 | 57 | class BodyUpdated(Event): 58 | diff: str 59 | 60 | @event(BodyUpdated) 61 | def _update_body(self, diff: str) -> None: 62 | new_body = apply_diff(old=self.body, diff=diff) 63 | self.body = new_body 64 | 65 | 66 | @dataclass 67 | class Slug(Aggregate): 68 | name: str 69 | page_id: UUID | None 70 | 71 | class Event(Aggregate.Event): 72 | pass 73 | 74 | @staticmethod 75 | def create_id(name: str) -> UUID: 76 | return uuid5(NAMESPACE_URL, f"/slugs/{name}") 77 | 78 | @event("PageUpdated") 79 | def update_page(self, page_id: UUID | None) -> None: 80 | self.page_id = page_id 81 | 82 | 83 | class PageLogged(DomainEvent): 84 | page_id: UUID 85 | -------------------------------------------------------------------------------- /tests/benchmark/benchmark_domain.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | from eventsourcing.domain import Aggregate, event 9 | 10 | if TYPE_CHECKING: 11 | from pytest_benchmark.fixture import BenchmarkFixture 12 | 13 | 14 | @pytest.mark.benchmark(group="construct-aggregate-base-class") 15 | def test_construct_aggregate_base_class(benchmark: BenchmarkFixture) -> None: 16 | benchmark(Aggregate) 17 | 18 | 19 | @pytest.mark.benchmark(group="define-aggregate-subclass") 20 | def test_define_aggregate(benchmark: BenchmarkFixture) -> None: 21 | def define_aggregate() -> None: 22 | class A(Aggregate): 23 | a: int 24 | b: int 25 | 26 | @event("Continued") 27 | def subsequent(self, a: int, b: int) -> None: 28 | self.a = a 29 | self.b = b 30 | 31 | benchmark(define_aggregate) 32 | 33 | 34 | @pytest.mark.benchmark(group="construct-aggregate-subclass") 35 | def test_construct_aggregate_subclass(benchmark: BenchmarkFixture) -> None: 36 | @dataclass 37 | class A(Aggregate): 38 | a: int 39 | b: int 40 | 41 | @event("Commanded") 42 | def command(self, a: int, b: int) -> None: 43 | self.a = a 44 | self.b = b 45 | 46 | def construct_custom() -> None: 47 | A(a=1, b=2) 48 | 49 | benchmark(construct_custom) 50 | 51 | 52 | @pytest.mark.benchmark(group="trigger-aggregate-event") 53 | def test_trigger_aggregate_event(benchmark: BenchmarkFixture) -> None: 54 | @dataclass 55 | class A(Aggregate): 56 | a: int 57 | b: int 58 | 59 | class Commanded(Aggregate.Event): 60 | a: int 61 | b: int 62 | 63 | def apply(self, aggregate: A) -> None: 64 | aggregate.a = self.a 65 | aggregate.b = self.b 66 | 67 | a = A(a=1, b=2) 68 | 69 | def func() -> None: 70 | a.trigger_event(A.Commanded, a=3, b=4) 71 | 72 | benchmark(func) 73 | 74 | 75 | @pytest.mark.benchmark(group="call-decorated-command-method") 76 | def test_call_decorated_command_method(benchmark: BenchmarkFixture) -> None: 77 | @dataclass 78 | class A(Aggregate): 79 | a: int 80 | b: int 81 | 82 | @event("Commanded") 83 | def command(self, a: int, b: int) -> None: 84 | self.a = a 85 | self.b = b 86 | 87 | a = A(a=1, b=2) 88 | 89 | def func() -> None: 90 | a.command(a=3, b=4) 91 | 92 | benchmark(func) 93 | -------------------------------------------------------------------------------- /docs/topics/examples/aggregate1.rst: -------------------------------------------------------------------------------- 1 | .. _Aggregate example 1: 2 | 3 | Aggregate 1 - Declarative syntax 4 | ================================ 5 | 6 | This example shows an aggregate that uses the library's declarative syntax for aggregates, as described 7 | in the :doc:`tutorial ` and :doc:`module docs `. 8 | 9 | Domain model 10 | ------------ 11 | 12 | The :class:`~examples.aggregate1.domainmodel.Dog` class in this example uses the library's 13 | :ref:`aggregate base class ` and the :ref:`event decorator ` 14 | to define aggregate event classes from command method signatures. The event class names 15 | are given as the argument to the event decorator. The event attributes are defined automatically 16 | by the decorator to match the command method arguments. The bodies of the command methods are used 17 | to evolve the state of an aggregate instance, both when a new event is triggered and when an aggregate 18 | is reconstructed from stored events. 19 | 20 | .. literalinclude:: ../../../examples/aggregate1/domainmodel.py 21 | :pyobject: Dog 22 | 23 | 24 | Application 25 | ----------- 26 | 27 | The :class:`~examples.aggregate1.application.DogSchool` application class in this example uses the 28 | library's :ref:`application base class `. It fully encapsulates the 29 | :class:`~examples.aggregate1.aggregate.Dog` aggregate, defining command and query methods 30 | that use the event-sourced aggregate class as if it were a normal Python object class. 31 | 32 | .. literalinclude:: ../../../examples/aggregate1/application.py 33 | :pyobject: DogSchool 34 | 35 | 36 | Test case 37 | --------- 38 | 39 | The :class:`~examples.aggregate1.test_application.TestDogSchool` test case shows how the 40 | :class:`~examples.aggregate1.application.DogSchool` application can be used. 41 | 42 | .. literalinclude:: ../../../examples/aggregate1/test_application.py 43 | :pyobject: TestDogSchool 44 | 45 | 46 | Code reference 47 | -------------- 48 | 49 | .. automodule:: examples.aggregate1.domainmodel 50 | :show-inheritance: 51 | :member-order: bysource 52 | :members: 53 | :undoc-members: 54 | :special-members: __init__ 55 | 56 | .. automodule:: examples.aggregate1.application 57 | :show-inheritance: 58 | :member-order: bysource 59 | :members: 60 | :undoc-members: 61 | :special-members: __init__ 62 | 63 | .. automodule:: examples.aggregate1.test_application 64 | :show-inheritance: 65 | :member-order: bysource 66 | :members: 67 | :undoc-members: 68 | :special-members: __init__ 69 | 70 | -------------------------------------------------------------------------------- /examples/dcb_enrolment/test_application.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from eventsourcing.persistence import IntegrityError 4 | from eventsourcing.tests.postgres_utils import drop_tables 5 | from examples.dcb_enrolment.application import EnrolmentWithAggregates 6 | from examples.dcb_enrolment.test_enrolment import EnrolmentTestCase 7 | 8 | 9 | class TestEnrolmentWithAggregates(EnrolmentTestCase): 10 | def test_enrolment_in_memory(self) -> None: 11 | self.assert_implementation(EnrolmentWithAggregates()) 12 | 13 | def test_enrolment_with_postgres(self) -> None: 14 | env = { 15 | "PERSISTENCE_MODULE": "eventsourcing.postgres", 16 | "POSTGRES_DBNAME": "eventsourcing", 17 | "POSTGRES_HOST": "127.0.0.1", 18 | "POSTGRES_PORT": "5432", 19 | "POSTGRES_USER": "eventsourcing", 20 | "POSTGRES_PASSWORD": "eventsourcing", 21 | } 22 | try: 23 | app = EnrolmentWithAggregates(env) 24 | self.assert_implementation(app) 25 | finally: 26 | drop_tables() 27 | 28 | def test_consistency_boundary(self) -> None: 29 | app = EnrolmentWithAggregates() 30 | 31 | # Register courses. 32 | french = app.register_course("French", places=5) 33 | 34 | # Register students. 35 | sara = app.register_student("Sara", max_courses=3) 36 | bastian = app.register_student("Bastian", max_courses=3) 37 | 38 | # Try to break recorded consistency with concurrent operation. 39 | assert isinstance(app, EnrolmentWithAggregates) 40 | student = app.get_student(sara) 41 | course = app.get_course(french) 42 | student.join_course(course.id) 43 | course.accept_student(student.id) 44 | 45 | # During this operation, Bastian joins French. 46 | app.join_course(bastian, french) 47 | 48 | # Can't proceed with concurrent operation because course changed. 49 | with self.assertRaises(IntegrityError): 50 | app.save(student, course) 51 | 52 | # Check Sara doesn't have French, and French doesn't have Sara. 53 | self.assertNotIn("Sara", app.list_students_for_course(french)) 54 | self.assertNotIn("French", app.list_courses_for_student(sara)) 55 | 56 | 57 | # test_cases = (TestEnrolmentWithAggregates, TestEnrolmentConsistency) 58 | # 59 | # 60 | # def load_tests(loader: TestLoader, _: TestSuite, __: str | None) -> TestSuite: 61 | # suite = TestSuite() 62 | # for test_class in test_cases: 63 | # tests = loader.loadTestsFromTestCase(test_class) 64 | # suite.addTests(tests) 65 | # return suite 66 | -------------------------------------------------------------------------------- /examples/dcb_enrolment/domainmodel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime # noqa: TC003 4 | from typing import Any 5 | from uuid import uuid4 6 | 7 | import msgspec 8 | from typing_extensions import TypeVar 9 | 10 | from eventsourcing.domain import ( 11 | BaseAggregate, 12 | CanInitAggregate, 13 | CanMutateAggregate, 14 | event, 15 | ) 16 | from examples.dcb_enrolment.interface import ( 17 | AlreadyJoinedError, 18 | CourseID, 19 | FullyBookedError, 20 | StudentID, 21 | TooManyCoursesError, 22 | ) 23 | 24 | 25 | class DomainEvent(msgspec.Struct, frozen=True): 26 | originator_id: str 27 | originator_version: int 28 | timestamp: datetime 29 | 30 | 31 | class MsgspecStringIDEvent(DomainEvent, CanMutateAggregate[str], frozen=True): 32 | def _as_dict(self) -> dict[str, Any]: 33 | return {key: getattr(self, key) for key in self.__struct_fields__} 34 | 35 | 36 | class MsgspecStringIDCreatedEvent(DomainEvent, CanInitAggregate[str], frozen=True): 37 | originator_topic: str 38 | 39 | 40 | TID = TypeVar("TID", bound=str, default=str) 41 | 42 | 43 | class MsgspecStringIDAggregate(BaseAggregate[TID]): 44 | class Event(MsgspecStringIDEvent, frozen=True): 45 | pass 46 | 47 | class Created(Event, MsgspecStringIDCreatedEvent, frozen=True): 48 | pass 49 | 50 | 51 | class Aggregate(MsgspecStringIDAggregate[TID]): 52 | pass 53 | 54 | 55 | class Student(Aggregate[StudentID]): 56 | @staticmethod 57 | def create_id() -> StudentID: 58 | return StudentID("student-" + str(uuid4())) 59 | 60 | def __init__(self, name: str, max_courses: int) -> None: 61 | self.name = name 62 | self.max_courses = max_courses 63 | self.course_ids: list[CourseID] = [] 64 | 65 | @event("CourseJoined") 66 | def join_course(self, course_id: CourseID) -> None: 67 | if len(self.course_ids) >= self.max_courses: 68 | raise TooManyCoursesError 69 | self.course_ids.append(course_id) 70 | 71 | 72 | class Course(Aggregate[CourseID]): 73 | @staticmethod 74 | def create_id() -> CourseID: 75 | return CourseID("course-" + str(uuid4())) 76 | 77 | def __init__(self, name: str, places: int) -> None: 78 | self.name = name 79 | self.places = places 80 | self.student_ids: list[StudentID] = [] 81 | 82 | @event("StudentAccepted") 83 | def accept_student(self, student_id: StudentID) -> None: 84 | if len(self.student_ids) >= self.places: 85 | raise FullyBookedError 86 | if student_id in self.student_ids: 87 | raise AlreadyJoinedError 88 | self.student_ids.append(student_id) 89 | -------------------------------------------------------------------------------- /docs/topics/examples/aggregate8.rst: -------------------------------------------------------------------------------- 1 | .. _Aggregate example 8: 2 | 3 | Aggregate 8 - Pydantic with declarative syntax 4 | ============================================== 5 | 6 | This example shows how to use Pydantic with the library's declarative syntax. 7 | 8 | Similar to :doc:`example 1 `, aggregates are expressed 9 | using the library's declarative syntax. This is the most concise way of defining an 10 | event-sourced aggregate. 11 | 12 | Similar to :doc:`example 7 `, domain event and custom value objects 13 | are defined using Pydantic. The main advantage of using Pydantic here is that any custom value objects 14 | used in the domain model will be automatically serialised and deserialised, without needing also to 15 | define custom :ref:`transcoding` classes. 16 | 17 | 18 | Pydantic model for mutable aggregate 19 | ------------------------------------ 20 | 21 | The code below shows how to define base classes for mutable aggregates that use Pydantic. 22 | 23 | .. literalinclude:: ../../../examples/aggregate8/mutablemodel.py 24 | 25 | 26 | Domain model 27 | ------------ 28 | 29 | The code below shows how to define a mutable aggregate with the library's declarative syntax, using the Pydantic module for mutable aggregates 30 | 31 | .. literalinclude:: ../../../examples/aggregate8/domainmodel.py 32 | 33 | 34 | Application 35 | ----------- 36 | 37 | The :class:`~examples.aggregate8.application.DogSchool` application in this example uses the 38 | :class:`~examples.aggregate7.orjsonpydantic.PydanticApplication` class 39 | from :doc:`example 7 `. 40 | 41 | .. literalinclude:: ../../../examples/aggregate8/application.py 42 | 43 | 44 | Test case 45 | --------- 46 | 47 | The :class:`~examples.aggregate8.test_application.TestDogSchool` test case shows how the 48 | :class:`~examples.aggregate8.application.DogSchool` application can be used. 49 | 50 | .. literalinclude:: ../../../examples/aggregate8/test_application.py 51 | 52 | 53 | Code reference 54 | -------------- 55 | 56 | .. automodule:: examples.aggregate8.mutablemodel 57 | :show-inheritance: 58 | :member-order: bysource 59 | :members: 60 | :undoc-members: 61 | 62 | .. automodule:: examples.aggregate8.domainmodel 63 | :show-inheritance: 64 | :member-order: bysource 65 | :members: 66 | :undoc-members: 67 | 68 | .. automodule:: examples.aggregate8.application 69 | :show-inheritance: 70 | :member-order: bysource 71 | :members: 72 | :undoc-members: 73 | 74 | .. automodule:: examples.aggregate8.test_application 75 | :show-inheritance: 76 | :member-order: bysource 77 | :members: 78 | :undoc-members: 79 | 80 | -------------------------------------------------------------------------------- /docs/topics/examples/aggregate2.rst: -------------------------------------------------------------------------------- 1 | .. _Aggregate example 2: 2 | 3 | Aggregate 2 - Explicit event classes 4 | ==================================== 5 | 6 | This example shows an aggregate that uses the library's :ref:`explicit syntax ` 7 | for defining aggregate events, as described in the :doc:`tutorial ` and :doc:`module docs `. The 8 | difference between this example and :doc:`example 1 ` is that the aggregate 9 | event classes are defined explicitly. 10 | 11 | 12 | Domain model 13 | ------------ 14 | 15 | The :class:`~examples.aggregate2.domainmodel.Dog` class in this example uses the library's 16 | :ref:`aggregate base class ` and the :ref:`event decorator `. 17 | Event classes that match command method signatures are defined explicitly. Events are triggered by the decorator 18 | when the command methods are called, and the bodies of the command methods are used by the events to mutate 19 | the state of the aggregate, both after command methods are called and when reconstructing the state of an 20 | aggregate from stored events. 21 | 22 | .. literalinclude:: ../../../examples/aggregate2/domainmodel.py 23 | :pyobject: Dog 24 | 25 | 26 | Application 27 | ----------- 28 | 29 | As in :doc:`example 1 `, the :class:`~examples.aggregate2.application.DogSchool` 30 | application class in this example uses the library's :ref:`application base class `. It 31 | fully encapsulates the :class:`~examples.aggregate2.domainmodel.Dog` aggregate, defining command and query methods 32 | that use the event-sourced aggregate class as if it were a normal Python object class. 33 | 34 | .. literalinclude:: ../../../examples/aggregate2/application.py 35 | :pyobject: DogSchool 36 | 37 | 38 | Test case 39 | --------- 40 | 41 | The :class:`~examples.aggregate2.test_application.TestDogSchool` test case shows how the 42 | :class:`~examples.aggregate2.application.DogSchool` application can be used. 43 | 44 | .. literalinclude:: ../../../examples/aggregate2/test_application.py 45 | :pyobject: TestDogSchool 46 | 47 | 48 | Code reference 49 | -------------- 50 | 51 | .. automodule:: examples.aggregate2.domainmodel 52 | :show-inheritance: 53 | :member-order: bysource 54 | :members: 55 | :undoc-members: 56 | 57 | .. automodule:: examples.aggregate2.application 58 | :show-inheritance: 59 | :member-order: bysource 60 | :members: 61 | :undoc-members: 62 | 63 | .. automodule:: examples.aggregate2.test_application 64 | :show-inheritance: 65 | :member-order: bysource 66 | :members: 67 | :undoc-members: 68 | 69 | -------------------------------------------------------------------------------- /tests/persistence_tests/test_eventstore.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from unittest.case import TestCase 3 | from uuid import UUID 4 | 5 | from eventsourcing.domain import CanMutateProtocol 6 | from eventsourcing.persistence import ( 7 | DatetimeAsISO, 8 | DecimalAsStr, 9 | EventStore, 10 | JSONTranscoder, 11 | Mapper, 12 | UUIDAsHex, 13 | ) 14 | from eventsourcing.sqlite import SQLiteAggregateRecorder, SQLiteDatastore 15 | from eventsourcing.tests.application import EmailAddressAsStr 16 | from eventsourcing.tests.domain import BankAccount 17 | 18 | 19 | class TestEventStore(TestCase): 20 | def test(self) -> None: 21 | # Open an account. 22 | account = BankAccount.open( 23 | full_name="Alice", 24 | email_address="alice@example.com", 25 | ) 26 | 27 | # Credit the account. 28 | account.append_transaction(Decimal("10.00")) 29 | account.append_transaction(Decimal("25.00")) 30 | account.append_transaction(Decimal("30.00")) 31 | 32 | # Collect pending events. 33 | pending = account.collect_events() 34 | 35 | # Construct event store. 36 | transcoder = JSONTranscoder() 37 | transcoder.register(UUIDAsHex()) 38 | transcoder.register(DecimalAsStr()) 39 | transcoder.register(DatetimeAsISO()) 40 | transcoder.register(EmailAddressAsStr()) 41 | recorder = SQLiteAggregateRecorder(SQLiteDatastore(":memory:")) 42 | event_store = EventStore[UUID]( 43 | mapper=Mapper(transcoder), 44 | recorder=recorder, 45 | ) 46 | recorder.create_table() 47 | 48 | # Get last event. 49 | stored_events = event_store.get(account.id, desc=True, limit=1) 50 | self.assertEqual(list(stored_events), []) 51 | 52 | # Store pending events. 53 | event_store.put(pending) 54 | 55 | # Get domain events. 56 | domain_events = event_store.get(account.id) 57 | 58 | # Reconstruct the bank account. 59 | copy = None 60 | for domain_event in domain_events: 61 | assert isinstance(domain_event, CanMutateProtocol) 62 | copy = domain_event.mutate(copy) 63 | 64 | # Check copy has correct attribute values. 65 | assert copy is not None 66 | self.assertEqual(copy.id, account.id) 67 | self.assertEqual(copy.balance, Decimal("65.00")) 68 | 69 | # Get last event. 70 | events = tuple(event_store.get(account.id, desc=True, limit=1)) 71 | self.assertEqual(len(events), 1) 72 | last_event = events[0] 73 | 74 | self.assertEqual(last_event.originator_id, account.id) 75 | assert type(last_event) is BankAccount.TransactionAppended 76 | -------------------------------------------------------------------------------- /docs/topics/examples/aggregate10.rst: -------------------------------------------------------------------------------- 1 | .. _Aggregate example 10: 2 | 3 | Aggregate 10 - msgspec with declarative syntax 4 | ============================================== 5 | 6 | This example shows how to use msgspec with the library's declarative syntax. 7 | 8 | Similar to :doc:`example 1 `, aggregates are expressed 9 | using the library's declarative syntax. This is the most concise way of defining an 10 | event-sourced aggregate. 11 | 12 | Similar to :doc:`example 9 `, domain event and custom value objects 13 | are defined using msgspec. The main advantage of using msgspec here is that any custom value objects 14 | used in the domain model will be automatically serialised and deserialised, without needing also to 15 | define custom :ref:`transcoding` classes. The advantage of msgspec structs 16 | over Pydantic v2 is performance. 17 | 18 | msgspec model for mutable aggregate 19 | ----------------------------------- 20 | 21 | The code below shows how to define base classes for mutable aggregates that use msgspec. 22 | 23 | .. literalinclude:: ../../../examples/aggregate10/mutablemodel.py 24 | 25 | 26 | Domain model 27 | ------------ 28 | 29 | The code below shows how to define a mutable aggregate with the library's declarative syntax, using the msgspec module for mutable aggregates 30 | 31 | .. literalinclude:: ../../../examples/aggregate10/domainmodel.py 32 | 33 | 34 | Application 35 | ----------- 36 | 37 | The :class:`~examples.aggregate10.application.DogSchool` application in this example uses the 38 | :class:`~examples.aggregate9.msgpack.MsgspecApplication` class 39 | from :doc:`example 9 `. 40 | 41 | .. literalinclude:: ../../../examples/aggregate10/application.py 42 | 43 | 44 | Test case 45 | --------- 46 | 47 | The :class:`~examples.aggregate10.test_application.TestDogSchool` test case shows how the 48 | :class:`~examples.aggregate10.application.DogSchool` application can be used. 49 | 50 | .. literalinclude:: ../../../examples/aggregate10/test_application.py 51 | 52 | 53 | Code reference 54 | -------------- 55 | 56 | .. automodule:: examples.aggregate10.mutablemodel 57 | :show-inheritance: 58 | :member-order: bysource 59 | :members: 60 | :undoc-members: 61 | 62 | .. automodule:: examples.aggregate10.domainmodel 63 | :show-inheritance: 64 | :member-order: bysource 65 | :members: 66 | :undoc-members: 67 | 68 | .. automodule:: examples.aggregate10.application 69 | :show-inheritance: 70 | :member-order: bysource 71 | :members: 72 | :undoc-members: 73 | 74 | .. automodule:: examples.aggregate10.test_application 75 | :show-inheritance: 76 | :member-order: bysource 77 | :members: 78 | :undoc-members: 79 | 80 | -------------------------------------------------------------------------------- /docs/topics/examples/searchable-timestamps.rst: -------------------------------------------------------------------------------- 1 | .. _Searchable timestamps example: 2 | 3 | Application 4 - Searchable timestamps 4 | ===================================== 5 | 6 | This example demonstrates how to extend the library's application recorder classes 7 | to support retrieving aggregates at a particular point in time with both PostgreSQL 8 | and SQLite. 9 | 10 | Application 11 | ----------- 12 | 13 | The application class ``SearchableTimestampsApplication`` extends the ``BookingApplication`` 14 | presented in the :doc:`cargo shipping example `. It extends 15 | the application ``_record()`` method by setting in the processing event a list of ``Cargo`` 16 | events that will be used by the recorder to insert event timestamps into an index. It also 17 | introduces a ``get_cargo_at_timestamp()`` method that expects a ``tracking_id`` and a 18 | ``timestamp`` argument, then returns a ``Cargo`` aggregate as it was at the specified time. 19 | 20 | .. literalinclude:: ../../../examples/searchabletimestamps/application.py 21 | 22 | 23 | Persistence 24 | ----------- 25 | 26 | The recorder classes ``SearchableTimestampsApplicationRecorder`` extend the PostgreSQL 27 | and SQLite ``ApplicationRecorder`` classes by creating a table that contains rows 28 | with the originator ID, timestamp, and originator version of aggregate events. The 29 | define SQL statements that insert and select from the rows of the table. 30 | They define a ``get_version_at_timestamp()`` method which returns the version of 31 | an aggregate at a particular time. 32 | 33 | .. literalinclude:: ../../../examples/searchabletimestamps/persistence.py 34 | 35 | The application recorder classes extend the ``_insert_events()`` method by inserting rows, 36 | according to the event timestamp data passed down from the application. 37 | 38 | The infrastructure factory classes ``SearchableTimestampsInfrastructureFactory`` extend the 39 | PostgreSQL and SQLite ``Factory`` classes by overriding the ``application_recorder()`` method 40 | so that a ``SearchableTimestampsApplicationRecorder`` is constructed as the application recorder. 41 | 42 | 43 | PostgreSQL 44 | ---------- 45 | 46 | .. literalinclude:: ../../../examples/searchabletimestamps/postgres.py 47 | 48 | 49 | SQLite 50 | ------ 51 | 52 | .. literalinclude:: ../../../examples/searchabletimestamps/sqlite.py 53 | 54 | 55 | Test case 56 | --------- 57 | 58 | The test case ``SearchableTimestampsTestCase`` uses the application to evolve the 59 | state of a ``Cargo`` aggregate. The aggregate is then reconstructed as it was at 60 | particular times in its evolution. The test is executed twice, with the application 61 | configured for both PostgreSQL and SQLite. 62 | 63 | .. literalinclude:: ../../../examples/searchabletimestamps/test_searchabletimestamps.py 64 | -------------------------------------------------------------------------------- /tests/projection_tests/test_application_subscription.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from uuid import UUID 3 | 4 | from eventsourcing.application import Application 5 | from eventsourcing.domain import Aggregate 6 | from eventsourcing.persistence import Tracking 7 | from eventsourcing.projection import ApplicationSubscription 8 | from eventsourcing.utils import get_topic 9 | 10 | 11 | class TestApplicationSubscription(TestCase): 12 | def test(self) -> None: 13 | app = Application[UUID]() 14 | 15 | max_notification_id = app.recorder.max_notification_id() 16 | 17 | aggregate = Aggregate() 18 | aggregate.trigger_event(Aggregate.Event) 19 | aggregate.trigger_event(Aggregate.Event) 20 | aggregate.trigger_event(Aggregate.Event) 21 | app.save(aggregate) 22 | 23 | subscription = ApplicationSubscription(app=app, gt=max_notification_id) 24 | 25 | # Catch up. 26 | for domain_event, tracking in subscription: 27 | self.assertIsInstance(domain_event, Aggregate.Event) 28 | self.assertIsInstance(tracking, Tracking) 29 | self.assertEqual(tracking.application_name, app.name) 30 | if max_notification_id is not None: 31 | self.assertGreater(tracking.notification_id, max_notification_id) 32 | if tracking.notification_id == app.recorder.max_notification_id(): 33 | break 34 | 35 | max_notification_id = app.recorder.max_notification_id() 36 | 37 | aggregate.trigger_event(Aggregate.Event) 38 | aggregate.trigger_event(Aggregate.Event) 39 | aggregate.trigger_event(Aggregate.Event) 40 | app.save(aggregate) 41 | 42 | # Continue. 43 | for domain_event, tracking in subscription: 44 | self.assertIsInstance(domain_event, Aggregate.Event) 45 | self.assertIsInstance(tracking, Tracking) 46 | self.assertEqual(tracking.application_name, app.name) 47 | if max_notification_id is not None: 48 | self.assertGreater(tracking.notification_id, max_notification_id) 49 | if tracking.notification_id == app.recorder.max_notification_id(): 50 | break 51 | 52 | # Check 'topics' are effective. 53 | class FilteredEvent(Aggregate.Event): 54 | pass 55 | 56 | aggregate.trigger_event(FilteredEvent) 57 | app.save(aggregate) 58 | 59 | subscription = ApplicationSubscription( 60 | app=app, 61 | gt=max_notification_id, 62 | topics=[get_topic(FilteredEvent)], 63 | ) 64 | 65 | for domain_event, _ in subscription: 66 | if not isinstance(domain_event, FilteredEvent): 67 | self.fail(f"Got an unexpected domain event: {domain_event}") 68 | break 69 | --------------------------------------------------------------------------------