From 48c8aa992ba45282b23ea167dac11f87c44d9d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20G=C3=B3recki?= Date: Thu, 17 Jun 2021 14:21:11 +0200 Subject: [PATCH] add ADR --- LICENSE | 21 ++++++ diary.md | 69 +++++++++++++++++++ docs/architecture_decision_log/001_initial.md | 21 ++++++ .../002_use_modular_monolith.md | 22 ++++++ .../003_use_python.md | 19 +++++ .../004_divide_system_into_2_modules copy.md | 35 ++++++++++ .../005_separate_commands_and_queries.md | 33 +++++++++ ...ror_handling_during_operation_execution.md | 37 ++++++++++ .../catalog/application/command_handlers.py | 48 +++++++++++++ src/modules/catalog/application/commands.py | 25 +++++++ src/modules/catalog/domain/entities.py | 14 ++++ .../{listing => catalog}/domain/events.py | 0 src/modules/catalog/domain/repositories.py | 2 + .../{listing => catalog}/domain/rules.py | 0 src/modules/catalog/tests/.editorconfig | 12 ++++ .../application/test_command_handlers.py | 27 ++++++++ .../tests/domain/test_rules.py | 2 +- src/modules/listing/domain/entities.py | 5 -- src/seedwork/application/command_handlers.py | 34 +++++++++ .../application/{command.py => commands.py} | 0 src/seedwork/application/decorators.py | 14 ++++ src/seedwork/infrastructure/repository.py | 10 ++- .../infrastructure/test_repository.py | 6 +- 23 files changed, 446 insertions(+), 10 deletions(-) create mode 100644 LICENSE create mode 100644 diary.md create mode 100644 docs/architecture_decision_log/001_initial.md create mode 100644 docs/architecture_decision_log/002_use_modular_monolith.md create mode 100644 docs/architecture_decision_log/003_use_python.md create mode 100644 docs/architecture_decision_log/004_divide_system_into_2_modules copy.md create mode 100644 docs/architecture_decision_log/005_separate_commands_and_queries.md create mode 100644 docs/architecture_decision_log/006_error_handling_during_operation_execution.md create mode 100644 src/modules/catalog/application/command_handlers.py create mode 100644 src/modules/catalog/application/commands.py create mode 100644 src/modules/catalog/domain/entities.py rename src/modules/{listing => catalog}/domain/events.py (100%) create mode 100644 src/modules/catalog/domain/repositories.py rename src/modules/{listing => catalog}/domain/rules.py (100%) create mode 100644 src/modules/catalog/tests/.editorconfig create mode 100644 src/modules/catalog/tests/application/test_command_handlers.py rename src/modules/{listing => catalog}/tests/domain/test_rules.py (83%) delete mode 100644 src/modules/listing/domain/entities.py create mode 100644 src/seedwork/application/command_handlers.py rename src/seedwork/application/{command.py => commands.py} (100%) create mode 100644 src/seedwork/application/decorators.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..abacc49 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Ermlab sp. z o. o. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/diary.md b/diary.md new file mode 100644 index 0000000..bfa370f --- /dev/null +++ b/diary.md @@ -0,0 +1,69 @@ +## Seedwork + + +## Architecture of Catalog module (2021-05-25) + +What kind of architecture should we choose for the *Catalog* module? Should we use CQRS or maybe something simpler and easier to implement? Let's see what others says about it? + +> CQRS stands for Command Query Responsibility Segregation. It's a pattern that I first heard described by Greg Young. At its heart is the notion that you can use a different model to update information than the model you use to read information. For some situations, this separation can be valuable, but beware that for most systems CQRS adds risky complexity. [1] + +Since *Catalog* module is mostly about managing list items, it will likely contain a lot of CRUD-like functionality. We don't need separate read and writes models in this case, it seems like an overkill to me. So having full CQRS is not worth it. However, sticking to separation of commands and queries still looks tempting. Let's check out the alternatives: + +1. "The typical entry point for this in DDD is an Application Service. Application services orchestrate calls to repositories and domain objects" [2][3]. + + +Maybe we could implementing using a generic CRUD handler? + + +## Handling commands + +The typical way of reading the system state via queries and changing the system state is via comands. The high-level route controller code could look like: + +``` +def get_route_controller(request, module): + module.execute_query(MyQuery( + foo=request.GET.foo, + bar=request.GET.bar, + )) + return Response(HTTP_200_OK) + + +def post_route_controller(request, module): + result = module.execute_command(MyCommand( + foo=request.POST.foo, + bar=request.POST.bar, + )) + return Response(HTTP_200_OK) +``` + +In this case `execute_command` is responsible for passing a command to an appriopriate command handler. + + +Keep in mind this is a happy path and bad things can happen along a way. In particular: +- creating command can fail: i.e. command can have incorrect params, or some params can be missing (command param validation) [400] +- command execution can fail due to numerous reasons: + - an object that we want act upon may not exist [404] + - a user may not have permissions to perform certain command [403] + - business rule may fail [400] + - application level policy may fail, i.e. too many requests issued by the same user [429] + - .... + + + +For example, query or command can be invalid, an , user may + + +References: + +- [1] https://martinfowler.com/bliki/CQRS.html +- [2] https://softwareengineering.stackexchange.com/questions/302187/crud-operations-in-ddd +- [3] https://lostechies.com/jimmybogard/2008/08/21/services-in-domain-driven-design/ + + + + + + +## Other references + +- https://github.com/VaughnVernon/IDDD_Samples/tree/master \ No newline at end of file diff --git a/docs/architecture_decision_log/001_initial.md b/docs/architecture_decision_log/001_initial.md new file mode 100644 index 0000000..996bc27 --- /dev/null +++ b/docs/architecture_decision_log/001_initial.md @@ -0,0 +1,21 @@ +# 1. Record architecture decisions + +Date: 2021-05-25 + +## Status + +Accepted + +## Context + +As the project is an example of a more advanced monolith architecture, it is necessary to save all architectural decisions in one place. + +## Decision + +For all architectural decisions Architecture Decision Log (ADL) is created. All decisions will be recorded as Architecture Decision Records (ADR). + +Each ADR will be recorded using [Michael Nygard template](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions), which contains following sections: Status, Context, Decision and Consequences. + +## Consequences + +All architectural decisions should be recorded in log. Old decisions should be recorded as well with an approximate decision date. New decisions should be recorded on a regular basis. \ No newline at end of file diff --git a/docs/architecture_decision_log/002_use_modular_monolith.md b/docs/architecture_decision_log/002_use_modular_monolith.md new file mode 100644 index 0000000..30069f8 --- /dev/null +++ b/docs/architecture_decision_log/002_use_modular_monolith.md @@ -0,0 +1,22 @@ +# 2. Use Modular Monolith System Architecture + +Date: 2021-05-25 + +## Status + +Accepted + +## Context + +An example of Modular Monolith architecture and tactical DDD implementation in Python is missing on the internet. + +## Decision + +We decided to create nontrivial application using Modular Monolith architecture and Domain-Driven Design tactical patterns. + +## Consequences + +- All modules must run in one single process as single application (Monolith) +- All modules should have maximum autonomy (Modular) +- DDD Bounded Contexts will be used to divide monolith into modules +- DDD tactical patterns will be used to implement most of modules \ No newline at end of file diff --git a/docs/architecture_decision_log/003_use_python.md b/docs/architecture_decision_log/003_use_python.md new file mode 100644 index 0000000..0ede8bf --- /dev/null +++ b/docs/architecture_decision_log/003_use_python.md @@ -0,0 +1,19 @@ +# 3. Use .NET Core and C# language + +Date: 2021-05-25 + +## Status + +Accepted + +## Context + +As it is monolith, only one language must be selected for implementation. + +## Decision + +We decided to use Python 3.9 as it is the newest stable Python release at the moment of writing this document. The choice of web framework is deferred at this point. + +## Consequences + +- Whole application will be implemented in Python \ No newline at end of file diff --git a/docs/architecture_decision_log/004_divide_system_into_2_modules copy.md b/docs/architecture_decision_log/004_divide_system_into_2_modules copy.md new file mode 100644 index 0000000..3c442a4 --- /dev/null +++ b/docs/architecture_decision_log/004_divide_system_into_2_modules copy.md @@ -0,0 +1,35 @@ +# 4. Divide the system into 4 modules + +Date: 2021-05-25 + +## Status + +Accepted + +## Context + +The "Auction" domain contains 3 main subdomains: Bidding (core domain), Catalog (supporting subdomain), and User Access (generic domain). + +Since we decided to use Modular Monolith, all subdomains should be implemented as modules within a single system. + +## Possible solutions +1. Create one "Auction" module and divide it into sub-modules. This solution is simpler to implement at the beginning. We do not have to set module boundaries and think how to communicate between them. On the other hand, this causes a lack of autonomy and can lead to Big Ball Of Mud anti-pattern. +2. Create 3 modules based on Bounded Contexts which in this scenario maps 1:1 to domains. This solution is more difficult at the beginning. We need to set modules boundaries, communication strategy between modules and have more advanced infrastructure code. It is a more complex solution. On the other hand, it supports autonomy, maintainability, readability. We can develop our Domain Models in all of the Bounded Contexts independently. + +## Decision + +Solution 2. + +We created 3 modules: Bidding, Catalog, User Access. The key factor here is module autonomy and maintainability. We want to develop each module independently. This is more cleaner solution. It involves more work at the beginning but we are ready to pay this price. + +## Consequences +- We can implement each module/Bounded Context independently. +- We need to set clear boundaries between modules and communication strategy between modules (and implement them) +- We need to define the API of each module +- The API/GUI layer needs to know about all of the modules +- We need to create shared libraries/classes to limit boilerplate code which will be the same in all modules +- Complexity of the whole solution will increase +- Complexity of each module will decrease +- We will have clear separation of concerns +- In addition to the application, we must divide the data +- We can delegate development of particular module to defined team, work should be done without any conflicts on codebase \ No newline at end of file diff --git a/docs/architecture_decision_log/005_separate_commands_and_queries.md b/docs/architecture_decision_log/005_separate_commands_and_queries.md new file mode 100644 index 0000000..a9af2a4 --- /dev/null +++ b/docs/architecture_decision_log/005_separate_commands_and_queries.md @@ -0,0 +1,33 @@ +# 4. Separate commands and queries + +Date: 2021-05-25 + +## Status + +Accepted + +## Context + +We want to keep controllers as thin as possible. Therefore each controller should have access to a module, which exposes an interface allowing to read system state using `queries` and change system state using `commands`. However, keep in mind that this does not imply having separate read and write models (as in pure CQRS) - it's up to a module architecture if same or different models are needed. + +``` +def get_route_controller(request, module): + module.execute_query(MyQuery( + foo=request.GET.foo, + bar=request.GET.bar, + )) + return Response(HTTP_200_OK) + + +def post_route_controller(request, module): + result = module.execute_command(MyCommand( + foo=request.POST.foo, + bar=request.POST.bar, + )) + return Response(HTTP_200_OK) +``` + + +## Consequences +- controllers are thin +- each module must implement `execute_query` and `execute_command` methods \ No newline at end of file diff --git a/docs/architecture_decision_log/006_error_handling_during_operation_execution.md b/docs/architecture_decision_log/006_error_handling_during_operation_execution.md new file mode 100644 index 0000000..1267ace --- /dev/null +++ b/docs/architecture_decision_log/006_error_handling_during_operation_execution.md @@ -0,0 +1,37 @@ +# 4. Error handling during operation execution + +Date: 2021-05-25 + +## Status + +Pending + +## Context + +When executing an operation (command or query), many bad things can happen. This can be related to access permission, data validation, business rule validation, infrastructure errors, etc. We need to collect and handle such errors in an unified way for further processing (API responses, UI notifications for a user). The solution should also support a way to convert errors into meaningful messages for an end user (including language translations). + +## Possible solutions +1. Raise an exception during operation processing. This solutioun relies on throwing an exception during command execution and handling it somewhere upper in the call stack. + +Pros: +- can throw exception from anywhere + +Cons: +- non-linear program flow can become a mess really quickly because it’s hard to trace all existing connections between throw and catch statements. + +2. Explicitly return values indicating success or failure of an operation instead of throwing exceptions using a `Result` class. Expected erros should be reported to a `Result` object. Unexpected errors should be handled using exceptions. + +Pros: +- can return multiple errors within one operation + +Cons: +- ... + +## Decision + +TBA + + +## Consequences + +.... \ No newline at end of file diff --git a/src/modules/catalog/application/command_handlers.py b/src/modules/catalog/application/command_handlers.py new file mode 100644 index 0000000..4bcb5e9 --- /dev/null +++ b/src/modules/catalog/application/command_handlers.py @@ -0,0 +1,48 @@ +from modules.catalog.application.commands import ( + CreateAuctionItemCommand, + UpdateAuctionItemCommand, + DeleteAuctionItemCommand, + PublishAuctionItemCommand, + UnpublishAuctionItemCommand, +) +from modules.catalog.domain.entities import AuctionItem +from seedwork.application.commands import Command +from seedwork.application.command_handlers import CommandResult +from seedwork.application.decorators import command_handler + +# @unit_of_work +@command_handler +def create_auction_item(command: CreateAuctionItemCommand, repository) -> CommandResult: + item = AuctionItem(**command.dict()) + try: + repository.insert(item) + except: + return CommandResult.fail("Failed to add item") + return CommandResult.succed(id=item.id) + + +# @unit_of_work +# @command_handler +def update_auction_item(command: UpdateAuctionItemCommand, repository): + item = repository.find_by_id(command.id) + item.title = command.title + item.description = command.description + repository.update(item) + + +# @unit_of_work +# @command_handler +def delete_auction_item(command: DeleteAuctionItemCommand, repository): + repository.delete(command.id) + + +def publish_in_catalog(command: PublishAuctionItemCommand): + pass + + +def unpublish_from_catalog(command: UnpublishAuctionItemCommand): + pass + + +def xyz_view(request): + publish_in_catalog(request.POST["id"]) diff --git a/src/modules/catalog/application/commands.py b/src/modules/catalog/application/commands.py new file mode 100644 index 0000000..088d2df --- /dev/null +++ b/src/modules/catalog/application/commands.py @@ -0,0 +1,25 @@ +from seedwork.application.commands import Command +from seedwork.domain.value_objects import Currency + + +class CreateAuctionItemCommand(Command): + title: str + description: str + price: Currency + + +class UpdateAuctionItemCommand(Command): + title: str + description: str + + +class DeleteAuctionItemCommand(Command): + pass + + +class PublishAuctionItemCommand(Command): + pass + + +class UnpublishAuctionItemCommand(Command): + pass diff --git a/src/modules/catalog/domain/entities.py b/src/modules/catalog/domain/entities.py new file mode 100644 index 0000000..bce6036 --- /dev/null +++ b/src/modules/catalog/domain/entities.py @@ -0,0 +1,14 @@ +from typing import Any +from seedwork.domain.entities import Entity +from seedwork.domain.value_objects import Currency +from .rules import AuctionItemPriceMustBeGreaterThanZero + + +class AuctionItem(Entity): + title: str + description: str + price: Currency + + def __init__(self, **data: Any) -> None: + super().__init__(**data) + self.check_rule(AuctionItemPriceMustBeGreaterThanZero(self.price)) diff --git a/src/modules/listing/domain/events.py b/src/modules/catalog/domain/events.py similarity index 100% rename from src/modules/listing/domain/events.py rename to src/modules/catalog/domain/events.py diff --git a/src/modules/catalog/domain/repositories.py b/src/modules/catalog/domain/repositories.py new file mode 100644 index 0000000..63e8d93 --- /dev/null +++ b/src/modules/catalog/domain/repositories.py @@ -0,0 +1,2 @@ +class ListingRepository: + pass diff --git a/src/modules/listing/domain/rules.py b/src/modules/catalog/domain/rules.py similarity index 100% rename from src/modules/listing/domain/rules.py rename to src/modules/catalog/domain/rules.py diff --git a/src/modules/catalog/tests/.editorconfig b/src/modules/catalog/tests/.editorconfig new file mode 100644 index 0000000..c1322dc --- /dev/null +++ b/src/modules/catalog/tests/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/src/modules/catalog/tests/application/test_command_handlers.py b/src/modules/catalog/tests/application/test_command_handlers.py new file mode 100644 index 0000000..23a13f4 --- /dev/null +++ b/src/modules/catalog/tests/application/test_command_handlers.py @@ -0,0 +1,27 @@ +import pytest +from modules.catalog.application.commands import CreateAuctionItemCommand +from modules.catalog.application.command_handlers import create_auction_item +from seedwork.infrastructure.repository import InMemoryRepository + + +def test_create_auction_item_happy_path(): + # arrange + command = CreateAuctionItemCommand(title="foo", description="bar", price=1) + repository = InMemoryRepository() + + # act + result = create_auction_item(command, repository) + + # assert + assert repository.get_by_id(result.id).title == "foo" + assert result.has_errors() is False + + +def test_create_auction_item_business_rule_validation_fails(): + command = CreateAuctionItemCommand(title="", description="bar") + repository = InMemoryRepository() + + # with pytest.raises(Exception) + # act + result = create_auction_item(command, repository) + assert result.has_errors() diff --git a/src/modules/listing/tests/domain/test_rules.py b/src/modules/catalog/tests/domain/test_rules.py similarity index 83% rename from src/modules/listing/tests/domain/test_rules.py rename to src/modules/catalog/tests/domain/test_rules.py index 8b1f6b1..11ccfb8 100644 --- a/src/modules/listing/tests/domain/test_rules.py +++ b/src/modules/catalog/tests/domain/test_rules.py @@ -1,4 +1,4 @@ -from modules.listing.domain.rules import AuctionItemPriceMustBeGreaterThanZero +from modules.catalog.domain.rules import AuctionItemPriceMustBeGreaterThanZero def test_AuctionItemPriceMustBeGreaterThanZero_rule(): diff --git a/src/modules/listing/domain/entities.py b/src/modules/listing/domain/entities.py deleted file mode 100644 index 7745d2b..0000000 --- a/src/modules/listing/domain/entities.py +++ /dev/null @@ -1,5 +0,0 @@ -from seedwork.domain.entities import Entity - - -class AuctionItem(Entity): - pass diff --git a/src/seedwork/application/command_handlers.py b/src/seedwork/application/command_handlers.py new file mode 100644 index 0000000..120da85 --- /dev/null +++ b/src/seedwork/application/command_handlers.py @@ -0,0 +1,34 @@ +from pydantic import errors + + +class CommandResult: + def __init__(self, **kwargs) -> None: + self.__errors = [] + self.__kwargs = kwargs + + def __getattr__(self, attr): + assert ( + not self.has_errors() + ), f"Cannot access '{attr}'. CommandResult has errors.\n Errors: {self.__errors}" + return self.__kwargs[attr] + + def has_errors(self): + return len(self.__errors) > 0 + + def add_error(self, error): + self.__errors.append(error) + + @classmethod + def succed(cls, **kwargs): + return CommandResult(**kwargs) + + @classmethod + def fail(cls, errors): + result = CommandResult() + for error in errors: + result.add_error(error) + return result + + +class CommandHandler: + pass diff --git a/src/seedwork/application/command.py b/src/seedwork/application/commands.py similarity index 100% rename from src/seedwork/application/command.py rename to src/seedwork/application/commands.py diff --git a/src/seedwork/application/decorators.py b/src/seedwork/application/decorators.py new file mode 100644 index 0000000..c7b8856 --- /dev/null +++ b/src/seedwork/application/decorators.py @@ -0,0 +1,14 @@ +import functools +from pydantic.error_wrappers import ValidationError +from .command_handlers import CommandResult + + +def command_handler(fn): + @functools.wraps(fn) + def decorator(*args, **kwargs): + try: + return fn(*args, **kwargs) + except ValidationError as e: + return CommandResult.fail(errors=[e]) + + return decorator diff --git a/src/seedwork/infrastructure/repository.py b/src/seedwork/infrastructure/repository.py index 5b04b85..7908308 100644 --- a/src/seedwork/infrastructure/repository.py +++ b/src/seedwork/infrastructure/repository.py @@ -16,5 +16,13 @@ def get_by_id(self, id) -> Entity: except KeyError: raise EntityNotFoundException - def persist(self, entity: Entity): + def insert(self, entity: Entity): + assert issubclass(entity.__class__, Entity) self.objects[entity.id] = entity + + def update(self, entity: Entity): + assert issubclass(entity.__class__, Entity) + self.objects[entity.id] = entity + + def delete(self, entity_id): + del self.objects[entity_id] diff --git a/src/seedwork/infrastructure/test_repository.py b/src/seedwork/infrastructure/test_repository.py index c9ecf27..295a537 100644 --- a/src/seedwork/infrastructure/test_repository.py +++ b/src/seedwork/infrastructure/test_repository.py @@ -13,7 +13,7 @@ def test_InMemoryRepository_persist_one(): repository = InMemoryRepository() # act - repository.persist(person) + repository.insert(person) # assert assert repository.get_by_id(person.id) == person @@ -26,8 +26,8 @@ def test_InMemoryRepository_persist_two(): repository = InMemoryRepository() # act - repository.persist(person1) - repository.persist(person2) + repository.insert(person1) + repository.insert(person2) # assert assert repository.get_by_id(person1.id) == person1