From 56dd9e6bd33b0533a75674db4dd9600dd557c9b4 Mon Sep 17 00:00:00 2001 From: Subhash Bhushan Date: Wed, 19 Jun 2024 14:21:16 -0700 Subject: [PATCH] Add Event Handler Documentation --- docs/adapters/repository/elasticsearch.md | 51 ++++++++++++++ docs/guides/access-domain/index.md | 1 - .../command-handlers.md | 2 +- .../commands.md | 4 +- .../{persist-state => change-state}/index.md | 10 +-- .../persist-aggregates.md | 6 +- .../retrieve-aggregates.md} | 10 ++- .../unit-of-work.md | 0 docs/guides/compose-a-domain/configuration.md | 68 ++++++++++++++++++- docs/guides/domain-behavior/raising-events.md | 4 +- .../persist-state/database-specificity.md | 2 - docs/guides/propagate-state/event-handlers.md | 65 ++++++++++++++++++ docs/guides/propagate-state/index.md | 1 + .../{persist-state => change-state}/001.py | 0 .../{persist-state => change-state}/002.py | 0 .../{persist-state => change-state}/003.py | 0 .../{persist-state => change-state}/004.py | 0 .../{persist-state => change-state}/005.py | 0 .../001.py => change-state/006.py} | 0 .../002.py => change-state/007.py} | 0 docs_src/guides/propagate-state/001.py | 51 ++++++++++++++ mkdocs.yml | 22 +++--- 22 files changed, 266 insertions(+), 31 deletions(-) create mode 100644 docs/adapters/repository/elasticsearch.md delete mode 100644 docs/guides/access-domain/index.md rename docs/guides/{access-domain => change-state}/command-handlers.md (98%) rename docs/guides/{access-domain => change-state}/commands.md (97%) rename docs/guides/{persist-state => change-state}/index.md (75%) rename docs/guides/{persist-state => change-state}/persist-aggregates.md (96%) rename docs/guides/{persist-state/retreive-aggregates.md => change-state/retrieve-aggregates.md} (96%) rename docs/guides/{persist-state => change-state}/unit-of-work.md (100%) delete mode 100644 docs/guides/persist-state/database-specificity.md create mode 100644 docs/guides/propagate-state/event-handlers.md create mode 100644 docs/guides/propagate-state/index.md rename docs_src/guides/{persist-state => change-state}/001.py (100%) rename docs_src/guides/{persist-state => change-state}/002.py (100%) rename docs_src/guides/{persist-state => change-state}/003.py (100%) rename docs_src/guides/{persist-state => change-state}/004.py (100%) rename docs_src/guides/{persist-state => change-state}/005.py (100%) rename docs_src/guides/{access-domain/001.py => change-state/006.py} (100%) rename docs_src/guides/{access-domain/002.py => change-state/007.py} (100%) create mode 100644 docs_src/guides/propagate-state/001.py diff --git a/docs/adapters/repository/elasticsearch.md b/docs/adapters/repository/elasticsearch.md new file mode 100644 index 00000000..81ca6643 --- /dev/null +++ b/docs/adapters/repository/elasticsearch.md @@ -0,0 +1,51 @@ +# Elasticsearch + +To use Elasticsearch as a database provider, use the below configuration setting: + +```toml +[databases.elasticsearch] +provider = "elasticsearch" +database_uri = "{'hosts': ['localhost']}" +namespace_prefix = "${PROTEAN_ENV}" +settings = "{'number_of_shards': 3}" +``` + +## Options + +Additional options for finer control: + +### namespace_prefix =IE${lasticsearch instance are prefixed with the specified stri}norexample, if the namespace prefix is `prod`, the index for aggregate +`Person` will be `prod-person`. + +### NAMESPACE_SEPARATOR + +Custom character to join namespace_prefix =n ${Default} yphen(`-`). For example, with `NAMESPACE_SEPARATOR` as `_` and namespace +prefix as `prod`, the index of aggregate `Person` will be `prod_person`. + +### SETTINGS + +Index settings passed as-is to Elasticsearch instance. + +## Elasticsearch Model + +Note that if you supply a custom Elasticsearch Model with an `Index` inner class, the options specified in the +inner class override those at the config level. + +In the sample below, with the configuration settings specified above, the options at Aggregate level will be +overridden and the Elasticsearch Model will have the default index value `*` and number of shards as `1`. + +```python +class Person(BaseAggregate): + name = String() + about = Text() + + class Meta: + schema_name = "people" + +class PeopleModel(ElasticsearchModel): + name = Text(fields={"raw": Keyword()}) + about = Text() + + class Index: + settings = {"number_of_shards": 1} +``` diff --git a/docs/guides/access-domain/index.md b/docs/guides/access-domain/index.md deleted file mode 100644 index bfd52090..00000000 --- a/docs/guides/access-domain/index.md +++ /dev/null @@ -1 +0,0 @@ -# Accessing the Domain Model \ No newline at end of file diff --git a/docs/guides/access-domain/command-handlers.md b/docs/guides/change-state/command-handlers.md similarity index 98% rename from docs/guides/access-domain/command-handlers.md rename to docs/guides/change-state/command-handlers.md index ccf2fb62..723cd39d 100644 --- a/docs/guides/access-domain/command-handlers.md +++ b/docs/guides/change-state/command-handlers.md @@ -20,7 +20,7 @@ domain events. Command Handlers are defined with the `Domain.command_handler` decorator: ```python hl_lines="20-23 47-53" -{! docs_src/guides/access-domain/002.py !} +{! docs_src/guides/change-state/007.py !} ``` ## Workflow diff --git a/docs/guides/access-domain/commands.md b/docs/guides/change-state/commands.md similarity index 97% rename from docs/guides/access-domain/commands.md rename to docs/guides/change-state/commands.md index bd84384d..14d9062d 100644 --- a/docs/guides/access-domain/commands.md +++ b/docs/guides/change-state/commands.md @@ -22,8 +22,8 @@ to eventually make the rest of the system consistent. A command is defined with the `Domain.command` decorator: -```python hl_lines="12-15" -{! docs_src/guides/access-domain/001.py !} +```python hl_lines="13-16" +{! docs_src/guides/change-state/006.py !} ``` A command is always associated with an aggregate class with the `part_of` diff --git a/docs/guides/persist-state/index.md b/docs/guides/change-state/index.md similarity index 75% rename from docs/guides/persist-state/index.md rename to docs/guides/change-state/index.md index 07b247c0..2f774283 100644 --- a/docs/guides/persist-state/index.md +++ b/docs/guides/change-state/index.md @@ -1,4 +1,6 @@ -# Persisting State +# Changing State + +## Persisting State - About Repositories and Repository Pattern @@ -6,14 +8,14 @@ - Repository Configuration - Automatic generation of repositories -## Basic Structure +### Basic Structure A repository provides three primary methods to interact with the persistence store: -### **`add`** - Adds a new entity to the persistence store. +#### **`add`** - Adds a new entity to the persistence store. -### **`get`** - Retrieves an entity from the persistence store. +#### **`get`** - Retrieves an entity from the persistence store. - Persisting aggregates - Retreiving aggregates diff --git a/docs/guides/persist-state/persist-aggregates.md b/docs/guides/change-state/persist-aggregates.md similarity index 96% rename from docs/guides/persist-state/persist-aggregates.md rename to docs/guides/change-state/persist-aggregates.md index 35ad0a81..0c8edc0c 100644 --- a/docs/guides/persist-state/persist-aggregates.md +++ b/docs/guides/change-state/persist-aggregates.md @@ -4,7 +4,7 @@ Aggregates are saved into the configured database using `add` method of the repository. ```python hl_lines="20" -{! docs_src/guides/persist-state/001.py !} +{! docs_src/guides/change-state/001.py !} ``` 1. Identity, by default, is a string. @@ -44,7 +44,7 @@ This means changes across the aggregate cluster are committed as a single transaction (assuming the underlying database supports transactions, of course). ```python hl_lines="22-30 33" -{! docs_src/guides/persist-state/002.py !} +{! docs_src/guides/change-state/002.py !} ``` !!!note @@ -57,7 +57,7 @@ The `add` method also publishes events to configured brokers upon successfully persisting to the database. ```python hl_lines="15" -{! docs_src/guides/persist-state/003.py !} +{! docs_src/guides/change-state/003.py !} ``` ```shell hl_lines="12-16 21-22" diff --git a/docs/guides/persist-state/retreive-aggregates.md b/docs/guides/change-state/retrieve-aggregates.md similarity index 96% rename from docs/guides/persist-state/retreive-aggregates.md rename to docs/guides/change-state/retrieve-aggregates.md index fb808519..a6ea997c 100644 --- a/docs/guides/persist-state/retreive-aggregates.md +++ b/docs/guides/change-state/retrieve-aggregates.md @@ -4,7 +4,7 @@ An aggregate can be retreived with the repository's `get` method, if you know its identity: ```python hl_lines="16 20" -{! docs_src/guides/persist-state/001.py !} +{! docs_src/guides/change-state/001.py !} ``` 1. Identity is explicitly set to **1**. @@ -26,7 +26,7 @@ expected to enclose methods that represent business queries. Defining a custom repository is straight-forward: ```python hl_lines="16" -{! docs_src/guides/persist-state/004.py !} +{! docs_src/guides/change-state/004.py !} ``` 1. The repository is connected to `Person` aggregate through the `part_of` @@ -66,6 +66,10 @@ Out[8]: perform. `adults` is a good name for a method that fetches persons over the age of 18. +!!!note + A repository can be connected to a specific persistence store by specifying + the `database` parameter. + ## Data Acsess Objects (DAO) You would have observed the query in the repository above was performed on a @@ -88,7 +92,7 @@ For the purposes of this guide, assume that the following `Person` aggregates exist in the database: ```python hl_lines="7-11" -{! docs_src/guides/persist-state/005.py !} +{! docs_src/guides/change-state/005.py !} ``` ```shell diff --git a/docs/guides/persist-state/unit-of-work.md b/docs/guides/change-state/unit-of-work.md similarity index 100% rename from docs/guides/persist-state/unit-of-work.md rename to docs/guides/change-state/unit-of-work.md diff --git a/docs/guides/compose-a-domain/configuration.md b/docs/guides/compose-a-domain/configuration.md index cee41bf5..c888b056 100644 --- a/docs/guides/compose-a-domain/configuration.md +++ b/docs/guides/compose-a-domain/configuration.md @@ -41,10 +41,74 @@ needed. ## Adapter Configuration -### `database` +### `databases` + +Database repositories are used to access the underlying data store for +persisting and retrieving domain objects. + +They are defined in the `[databases]` section. + +```toml hl_lines="1 4 7" +[databases.default] +provider = "memory" + +[databases.memory] +provider = "memory" + +[databases.sqlite] +provider = "sqlalchemy" +database = "sqlite" +database_uri = "sqlite:///test.db" +``` + +You can define as many databases as you need. The default database is identified +by the `default` key, and is used when you do not specify a database name when +accessing the domain. + +The only other database defined by default is `memory`, which is the in-memory +stub database provider. You can name all other database definitions as +necessary. + +The persistence store defined here is then specified in the `provider` key of +aggregates and entities to assign them a specific database. + +```python hl_lines="1" +@domain.aggregate(provider="sqlite") # (1) +class User: + name = String(max_length=50) + email = String(max_length=254) +``` + +1. `sqlite` is the key of the database definition in the `[databases.sqlite]` +section. ### `cache` ### `broker` -### `event_store` \ No newline at end of file +### `event_store` + +## Custom Attributes + +Custom attributes can be defined in toml under the `[custom]` section (or +`[tool.protean.custom]` if you are leveraging the `pyproject.toml` file). + +Custom attributes are also made available on the domain object directly. + +```toml hl_lines="5" +debug = true +testing = true + +[custom] +FOO = "bar" +``` + +```shell hl_lines="3-4 6-7" +In [1]: domain = Domain(__file__) + +In [2]: domain.config["custom"]["FOO"] +Out[2]: 'bar' + +In [3]: domain.FOO +Out[3]: 'bar' +``` diff --git a/docs/guides/domain-behavior/raising-events.md b/docs/guides/domain-behavior/raising-events.md index 83b0f4a9..e9879c55 100644 --- a/docs/guides/domain-behavior/raising-events.md +++ b/docs/guides/domain-behavior/raising-events.md @@ -1,11 +1,9 @@ -# Propagating State +# Raising Events An aggregate rarely exists in isolation - it's state changes often mean that other parts of the system of the system have to sync up. In DDD, the mechanism to accomplish this is through Domain Events. -## Raising Events - When an aggregate mutates, it also (preferably) raises one or more events to record the state change in time, as well as propagate it within and beyond the bounded context. diff --git a/docs/guides/persist-state/database-specificity.md b/docs/guides/persist-state/database-specificity.md deleted file mode 100644 index da6a821a..00000000 --- a/docs/guides/persist-state/database-specificity.md +++ /dev/null @@ -1,2 +0,0 @@ -# Database-specific repositories - diff --git a/docs/guides/propagate-state/event-handlers.md b/docs/guides/propagate-state/event-handlers.md new file mode 100644 index 00000000..aae700cb --- /dev/null +++ b/docs/guides/propagate-state/event-handlers.md @@ -0,0 +1,65 @@ +# Event Handlers + +Event handlers consume events raised in an aggregate and help sync the state of +the aggregate with other aggregates and other systems. They are the preferred +mechanism to update multiple aggregates. + +## Defining an Event Handler + +Event Handlers are defined with the `Domain.event_handler` decorator. Below is +a simplified example of an Event Handler connected to `Inventory` aggregate +syncing stock levels corresponding to changes in the `Order` aggregate. + +```python hl_lines="26-27 44" +{! docs_src/guides/propagate-state/001.py !} +``` + +1. `Order` aggregate fires `OrderShipped` event on book being shipped. + +2. Event handler picks up the event and updates stock levels in `Inventory` +aggregate. + +Simulating a hypothetical example, we can see that the stock levels were +decreased in response to the `OrderShipped` event. + +```shell hl_lines="21" +In [1]: order = Order(book_id=1, quantity=10, total_amount=100) + +In [2]: domain.repository_for(Order).add(order) +Out[2]: + +In [3]: inventory = Inventory(book_id=1, in_stock=100) + +In [4]: domain.repository_for(Inventory).add(inventory) +Out[4]: + +In [5]: order.ship_order() + +In [6]: domain.repository_for(Order).add(order) +Out[6]: + +In [7]: stock = domain.repository_for(Inventory).get(inventory.id) + +In [8]: stock.to_dict() +Out[8]: { + 'book_id': '1', + 'in_stock': 90, + 'id': '9272d70f-b796-417d-8f30-e01302d9f1a9' + } + +In [9]: +``` + +## Configuration Options + +- **`part_of`**: The aggregate to which the event handler is connected. +- **`stream_name`**: The event handler listens to events on this stream. +The stream name defaults to the aggregate's stream. This option comes handy +when the event handler belongs to an aggregate and needs to listen to another +aggregate's events. +- **`source_stream`**: When specified, the event handler only consumes events +generated in response to events or commands from this original stream. +For example, `EmailNotifications` event handler listening to `OrderShipped` +events can be configured to generate a `NotificationSent` event only when the +`OrderShipped` event (in stream `orders`) is generated in response to a +`ShipOrder` (in stream `manage_order`) command. diff --git a/docs/guides/propagate-state/index.md b/docs/guides/propagate-state/index.md new file mode 100644 index 00000000..8d938658 --- /dev/null +++ b/docs/guides/propagate-state/index.md @@ -0,0 +1 @@ +# Propagate State \ No newline at end of file diff --git a/docs_src/guides/persist-state/001.py b/docs_src/guides/change-state/001.py similarity index 100% rename from docs_src/guides/persist-state/001.py rename to docs_src/guides/change-state/001.py diff --git a/docs_src/guides/persist-state/002.py b/docs_src/guides/change-state/002.py similarity index 100% rename from docs_src/guides/persist-state/002.py rename to docs_src/guides/change-state/002.py diff --git a/docs_src/guides/persist-state/003.py b/docs_src/guides/change-state/003.py similarity index 100% rename from docs_src/guides/persist-state/003.py rename to docs_src/guides/change-state/003.py diff --git a/docs_src/guides/persist-state/004.py b/docs_src/guides/change-state/004.py similarity index 100% rename from docs_src/guides/persist-state/004.py rename to docs_src/guides/change-state/004.py diff --git a/docs_src/guides/persist-state/005.py b/docs_src/guides/change-state/005.py similarity index 100% rename from docs_src/guides/persist-state/005.py rename to docs_src/guides/change-state/005.py diff --git a/docs_src/guides/access-domain/001.py b/docs_src/guides/change-state/006.py similarity index 100% rename from docs_src/guides/access-domain/001.py rename to docs_src/guides/change-state/006.py diff --git a/docs_src/guides/access-domain/002.py b/docs_src/guides/change-state/007.py similarity index 100% rename from docs_src/guides/access-domain/002.py rename to docs_src/guides/change-state/007.py diff --git a/docs_src/guides/propagate-state/001.py b/docs_src/guides/propagate-state/001.py new file mode 100644 index 00000000..a9d9ccdc --- /dev/null +++ b/docs_src/guides/propagate-state/001.py @@ -0,0 +1,51 @@ +from protean import Domain, handle +from protean.fields import Identifier, Integer, String + +domain = Domain(__file__, load_toml=False) +domain.config["event_processing"] = "sync" + + +@domain.event(part_of="Order") +class OrderShipped: + order_id = Identifier(required=True) + book_id = Identifier(required=True) + quantity = Integer(required=True) + total_amount = Integer(required=True) + + +@domain.aggregate +class Order: + book_id = Identifier(required=True) + quantity = Integer(required=True) + total_amount = Integer(required=True) + status = String(choices=["PENDING", "SHIPPED", "DELIVERED"], default="PENDING") + + def ship_order(self): + self.status = "SHIPPED" + + self.raise_( # (1) + OrderShipped( + order_id=self.id, + book_id=self.book_id, + quantity=self.quantity, + total_amount=self.total_amount, + ) + ) + + +@domain.aggregate +class Inventory: + book_id = Identifier(required=True) + in_stock = Integer(required=True) + + +@domain.event_handler(part_of=Inventory, stream_name="order") +class ManageInventory: + @handle(OrderShipped) + def reduce_stock_level(self, event: OrderShipped): + repo = domain.repository_for(Inventory) + inventory = repo._dao.find_by(book_id=event.book_id) + + inventory.in_stock -= event.quantity # (2) + + repo.add(inventory) diff --git a/mkdocs.yml b/mkdocs.yml index 7176a744..f8a3e47e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -126,16 +126,18 @@ nav: - guides/domain-behavior/aggregate-mutation.md - guides/domain-behavior/raising-events.md - guides/domain-behavior/domain-services.md - - Persisting State: - - guides/persist-state/index.md - - guides/persist-state/persist-aggregates.md - - guides/persist-state/unit-of-work.md - - guides/persist-state/retreive-aggregates.md - - guides/persist-state/database-specificity.md - - Accessing the domain: - - guides/access-domain/index.md - - guides/access-domain/commands.md - - guides/access-domain/command-handlers.md + - Changing State: + - guides/change-state/index.md + - guides/change-state/commands.md + - guides/change-state/command-handlers.md + - guides/change-state/persist-aggregates.md + - guides/change-state/unit-of-work.md + - guides/change-state/retrieve-aggregates.md + - Propagating State: + - guides/propagate-state/index.md + - guides/propagate-state/event-handlers.md + # - guides/propagate-state/events-across-contexts.md + # - guides/propagate-state/views.md # - Application Layer: # - guides/app-layer/index.md # - guides/app-layer/application-services.md