├── .github └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── demo ├── __init__.py ├── adapters │ ├── __init__.py │ ├── clients │ │ ├── __init__.py │ │ └── pubsub_client.py │ └── repositories │ │ ├── __init__.py │ │ └── user_repository.py ├── domain │ ├── __init__.py │ ├── command_model │ │ ├── __init__.py │ │ ├── email_set_event.py │ │ ├── kpi_event.py │ │ ├── save_user_command.py │ │ └── user.py │ └── model.py ├── entrypoints │ ├── __init__.py │ ├── bootstrapper.py │ └── pubsub │ │ ├── __init__.py │ │ └── main.py └── service_layer │ ├── __init__.py │ ├── command_handlers │ ├── __init__.py │ ├── change_email_command_handler.py │ └── save_user_command_handler.py │ └── event_handlers │ ├── __init__.py │ ├── email_changed_event_handler.py │ ├── email_set_event_handler.py │ ├── kpi_event_handler.py │ └── notify_slack_event_handler.py ├── requirements.txt ├── run_demo.py ├── setup.cfg ├── setup.py ├── src ├── __init__.py └── ddd │ ├── __init__.py │ ├── bootstrapper.py │ ├── error.py │ ├── factories.py │ ├── handlers.py │ ├── message_bus.py │ ├── model.py │ ├── repository.py │ └── unit_of_work.py └── tests ├── __init__.py └── test_bootstrapper.py /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python package 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install flake8 pytest 31 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Test with pytest 39 | run: | 40 | pytest 41 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | 135 | # pytype static type analyzer 136 | .pytype/ 137 | 138 | # Cython debug symbols 139 | cython_debug/ 140 | 141 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Victor Klapholz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Domain-Driven Design (DDD) Framework for Python Developers 2 | 3 | ## What is this library good for? 4 | This is a lightweight framework that provides a quick and simple setup for 5 | [Domain-Driven](https://en.wikipedia.org/wiki/Domain-driven_design) designed apps 6 | that are a pleasure to maintain and easy to unit test. 7 | 8 | These are the main features that are supported by the framework: 9 | 1. **Unit of Work** with a **commit** and **rollback** mechanism for application layer handlers 10 | 2. Definition of **Domain Commands** in the domain layer and their **Command Handlers** in the application layer 11 | 3. Definition of **Domain Events** in the domain layer and their **Event Handlers** in the application layer 12 | 4. **Event-Driven Architecture** based on **Domain Events** 13 | 14 | This library has no external dependencies and hence should be easy to add to any project that can benefit from 15 | DDD. 16 | 17 | Supports [asyncio](https://docs.python.org/3/library/asyncio.html). 18 | 19 | Many concepts used in this framework are based on the author's own experience and greatly inspired by amazing DDD books, 20 | such as: 21 | 22 | * [Domain-Driven Design: Tackling Complexity in the Heart of Software](https://www.oreilly.com/library/view/domain-driven-design-tackling/0321125215/) 23 | * [Architecture Patterns with Python](https://www.oreilly.com/library/view/architecture-patterns-with/9781492052197/) 24 | * [Event-Driven Architecture in Golang](https://www.packtpub.com/product/event-driven-architecture-in-golang/9781803238012) 25 | 26 | ## Installation 27 | 28 | ```shell 29 | pip install py-ddd-framework 30 | ``` 31 | 32 | ## Import 33 | 34 | ```python 35 | import asyncio 36 | 37 | import ddd 38 | 39 | def main(): 40 | bootstrapper = ddd.Bootstrapper() 41 | command = SaveUserCommand() 42 | # regular usage 43 | result = bootstrapper.handle_command(command) 44 | # async usage 45 | result = asyncio.run(bootstrapper.async_handle_command(command)) 46 | ``` 47 | 48 | ## How to implement it? 49 | 50 | A sample implementation is provided within the [demo](https://github.com/vklap/py_ddd_framework/tree/main/demo) folder 51 | in the source code (both for async and regular usage). 52 | 53 | To run the demo, please `cd` into the root folder and execute the following command: 54 | ```shell 55 | python run_demo.py 56 | ``` 57 | 58 | The below explanation is based on this sample implementation. 59 | 60 | ## Sample Implementation 61 | 62 | Let's imagine a simplified background job for saving a user's details that consists 63 | of the following steps within a unit of work: 64 | 65 | 1. Get the new user's data from a **PubSub message broker** (such as Amazon SQS, RabbitMQ, etc.) 66 | and transform it into a **command** object that can be handled by the **application layer** 67 | 2. Perform basic validations on the **command**'s data 68 | 3. Get the existing **user entity** data from the database, via a **repository** 69 | 4. Update the **user entity** with the data stored in the **command** object 70 | 5. **Save** the updated user entity **in the repository** 71 | 6. Either **commit** (and store the new data in the database) 72 | or **rollback** (and thus discard the changes recorded in the previous steps) 73 | 74 | Steps 2 (command validation) and 6 (commit or rollback) are triggered by the framework. 75 | 76 | ### How the code looks like? 77 | 78 | #### Domain Layer 79 | 80 | ##### User Entity 81 | 82 | ```python 83 | from __future__ import annotations 84 | 85 | import ddd 86 | from demo.domain.command_model.email_set_event import EmailSetEvent 87 | 88 | 89 | class User(ddd.AbstractEntity): 90 | def __init__(self, email: str | None = None, id_: str | None = None): 91 | super().__init__() 92 | self._id = id_ 93 | self._email = email 94 | 95 | def get_id(self) -> str: 96 | return self._id 97 | 98 | def set_id(self, value: str) -> None: 99 | self._id = value 100 | 101 | @property 102 | def email(self) -> str: 103 | return self._email 104 | 105 | def set_email(self, value: str) -> None: 106 | if value and self._email != value: 107 | self.add_event(EmailSetEvent(user_id=self._id, new_email=value, old_email=self._email)) 108 | self._email = value 109 | 110 | def __repr__(self) -> str: 111 | return f'<{type(self).__name__}(id={self._id}, email={self._email})>' 112 | ``` 113 | 114 | ##### SaveUserCommand 115 | 116 | ```python 117 | from __future__ import annotations 118 | 119 | import dataclasses 120 | 121 | import ddd 122 | 123 | 124 | @dataclasses.dataclass 125 | class SaveUserCommand(ddd.AbstractCommand): 126 | user_id: str | None = None 127 | email: str | None = None 128 | 129 | @property 130 | def name(self) -> str: 131 | return type(self).__name__ 132 | 133 | def validate(self) -> None: 134 | if not self.user_id: 135 | raise ddd.BoundedContextError(ddd.BAD_REQUEST, 'Missing user_id') 136 | if not self.email: 137 | raise ddd.BoundedContextError(ddd.BAD_REQUEST, 'Missing email') 138 | ``` 139 | 140 | ##### Repository 141 | 142 | Please note that we're using an in memory repository for demo purposes 143 | (and also for the [unit tests](https://github.com/vklap/py_ddd_framework/tree/main/tests)) 144 | 145 | ```python 146 | from __future__ import annotations 147 | 148 | import abc 149 | 150 | import ddd 151 | from demo.domain.command_model.user import User 152 | 153 | 154 | class AbstractUserRepository(ddd.RollbackCommitter, abc.ABC): 155 | def get_by_id(self, id_: str) -> User: 156 | return self._get_by_id(id_) 157 | 158 | def save(self, user: User) -> None: 159 | self._save(user) 160 | 161 | @abc.abstractmethod 162 | def _get_by_id(self, id_: str) -> User: 163 | raise NotImplementedError 164 | 165 | @abc.abstractmethod 166 | def _save(self, user: User) -> None: 167 | raise NotImplementedError 168 | 169 | 170 | class InMemoryUserRepository(AbstractUserRepository): 171 | def __init__(self): 172 | super().__init__() 173 | self.users_by_id: dict[str, User] = {} 174 | self._saved_users: list[User] = [] 175 | self.commit_called = False 176 | self.rollback_called = False 177 | self.commit_should_fail = False 178 | self.rollback_should_fail = False 179 | 180 | def _get_by_id(self, id_: str) -> User: 181 | result = self.users_by_id.get(id_) 182 | if not result: 183 | raise ddd.BoundedContextError(ddd.NOT_FOUND, f'User with ID "{id_}" does not exist') 184 | return result 185 | 186 | def _save(self, user: User) -> None: 187 | self._saved_users.append(user) 188 | 189 | def commit(self) -> None: 190 | self.commit_called = True 191 | if self.commit_should_fail: 192 | raise Exception('commit failed') 193 | for user in self._saved_users: 194 | self.users_by_id[user.get_id()] = user 195 | 196 | def rollback(self) -> None: 197 | self.rollback_called = True 198 | if self.rollback_should_fail: 199 | raise Exception('rollback failed') 200 | self._saved_users.clear() 201 | ``` 202 | 203 | ##### SaveUserCommandHandler 204 | 205 | This is the application layer flow that is triggered by the framework's unit of work - 206 | in order to either commit or rollback the changes. 207 | 208 | This handler is registered to the above defined `SaveUserCommand` - so that whenever this command is received, 209 | then this handler will be executed. The registration is handled by the `Bootstrapper` which will be shown later. 210 | 211 | ```python 212 | from __future__ import annotations 213 | 214 | import ddd 215 | from demo.adapters.repositories.user_repository import AbstractUserRepository 216 | from demo.domain.command_model.save_user_command import SaveUserCommand 217 | 218 | 219 | class SaveUserCommandHandler(ddd.AbstractCommandHandler[SaveUserCommand, str]): 220 | def __init__(self, user_repository: AbstractUserRepository): 221 | super().__init__() 222 | self._user_repository = user_repository 223 | self._events: list[ddd.AbstractEvent] = [] 224 | 225 | def handle(self, command: ddd.TCommand) -> ddd.THandleCommandResult: 226 | user = self._user_repository.get_by_id(command.user_id) 227 | user.set_email(command.email) 228 | self._events.extend(user.events) 229 | return user.get_id() 230 | 231 | @property 232 | def events(self) -> list[ddd.AbstractEvent]: 233 | return list(self._events) 234 | 235 | def commit(self) -> None: 236 | self._user_repository.commit() 237 | 238 | def rollback(self) -> None: 239 | self._user_repository.rollback() 240 | ``` 241 | 242 | ##### Registration of the SaveUserCommand with its handler: SaveUserCommandHandler 243 | This happens within the bootstrapper, like so: 244 | 245 | ```python 246 | from __future__ import annotations 247 | 248 | import ddd 249 | 250 | from demo.adapters.clients.pubsub_client import InMemoryPubSubClient 251 | from demo.adapters.repositories.user_repository import InMemoryUserRepository 252 | from demo.domain.command_model.save_user_command import SaveUserCommand 253 | from demo.service_layer.command_handlers.save_user_command_handler import SaveUserCommandHandler 254 | 255 | 256 | class DemoBootstrapper(ddd.Bootstrapper): 257 | def __init__(self): 258 | super().__init__() 259 | self.user_repository = InMemoryUserRepository() 260 | self.pubsub_client = InMemoryPubSubClient() 261 | self.register_command_handler_factory(SaveUserCommand().name, self.create_save_user_command_handler) 262 | 263 | def create_save_user_command_handler(self) -> ddd.AbstractCommandHandler: 264 | """ 265 | Made public for the framework's unit test. 266 | In realworld usage, this method should best be private. 267 | """ 268 | return SaveUserCommandHandler(self.user_repository) 269 | ``` 270 | 271 | ##### Handling the SaveUserCommand by the framework 272 | 273 | Based on the above created bootstrapper instance, 274 | this is how the command should be propagated into the framework: 275 | ```python 276 | bootstrapper = DemoBootstrapper() 277 | 278 | command = SaveUserCommand(user_id='1', email='eli.cohen@mossad.gov.il') 279 | 280 | bootstrapper.handle_command(command) 281 | ``` 282 | 283 | ### But wait, isn't this code over-engineered? 284 | 285 | Basically, if this is all the code should do, then this code is arguably too complex. 286 | Yet, what happens when the requirements grow, and you need to handle other tasks, such as: 287 | 1. Trigger a verification email to validate the provided email? 288 | 2. Notify a KPI Service about the changes - for further analysis 289 | 3. Handle other changes, as in a real world scenario the user entity should have much more properties - 290 | where each property change might require triggering other actions (a.k.a. `Domain Events`) 291 | 292 | 293 | The code might quickly look like this: 294 | ```python 295 | 296 | def handle(self, command: ddd.TCommand) -> ddd.THandleCommandResult: 297 | user = self._user_repository.get_by_id(command.user_id) 298 | user.set_email(command.email) 299 | # Side effect... 300 | if user.changed_email: 301 | self._pubsub_client.notify_email_changed(...) 302 | self._pubsub_client.notify_kpi_service(...) 303 | # Side effect... 304 | if user.phone_number_changed: 305 | self._pubsub_client.notify_phone_number_changed(...) 306 | self._pubsub_client.notify_kpi_service(...) 307 | return user.get_id() 308 | ``` 309 | 310 | The above code will contain lots side effects, and will defeat the SRP (Single Responsibility Principle) 311 | for which it was created - which is to save the new user details. 312 | Even worse, it will sooner than later become spaghetti code - that will be a nightmare to maintain and unit test. 313 | 314 | ### Event-Driven Architecture with EventHandlers to the Rescue 315 | All the above side effects should best be extracted out of the above code, and handled within other handlers. 316 | These handlers will be handled in the same way as the command handler, 317 | i.e. within units of work of their own - and may trigger other events which will be handled by the framework. 318 | 319 | Here are 2 sample event handlers: 320 | 321 | #### EmailSetEventHandler that will trigger a KPIEvent that will be handled by the KPIEventHandler 322 | ```python 323 | from __future__ import annotations 324 | 325 | import ddd 326 | from demo.adapters.clients.pubsub_client import AbstractPubSubClient 327 | from demo.domain.command_model.email_set_event import EmailSetEvent 328 | from demo.domain.command_model.kpi_event import KpiEvent 329 | 330 | 331 | class EmailSetEventHandler(ddd.AbstractEventHandler[EmailSetEvent]): 332 | def __init__(self, email_client: AbstractPubSubClient): 333 | super().__init__() 334 | self._email_client = email_client 335 | self._events: list[ddd.AbstractEvent] = [] 336 | 337 | def handle(self, event: ddd.TEvent) -> None: 338 | self._email_client.notify_email_changed(event.user_id, event.new_email, event.old_email) 339 | self._events.append( 340 | KpiEvent(action=event.name, data=f'{event!r}') 341 | ) 342 | 343 | @property 344 | def events(self) -> list[ddd.AbstractEvent]: 345 | return list(self._events) 346 | 347 | def commit(self) -> None: 348 | self._email_client.commit() 349 | 350 | def rollback(self) -> None: 351 | self._email_client.rollback() 352 | ``` 353 | 354 | ##### KpiEventHandler 355 | ```python 356 | from __future__ import annotations 357 | 358 | import ddd 359 | from demo.adapters.clients.pubsub_client import AbstractPubSubClient 360 | from demo.domain.command_model.kpi_event import KpiEvent 361 | 362 | 363 | class KpiEventHandler(ddd.AbstractEventHandler[KpiEvent]): 364 | def __init__(self, pubsub_client: AbstractPubSubClient): 365 | super().__init__() 366 | self._pubsub_client = pubsub_client 367 | self._events: list[ddd.AbstractEvent] = [] 368 | 369 | def handle(self, event: ddd.TEvent) -> None: 370 | self._pubsub_client.notify_kpi_service(event) 371 | 372 | @property 373 | def events(self) -> list[ddd.AbstractEvent]: 374 | return list(self._events) 375 | 376 | def commit(self) -> None: 377 | self._pubsub_client.commit() 378 | 379 | def rollback(self) -> None: 380 | self._pubsub_client.rollback() 381 | ``` 382 | 383 | ### Advantages of applying the above-mentioned Domain-Driven Design Tactical Patterns 384 | 385 | - A clear separation of concerns between the business rules (which reside solely inside the domain layer), 386 | the application flows (which reside in the service layer) and the IO related operations - 387 | such as communication with databases/web services/file system (which reside in the adapters layer) 388 | 389 | - This separation of concerns make this kind of code very suitable for unit & integration tests - 390 | the service & domain layers can be fully unit tested and the adapter layer can easily 391 | be integration tested (without being concerned with any business logic leaking from the other layers - 392 | so that the integration tests can remain simple) 393 | 394 | - A common code base structure makes it much easier for other developers, 395 | who are aware of this structure, to get into the code. 396 | -------------------------------------------------------------------------------- /demo/__init__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import sys 3 | 4 | sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / 'src')) 5 | -------------------------------------------------------------------------------- /demo/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vklap/py_ddd_framework/4b4beb4cdfa01a36fbb03d3e3b8b85a6098a0871/demo/adapters/__init__.py -------------------------------------------------------------------------------- /demo/adapters/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vklap/py_ddd_framework/4b4beb4cdfa01a36fbb03d3e3b8b85a6098a0871/demo/adapters/clients/__init__.py -------------------------------------------------------------------------------- /demo/adapters/clients/pubsub_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from collections.abc import AsyncIterator 5 | from collections.abc import Iterator 6 | 7 | import ddd 8 | from demo.domain.command_model.kpi_event import KpiEvent 9 | 10 | 11 | class AbstractPubSubClient(ddd.RollbackCommitter, abc.ABC): 12 | def get_save_user_messages(self) -> Iterator[dict]: 13 | return self._get_save_user_messages() 14 | 15 | def notify_email_changed(self, user_id: str, new_email: str, old_email: str) -> None: 16 | self._notify_email_changed(user_id, new_email, old_email) 17 | 18 | def notify_kpi_service(self, event: KpiEvent) -> None: 19 | self._notify_kpi_service(event) 20 | 21 | @abc.abstractmethod 22 | def _get_save_user_messages(self) -> Iterator[dict]: 23 | raise NotImplementedError 24 | 25 | @abc.abstractmethod 26 | def _notify_kpi_service(self, event: KpiEvent) -> None: 27 | raise NotImplementedError 28 | 29 | @abc.abstractmethod 30 | def _notify_email_changed(self, user_id: str, new_email: str, old_email: str) -> None: 31 | raise NotImplementedError 32 | 33 | 34 | class AbstractAsyncPubSubClient(ddd.AsyncRollbackCommitter, abc.ABC): 35 | async def get_save_user_messages(self) -> AsyncIterator[dict]: 36 | return await self._get_save_user_messages() 37 | 38 | async def notify_email_changed(self, user_id: str, new_email: str, old_email: str) -> None: 39 | await self._notify_email_changed(user_id, new_email, old_email) 40 | 41 | async def notify_kpi_service(self, event: KpiEvent) -> None: 42 | await self._notify_kpi_service(event) 43 | 44 | @abc.abstractmethod 45 | async def _get_save_user_messages(self) -> AsyncIterator[dict]: 46 | raise NotImplementedError 47 | 48 | @abc.abstractmethod 49 | async def _notify_kpi_service(self, event: KpiEvent) -> None: 50 | raise NotImplementedError 51 | 52 | @abc.abstractmethod 53 | async def _notify_email_changed(self, user_id: str, new_email: str, old_email: str) -> None: 54 | raise NotImplementedError 55 | 56 | 57 | class InMemoryPubSubClient(AbstractPubSubClient): 58 | def __init__(self): 59 | super().__init__() 60 | self.commands: list[dict] = [] 61 | self.commit_called = False 62 | self.commit_should_fail = False 63 | self.email_sent = False 64 | self.notify_email_set_called = False 65 | self.notify_email_set_failed = False 66 | self.notify_email_set_new_email = None 67 | self.notify_email_set_old_email = None 68 | self.notify_email_set_should_fail = False 69 | self.notify_email_set_user_id = False 70 | self.notify_kpi_called = False 71 | self.kpi_event: KpiEvent | None = None 72 | self.rollback_called = False 73 | self.rollback_should_fail = False 74 | self.kpi_event_sent = False 75 | 76 | def _get_save_user_messages(self) -> Iterator[dict]: 77 | yield from (command for command in self.commands) 78 | 79 | def _notify_kpi_service(self, event: KpiEvent) -> None: 80 | self.notify_kpi_called = True 81 | self.kpi_event = event 82 | 83 | def _notify_email_changed(self, user_id: str, new_email: str, old_email: str) -> None: 84 | self.notify_email_set_called = True 85 | if self.notify_email_set_should_fail: 86 | raise Exception('notify failed') 87 | self.notify_email_set_user_id = user_id 88 | self.notify_email_set_new_email = new_email 89 | self.notify_email_set_old_email = old_email 90 | 91 | def commit(self) -> None: 92 | self.commit_called = True 93 | if self.commit_should_fail: 94 | raise Exception('commit failed') 95 | if self.notify_email_set_called: 96 | self.email_sent = True 97 | if self.notify_kpi_called: 98 | self.kpi_event_sent = True 99 | 100 | def rollback(self) -> None: 101 | self.rollback_called = True 102 | if self.rollback_should_fail: 103 | raise Exception('rollback failed') 104 | 105 | 106 | class AsyncInMemoryPubSubClient(AbstractAsyncPubSubClient): 107 | 108 | def __init__(self): 109 | super().__init__() 110 | self.commands: list[dict] = [] 111 | self.commit_called = False 112 | self.commit_should_fail = False 113 | self.email_sent = False 114 | self.notify_email_set_called = False 115 | self.notify_email_set_failed = False 116 | self.notify_email_set_new_email = None 117 | self.notify_email_set_old_email = None 118 | self.notify_email_set_should_fail = False 119 | self.notify_email_set_user_id = False 120 | self.notify_kpi_called = False 121 | self.kpi_event: KpiEvent | None = None 122 | self.rollback_called = False 123 | self.rollback_should_fail = False 124 | self.kpi_event_sent = False 125 | 126 | async def _get_save_user_messages(self) -> AsyncIterator[dict]: 127 | for command in self.commands: 128 | yield command 129 | 130 | async def _notify_kpi_service(self, event: KpiEvent) -> None: 131 | self.notify_kpi_called = True 132 | self.kpi_event = event 133 | 134 | async def _notify_email_changed(self, user_id: str, new_email: str, old_email: str) -> None: 135 | self.notify_email_set_called = True 136 | if self.notify_email_set_should_fail: 137 | raise Exception('notify failed') 138 | self.notify_email_set_user_id = user_id 139 | self.notify_email_set_new_email = new_email 140 | self.notify_email_set_old_email = old_email 141 | 142 | async def commit(self) -> None: 143 | self.commit_called = True 144 | if self.commit_should_fail: 145 | raise Exception('commit failed') 146 | if self.notify_email_set_called: 147 | self.email_sent = True 148 | if self.notify_kpi_called: 149 | self.kpi_event_sent = True 150 | 151 | async def rollback(self) -> None: 152 | self.rollback_called = True 153 | if self.rollback_should_fail: 154 | raise Exception('rollback failed') 155 | -------------------------------------------------------------------------------- /demo/adapters/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vklap/py_ddd_framework/4b4beb4cdfa01a36fbb03d3e3b8b85a6098a0871/demo/adapters/repositories/__init__.py -------------------------------------------------------------------------------- /demo/adapters/repositories/user_repository.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | 5 | import ddd 6 | from demo.domain.command_model.user import User 7 | 8 | 9 | class AbstractUserRepository(ddd.RollbackCommitter, abc.ABC): 10 | def get_by_id(self, id_: str) -> User: 11 | return self._get_by_id(id_) 12 | 13 | def save(self, user: User) -> None: 14 | self._save(user) 15 | 16 | @abc.abstractmethod 17 | def _get_by_id(self, id_: str) -> User: 18 | raise NotImplementedError 19 | 20 | @abc.abstractmethod 21 | def _save(self, user: User) -> None: 22 | raise NotImplementedError 23 | 24 | 25 | class AbstractAsyncUserRepository(ddd.AsyncRollbackCommitter, abc.ABC): 26 | async def get_by_id(self, id_: str) -> User: 27 | return await self._get_by_id(id_) 28 | 29 | async def save(self, user: User) -> None: 30 | await self._save(user) 31 | 32 | @abc.abstractmethod 33 | async def _get_by_id(self, id_: str) -> User: 34 | raise NotImplementedError 35 | 36 | @abc.abstractmethod 37 | async def _save(self, user: User) -> None: 38 | raise NotImplementedError 39 | 40 | 41 | class InMemoryUserRepository(AbstractUserRepository): 42 | def __init__(self): 43 | super().__init__() 44 | self.users_by_id: dict[str, User] = {} 45 | self._saved_users: list[User] = [] 46 | self.commit_called = False 47 | self.rollback_called = False 48 | self.commit_should_fail = False 49 | self.rollback_should_fail = False 50 | 51 | def _get_by_id(self, id_: str) -> User: 52 | result = self.users_by_id.get(id_) 53 | if not result: 54 | raise ddd.BoundedContextError(ddd.NOT_FOUND, f'User with ID "{id_}" does not exist') 55 | return result 56 | 57 | def _save(self, user: User) -> None: 58 | self._saved_users.append(user) 59 | 60 | def commit(self) -> None: 61 | self.commit_called = True 62 | if self.commit_should_fail: 63 | raise Exception('commit failed') 64 | for user in self._saved_users: 65 | self.users_by_id[user.get_id()] = user 66 | 67 | def rollback(self) -> None: 68 | self.rollback_called = True 69 | if self.rollback_should_fail: 70 | raise Exception('rollback failed') 71 | self._saved_users.clear() 72 | 73 | 74 | class AsyncInMemoryUserRepository(AbstractAsyncUserRepository): 75 | def __init__(self): 76 | super().__init__() 77 | self.users_by_id: dict[str, User] = {} 78 | self._saved_users: list[User] = [] 79 | self.commit_called = False 80 | self.rollback_called = False 81 | self.commit_should_fail = False 82 | self.rollback_should_fail = False 83 | 84 | async def _get_by_id(self, id_: str) -> User: 85 | result = self.users_by_id.get(id_) 86 | if not result: 87 | raise ddd.BoundedContextError(ddd.NOT_FOUND, f'User with ID "{id_}" does not exist') 88 | return result 89 | 90 | async def _save(self, user: User) -> None: 91 | self._saved_users.append(user) 92 | 93 | async def commit(self) -> None: 94 | self.commit_called = True 95 | if self.commit_should_fail: 96 | raise Exception('commit failed') 97 | for user in self._saved_users: 98 | self.users_by_id[user.get_id()] = user 99 | 100 | async def rollback(self) -> None: 101 | self.rollback_called = True 102 | if self.rollback_should_fail: 103 | raise Exception('rollback failed') 104 | self._saved_users.clear() 105 | -------------------------------------------------------------------------------- /demo/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vklap/py_ddd_framework/4b4beb4cdfa01a36fbb03d3e3b8b85a6098a0871/demo/domain/__init__.py -------------------------------------------------------------------------------- /demo/domain/command_model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vklap/py_ddd_framework/4b4beb4cdfa01a36fbb03d3e3b8b85a6098a0871/demo/domain/command_model/__init__.py -------------------------------------------------------------------------------- /demo/domain/command_model/email_set_event.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | 5 | import ddd 6 | 7 | 8 | @dataclasses.dataclass 9 | class EmailSetEvent(ddd.AbstractEvent): 10 | user_id: str | None = None 11 | new_email: str | None = None 12 | old_email: str | None = None 13 | 14 | @property 15 | def name(self) -> str: 16 | return type(self).__name__ 17 | 18 | -------------------------------------------------------------------------------- /demo/domain/command_model/kpi_event.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | 5 | import ddd 6 | 7 | 8 | @dataclasses.dataclass 9 | class KpiEvent(ddd.AbstractEvent): 10 | action: str | None = None 11 | data: str | None = None 12 | 13 | @property 14 | def name(self) -> str: 15 | return type(self).__name__ 16 | -------------------------------------------------------------------------------- /demo/domain/command_model/save_user_command.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | 5 | import ddd 6 | 7 | 8 | @dataclasses.dataclass 9 | class SaveUserCommand(ddd.AbstractCommand): 10 | user_id: str | None = None 11 | email: str | None = None 12 | 13 | @property 14 | def name(self) -> str: 15 | return type(self).__name__ 16 | 17 | def validate(self) -> None: 18 | if not self.user_id: 19 | raise ddd.BoundedContextError(ddd.BAD_REQUEST, 'Missing user_id') 20 | if not self.email: 21 | raise ddd.BoundedContextError(ddd.BAD_REQUEST, 'Missing email') 22 | -------------------------------------------------------------------------------- /demo/domain/command_model/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ddd 4 | from demo.domain.command_model.email_set_event import EmailSetEvent 5 | 6 | 7 | class User(ddd.AbstractEntity): 8 | def __init__(self, email: str | None = None, id_: str | None = None): 9 | super().__init__() 10 | self._id = id_ 11 | self._email = email 12 | 13 | def get_id(self) -> str: 14 | return self._id 15 | 16 | def set_id(self, value: str) -> None: 17 | self._id = value 18 | 19 | @property 20 | def email(self) -> str: 21 | return self._email 22 | 23 | def set_email(self, value: str) -> None: 24 | if value and self._email != value: 25 | self.add_event(EmailSetEvent(user_id=self._id, new_email=value, old_email=self._email)) 26 | self._email = value 27 | 28 | def __repr__(self) -> str: 29 | return f'<{type(self).__name__}(id={self._id}, email={self._email})>' 30 | -------------------------------------------------------------------------------- /demo/domain/model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | 5 | import ddd 6 | 7 | 8 | @dataclasses.dataclass 9 | class ChangeEmailCommand(ddd.AbstractCommand): 10 | user_id: str | None = None 11 | new_email: str | None = None 12 | 13 | @property 14 | def name(self) -> str: 15 | return type(self).__name__ 16 | 17 | def validate(self) -> None: 18 | if not self.user_id: 19 | raise ddd.BoundedContextError(ddd.BAD_REQUEST, 'Missing user_id') 20 | if not self.new_email: 21 | raise ddd.BoundedContextError(ddd.BAD_REQUEST, 'Missing new_email') 22 | 23 | 24 | @dataclasses.dataclass 25 | class EmailChangedEvent(ddd.AbstractEvent): 26 | user_id: str | None = None 27 | new_email: str | None = None 28 | old_email: str | None = None 29 | 30 | @property 31 | def name(self) -> str: 32 | return type(self).__name__ 33 | 34 | 35 | @dataclasses.dataclass 36 | class NotifySlackEvent(ddd.AbstractEvent): 37 | message: str | None = None 38 | 39 | @property 40 | def name(self) -> str: 41 | return type(self).__name__ 42 | 43 | 44 | class User(ddd.AbstractEntity): 45 | def __init__(self, email: str | None = None, id_: str | None = None): 46 | super().__init__() 47 | self._id = id_ 48 | self._email = email 49 | 50 | def get_id(self) -> str: 51 | return self._id 52 | 53 | def set_id(self, value: str) -> None: 54 | self._id = value 55 | 56 | @property 57 | def email(self) -> str: 58 | return self._email 59 | 60 | def set_email(self, value: str) -> None: 61 | if self._email and self._email != value: 62 | self.add_event(EmailChangedEvent(user_id=self._id, new_email=value, old_email=self._email)) 63 | self._email = value 64 | 65 | def __repr__(self) -> str: 66 | return f'<{type(self).__name__}(id={self._id}, email={self._email})>' 67 | -------------------------------------------------------------------------------- /demo/entrypoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vklap/py_ddd_framework/4b4beb4cdfa01a36fbb03d3e3b8b85a6098a0871/demo/entrypoints/__init__.py -------------------------------------------------------------------------------- /demo/entrypoints/bootstrapper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ddd 4 | from demo.adapters.clients.pubsub_client import InMemoryPubSubClient, AsyncInMemoryPubSubClient 5 | from demo.adapters.repositories.user_repository import InMemoryUserRepository, AsyncInMemoryUserRepository 6 | from demo.domain.command_model.email_set_event import EmailSetEvent 7 | from demo.domain.command_model.kpi_event import KpiEvent 8 | from demo.domain.command_model.save_user_command import SaveUserCommand 9 | from demo.service_layer.command_handlers.save_user_command_handler import SaveUserCommandHandler, \ 10 | AsyncChangeEmailCommandHandler 11 | from demo.service_layer.event_handlers.email_set_event_handler import EmailSetEventHandler, \ 12 | AsyncEmailSetEventHandler 13 | from demo.service_layer.event_handlers.kpi_event_handler import KpiEventHandler, \ 14 | AsyncKpiEventHandler 15 | 16 | 17 | class DemoBootstrapper(ddd.Bootstrapper): 18 | def __init__(self): 19 | super().__init__() 20 | self.user_repository = InMemoryUserRepository() 21 | self.pubsub_client = InMemoryPubSubClient() 22 | self.async_user_repository = AsyncInMemoryUserRepository() 23 | self.async_pubsub_client = AsyncInMemoryPubSubClient() 24 | self.register_command_handler_factory(SaveUserCommand().name, self.create_save_user_command_handler) 25 | self.register_async_command_handler_factory( 26 | SaveUserCommand().name, self.create_async_save_user_command_handler 27 | ) 28 | self.register_event_handler_factory(EmailSetEvent().name, self.create_email_changed_event_handler) 29 | self.register_async_event_handler_factory( 30 | EmailSetEvent().name, self.create_async_email_changed_event_handler 31 | ) 32 | self.register_event_handler_factory(KpiEvent().name, self.create_kpi_event_handler) 33 | self.register_async_event_handler_factory( 34 | KpiEvent().name, self.create_async_kpi_event_handler 35 | ) 36 | 37 | def create_save_user_command_handler(self) -> ddd.AbstractCommandHandler: 38 | """ 39 | Made public for the framework's unit test. 40 | In realworld usage, this method should best be private. 41 | """ 42 | return SaveUserCommandHandler(self.user_repository) 43 | 44 | def create_email_changed_event_handler(self) -> ddd.AbstractEventHandler: 45 | """ 46 | Made public for the framework's unit test. 47 | In realworld usage, this method should best be private. 48 | """ 49 | return EmailSetEventHandler(self.pubsub_client) 50 | 51 | def create_kpi_event_handler(self) -> ddd.AbstractEventHandler: 52 | """ 53 | Made public for the framework's unit test. 54 | In realworld usage, this method should best be private. 55 | """ 56 | return KpiEventHandler(self.pubsub_client) 57 | 58 | def create_async_save_user_command_handler(self) -> ddd.AbstractAsyncCommandHandler: 59 | """ 60 | Made public for the framework's unit test. 61 | In realworld usage, this method should best be private. 62 | """ 63 | return AsyncChangeEmailCommandHandler(self.async_user_repository) 64 | 65 | def create_async_email_changed_event_handler(self) -> ddd.AbstractAsyncEventHandler: 66 | """ 67 | Made public for the framework's unit test. 68 | In realworld usage, this method should best be private. 69 | """ 70 | return AsyncEmailSetEventHandler(self.async_pubsub_client) 71 | 72 | def create_async_kpi_event_handler(self) -> ddd.AbstractAsyncEventHandler: 73 | """ 74 | Made public for the framework's unit test. 75 | In realworld usage, this method should best be private. 76 | """ 77 | return AsyncKpiEventHandler(self.async_pubsub_client) 78 | -------------------------------------------------------------------------------- /demo/entrypoints/pubsub/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vklap/py_ddd_framework/4b4beb4cdfa01a36fbb03d3e3b8b85a6098a0871/demo/entrypoints/pubsub/__init__.py -------------------------------------------------------------------------------- /demo/entrypoints/pubsub/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from demo.domain.command_model.save_user_command import SaveUserCommand 4 | from demo.domain.command_model.user import User 5 | from demo.entrypoints.bootstrapper import DemoBootstrapper 6 | 7 | 8 | def main(): 9 | # Setup demo bootstrap with fake in memory data 10 | bootstrapper = DemoBootstrapper() 11 | bootstrapper.async_user_repository.users_by_id['1'] = User(email='kamel.amin@thaabet.sy', id_='1') 12 | 13 | # Imagine you just received a ChangeEmail message from pubsub 14 | command = SaveUserCommand(user_id='1', email='eli.cohen@mossad.gov.il') 15 | 16 | asyncio.run(bootstrapper.async_handle_command(command)) 17 | 18 | 19 | if __name__ == '__main__': 20 | main() 21 | -------------------------------------------------------------------------------- /demo/service_layer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vklap/py_ddd_framework/4b4beb4cdfa01a36fbb03d3e3b8b85a6098a0871/demo/service_layer/__init__.py -------------------------------------------------------------------------------- /demo/service_layer/command_handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vklap/py_ddd_framework/4b4beb4cdfa01a36fbb03d3e3b8b85a6098a0871/demo/service_layer/command_handlers/__init__.py -------------------------------------------------------------------------------- /demo/service_layer/command_handlers/change_email_command_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ddd 4 | from demo.adapters.repositories.user_repository import AbstractUserRepository, AbstractAsyncUserRepository 5 | from demo.domain.model import ChangeEmailCommand 6 | 7 | 8 | class ChangeEmailCommandHandler(ddd.AbstractCommandHandler[ChangeEmailCommand, str]): 9 | def __init__(self, user_repository: AbstractUserRepository): 10 | super().__init__() 11 | self._user_repository = user_repository 12 | self._events: list[ddd.AbstractEvent] = [] 13 | 14 | def handle(self, command: ddd.TCommand) -> ddd.THandleCommandResult: 15 | user = self._user_repository.get_by_id(command.user_id) 16 | user.set_email(command.new_email) 17 | self._events.extend(user.events) 18 | return user.get_id() 19 | 20 | @property 21 | def events(self) -> list[ddd.AbstractEvent]: 22 | return list(self._events) 23 | 24 | def commit(self) -> None: 25 | self._user_repository.commit() 26 | 27 | def rollback(self) -> None: 28 | self._user_repository.rollback() 29 | 30 | 31 | class AsyncChangeEmailCommandHandler(ddd.AbstractAsyncCommandHandler[ChangeEmailCommand, str]): 32 | def __init__(self, user_repository: AbstractAsyncUserRepository): 33 | super().__init__() 34 | self._user_repository = user_repository 35 | self._events: list[ddd.AbstractEvent] = [] 36 | 37 | async def handle(self, command: ddd.TCommand) -> ddd.THandleCommandResult: 38 | user = await self._user_repository.get_by_id(command.user_id) 39 | user.set_email(command.new_email) 40 | self._events.extend(user.events) 41 | return user.get_id() 42 | 43 | @property 44 | def events(self) -> list[ddd.AbstractEvent]: 45 | return list(self._events) 46 | 47 | async def commit(self) -> None: 48 | await self._user_repository.commit() 49 | 50 | async def rollback(self) -> None: 51 | await self._user_repository.rollback() 52 | -------------------------------------------------------------------------------- /demo/service_layer/command_handlers/save_user_command_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ddd 4 | from demo.adapters.repositories.user_repository import AbstractUserRepository, AbstractAsyncUserRepository 5 | from demo.domain.command_model.save_user_command import SaveUserCommand 6 | 7 | 8 | class SaveUserCommandHandler(ddd.AbstractCommandHandler[SaveUserCommand, str]): 9 | def __init__(self, user_repository: AbstractUserRepository): 10 | super().__init__() 11 | self._user_repository = user_repository 12 | self._events: list[ddd.AbstractEvent] = [] 13 | 14 | def handle(self, command: ddd.TCommand) -> ddd.THandleCommandResult: 15 | user = self._user_repository.get_by_id(command.user_id) 16 | user.set_email(command.email) 17 | self._events.extend(user.events) 18 | return user.get_id() 19 | 20 | @property 21 | def events(self) -> list[ddd.AbstractEvent]: 22 | return list(self._events) 23 | 24 | def commit(self) -> None: 25 | self._user_repository.commit() 26 | 27 | def rollback(self) -> None: 28 | self._user_repository.rollback() 29 | 30 | 31 | class AsyncChangeEmailCommandHandler(ddd.AbstractAsyncCommandHandler[SaveUserCommand, str]): 32 | def __init__(self, user_repository: AbstractAsyncUserRepository): 33 | super().__init__() 34 | self._user_repository = user_repository 35 | self._events: list[ddd.AbstractEvent] = [] 36 | 37 | async def handle(self, command: ddd.TCommand) -> ddd.THandleCommandResult: 38 | user = await self._user_repository.get_by_id(command.user_id) 39 | user.set_email(command.email) 40 | self._events.extend(user.events) 41 | return user.get_id() 42 | 43 | @property 44 | def events(self) -> list[ddd.AbstractEvent]: 45 | return list(self._events) 46 | 47 | async def commit(self) -> None: 48 | await self._user_repository.commit() 49 | 50 | async def rollback(self) -> None: 51 | await self._user_repository.rollback() 52 | -------------------------------------------------------------------------------- /demo/service_layer/event_handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vklap/py_ddd_framework/4b4beb4cdfa01a36fbb03d3e3b8b85a6098a0871/demo/service_layer/event_handlers/__init__.py -------------------------------------------------------------------------------- /demo/service_layer/event_handlers/email_changed_event_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ddd 4 | from demo.adapters.clients.pubsub_client import AbstractPubSubClient, AbstractAsyncPubSubClient 5 | from demo.domain.model import EmailChangedEvent, NotifySlackEvent 6 | 7 | 8 | class EmailChangedEventHandler(ddd.AbstractEventHandler[EmailChangedEvent]): 9 | def __init__(self, email_client: AbstractPubSubClient): 10 | super().__init__() 11 | self._email_client = email_client 12 | self._events: list[ddd.AbstractEvent] = [] 13 | 14 | def handle(self, event: ddd.TEvent) -> None: 15 | self._email_client.notify_email_changed(event.user_id, event.new_email, event.old_email) 16 | self._events.append( 17 | NotifySlackEvent(message=f'requested to notify user_id "{event.user_id}" about email modification') 18 | ) 19 | 20 | @property 21 | def events(self) -> list[ddd.AbstractEvent]: 22 | return list(self._events) 23 | 24 | def commit(self) -> None: 25 | self._email_client.commit() 26 | 27 | def rollback(self) -> None: 28 | self._email_client.rollback() 29 | 30 | 31 | class AsyncEmailChangedEventHandler(ddd.AbstractAsyncEventHandler[EmailChangedEvent]): 32 | def __init__(self, email_client: AbstractAsyncPubSubClient): 33 | super().__init__() 34 | self._email_client = email_client 35 | self._events: list[ddd.AbstractEvent] = [] 36 | 37 | async def handle(self, event: ddd.TEvent) -> None: 38 | await self._email_client.notify_email_changed(event.user_id, event.new_email, event.old_email) 39 | self._events.append( 40 | NotifySlackEvent(message=f'requested to notify user_id "{event.user_id}" about email modification') 41 | ) 42 | 43 | @property 44 | def events(self) -> list[ddd.AbstractEvent]: 45 | return list(self._events) 46 | 47 | async def commit(self) -> None: 48 | await self._email_client.commit() 49 | 50 | async def rollback(self) -> None: 51 | await self._email_client.rollback() 52 | -------------------------------------------------------------------------------- /demo/service_layer/event_handlers/email_set_event_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ddd 4 | from demo.adapters.clients.pubsub_client import AbstractPubSubClient, AbstractAsyncPubSubClient 5 | from demo.domain.command_model.email_set_event import EmailSetEvent 6 | from demo.domain.command_model.kpi_event import KpiEvent 7 | 8 | 9 | class EmailSetEventHandler(ddd.AbstractEventHandler[EmailSetEvent]): 10 | def __init__(self, email_client: AbstractPubSubClient): 11 | super().__init__() 12 | self._email_client = email_client 13 | self._events: list[ddd.AbstractEvent] = [] 14 | 15 | def handle(self, event: ddd.TEvent) -> None: 16 | self._email_client.notify_email_changed(event.user_id, event.new_email, event.old_email) 17 | self._events.append( 18 | KpiEvent(action=event.name, data=f'{event!r}') 19 | ) 20 | 21 | @property 22 | def events(self) -> list[ddd.AbstractEvent]: 23 | return list(self._events) 24 | 25 | def commit(self) -> None: 26 | self._email_client.commit() 27 | 28 | def rollback(self) -> None: 29 | self._email_client.rollback() 30 | 31 | 32 | class AsyncEmailSetEventHandler(ddd.AbstractAsyncEventHandler[EmailSetEvent]): 33 | def __init__(self, email_client: AbstractAsyncPubSubClient): 34 | super().__init__() 35 | self._email_client = email_client 36 | self._events: list[ddd.AbstractEvent] = [] 37 | 38 | async def handle(self, event: ddd.TEvent) -> None: 39 | await self._email_client.notify_email_changed(event.user_id, event.new_email, event.old_email) 40 | self._events.append( 41 | KpiEvent(action=event.name, data=f'{event!r}') 42 | ) 43 | 44 | @property 45 | def events(self) -> list[ddd.AbstractEvent]: 46 | return list(self._events) 47 | 48 | async def commit(self) -> None: 49 | await self._email_client.commit() 50 | 51 | async def rollback(self) -> None: 52 | await self._email_client.rollback() 53 | -------------------------------------------------------------------------------- /demo/service_layer/event_handlers/kpi_event_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ddd 4 | from demo.adapters.clients.pubsub_client import AbstractPubSubClient, AbstractAsyncPubSubClient 5 | from demo.domain.command_model.kpi_event import KpiEvent 6 | 7 | 8 | class KpiEventHandler(ddd.AbstractEventHandler[KpiEvent]): 9 | def __init__(self, pubsub_client: AbstractPubSubClient): 10 | super().__init__() 11 | self._pubsub_client = pubsub_client 12 | self._events: list[ddd.AbstractEvent] = [] 13 | 14 | def handle(self, event: ddd.TEvent) -> None: 15 | self._pubsub_client.notify_kpi_service(event) 16 | 17 | @property 18 | def events(self) -> list[ddd.AbstractEvent]: 19 | return list(self._events) 20 | 21 | def commit(self) -> None: 22 | self._pubsub_client.commit() 23 | 24 | def rollback(self) -> None: 25 | self._pubsub_client.rollback() 26 | 27 | 28 | class AsyncKpiEventHandler(ddd.AbstractAsyncEventHandler[KpiEvent]): 29 | def __init__(self, pubsub_client: AbstractAsyncPubSubClient): 30 | super().__init__() 31 | self._pubsub_client = pubsub_client 32 | self._events: list[ddd.AbstractEvent] = [] 33 | 34 | async def handle(self, event: ddd.TEvent) -> None: 35 | await self._pubsub_client.notify_kpi_service(event) 36 | 37 | @property 38 | def events(self) -> list[ddd.AbstractEvent]: 39 | return list(self._events) 40 | 41 | async def commit(self) -> None: 42 | await self._pubsub_client.commit() 43 | 44 | async def rollback(self) -> None: 45 | await self._pubsub_client.rollback() 46 | -------------------------------------------------------------------------------- /demo/service_layer/event_handlers/notify_slack_event_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ddd 4 | from demo.adapters.clients.pubsub_client import AbstractPubSubClient, AbstractAsyncPubSubClient 5 | from demo.domain.model import NotifySlackEvent 6 | 7 | 8 | class NotifySlackEventHandler(ddd.AbstractEventHandler[NotifySlackEvent]): 9 | def __init__(self, email_client: AbstractPubSubClient): 10 | super().__init__() 11 | self._email_client = email_client 12 | self._events: list[ddd.AbstractEvent] = [] 13 | 14 | def handle(self, event: ddd.TEvent) -> None: 15 | self._email_client.notify_slack(event.message) 16 | 17 | @property 18 | def events(self) -> list[ddd.AbstractEvent]: 19 | return list(self._events) 20 | 21 | def commit(self) -> None: 22 | self._email_client.commit() 23 | 24 | def rollback(self) -> None: 25 | self._email_client.rollback() 26 | 27 | 28 | class AsyncNotifySlackEventHandler(ddd.AbstractAsyncEventHandler[NotifySlackEvent]): 29 | def __init__(self, email_client: AbstractAsyncPubSubClient): 30 | super().__init__() 31 | self._email_client = email_client 32 | self._events: list[ddd.AbstractEvent] = [] 33 | 34 | async def handle(self, event: ddd.TEvent) -> None: 35 | await self._email_client.notify_slack(event.message) 36 | 37 | @property 38 | def events(self) -> list[ddd.AbstractEvent]: 39 | return list(self._events) 40 | 41 | async def commit(self) -> None: 42 | await self._email_client.commit() 43 | 44 | async def rollback(self) -> None: 45 | await self._email_client.rollback() 46 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==7.2.0 2 | pytest-asyncio==0.20.3 -------------------------------------------------------------------------------- /run_demo.py: -------------------------------------------------------------------------------- 1 | from demo.entrypoints.pubsub.main import main 2 | 3 | if __name__ == '__main__': 4 | main() 5 | print('Demo run successfully') 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup 4 | 5 | __version__ = '0.1.0' 6 | 7 | 8 | def get_long_description(filename): 9 | root_dir = os.path.abspath(os.path.curdir) 10 | if root_dir.endswith('/src'): 11 | root_dir = root_dir.replace('/src', '') 12 | full_path = os.path.join(root_dir, filename) 13 | if not os.path.exists(full_path): 14 | raise ValueError(f'{filename} could not be found at {full_path}') 15 | 16 | with open(full_path) as f: 17 | description = f.read() 18 | 19 | return description 20 | 21 | 22 | setup( 23 | name='py_ddd_framework', 24 | version=__version__, 25 | description='Python Domain-Driven Design (DDD) Framework', 26 | long_description=get_long_description('README.md'), 27 | long_description_content_type='text/markdown', 28 | author='Victor Klapholz', 29 | author_email='victor.klapholz@gmail.com', 30 | url='https://github.com/vklap/py_ddd_framework', 31 | keywords='DDD Domain Driven Design Framework Domain-Driven', 32 | license='MIT', 33 | packages=['ddd'], 34 | package_dir={'': 'src'}, 35 | install_requires=[], 36 | tests_require=[ 37 | "pytest>=7.2.0", 38 | "pytest-asyncio>=0.20.3", 39 | ], 40 | classifiers=[ 41 | 'Programming Language :: Python :: 3.7', 42 | 'Programming Language :: Python :: 3.8', 43 | 'Programming Language :: Python :: 3.9', 44 | 'Programming Language :: Python :: 3.10', 45 | 'Programming Language :: Python :: 3.11', 46 | 'License :: OSI Approved :: MIT License', 47 | 'Operating System :: OS Independent', 48 | ], 49 | ) 50 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import sys 3 | 4 | sys.path.append(str(pathlib.Path(__file__).parent)) 5 | 6 | -------------------------------------------------------------------------------- /src/ddd/__init__.py: -------------------------------------------------------------------------------- 1 | from ddd.bootstrapper import * 2 | from ddd.error import * 3 | from ddd.handlers import * 4 | from ddd.model import * 5 | from ddd.repository import * 6 | -------------------------------------------------------------------------------- /src/ddd/bootstrapper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from collections.abc import Callable 5 | from typing import Any, Type 6 | 7 | from ddd.factories import CommandHandlerFactory, EventHandlersFactory, CreateCommandHandler, CreateEventHandler, \ 8 | AsyncCommandHandlerFactory, AsyncEventHandlersFactory 9 | from ddd.handlers import CreateAsyncCommandHandler, CreateAsyncEventHandler, AbstractCommandHandler, \ 10 | AbstractEventHandler, AbstractAsyncCommandHandler, AbstractAsyncEventHandler 11 | from ddd.message_bus import MessageBus, AsyncMessageBus 12 | from ddd.model import AbstractCommand 13 | 14 | 15 | class Bootstrapper: 16 | def __init__(self): 17 | self._command_handler_factory = CommandHandlerFactory() 18 | self._async_command_handler_factory = AsyncCommandHandlerFactory() 19 | self._event_handlers_factory = EventHandlersFactory() 20 | self._async_event_handlers_factory = AsyncEventHandlersFactory() 21 | 22 | def register_command_handler_factory(self, command_name: str, factory: CreateCommandHandler) -> None: 23 | self._validate_type_returned_by(factory, AbstractCommandHandler) 24 | self._command_handler_factory.register(command_name, factory) 25 | 26 | def register_event_handler_factory(self, event_name: str, factory: CreateEventHandler) -> None: 27 | self._validate_type_returned_by(factory, AbstractEventHandler) 28 | self._event_handlers_factory.register(event_name, factory) 29 | 30 | def register_async_command_handler_factory(self, command_name: str, factory: CreateAsyncCommandHandler) -> None: 31 | self._validate_type_returned_by(factory, AbstractAsyncCommandHandler) 32 | self._async_command_handler_factory.register(command_name, factory) 33 | 34 | def register_async_event_handler_factory(self, event_name: str, factory: CreateAsyncEventHandler) -> None: 35 | self._validate_type_returned_by(factory, AbstractAsyncEventHandler) 36 | self._async_event_handlers_factory.register(event_name, factory) 37 | 38 | def handle_command(self, command: AbstractCommand) -> Any: 39 | message_bus = MessageBus(self._command_handler_factory, self._event_handlers_factory) 40 | result = message_bus.publish(command) 41 | return result 42 | 43 | async def async_handle_command(self, command: AbstractCommand) -> Any: 44 | message_bus = AsyncMessageBus(self._async_command_handler_factory, self._async_event_handlers_factory) 45 | result = await message_bus.publish(command) 46 | return result 47 | 48 | @classmethod 49 | def _validate_type_returned_by(cls, func: Callable, type_: Type) -> None: 50 | signature = inspect.signature(func) 51 | if signature.return_annotation is inspect.Signature.empty: 52 | return 53 | if signature.return_annotation.split('.')[-1] != type_.__name__: 54 | raise ValueError(f'register expected "{type_.__name__}", got "{signature.return_annotation}"') 55 | 56 | -------------------------------------------------------------------------------- /src/ddd/error.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | NOT_FOUND = 'not_found' 4 | BAD_REQUEST = 'bad_request' 5 | SERVER_ERROR = 'server_error' 6 | 7 | 8 | class BoundedContextError(Exception): 9 | def __init__(self, status_code: str = None, *args): 10 | super().__init__(*args) 11 | self._status_code = status_code 12 | 13 | @property 14 | def error(self) -> str: 15 | return str(self) 16 | 17 | @property 18 | def status_code(self) -> str | None: 19 | return self._status_code 20 | 21 | -------------------------------------------------------------------------------- /src/ddd/factories.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import collections 5 | from typing import Generic, TypeVar, Union 6 | 7 | from ddd.handlers import AbstractCommandHandler, AbstractEventHandler, CreateCommandHandler, CreateEventHandler, \ 8 | AbstractAsyncCommandHandler, CreateAsyncCommandHandler, CreateAsyncEventHandler, AbstractAsyncEventHandler 9 | 10 | TCreateCommandHandler = TypeVar('TCreateCommandHandler', bound=Union[CreateCommandHandler, CreateAsyncCommandHandler]) 11 | TAbstractCommandHandler = TypeVar( 12 | 'TAbstractCommandHandler', bound=Union[AbstractCommandHandler, AbstractAsyncCommandHandler] 13 | ) 14 | TCreateEventHandler = TypeVar('TCreateEventHandler', bound=Union[CreateEventHandler, CreateAsyncEventHandler]) 15 | TAbstractEventHandler = TypeVar( 16 | 'TAbstractEventHandler', bound=Union[AbstractEventHandler, AbstractAsyncEventHandler] 17 | ) 18 | 19 | 20 | class _AbstractCommandHandlerFactory(Generic[TCreateCommandHandler, TAbstractCommandHandler], abc.ABC): 21 | def __init__(self): 22 | self._handler_factories: dict[str, TCreateCommandHandler] = {} 23 | 24 | def register(self, command_name: str, factory: TCreateCommandHandler) -> None: 25 | self._handler_factories[command_name] = factory 26 | 27 | def create_handler(self, command_name: str) -> TAbstractCommandHandler: 28 | factory = self._handler_factories.get(command_name) 29 | if not factory: 30 | raise ValueError(f'Handler factory was not registered for command: "{command_name}"') 31 | return factory() 32 | 33 | 34 | class CommandHandlerFactory(_AbstractCommandHandlerFactory[CreateCommandHandler, AbstractCommandHandler]): 35 | """CommandHandlerFactory""" 36 | 37 | 38 | class AsyncCommandHandlerFactory( 39 | _AbstractCommandHandlerFactory[CreateAsyncCommandHandler, AbstractAsyncCommandHandler] 40 | ): 41 | """AsyncCommandHandlerFactory""" 42 | 43 | 44 | class _AbstractEventHandlersFactory(Generic[TCreateEventHandler, TAbstractEventHandler], abc.ABC): 45 | def __init__(self): 46 | self._handler_factories: dict[str, [TCreateEventHandler]] = collections.defaultdict(list) 47 | 48 | def register(self, event_name: str, factory: TCreateEventHandler) -> None: 49 | self._handler_factories[event_name].append(factory) 50 | 51 | def create_handlers(self, event_name: str) -> list[TAbstractEventHandler]: 52 | factories = self._handler_factories[event_name] 53 | result = [] 54 | for factory in factories: 55 | handler = factory() 56 | result.append(handler) 57 | return result 58 | 59 | 60 | class EventHandlersFactory(_AbstractEventHandlersFactory[CreateEventHandler, AbstractEventHandler]): 61 | """EventHandlersFactory""" 62 | 63 | 64 | class AsyncEventHandlersFactory(_AbstractEventHandlersFactory[CreateAsyncEventHandler, AbstractAsyncEventHandler]): 65 | """AsyncEventHandlersFactory""" 66 | -------------------------------------------------------------------------------- /src/ddd/handlers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from typing import TypeVar, Generic, Callable 5 | 6 | from ddd.model import AbstractCommand, AbstractEvent 7 | from ddd.repository import RollbackCommitter, AsyncRollbackCommitter 8 | 9 | THandleCommandResult = TypeVar('THandleCommandResult') 10 | TCommand = TypeVar('TCommand', bound=AbstractCommand) 11 | TEvent = TypeVar('TEvent', bound=AbstractEvent) 12 | 13 | 14 | class _EventsReporter(abc.ABC): 15 | @property 16 | @abc.abstractmethod 17 | def events(self) -> list[AbstractEvent]: 18 | raise NotImplementedError 19 | 20 | 21 | class AbstractCommandHandler(Generic[TCommand, THandleCommandResult], _EventsReporter, RollbackCommitter, abc.ABC): 22 | def handle(self, command: TCommand) -> THandleCommandResult: 23 | raise NotImplementedError 24 | 25 | 26 | class AbstractAsyncCommandHandler( 27 | Generic[TCommand, THandleCommandResult], _EventsReporter, AsyncRollbackCommitter, abc.ABC 28 | ): 29 | async def handle(self, command: TCommand) -> THandleCommandResult: 30 | raise NotImplementedError 31 | 32 | 33 | class AbstractEventHandler(Generic[TEvent], _EventsReporter, RollbackCommitter, abc.ABC): 34 | def handle(self, event: TEvent) -> None: 35 | raise NotImplementedError 36 | 37 | 38 | class AbstractAsyncEventHandler(Generic[TEvent], _EventsReporter, RollbackCommitter, abc.ABC): 39 | async def handle(self, event: TEvent) -> None: 40 | raise NotImplementedError 41 | 42 | 43 | CreateCommandHandler = Callable[[], AbstractCommandHandler] 44 | CreateAsyncCommandHandler = Callable[[], AbstractAsyncCommandHandler] 45 | CreateEventHandler = Callable[[], AbstractEventHandler] 46 | CreateAsyncEventHandler = Callable[[], AbstractAsyncEventHandler] 47 | -------------------------------------------------------------------------------- /src/ddd/message_bus.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections 4 | from typing import Deque, Any 5 | 6 | from ddd.factories import CommandHandlerFactory, EventHandlersFactory, AsyncCommandHandlerFactory, \ 7 | AsyncEventHandlersFactory 8 | from ddd.model import AbstractEvent, AbstractCommand 9 | from ddd.unit_of_work import CommandUnitOfWork, EventUnitOfWork, AsyncCommandUnitOfWork, AsyncEventUnitOfWork 10 | 11 | 12 | class MessageBus: 13 | def __init__(self, command_handler_factory: CommandHandlerFactory, event_handlers_factory: EventHandlersFactory): 14 | self._command_handler_factory = command_handler_factory 15 | self._event_handlers_factory = event_handlers_factory 16 | self._events: Deque[AbstractEvent] = collections.deque() 17 | 18 | def publish(self, command: AbstractCommand) -> Any: 19 | command.validate() 20 | handler = self._command_handler_factory.create_handler(command.name) 21 | with CommandUnitOfWork(handler) as uow: 22 | result = uow.handle(command) 23 | self._events.extend(handler.events) 24 | self._handle_events() 25 | return result 26 | 27 | def _handle_events(self) -> None: 28 | while self._events: 29 | event = self._events.popleft() 30 | handlers = self._event_handlers_factory.create_handlers(event.name) 31 | for handler in handlers: 32 | with EventUnitOfWork(handler) as uow: 33 | uow.handle(event) 34 | self._events.extend(handler.events) 35 | 36 | 37 | class AsyncMessageBus: 38 | def __init__( 39 | self, command_handler_factory: AsyncCommandHandlerFactory, event_handlers_factory: AsyncEventHandlersFactory 40 | ): 41 | self._command_handler_factory = command_handler_factory 42 | self._event_handlers_factory = event_handlers_factory 43 | self._events: Deque[AbstractEvent] = collections.deque() 44 | 45 | async def publish(self, command: AbstractCommand) -> Any: 46 | command.validate() 47 | handler = self._command_handler_factory.create_handler(command.name) 48 | async with AsyncCommandUnitOfWork(handler) as uow: 49 | result = await uow.handle(command) 50 | self._events.extend(handler.events) 51 | await self._handle_events() 52 | return result 53 | 54 | async def _handle_events(self) -> None: 55 | while self._events: 56 | event = self._events.popleft() 57 | handlers = self._event_handlers_factory.create_handlers(event.name) 58 | for handler in handlers: 59 | async with AsyncEventUnitOfWork(handler) as uow: 60 | await uow.handle(event) 61 | self._events.extend(handler.events) 62 | -------------------------------------------------------------------------------- /src/ddd/model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | 5 | 6 | class AbstractCommand(abc.ABC): 7 | @property 8 | @abc.abstractmethod 9 | def name(self) -> str: 10 | raise NotImplementedError 11 | 12 | @abc.abstractmethod 13 | def validate(self) -> None: 14 | raise NotImplementedError 15 | 16 | 17 | class AbstractEvent(abc.ABC): 18 | @property 19 | @abc.abstractmethod 20 | def name(self) -> str: 21 | raise NotImplementedError 22 | 23 | 24 | class AbstractEntity(abc.ABC): 25 | def __init__(self): 26 | self._events: list[AbstractEvent] = [] 27 | 28 | @abc.abstractmethod 29 | def get_id(self) -> str: 30 | raise NotImplementedError 31 | 32 | def set_id(self, value: str) -> str: 33 | raise NotImplementedError 34 | 35 | def add_event(self, event: AbstractEvent) -> None: 36 | self._events.append(event) 37 | 38 | @property 39 | def events(self) -> list[AbstractEvent]: 40 | return list(self._events) 41 | -------------------------------------------------------------------------------- /src/ddd/repository.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | 5 | 6 | class RollbackCommitter(abc.ABC): 7 | @abc.abstractmethod 8 | def commit(self) -> None: 9 | raise NotImplementedError 10 | 11 | @abc.abstractmethod 12 | def rollback(self) -> None: 13 | raise NotImplementedError 14 | 15 | 16 | class AsyncRollbackCommitter(abc.ABC): 17 | @abc.abstractmethod 18 | async def commit(self) -> None: 19 | raise NotImplementedError 20 | 21 | @abc.abstractmethod 22 | async def rollback(self) -> None: 23 | raise NotImplementedError 24 | -------------------------------------------------------------------------------- /src/ddd/unit_of_work.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from types import TracebackType 5 | from typing import Any, Generic, Type, TypeVar, Union 6 | 7 | from ddd.handlers import ( 8 | AbstractCommandHandler, 9 | AbstractEventHandler, 10 | AbstractAsyncCommandHandler, 11 | AbstractAsyncEventHandler, 12 | ) 13 | from ddd.model import AbstractCommand, AbstractEvent 14 | 15 | Message = Union[AbstractCommand, AbstractEvent] 16 | TMessage = TypeVar('TMessage', bound=Message) 17 | Handler = Union[AbstractCommandHandler, AbstractEventHandler] 18 | THandler = TypeVar('THandler', bound=Handler) 19 | AsyncHandler = Union[AbstractAsyncCommandHandler, AbstractAsyncEventHandler] 20 | TAsyncHandler = TypeVar('TAsyncHandler', bound=AsyncHandler) 21 | 22 | 23 | class AbstractUnitOfWork(Generic[TMessage, THandler], abc.ABC): 24 | def __init__(self, handler: THandler): 25 | self._handler = handler 26 | 27 | def __enter__(self) -> AbstractUnitOfWork: 28 | return self 29 | 30 | def __exit__(self, exc_type: Type, exc_val: Exception, exc_tb: TracebackType) -> bool | None: 31 | if exc_val: 32 | self._handler.rollback() 33 | else: 34 | self._handler.commit() 35 | 36 | def handle(self, message: TMessage) -> Any: 37 | result = self._handler.handle(message) 38 | return result 39 | 40 | 41 | class AbstractAsyncUnitOfWork(Generic[TMessage, TAsyncHandler], abc.ABC): 42 | def __init__(self, handler: TAsyncHandler): 43 | self._handler = handler 44 | 45 | async def __aenter__(self) -> AbstractAsyncUnitOfWork: 46 | return self 47 | 48 | async def __aexit__(self, exc_type: Type, exc_val: Exception, exc_tb: TracebackType) -> bool | None: 49 | if exc_val: 50 | await self._handler.rollback() 51 | else: 52 | await self._handler.commit() 53 | 54 | async def handle(self, message: TMessage) -> Any: 55 | result = await self._handler.handle(message) 56 | return result 57 | 58 | 59 | class CommandUnitOfWork(AbstractUnitOfWork[AbstractCommand, AbstractCommandHandler]): 60 | """CommandUnitOfWork""" 61 | 62 | 63 | class AsyncCommandUnitOfWork(AbstractAsyncUnitOfWork[AbstractCommand, AbstractAsyncCommandHandler]): 64 | """CommandUnitOfWork""" 65 | 66 | 67 | class EventUnitOfWork(AbstractUnitOfWork[AbstractEvent, AbstractEventHandler]): 68 | """EventUnitOfWork""" 69 | 70 | 71 | class AsyncEventUnitOfWork(AbstractAsyncUnitOfWork[AbstractEvent, AbstractAsyncEventHandler]): 72 | """AsyncEventUnitOfWork""" 73 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pathlib 3 | 4 | 5 | sys.path.append(str(pathlib.Path(__file__).parent.parent / 'src')) 6 | -------------------------------------------------------------------------------- /tests/test_bootstrapper.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import ddd 4 | from demo.domain.command_model.save_user_command import SaveUserCommand 5 | from demo.domain.command_model.user import User 6 | from demo.entrypoints.bootstrapper import DemoBootstrapper 7 | 8 | 9 | class TestBootstrapper: 10 | A_NAME = 'a name' 11 | COMMIT_FAILED = 'commit failed' 12 | ROLLBACK_FAILED = 'rollback failed' 13 | NEW_EMAIL = 'eli.cohen@mossad.gov.il' 14 | OLD_EMAIL = 'kamel.amin@thaabet.sy' 15 | USER_ID = 'agent_566' 16 | 17 | @pytest.fixture 18 | def bootstrapper(self) -> DemoBootstrapper: 19 | return DemoBootstrapper() 20 | 21 | def test_change_email(self, bootstrapper): 22 | self._fill_user_in_repo(bootstrapper) 23 | command = SaveUserCommand(self.USER_ID, self.NEW_EMAIL) 24 | 25 | result = bootstrapper.handle_command(command) 26 | 27 | assert result == self.USER_ID 28 | user = bootstrapper.user_repository.users_by_id[self.USER_ID] 29 | assert user.email == self.NEW_EMAIL 30 | assert bootstrapper.user_repository.commit_called 31 | assert not bootstrapper.user_repository.rollback_called 32 | assert bootstrapper.pubsub_client.notify_email_set_new_email == self.NEW_EMAIL 33 | assert bootstrapper.pubsub_client.notify_email_set_old_email == self.OLD_EMAIL 34 | assert bootstrapper.pubsub_client.notify_email_set_user_id == self.USER_ID 35 | assert bootstrapper.pubsub_client.kpi_event_sent 36 | 37 | def test_invalid_command(self, bootstrapper): 38 | self._fill_user_in_repo(bootstrapper) 39 | command = SaveUserCommand(self.USER_ID, email='') 40 | 41 | with pytest.raises(ddd.BoundedContextError) as e: 42 | bootstrapper.handle_command(command) 43 | 44 | assert e.value.status_code == ddd.error.BAD_REQUEST 45 | assert 'missing' in str(e.value).lower() 46 | 47 | def test_user_does_not_exist(self, bootstrapper): 48 | self._fill_user_in_repo(bootstrapper) 49 | command = SaveUserCommand('not-existing-user-id', self.NEW_EMAIL) 50 | 51 | with pytest.raises(ddd.BoundedContextError) as e: 52 | bootstrapper.handle_command(command) 53 | 54 | assert e.value.status_code == ddd.error.NOT_FOUND 55 | assert 'does not exist' in str(e.value).lower() 56 | assert bootstrapper.user_repository.rollback_called 57 | 58 | def test_command_handler_commit_raises(self, bootstrapper): 59 | self._fill_user_in_repo(bootstrapper) 60 | bootstrapper.user_repository.commit_should_fail = True 61 | command = SaveUserCommand(self.USER_ID, self.NEW_EMAIL) 62 | 63 | with pytest.raises(Exception) as e: 64 | bootstrapper.handle_command(command) 65 | 66 | assert self.COMMIT_FAILED in str(e.value).lower() 67 | assert bootstrapper.user_repository.commit_called 68 | assert not bootstrapper.user_repository.rollback_called 69 | 70 | def test_command_handler_rollback_raises(self, bootstrapper): 71 | self._fill_user_in_repo(bootstrapper) 72 | bootstrapper.user_repository.rollback_should_fail = True 73 | command = SaveUserCommand('non-existing-user-id', self.NEW_EMAIL) 74 | 75 | with pytest.raises(Exception) as e: 76 | bootstrapper.handle_command(command) 77 | 78 | assert self.ROLLBACK_FAILED in str(e.value).lower() 79 | assert not bootstrapper.user_repository.commit_called 80 | assert bootstrapper.user_repository.rollback_called 81 | 82 | def test_event_handler_commit_raises(self, bootstrapper): 83 | self._fill_user_in_repo(bootstrapper) 84 | bootstrapper.pubsub_client.commit_should_fail = True 85 | command = SaveUserCommand(self.USER_ID, self.NEW_EMAIL) 86 | 87 | with pytest.raises(Exception) as e: 88 | bootstrapper.handle_command(command) 89 | 90 | assert self.COMMIT_FAILED in str(e.value).lower() 91 | assert bootstrapper.user_repository.commit_called 92 | assert bootstrapper.pubsub_client.commit_called 93 | assert not bootstrapper.pubsub_client.rollback_called 94 | 95 | def test_event_handler_rollback_raises(self, bootstrapper): 96 | self._fill_user_in_repo(bootstrapper) 97 | bootstrapper.pubsub_client.notify_email_set_should_fail = True 98 | bootstrapper.pubsub_client.rollback_should_fail = True 99 | command = SaveUserCommand(self.USER_ID, self.NEW_EMAIL) 100 | 101 | with pytest.raises(Exception) as e: 102 | bootstrapper.handle_command(command) 103 | 104 | assert self.ROLLBACK_FAILED in str(e.value).lower() 105 | assert bootstrapper.user_repository.commit_called 106 | assert not bootstrapper.pubsub_client.commit_called 107 | assert bootstrapper.pubsub_client.rollback_called 108 | 109 | @pytest.mark.asyncio 110 | async def test_async_change_email(self, bootstrapper): 111 | self._fill_user_in_repo(bootstrapper) 112 | command = SaveUserCommand(self.USER_ID, self.NEW_EMAIL) 113 | 114 | result = await bootstrapper.async_handle_command(command) 115 | 116 | assert result == self.USER_ID 117 | user = bootstrapper.async_user_repository.users_by_id[self.USER_ID] 118 | assert user.email == self.NEW_EMAIL 119 | assert bootstrapper.async_user_repository.commit_called 120 | assert not bootstrapper.async_user_repository.rollback_called 121 | assert bootstrapper.async_pubsub_client.email_sent 122 | assert bootstrapper.async_pubsub_client.notify_email_set_new_email == self.NEW_EMAIL 123 | assert bootstrapper.async_pubsub_client.notify_email_set_old_email == self.OLD_EMAIL 124 | assert bootstrapper.async_pubsub_client.notify_email_set_user_id == self.USER_ID 125 | assert bootstrapper.async_pubsub_client.kpi_event_sent 126 | 127 | @pytest.mark.asyncio 128 | async def test_async_invalid_command(self, bootstrapper): 129 | self._fill_user_in_repo(bootstrapper) 130 | command = SaveUserCommand(self.USER_ID, email='') 131 | 132 | with pytest.raises(ddd.BoundedContextError) as e: 133 | await bootstrapper.async_handle_command(command) 134 | 135 | assert e.value.status_code == ddd.error.BAD_REQUEST 136 | assert 'missing' in str(e.value).lower() 137 | 138 | @pytest.mark.asyncio 139 | async def test_async_user_does_not_exist(self, bootstrapper): 140 | self._fill_user_in_repo(bootstrapper) 141 | command = SaveUserCommand('not-existing-user-id', self.NEW_EMAIL) 142 | 143 | with pytest.raises(ddd.BoundedContextError) as e: 144 | await bootstrapper.async_handle_command(command) 145 | 146 | assert e.value.status_code == ddd.error.NOT_FOUND 147 | assert 'does not exist' in str(e.value).lower() 148 | assert bootstrapper.async_user_repository.rollback_called 149 | 150 | @pytest.mark.asyncio 151 | async def test_async_command_handler_commit_raises(self, bootstrapper): 152 | self._fill_user_in_repo(bootstrapper) 153 | bootstrapper.async_user_repository.commit_should_fail = True 154 | command = SaveUserCommand(self.USER_ID, self.NEW_EMAIL) 155 | 156 | with pytest.raises(Exception) as e: 157 | await bootstrapper.async_handle_command(command) 158 | 159 | assert self.COMMIT_FAILED in str(e.value).lower() 160 | assert bootstrapper.async_user_repository.commit_called 161 | assert not bootstrapper.async_user_repository.rollback_called 162 | 163 | @pytest.mark.asyncio 164 | async def test_async_command_handler_rollback_raises(self, bootstrapper): 165 | self._fill_user_in_repo(bootstrapper) 166 | bootstrapper.async_user_repository.rollback_should_fail = True 167 | command = SaveUserCommand('non-existing-user-id', self.NEW_EMAIL) 168 | 169 | with pytest.raises(Exception) as e: 170 | await bootstrapper.async_handle_command(command) 171 | 172 | assert self.ROLLBACK_FAILED in str(e.value).lower() 173 | assert not bootstrapper.async_user_repository.commit_called 174 | assert bootstrapper.async_user_repository.rollback_called 175 | 176 | @pytest.mark.asyncio 177 | async def test_async_event_handler_commit_raises(self, bootstrapper): 178 | self._fill_user_in_repo(bootstrapper) 179 | bootstrapper.async_pubsub_client.commit_should_fail = True 180 | command = SaveUserCommand(self.USER_ID, self.NEW_EMAIL) 181 | 182 | with pytest.raises(Exception) as e: 183 | await bootstrapper.async_handle_command(command) 184 | 185 | assert self.COMMIT_FAILED in str(e.value).lower() 186 | assert bootstrapper.async_user_repository.commit_called 187 | assert bootstrapper.async_pubsub_client.commit_called 188 | assert not bootstrapper.async_pubsub_client.rollback_called 189 | 190 | @pytest.mark.asyncio 191 | async def test_async_event_handler_rollback_raises(self, bootstrapper): 192 | self._fill_user_in_repo(bootstrapper) 193 | bootstrapper.async_pubsub_client.notify_email_set_should_fail = True 194 | bootstrapper.async_pubsub_client.rollback_should_fail = True 195 | command = SaveUserCommand(self.USER_ID, self.NEW_EMAIL) 196 | 197 | with pytest.raises(Exception) as e: 198 | await bootstrapper.async_handle_command(command) 199 | 200 | assert self.ROLLBACK_FAILED in str(e.value).lower() 201 | assert bootstrapper.async_user_repository.commit_called 202 | assert not bootstrapper.async_pubsub_client.commit_called 203 | assert bootstrapper.async_pubsub_client.rollback_called 204 | 205 | def test_register_async_command_handler_factory_with_non_async_callable(self, bootstrapper): 206 | with pytest.raises(ValueError): 207 | bootstrapper.register_async_command_handler_factory( 208 | self.A_NAME, 209 | bootstrapper.create_save_user_command_handler, # type: ignore 210 | ) 211 | 212 | def test_register_command_handler_factory_with_async_callable(self, bootstrapper): 213 | with pytest.raises(ValueError): 214 | bootstrapper.register_command_handler_factory( 215 | self.A_NAME, 216 | bootstrapper.create_async_save_user_command_handler, # type: ignore 217 | ) 218 | 219 | def test_register_async_event_handler_factory_with_non_async_callable(self, bootstrapper): 220 | with pytest.raises(ValueError): 221 | bootstrapper.register_async_event_handler_factory( 222 | self.A_NAME, 223 | bootstrapper.create_email_changed_event_handler, # type: ignore 224 | ) 225 | 226 | def test_register_event_handler_factory_with_async_callable(self, bootstrapper): 227 | with pytest.raises(ValueError): 228 | bootstrapper.register_event_handler_factory( 229 | self.A_NAME, 230 | bootstrapper.create_async_email_changed_event_handler, # type: ignore 231 | ) 232 | 233 | @classmethod 234 | def _fill_user_in_repo(cls, bs: DemoBootstrapper) -> None: 235 | bs.user_repository.users_by_id[cls.USER_ID] = User(email=cls.OLD_EMAIL, id_=cls.USER_ID) 236 | bs.async_user_repository.users_by_id[cls.USER_ID] = User(email=cls.OLD_EMAIL, id_=cls.USER_ID) 237 | 238 | --------------------------------------------------------------------------------