├── src ├── cli │ ├── dependencies.py │ ├── __init__.py │ ├── README.md │ └── __main__.py ├── config │ ├── __init__.py │ └── api_config.py ├── api │ ├── models │ │ ├── __init__.py │ │ ├── catalog.py │ │ └── bidding.py │ ├── infrastructure │ │ └── database.py │ ├── README.md │ ├── __main__.py │ ├── __init__.py │ ├── tests │ │ ├── test_diagnostics.py │ │ ├── test_common.py │ │ ├── test_bidding.py │ │ ├── test_login.py │ │ └── test_catalog.py │ ├── routers │ │ ├── diagnostics.py │ │ ├── iam.py │ │ ├── bidding.py │ │ └── catalog.py │ ├── dependencies.py │ └── main.py ├── modules │ ├── iam │ │ ├── __init__.py │ │ ├── infrastructure │ │ │ ├── user_repository.py │ │ │ └── repository.py │ │ ├── application │ │ │ ├── __init__.py │ │ │ ├── exceptions.py │ │ │ ├── repository.py │ │ │ └── services.py │ │ ├── README.md │ │ ├── domain │ │ │ └── entities.py │ │ └── tests │ │ │ └── test_iam_service.py │ ├── bidding │ │ ├── __init__.py │ │ ├── domain │ │ │ ├── __init__.py │ │ │ ├── exceptions.py │ │ │ ├── repositories.py │ │ │ ├── events.py │ │ │ ├── value_objects.py │ │ │ ├── services.py │ │ │ ├── rules.py │ │ │ └── entities.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── domain │ │ │ │ ├── __init__.py │ │ │ │ └── test_bidding.py │ │ │ ├── infrastructure │ │ │ │ ├── __init__.py │ │ │ │ └── test_listing_repository.py │ │ │ └── application │ │ │ │ └── test_create_listing_when_draft_is_published.py │ │ ├── infrastructure │ │ │ ├── __init__.py │ │ │ └── listing_repository.py │ │ ├── application │ │ │ ├── command │ │ │ │ ├── __init__.py │ │ │ │ ├── retract_bid.py │ │ │ │ └── place_bid.py │ │ │ ├── query │ │ │ │ ├── __init__.py │ │ │ │ ├── model_mappers.py │ │ │ │ ├── get_pastdue_listings.py │ │ │ │ └── get_bidding_details.py │ │ │ ├── event │ │ │ │ ├── __init__.py │ │ │ │ ├── notify_outbid_winner.py │ │ │ │ └── when_listing_is_published_start_auction.py │ │ │ └── __init__.py │ │ └── README.md │ ├── catalog │ │ ├── __init__.py │ │ ├── domain │ │ │ ├── __init__.py │ │ │ ├── repositories.py │ │ │ ├── value_objects.py │ │ │ ├── events.py │ │ │ ├── services.py │ │ │ ├── entities.py │ │ │ └── rules.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── infrastructure │ │ │ │ ├── __init__.py │ │ │ │ └── test_listing_repository.py │ │ │ ├── domain │ │ │ │ ├── test_rules.py │ │ │ │ └── test_entities.py │ │ │ └── application │ │ │ │ ├── test_create_listing_draft.py │ │ │ │ ├── test_update_listing_draft.py │ │ │ │ ├── test_delete_listing_draft.py │ │ │ │ └── test_publish_listing.py │ │ ├── infrastructure │ │ │ ├── __init__.py │ │ │ └── listing_repository.py │ │ ├── application │ │ │ ├── event │ │ │ │ ├── __init__.py │ │ │ │ └── do_nothing_when_listing_published.py │ │ │ ├── query │ │ │ │ ├── __init__.py │ │ │ │ ├── model_mappers.py │ │ │ │ ├── get_all_listings.py │ │ │ │ ├── get_listing_details.py │ │ │ │ └── get_listings_of_seller.py │ │ │ ├── __init__.py │ │ │ └── command │ │ │ │ ├── __init__.py │ │ │ │ ├── update_listing_draft.py │ │ │ │ ├── create_listing_draft.py │ │ │ │ ├── publish_listing_draft.py │ │ │ │ └── delete_listing_draft.py │ │ └── README.md │ ├── __init__.py │ └── README.md ├── seedwork │ ├── __init__.py │ ├── domain │ │ ├── __init__.py │ │ ├── aggregates.py │ │ ├── type_hints.py │ │ ├── services.py │ │ ├── mixins.py │ │ ├── exceptions.py │ │ ├── events.py │ │ ├── rules.py │ │ ├── entities.py │ │ ├── repositories.py │ │ └── value_objects.py │ ├── utils │ │ ├── __init__.py │ │ └── data_structures.py │ ├── infrastructure │ │ ├── __init__.py │ │ ├── exceptions.py │ │ ├── database.py │ │ ├── json_data_mapper.py │ │ ├── data_mapper.py │ │ ├── logging.py │ │ └── repository.py │ ├── application │ │ ├── event_dispatcher.py │ │ ├── queries.py │ │ ├── exceptions.py │ │ ├── commands.py │ │ ├── utils.py │ │ ├── inbox_outbox.py │ │ ├── query_handlers.py │ │ ├── command_handlers.py │ │ └── events.py │ ├── README.md │ └── tests │ │ ├── application │ │ ├── test_utils.py │ │ ├── test_application_with_outbox.py │ │ ├── test_application_and_one_module_linear.py │ │ ├── test_application_and_one_module_branching.py │ │ └── test_application.py │ │ ├── domain │ │ ├── test_value_objects.py │ │ └── test_entity.py │ │ └── infrastructure │ │ ├── test_data_mapper.py │ │ ├── test_repository.py │ │ └── test_sqlalchemy_repository.py ├── alembic │ ├── README │ ├── script.py.mako │ ├── versions │ │ └── d6c2334f4816_initial_listing_model.py │ └── env.py ├── mypy.ini ├── conftest.py └── alembic.ini ├── .python-version ├── migrations ├── README ├── script.py.mako └── env.py ├── docs ├── images │ ├── bidding_process.png │ ├── draft_management.png │ ├── auctions_ContextMap.png │ └── publishing_to_catalog.png ├── architecture_decision_log │ ├── 003_use_python.md │ ├── 002_use_modular_monolith.md │ ├── 001_initial.md │ ├── 005_separate_commands_and_queries.md │ ├── 006_error_handling_during_operation_execution.md │ └── 004_divide_system_into_2_modules copy.md ├── auctions.cml └── example.cml ├── .gitignore ├── pytest.ini ├── .editorconfig ├── .pre-commit-config.yaml ├── docker-compose.dev.yml ├── LICENSE ├── .github └── workflows │ └── pytest.yml ├── pyproject.toml ├── diary.md ├── alembic.ini └── README.md /src/cli/dependencies.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /src/api/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/iam/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/seedwork/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/bidding/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/catalog/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/seedwork/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/seedwork/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/infrastructure/database.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/bidding/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/bidding/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/catalog/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/catalog/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/__init__.py: -------------------------------------------------------------------------------- 1 | foo = "bar" 2 | -------------------------------------------------------------------------------- /src/seedwork/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/bidding/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/bidding/tests/domain/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/catalog/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/seedwork/application/event_dispatcher.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/bidding/tests/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/catalog/application/event/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/catalog/tests/infrastructure/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/modules/iam/infrastructure/user_repository.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /src/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /src/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | plugins = pydantic.mypy -------------------------------------------------------------------------------- /src/modules/README.md: -------------------------------------------------------------------------------- 1 | This directory contains all modules related to bounded contexts. -------------------------------------------------------------------------------- /src/api/README.md: -------------------------------------------------------------------------------- 1 | # API application 2 | 3 | HTTP(S) REST API endpoint - implemented using FastAPI. -------------------------------------------------------------------------------- /src/seedwork/infrastructure/exceptions.py: -------------------------------------------------------------------------------- 1 | class InfrastructureException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /docs/images/bidding_process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgorecki/python-ddd/HEAD/docs/images/bidding_process.png -------------------------------------------------------------------------------- /src/seedwork/domain/aggregates.py: -------------------------------------------------------------------------------- 1 | from .entities import Entity 2 | 3 | 4 | class Aggregate(Entity): 5 | ... 6 | -------------------------------------------------------------------------------- /docs/images/draft_management.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgorecki/python-ddd/HEAD/docs/images/draft_management.png -------------------------------------------------------------------------------- /docs/images/auctions_ContextMap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgorecki/python-ddd/HEAD/docs/images/auctions_ContextMap.png -------------------------------------------------------------------------------- /src/api/__main__.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from api.main import app 4 | 5 | uvicorn.run(app, host="0.0.0.0", port=8000) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .vscode 3 | src-gen/ 4 | .coverage 5 | .env 6 | htmlcov/ 7 | logs.json 8 | tmp/ 9 | .idea/ 10 | -------------------------------------------------------------------------------- /docs/images/publishing_to_catalog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pgorecki/python-ddd/HEAD/docs/images/publishing_to_catalog.png -------------------------------------------------------------------------------- /src/modules/bidding/application/command/__init__.py: -------------------------------------------------------------------------------- 1 | from .place_bid import PlaceBidCommand 2 | from .retract_bid import RetractBidCommand 3 | -------------------------------------------------------------------------------- /src/modules/iam/application/__init__.py: -------------------------------------------------------------------------------- 1 | from seedwork.application import ApplicationModule 2 | 3 | iam_module = ApplicationModule("iam") 4 | -------------------------------------------------------------------------------- /src/seedwork/application/queries.py: -------------------------------------------------------------------------------- 1 | from lato import Command 2 | 3 | 4 | class Query(Command): 5 | """Base class for all queries""" 6 | -------------------------------------------------------------------------------- /src/seedwork/domain/type_hints.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from seedwork.domain.events import DomainEvent 3 | 4 | DomainEvents = List[DomainEvent] 5 | -------------------------------------------------------------------------------- /src/modules/bidding/application/query/__init__.py: -------------------------------------------------------------------------------- 1 | from .get_bidding_details import GetBiddingDetails 2 | from .get_pastdue_listings import GetPastdueListings 3 | -------------------------------------------------------------------------------- /src/modules/bidding/domain/exceptions.py: -------------------------------------------------------------------------------- 1 | from seedwork.domain.exceptions import DomainException 2 | 3 | 4 | class BidCannotBePlacedException(DomainException): 5 | ... 6 | -------------------------------------------------------------------------------- /src/seedwork/application/exceptions.py: -------------------------------------------------------------------------------- 1 | class ApplicationException(Exception): 2 | pass 3 | 4 | 5 | class UnitOfWorkNotSetException(ApplicationException): 6 | pass 7 | -------------------------------------------------------------------------------- /src/api/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | # add base project path to PYTHONPATH 5 | BASE_DIR = Path(__file__).resolve().parent.parent 6 | sys.path.append(str(BASE_DIR)) 7 | -------------------------------------------------------------------------------- /src/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | # add base project path to PYTHONPATH 5 | BASE_DIR = Path(__file__).resolve().parent.parent 6 | sys.path.append(str(BASE_DIR)) 7 | -------------------------------------------------------------------------------- /src/modules/catalog/application/query/__init__.py: -------------------------------------------------------------------------------- 1 | from .get_all_listings import GetAllListings 2 | from .get_listing_details import GetListingDetails 3 | from .get_listings_of_seller import GetListingsOfSeller 4 | -------------------------------------------------------------------------------- /src/modules/bidding/application/event/__init__.py: -------------------------------------------------------------------------------- 1 | from .notify_outbid_winner import notify_outbid_winner 2 | from .when_listing_is_published_start_auction import ( 3 | when_listing_is_published_start_auction, 4 | ) 5 | -------------------------------------------------------------------------------- /src/api/tests/test_diagnostics.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.integration 5 | def test_debug_endpoint(authenticated_api_client): 6 | response = authenticated_api_client.get("/debug") 7 | assert response.status_code == 200 8 | -------------------------------------------------------------------------------- /src/seedwork/application/commands.py: -------------------------------------------------------------------------------- 1 | from lato import Command as LatoCommand 2 | from pydantic import ConfigDict 3 | 4 | 5 | class Command(LatoCommand): 6 | """Abstract base class for all commands""" 7 | model_config = ConfigDict(arbitrary_types_allowed=True) -------------------------------------------------------------------------------- /src/seedwork/domain/services.py: -------------------------------------------------------------------------------- 1 | from .mixins import BusinessRuleValidationMixin 2 | 3 | 4 | class DomainService(BusinessRuleValidationMixin): 5 | """ 6 | Domain services carry domain knowledge that doesn’t naturally fit entities and value objects. 7 | """ 8 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = tmp 3 | 4 | markers = 5 | unit: marks test as unit test i.e. not using any external services (deselect with '-m "not unit"') 6 | integration: marks tests as integration i.e. using a database (deselect with '-m "not integration"') 7 | serial -------------------------------------------------------------------------------- /src/modules/catalog/application/__init__.py: -------------------------------------------------------------------------------- 1 | from lato import ApplicationModule 2 | import importlib 3 | 4 | catalog_module = ApplicationModule("catalog") 5 | importlib.import_module("modules.catalog.application.command") 6 | importlib.import_module("modules.catalog.application.query") 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false -------------------------------------------------------------------------------- /src/modules/catalog/application/command/__init__.py: -------------------------------------------------------------------------------- 1 | from .create_listing_draft import CreateListingDraftCommand 2 | from .delete_listing_draft import DeleteListingDraftCommand 3 | from .publish_listing_draft import PublishListingDraftCommand 4 | from .update_listing_draft import UpdateListingDraftCommand 5 | -------------------------------------------------------------------------------- /src/modules/bidding/domain/repositories.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | from modules.bidding.domain.entities import GenericUUID, Listing 4 | from seedwork.domain.repositories import GenericRepository 5 | 6 | 7 | class ListingRepository(GenericRepository[GenericUUID, Listing], ABC): 8 | """An interface for Listing repository""" 9 | -------------------------------------------------------------------------------- /src/modules/catalog/domain/repositories.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | 3 | from modules.catalog.domain.entities import GenericUUID, Listing 4 | from seedwork.domain.repositories import GenericRepository 5 | 6 | 7 | class ListingRepository(GenericRepository[GenericUUID, Listing], ABC): 8 | """An interface for Listing repository""" 9 | -------------------------------------------------------------------------------- /src/modules/bidding/application/__init__.py: -------------------------------------------------------------------------------- 1 | from lato import ApplicationModule 2 | import importlib 3 | 4 | 5 | bidding_module = ApplicationModule("bidding") 6 | importlib.import_module("modules.bidding.application.command") 7 | importlib.import_module("modules.bidding.application.query") 8 | importlib.import_module("modules.bidding.application.event") 9 | -------------------------------------------------------------------------------- /src/modules/catalog/domain/value_objects.py: -------------------------------------------------------------------------------- 1 | from seedwork.domain.value_objects import GenericUUID, ValueObject 2 | from enum import Enum 3 | 4 | # some aliases to fight primitive obsession 5 | ListingId = GenericUUID 6 | SellerId = GenericUUID 7 | 8 | 9 | class ListingStatus(str, Enum): 10 | DRAFT = "draft" 11 | PUBLISHED = "published" 12 | -------------------------------------------------------------------------------- /src/cli/README.md: -------------------------------------------------------------------------------- 1 | This is a sample command line script to print all listings 2 | 3 | 1. Start the database 4 | 5 | ``` 6 | docker-compose -f docker-compose.dev.yml up 7 | ``` 8 | 9 | 2. Apply all migrations 10 | 11 | ``` 12 | cd src 13 | alembic upgrade head 14 | ``` 15 | 16 | 3. Run the script (from src directory): 17 | 18 | ``` 19 | python -m cli 20 | ``` 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/seedwork/domain/mixins.py: -------------------------------------------------------------------------------- 1 | from .exceptions import BusinessRuleValidationException 2 | from .rules import BusinessRule 3 | 4 | 5 | def check_rule(rule: BusinessRule): 6 | if rule.is_broken(): 7 | raise BusinessRuleValidationException(rule) 8 | 9 | 10 | class BusinessRuleValidationMixin: 11 | def check_rule(self, rule: BusinessRule): 12 | check_rule(rule) 13 | -------------------------------------------------------------------------------- /src/modules/bidding/application/event/notify_outbid_winner.py: -------------------------------------------------------------------------------- 1 | from modules.bidding.application import bidding_module 2 | from modules.bidding.domain.events import BidWasPlaced 3 | from seedwork.infrastructure.logging import logger 4 | 5 | 6 | @bidding_module.handler(BidWasPlaced) 7 | def notify_outbid_winner(event: BidWasPlaced): 8 | logger.info(f"Message from a handler: Listing {event.listing_id} was published") 9 | -------------------------------------------------------------------------------- /src/seedwork/README.md: -------------------------------------------------------------------------------- 1 | A **framework** is supposed to be a part-baked application that you extend in controlled ways to provide what you need. A **seedwork** is some minimal functionality that you modify however you like to get what you need. Of course this means that there's no way for you to get common updates to the seedwork, once you grow it you own it. 2 | 3 | 4 | References: 5 | 6 | - https://martinfowler.com/bliki/Seedwork.html -------------------------------------------------------------------------------- /src/modules/iam/application/exceptions.py: -------------------------------------------------------------------------------- 1 | from seedwork.application import ApplicationException 2 | 3 | 4 | class InvalidCredentialsException(ApplicationException): 5 | def __init__(self, message="Invalid password"): 6 | super().__init__(message) 7 | 8 | 9 | class InvalidAccessTokenException(ApplicationException): 10 | def __init__(self, message="Invalid access token"): 11 | super().__init__(message) 12 | -------------------------------------------------------------------------------- /src/seedwork/application/utils.py: -------------------------------------------------------------------------------- 1 | from seedwork.application.commands import CommandResult 2 | from seedwork.application.events import EventResult 3 | 4 | 5 | def as_event_result(command_result: CommandResult) -> EventResult: 6 | """Translates command result to event result""" 7 | return EventResult( 8 | payload=command_result.payload, 9 | events=command_result.events, 10 | errors=command_result.errors, 11 | ) 12 | -------------------------------------------------------------------------------- /src/modules/catalog/application/event/do_nothing_when_listing_published.py: -------------------------------------------------------------------------------- 1 | from modules.catalog.domain.events import ListingPublishedEvent 2 | from seedwork.infrastructure.logging import logger 3 | from modules.catalog.application import catalog_module 4 | 5 | 6 | @catalog_module.handler(ListingPublishedEvent) 7 | def do_nothing_when_listing_published(event: ListingPublishedEvent): 8 | logger.info(f"Message from a handler: Listing {event.listing_id} was published") 9 | -------------------------------------------------------------------------------- /src/seedwork/tests/application/test_utils.py: -------------------------------------------------------------------------------- 1 | from seedwork.application.events import DomainEvent 2 | from typing import Type 3 | 4 | class FakeEventPublisher: 5 | def __init__(self): 6 | self.events = [] 7 | 8 | def __call__(self, event): 9 | self.events.append(event) 10 | 11 | def contains(self, event: str | Type[DomainEvent]) -> bool: 12 | return any([ev.__class__.__name__ == event or isinstance(ev, event) for ev in self.events]) -------------------------------------------------------------------------------- /src/seedwork/utils/data_structures.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | 4 | class OrderedSet(OrderedDict): 5 | def __init__(self, iterable=None): 6 | super().__init__() 7 | if iterable: 8 | for item in iterable: 9 | self.add(item) 10 | 11 | def add(self, item): 12 | self[item] = None 13 | 14 | def update(self, iterable): 15 | for item in iterable: 16 | self.add(item) 17 | -------------------------------------------------------------------------------- /src/seedwork/tests/domain/test_value_objects.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import BaseModel 3 | 4 | from seedwork.domain.value_objects import GenericUUID, Money 5 | 6 | 7 | class CustomPydanticModel(BaseModel): 8 | uuid: GenericUUID 9 | 10 | 11 | @pytest.mark.unit 12 | def test_money_equality(): 13 | assert Money(10, "USD") == Money(10, "USD") 14 | 15 | 16 | @pytest.mark.unit 17 | def test_money_ordering(): 18 | assert Money(10, "USD") < Money(100, "USD") 19 | -------------------------------------------------------------------------------- /src/modules/iam/README.md: -------------------------------------------------------------------------------- 1 | # Identity and Access Management module 2 | 3 | 4 | 5 | > Identity and access management (IAM) is a framework of business processes, policies and technologies that facilitates the management of electronic or digital identities. With an IAM framework in place, information technology (IT) managers can control user access to critical information within their organizations. 6 | 7 | 8 | References: 9 | 10 | - https://craftingjava.com/blog/user-management-domain-model-rest-api/ -------------------------------------------------------------------------------- /src/modules/bidding/domain/events.py: -------------------------------------------------------------------------------- 1 | from seedwork.domain.events import DomainEvent 2 | from seedwork.domain.value_objects import GenericUUID 3 | 4 | 5 | class BidWasPlaced(DomainEvent): 6 | listing_id: GenericUUID 7 | bidder_id: GenericUUID 8 | 9 | 10 | class HighestBidderWasOutbid(DomainEvent): 11 | listing_id: GenericUUID 12 | outbid_bidder_id: GenericUUID 13 | 14 | 15 | class BidWasRetracted(DomainEvent): 16 | ... 17 | 18 | 19 | class ListingWasCancelled(DomainEvent): 20 | ... 21 | -------------------------------------------------------------------------------- /src/seedwork/infrastructure/database.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import uuid 4 | 5 | from sqlalchemy.orm import declarative_base 6 | from sqlalchemy_utils import force_auto_coercion 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | force_auto_coercion() 12 | Base = declarative_base() 13 | 14 | 15 | def _default(val): 16 | if isinstance(val, uuid.UUID): 17 | return str(val) 18 | raise TypeError() 19 | 20 | 21 | def dumps(d): 22 | return json.dumps(d, default=_default) 23 | -------------------------------------------------------------------------------- /docs/architecture_decision_log/003_use_python.md: -------------------------------------------------------------------------------- 1 | # 3. Use .NET Core and C# language 2 | 3 | Date: 2021-05-25 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | As it is monolith, only one language must be selected for implementation. 12 | 13 | ## Decision 14 | 15 | 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. 16 | 17 | ## Consequences 18 | 19 | - Whole application will be implemented in Python -------------------------------------------------------------------------------- /src/modules/iam/application/repository.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | 3 | from modules.iam.application.services import User 4 | from seedwork.domain.repositories import GenericRepository 5 | from seedwork.domain.value_objects import Email, GenericUUID 6 | 7 | 8 | class UserRepository(GenericRepository[GenericUUID, User]): 9 | @abstractmethod 10 | def get_by_email(self, email: Email) -> User | None: 11 | ... 12 | 13 | @abstractmethod 14 | def get_by_access_token(self, access_token: str) -> User | None: 15 | ... 16 | -------------------------------------------------------------------------------- /src/seedwork/domain/exceptions.py: -------------------------------------------------------------------------------- 1 | class DomainException(Exception): 2 | pass 3 | 4 | 5 | class BusinessRuleValidationException(DomainException): 6 | def __init__(self, rule): 7 | self.rule = rule 8 | 9 | def __str__(self): 10 | return str(self.rule) 11 | 12 | 13 | class EntityNotFoundException(Exception): 14 | def __init__(self, repository, **kwargs): 15 | 16 | message = f"Entity with {kwargs} not found" 17 | super().__init__(message) 18 | self.repository = repository 19 | self.kwargs = kwargs 20 | -------------------------------------------------------------------------------- /src/api/tests/test_common.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.integration 5 | def test_homepage_returns_200(api_client): 6 | response = api_client.get("/") 7 | assert response.status_code == 200 8 | 9 | 10 | @pytest.mark.integration 11 | def test_docs_page_returns_200(api_client): 12 | response = api_client.get("/docs") 13 | assert response.status_code == 200 14 | 15 | 16 | @pytest.mark.integration 17 | def test_openapi_schema_returns_200(api_client): 18 | response = api_client.get("/openapi.json") 19 | assert response.status_code == 200 20 | -------------------------------------------------------------------------------- /src/modules/catalog/domain/events.py: -------------------------------------------------------------------------------- 1 | from seedwork.domain.events import DomainEvent 2 | from seedwork.domain.value_objects import GenericUUID, Money 3 | 4 | 5 | class ListingDraftCreatedEvent(DomainEvent): 6 | listing_id: GenericUUID 7 | 8 | 9 | class ListingDraftUpdatedEvent(DomainEvent): 10 | listing_id: GenericUUID 11 | 12 | 13 | class ListingDraftDeletedEvent(DomainEvent): 14 | listing_id: GenericUUID 15 | 16 | 17 | class ListingPublishedEvent(DomainEvent): 18 | listing_id: GenericUUID 19 | seller_id: GenericUUID 20 | ask_price: Money 21 | -------------------------------------------------------------------------------- /src/seedwork/domain/events.py: -------------------------------------------------------------------------------- 1 | from lato import Event 2 | 3 | 4 | class DomainEvent(Event): 5 | """ 6 | Domain events are used to communicate between aggregates within a single transaction boundary via in-memory queue. 7 | Domain events are synchronous in nature. 8 | """ 9 | 10 | class Config: 11 | arbitrary_types_allowed = True 12 | 13 | def __next__(self): 14 | yield self 15 | 16 | 17 | class CompositeDomainEvent(DomainEvent): 18 | events: list[DomainEvent] 19 | 20 | def __next__(self): 21 | yield from self.events 22 | -------------------------------------------------------------------------------- /src/modules/catalog/application/query/model_mappers.py: -------------------------------------------------------------------------------- 1 | from modules.catalog.infrastructure.listing_repository import ListingModel 2 | 3 | 4 | def map_listing_model_to_dao(instance: ListingModel): 5 | """maps ListingModel to a data access object (a dictionary)""" 6 | data = instance.data 7 | return dict( 8 | id=instance.id, 9 | title=data["title"], 10 | description=data["description"], 11 | ask_price_amount=data["ask_price"]["amount"], 12 | ask_price_currency=data["ask_price"]["currency"], 13 | seller_id=data["seller_id"], 14 | ) 15 | -------------------------------------------------------------------------------- /src/seedwork/domain/rules.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class BusinessRule(BaseModel): 5 | """This is a base class for implementing domain rules""" 6 | 7 | class Config: 8 | arbitrary_types_allowed = True 9 | 10 | # This is an error message that broken rule reports back 11 | __message: str = "Business rule is broken" 12 | 13 | def get_message(self) -> str: 14 | return self.__message 15 | 16 | def is_broken(self) -> bool: 17 | pass 18 | 19 | def __str__(self): 20 | return f"{self.__class__.__name__} {super().__str__()}" 21 | -------------------------------------------------------------------------------- /src/modules/catalog/tests/domain/test_rules.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from modules.catalog.domain.rules import ListingAskPriceMustBeGreaterThanZero 4 | from seedwork.domain.value_objects import Money 5 | 6 | 7 | @pytest.mark.unit 8 | def test_AuctionItemPriceMustBeGreaterThanZero_rule(): 9 | rule = ListingAskPriceMustBeGreaterThanZero(ask_price=Money(1)) 10 | assert not rule.is_broken() 11 | 12 | 13 | @pytest.mark.unit 14 | def test_AuctionItemPriceMustBeGreaterThanZero_rule_is_broken(): 15 | rule = ListingAskPriceMustBeGreaterThanZero(ask_price=Money(0)) 16 | assert rule.is_broken() 17 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /src/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /src/modules/bidding/domain/value_objects.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from dataclasses import dataclass, field 4 | 5 | from seedwork.domain.value_objects import GenericUUID, Money, ValueObject 6 | 7 | 8 | @dataclass(frozen=True) 9 | class Bidder(ValueObject): 10 | id: GenericUUID 11 | 12 | 13 | @dataclass(frozen=True) 14 | class Seller(ValueObject): 15 | id: GenericUUID 16 | 17 | 18 | @dataclass(frozen=True) 19 | class Bid(ValueObject): 20 | max_price: Money # a maximum price that a bidder is willing to pay 21 | bidder: Bidder 22 | placed_at: datetime = field(default_factory=datetime.utcnow) 23 | -------------------------------------------------------------------------------- /src/seedwork/tests/domain/test_entity.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from seedwork.domain.entities import AggregateRoot, Entity 6 | 7 | 8 | @dataclass 9 | class PersonEntity(Entity): 10 | name: str 11 | 12 | 13 | @dataclass 14 | class PersonAggregate(AggregateRoot): 15 | name: str 16 | 17 | 18 | @pytest.mark.unit 19 | def test_entity(): 20 | bob = PersonEntity(id=PersonEntity.next_id(), name="Bob") 21 | assert bob.id is not None 22 | 23 | 24 | @pytest.mark.unit 25 | def test_aggregate(): 26 | bob = PersonAggregate(id=PersonEntity.next_id(), name="Bob") 27 | assert bob.id is not None 28 | -------------------------------------------------------------------------------- /src/modules/catalog/domain/services.py: -------------------------------------------------------------------------------- 1 | # from seedwork.domain.services import DomainService 2 | # from seedwork.domain.value_objects import UUID 3 | # from .entities import Listing, Seller 4 | # from .repositories import ListingRepository 5 | # from .rules import ( 6 | # ListingMustBeInDraftState, 7 | # SellerMustBeEligibleForAddingNextListing, 8 | # ) 9 | 10 | 11 | # class CatalogService: 12 | # def publish_listing(self, listing: Listing, seller: Seller): 13 | # self.check_rule(ListingMustBeInDraftState(listing.status)) 14 | # self.check_rule(SellerMustBeEligibleForAddingNextListing(seller)) 15 | # listing.publish() 16 | -------------------------------------------------------------------------------- /src/modules/bidding/application/query/model_mappers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel 4 | 5 | from modules.bidding.infrastructure.listing_repository import ListingModel 6 | from seedwork.domain.value_objects import GenericUUID 7 | 8 | 9 | class ListingDAO(BaseModel): 10 | id: GenericUUID 11 | ends_at: datetime 12 | bids: list 13 | 14 | 15 | def map_listing_model_to_dao(instance: ListingModel): 16 | """maps ListingModel to a data access object (a dictionary)""" 17 | data = instance.data 18 | return ListingDAO( 19 | id=instance.id, 20 | ends_at=data["ends_at"], 21 | bids=[], 22 | ) 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.11 3 | 4 | repos: 5 | # Only for removing unused imports > Other staff done by Black 6 | - repo: https://github.com/myint/autoflake 7 | rev: "v1.4" # Version to check 8 | hooks: 9 | - id: autoflake 10 | args: 11 | - --in-place 12 | - --remove-all-unused-imports 13 | - --ignore-init-module-imports 14 | 15 | - repo: https://github.com/pycqa/isort 16 | rev: 5.12.0 17 | hooks: 18 | - id: isort 19 | name: isort (python) 20 | args: ["--profile", "black"] 21 | 22 | - repo: https://github.com/ambv/black 23 | rev: 22.3.0 24 | hooks: 25 | - id: black 26 | -------------------------------------------------------------------------------- /src/modules/bidding/application/query/get_pastdue_listings.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | 4 | from modules.bidding.application import bidding_module 5 | from modules.bidding.domain.repositories import ListingRepository 6 | from seedwork.application.queries import Query 7 | from seedwork.application.query_handlers import QueryResult 8 | 9 | 10 | class GetPastdueListings(Query): 11 | now: datetime = field(default_factory=datetime.utcnow) 12 | 13 | 14 | @bidding_module.handler(GetPastdueListings) 15 | def get_past_due_listings( 16 | query: GetPastdueListings, listing_repository: ListingRepository 17 | ): 18 | # TODO: not yet implemented 19 | return [] 20 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | db: 4 | image: postgres:12.3 5 | environment: 6 | POSTGRES_PASSWORD: password 7 | volumes: 8 | - ./tmp/db/data:/var/lib/postgresql/data 9 | ports: 10 | - "5432:5432" 11 | networks: 12 | - internal-network 13 | - external-network 14 | test_db: 15 | image: postgres:12.3 16 | environment: 17 | POSTGRES_PASSWORD: password 18 | volumes: 19 | - ./tmp/test_db/data:/var/lib/postgresql/data 20 | ports: 21 | - "5433:5432" 22 | networks: 23 | - internal-network 24 | - external-network 25 | 26 | networks: 27 | internal-network: 28 | internal: true 29 | external-network: 30 | 31 | -------------------------------------------------------------------------------- /src/config/api_config.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | from pydantic_settings import BaseSettings 3 | 4 | # env_filename = os.getenv("ENV_FILENAME", ".env") 5 | 6 | 7 | class ApiConfig(BaseSettings): 8 | """ 9 | All API Settings are here 10 | """ 11 | 12 | APP_NAME: str = "Online Auctions API" 13 | DEBUG: bool = Field(default=True) 14 | DATABASE_ECHO: bool = Field(default=False) 15 | DATABASE_URL: str = Field( 16 | default="postgresql://postgres:password@localhost:5432/postgres", 17 | ) 18 | LOGGER_NAME: str = "api" 19 | 20 | 21 | # SECRET_KEY = config("SECRET_KEY", cast=Secret, default="secret") 22 | # ALLOWED_HOSTS = config("ALLOWED_HOSTS", cast=CommaSeparatedStrings, default="*") 23 | -------------------------------------------------------------------------------- /docs/architecture_decision_log/002_use_modular_monolith.md: -------------------------------------------------------------------------------- 1 | # 2. Use Modular Monolith System Architecture 2 | 3 | Date: 2021-05-25 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | An example of Modular Monolith architecture and tactical DDD implementation in Python is missing on the internet. 12 | 13 | ## Decision 14 | 15 | We decided to create nontrivial application using Modular Monolith architecture and Domain-Driven Design tactical patterns. 16 | 17 | ## Consequences 18 | 19 | - All modules must run in one single process as single application (Monolith) 20 | - All modules should have maximum autonomy (Modular) 21 | - DDD Bounded Contexts will be used to divide monolith into modules 22 | - DDD tactical patterns will be used to implement most of modules -------------------------------------------------------------------------------- /src/modules/catalog/application/query/get_all_listings.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from modules.catalog.application import catalog_module 4 | from modules.catalog.application.query.model_mappers import map_listing_model_to_dao 5 | from modules.catalog.infrastructure.listing_repository import ListingModel 6 | from seedwork.application.queries import Query 7 | 8 | 9 | class GetAllListings(Query): 10 | """This query does not need any parameters""" 11 | 12 | 13 | @catalog_module.handler(GetAllListings) 14 | async def get_all_listings( 15 | query: GetAllListings, 16 | session: Session, 17 | ) -> list[ListingModel]: 18 | queryset = session.query(ListingModel) 19 | listings = [map_listing_model_to_dao(row) for row in queryset.all()] 20 | return listings 21 | -------------------------------------------------------------------------------- /src/api/models/catalog.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID, uuid4 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class CurrentUser(BaseModel): 7 | id: UUID 8 | username: str 9 | 10 | @classmethod 11 | def fake_user(cls): 12 | return CurrentUser(id=uuid4(), username="fake_user") 13 | 14 | 15 | class ListingWriteModel(BaseModel): 16 | title: str 17 | description: str 18 | ask_price_amount: float 19 | ask_price_currency: str = "USD" 20 | 21 | 22 | class ListingPublishModel(BaseModel): 23 | id: UUID 24 | 25 | 26 | class ListingReadModel(BaseModel): 27 | id: UUID 28 | title: str = "" 29 | description: str 30 | ask_price_amount: float 31 | ask_price_currency: str 32 | 33 | 34 | class ListingIndexModel(BaseModel): 35 | data: list[ListingReadModel] 36 | -------------------------------------------------------------------------------- /src/seedwork/application/inbox_outbox.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | 4 | class InMemoryInbox: 5 | def __init__(self): 6 | self.events = [] 7 | 8 | def is_empty(self): 9 | return len(self.events) == 0 10 | 11 | @contextlib.contextmanager 12 | def get_next_event(self): 13 | yield self.events.pop(0) 14 | 15 | def enqueue(self, event): 16 | self.events.append(event) 17 | 18 | 19 | class ProcessInboxUntilEmptyStrategy: 20 | def __init__(self, inbox: InMemoryInbox): 21 | self.inbox = inbox 22 | 23 | def should_process_next_event(self): 24 | return not self.inbox.is_empty() 25 | 26 | 27 | class InMemoryOutbox: 28 | def __init__(self): 29 | self.events = [] 30 | 31 | def save(self, event): 32 | self.events.append(event) 33 | -------------------------------------------------------------------------------- /src/api/models/bidding.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel 4 | 5 | from seedwork.domain.value_objects import GenericUUID 6 | 7 | 8 | class BidReadModel(BaseModel): 9 | amount: float 10 | currency: str 11 | bidder_id: GenericUUID 12 | bidder_username: str 13 | 14 | class Config: 15 | arbitrary_types_allowed = True 16 | 17 | 18 | class BiddingResponse(BaseModel): 19 | listing_id: GenericUUID 20 | auction_status: str = "active" # active, ended 21 | auction_end_date: datetime 22 | bids: list[BidReadModel] 23 | 24 | class Config: 25 | arbitrary_types_allowed = True 26 | 27 | 28 | class PlaceBidRequest(BaseModel): 29 | bidder_id: GenericUUID 30 | amount: float 31 | 32 | class Config: 33 | arbitrary_types_allowed = True 34 | -------------------------------------------------------------------------------- /src/modules/bidding/application/command/retract_bid.py: -------------------------------------------------------------------------------- 1 | from modules.bidding.application import bidding_module 2 | from modules.bidding.domain.entities import Listing 3 | from modules.bidding.domain.repositories import ListingRepository 4 | from modules.bidding.domain.value_objects import Bidder 5 | from seedwork.application.commands import Command 6 | from seedwork.domain.value_objects import GenericUUID 7 | 8 | 9 | class RetractBidCommand(Command): 10 | listing_id: GenericUUID 11 | bidder_id: GenericUUID 12 | 13 | 14 | @bidding_module.handler(RetractBidCommand) 15 | def retract_bid( 16 | command: RetractBidCommand, listing_repository: ListingRepository 17 | ): 18 | bidder = Bidder(id=command.bidder_id) 19 | 20 | listing: Listing = listing_repository.get_by_id(id=command.listing_id) 21 | listing.retract_bid_of(bidder) 22 | -------------------------------------------------------------------------------- /src/api/routers/diagnostics.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends 4 | 5 | from api.dependencies import ( 6 | Application, 7 | User, 8 | get_authenticated_user, 9 | get_application, 10 | ) 11 | 12 | from .iam import UserResponse 13 | 14 | router = APIRouter() 15 | 16 | 17 | @router.get("/debug", tags=["diagnostics"]) 18 | async def debug( 19 | app: Annotated[Application, Depends(get_application)], 20 | current_user: Annotated[User, Depends(get_authenticated_user)], 21 | ): 22 | return dict( 23 | app_id=id(app), 24 | name=app.name, 25 | version=app["app_version"], 26 | user=UserResponse( 27 | id=str(current_user.id), 28 | username=current_user.username, 29 | access_token=current_user.access_token, 30 | ), 31 | ) 32 | -------------------------------------------------------------------------------- /src/modules/catalog/README.md: -------------------------------------------------------------------------------- 1 | # Listing catalog module 2 | 3 | This part is a seller portal. It allows sellers to create and manage their listings. 4 | 5 | # Business rules 6 | 7 | # User stories: 8 | 9 | - [x] As a seller, I want to create a listing draft for a product I want to sell. 10 | 11 | - [x] As a seller, I want to update a listing draft. 12 | 13 | - [x] As a seller, I want to delete a listing draft. 14 | 15 | - [x] As a seller, I want to publish a listing draft immediately. 16 | 17 | - [ ] As a seller, I want to schedule a listing draft for publishing. 18 | 19 | 20 | - [ ] As a seller, I want to view all my listings (published, unpublished, ended). 21 | 22 | - [ ] As a seller, I want to view details of a listing. 23 | 24 | - [ ] As a system, I want to notify a seller when a listing is published. 25 | 26 | - [ ] As a system, I want to notify a seller when a listing is ended. -------------------------------------------------------------------------------- /src/modules/bidding/tests/application/test_create_listing_when_draft_is_published.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from modules.bidding.domain.repositories import ( 4 | ListingRepository as BiddingListingRepository, 5 | ) 6 | from modules.catalog.domain.events import ListingPublishedEvent 7 | from seedwork.domain.value_objects import GenericUUID, Money 8 | 9 | 10 | @pytest.mark.integration 11 | @pytest.mark.asyncio 12 | async def test_create_listing_on_draft_published_event(app, engine): 13 | listing_id = GenericUUID(int=1) 14 | await app.publish_async( 15 | ListingPublishedEvent( 16 | listing_id=listing_id, 17 | seller_id=GenericUUID.next_id(), 18 | ask_price=Money(10), 19 | ) 20 | ) 21 | 22 | with app.transaction_context() as ctx: 23 | listing_repository = ctx[BiddingListingRepository] 24 | assert listing_repository.count() == 1 25 | -------------------------------------------------------------------------------- /src/modules/catalog/application/query/get_listing_details.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from modules.catalog.application import catalog_module 6 | from modules.catalog.application.query.model_mappers import map_listing_model_to_dao 7 | from modules.catalog.infrastructure.listing_repository import ListingModel 8 | from seedwork.application.queries import Query 9 | from seedwork.application.query_handlers import QueryResult 10 | from seedwork.domain.value_objects import GenericUUID 11 | 12 | 13 | class GetListingDetails(Query): 14 | listing_id: GenericUUID 15 | 16 | 17 | @catalog_module.handler(GetListingDetails) 18 | def get_listing_details(query: GetListingDetails, session: Session) -> QueryResult: 19 | row = session.query(ListingModel).filter_by(id=query.listing_id).one() 20 | details = map_listing_model_to_dao(row) 21 | return details 22 | -------------------------------------------------------------------------------- /src/modules/bidding/application/event/when_listing_is_published_start_auction.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from modules.bidding.application import bidding_module 4 | from modules.bidding.domain.entities import Listing 5 | from modules.bidding.domain.repositories import ListingRepository 6 | from modules.bidding.domain.value_objects import Seller 7 | from modules.catalog.domain.events import ListingPublishedEvent 8 | 9 | 10 | @bidding_module.handler(ListingPublishedEvent) 11 | def when_listing_is_published_start_auction( 12 | event: ListingPublishedEvent, listing_repository: ListingRepository 13 | ): 14 | listing = Listing( 15 | id=event.listing_id, 16 | seller=Seller(id=event.seller_id), 17 | ask_price=event.ask_price, 18 | starts_at=datetime.now(), 19 | ends_at=datetime.now() + timedelta(days=7), 20 | ) 21 | listing_repository.add(listing) 22 | -------------------------------------------------------------------------------- /docs/architecture_decision_log/001_initial.md: -------------------------------------------------------------------------------- 1 | # 1. Record architecture decisions 2 | 3 | Date: 2021-05-25 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | As the project is an example of a more advanced monolith architecture, it is necessary to save all architectural decisions in one place. 12 | 13 | ## Decision 14 | 15 | For all architectural decisions Architecture Decision Log (ADL) is created. All decisions will be recorded as Architecture Decision Records (ADR). 16 | 17 | 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. 18 | 19 | ## Consequences 20 | 21 | 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. -------------------------------------------------------------------------------- /src/modules/bidding/application/command/place_bid.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from modules.bidding.application import bidding_module 4 | from modules.bidding.domain.repositories import ListingRepository 5 | from modules.bidding.domain.value_objects import Bid, Bidder, Money 6 | from seedwork.application.commands import Command 7 | from seedwork.domain.value_objects import GenericUUID 8 | 9 | 10 | class PlaceBidCommand(Command): 11 | listing_id: GenericUUID 12 | bidder_id: GenericUUID 13 | amount: int # todo: Decimal 14 | currency: str = "USD" 15 | 16 | 17 | @bidding_module.handler(PlaceBidCommand) 18 | def place_bid( 19 | command: PlaceBidCommand, listing_repository: ListingRepository 20 | ): 21 | bidder = Bidder(id=command.bidder_id) 22 | bid = Bid(bidder=bidder, max_price=Money(command.amount)) 23 | 24 | listing = listing_repository.get_by_id(command.listing_id) 25 | listing.place_bid(bid) 26 | -------------------------------------------------------------------------------- /src/modules/catalog/application/query/get_listings_of_seller.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Session 2 | 3 | from modules.catalog.application import catalog_module 4 | from modules.catalog.infrastructure.listing_repository import ListingModel 5 | from seedwork.application.queries import Query 6 | from seedwork.application.query_handlers import QueryResult 7 | from seedwork.domain.value_objects import GenericUUID 8 | 9 | 10 | class GetListingsOfSeller(Query): 11 | seller_id: GenericUUID 12 | 13 | 14 | @catalog_module.handler(GetListingsOfSeller) 15 | def get_listings_of_seller(query: GetListingsOfSeller, session: Session) -> list[dict]: 16 | # FIXME: use seller_id to filter out listings 17 | queryset = session.query(ListingModel) # .filter( 18 | # listing_repository.model.data['seller'].astext.cast(UUID) == query.seller_id 19 | # ) 20 | listings = [dict(id=row.id, **row.data) for row in queryset.all()] 21 | return listings 22 | -------------------------------------------------------------------------------- /src/modules/iam/domain/entities.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from seedwork.domain.entities import AggregateRoot 4 | from seedwork.domain.value_objects import Email, GenericUUID 5 | 6 | UserId = GenericUUID 7 | 8 | 9 | @dataclass 10 | class User(AggregateRoot): 11 | id: GenericUUID 12 | email: Email 13 | password_hash: bytes 14 | access_token: str 15 | is_superuser: bool = False 16 | 17 | @property 18 | def username(self): 19 | return self.email 20 | 21 | @username.setter 22 | def username(self, value): 23 | self.email = value 24 | 25 | 26 | class AnonymousUser(User): 27 | def __init__(self): 28 | super().__init__( 29 | id=GenericUUID("00000000-0000-0000-0000-000000000000"), 30 | email=None, 31 | password_hash=b"", 32 | access_token="", 33 | ) 34 | 35 | @property 36 | def username(self): 37 | return "" 38 | -------------------------------------------------------------------------------- /src/seedwork/infrastructure/json_data_mapper.py: -------------------------------------------------------------------------------- 1 | from seedwork.domain.entities import Entity, GenericUUID 2 | 3 | 4 | class JSONDataMapper: 5 | """Used to serialize/deserialize entities from/to JSON data format""" 6 | 7 | def data_to_entity(self, data: dict, entity_class: type[Entity]) -> Entity: 8 | """Creates business entity from dictionary with a `data` attribute""" 9 | entity_id = GenericUUID(data.pop("id")) 10 | entity_dict = { 11 | "id": entity_id, 12 | **data, 13 | } 14 | return entity_class(**entity_dict) 15 | 16 | def entity_to_data(self, entity: Entity, model_class): 17 | """Stores entity attributes in dictionary with a `data` attribute""" 18 | data = dict(**entity.__dict__) 19 | entity_id = str(data.pop("id")) 20 | return model_class( 21 | **{ 22 | "id": entity_id, 23 | "data": data, 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /src/modules/bidding/domain/services.py: -------------------------------------------------------------------------------- 1 | # TODO: Work in progress... 2 | 3 | 4 | # class EndListingPolicy: 5 | # def end_listing(self, listing): 6 | # specification = EndListingSpecification() 7 | # if specification.is_satisfied_by(listing): 8 | # command = EndListingCommand(...) 9 | # dispatch(command) 10 | # 11 | # 12 | # class EndingListingService(ApplicationService): 13 | # def end_pastdue_listings(self): 14 | # listing_ids = get_listing_ids_for_overdue_listings() 15 | # 16 | # # v1 17 | # for listing_id in listing_ids: 18 | # listing = listing_repository.get_by_id(listing_id) 19 | # events = listing.end() 20 | # events_publisher.publish(events) 21 | # 22 | # # v2 23 | # policy = EndListingPolicy() 24 | # for listing_id in listing_ids: 25 | # listing = listing_repository.get_by_id(listing_id) 26 | # policy.end_listing(listing) 27 | -------------------------------------------------------------------------------- /src/seedwork/application/query_handlers.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from dataclasses import dataclass, field 3 | from typing import Any, Generic, Optional, TypeVar 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | @dataclass 9 | class QueryResult(Generic[T]): 10 | payload: Optional[T] = None 11 | errors: list[Any] = field(default_factory=list) 12 | 13 | def has_errors(self): 14 | return len(self.errors) > 0 15 | 16 | def is_success(self) -> bool: 17 | return not self.has_errors() 18 | 19 | @classmethod 20 | def failure(cls, message="Failure", exception=None) -> "QueryResult": 21 | """Creates a failed result""" 22 | exception_info = sys.exc_info() 23 | errors = [(message, exception, exception_info)] 24 | result = cls(errors=errors) 25 | return result 26 | 27 | @classmethod 28 | def success(cls, payload=None) -> "QueryResult": 29 | """Creates a successful result""" 30 | return cls(payload=payload) 31 | -------------------------------------------------------------------------------- /src/modules/catalog/application/command/update_listing_draft.py: -------------------------------------------------------------------------------- 1 | from modules.catalog.application import catalog_module 2 | from modules.catalog.domain.entities import Listing 3 | from modules.catalog.domain.repositories import ListingRepository 4 | from lato import Command 5 | from seedwork.domain.value_objects import GenericUUID, Money 6 | 7 | 8 | class UpdateListingDraftCommand(Command): 9 | """A command for updating a listing""" 10 | 11 | listing_id: GenericUUID 12 | title: str 13 | description: str 14 | ask_price: Money 15 | modify_user_id: GenericUUID 16 | 17 | 18 | @catalog_module.handler(UpdateListingDraftCommand) 19 | def update_listing_draft( 20 | command: UpdateListingDraftCommand, repository: ListingRepository 21 | ): 22 | listing: Listing = repository.get_by_id(command.listing_id) 23 | listing.change_main_attributes( 24 | title=command.title, 25 | description=command.description, 26 | ask_price=command.ask_price, 27 | ) 28 | -------------------------------------------------------------------------------- /src/modules/bidding/application/query/get_bidding_details.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from modules.bidding.application import bidding_module 6 | from modules.bidding.application.query.model_mappers import ( 7 | ListingDAO, 8 | map_listing_model_to_dao, 9 | ) 10 | from modules.bidding.infrastructure.listing_repository import ListingModel 11 | from seedwork.application.queries import Query 12 | from seedwork.application.query_handlers import QueryResult 13 | from seedwork.domain.value_objects import GenericUUID 14 | 15 | 16 | class GetBiddingDetails(Query): 17 | listing_id: GenericUUID 18 | 19 | 20 | @bidding_module.handler(GetBiddingDetails) 21 | def get_bidding_details( 22 | query: GetBiddingDetails, 23 | session: Session, 24 | ) -> ListingDAO: 25 | listing_model = ( 26 | session.query(ListingModel).filter_by(id=str(query.listing_id)).one() 27 | ) 28 | dao = map_listing_model_to_dao(listing_model) 29 | return dao 30 | -------------------------------------------------------------------------------- /src/seedwork/domain/entities.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Generic, TypeVar 3 | 4 | from seedwork.domain.events import DomainEvent 5 | from seedwork.domain.mixins import BusinessRuleValidationMixin 6 | from seedwork.domain.value_objects import GenericUUID 7 | 8 | EntityId = TypeVar("EntityId", bound=GenericUUID) 9 | 10 | 11 | @dataclass 12 | class Entity(Generic[EntityId]): 13 | id: EntityId = field(hash=True) 14 | 15 | @classmethod 16 | def next_id(cls) -> EntityId: 17 | return GenericUUID.next_id() 18 | 19 | 20 | @dataclass(kw_only=True) 21 | class AggregateRoot(BusinessRuleValidationMixin, Entity[EntityId]): 22 | """Consists of 1+ entities. Spans transaction boundaries.""" 23 | 24 | events: list = field(default_factory=list) 25 | 26 | def register_event(self, event: DomainEvent): 27 | self.events.append(event) 28 | 29 | def collect_events(self): 30 | events = self.events 31 | self.events = [] 32 | return events 33 | -------------------------------------------------------------------------------- /src/alembic/versions/d6c2334f4816_initial_listing_model.py: -------------------------------------------------------------------------------- 1 | """initial listing model 2 | 3 | Revision ID: d6c2334f4816 4 | Revises: 5 | Create Date: 2021-09-27 17:33:02.166128 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from sqlalchemy.dialects import postgresql 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "d6c2334f4816" 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "catalog_listing", 24 | sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), 25 | sa.Column("data", postgresql.JSONB(astext_type=sa.Text()), nullable=True), 26 | sa.PrimaryKeyConstraint("id"), 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_table("catalog_listing") 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /docs/auctions.cml: -------------------------------------------------------------------------------- 1 | ContextMap OnlineAuctionsContextMap { 2 | type = SYSTEM_LANDSCAPE 3 | state = TO_BE 4 | contains ListingContext 5 | contains BiddingContext 6 | contains PaymentContext 7 | 8 | BiddingContext [SK]<->[SK] ListingContext 9 | 10 | BiddingContext [D, CF] <- PaymentContext 11 | } 12 | 13 | BoundedContext ListingContext implements ListingDomain 14 | 15 | BoundedContext BiddingContext implements BiddingDomain 16 | 17 | BoundedContext PaymentContext implements PaymentDomain 18 | 19 | Domain OnlineAuctionsDomain { 20 | Subdomain ListingDomain { 21 | type = SUPPORTING_DOMAIN 22 | domainVisionStatement = "Subdomain managing all listings of items for sale" 23 | } 24 | 25 | Subdomain BiddingDomain { 26 | type = CORE_DOMAIN 27 | domainVisionStatement = "Subdomain managing ongoing bidding" 28 | } 29 | 30 | Subdomain PaymentDomain { 31 | type = GENERIC_SUBDOMAIN 32 | domainVisionStatement = "Subdomain managing payments" 33 | } 34 | 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/api/dependencies.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import Depends, Request 4 | from fastapi.security import OAuth2PasswordBearer 5 | from lato import Application, TransactionContext 6 | 7 | from modules.iam.application.services import IamService 8 | from modules.iam.domain.entities import User 9 | 10 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 11 | 12 | 13 | async def get_application(request: Request) -> Application: 14 | return request.state.lato_application 15 | 16 | 17 | async def get_transaction_context( 18 | app: Annotated[Application, Depends(get_application)], 19 | ) -> TransactionContext: 20 | """Creates a new transaction context for each request""" 21 | async with app.transaction_context() as ctx: 22 | yield ctx 23 | 24 | 25 | async def get_authenticated_user( 26 | access_token: Annotated[str, Depends(oauth2_scheme)], 27 | ctx: Annotated[TransactionContext, Depends(get_transaction_context)], 28 | ) -> User: 29 | current_user = ctx[IamService].find_user_by_access_token(access_token) 30 | return current_user 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ermlab sp. z o. o. 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. -------------------------------------------------------------------------------- /docs/architecture_decision_log/005_separate_commands_and_queries.md: -------------------------------------------------------------------------------- 1 | # 4. Separate commands and queries 2 | 3 | Date: 2021-05-25 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | 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. 12 | 13 | ``` 14 | def get_route_controller(request, module): 15 | module.execute_query(MyQuery( 16 | foo=request.GET.foo, 17 | bar=request.GET.bar, 18 | )) 19 | return Response(HTTP_200_OK) 20 | 21 | 22 | def post_route_controller(request, module): 23 | result = module.execute(MyCommand( 24 | foo=request.POST.foo, 25 | bar=request.POST.bar, 26 | )) 27 | return Response(HTTP_200_OK) 28 | ``` 29 | 30 | 31 | ## Consequences 32 | - controllers are thin 33 | - each module must implement `execute_query` and `execute_command` methods -------------------------------------------------------------------------------- /src/modules/catalog/tests/application/test_create_listing_draft.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from modules.catalog.application.command.create_listing_draft import ( 4 | CreateListingDraftCommand, 5 | create_listing_draft, 6 | ) 7 | from modules.catalog.domain.entities import Seller 8 | from seedwork.domain.value_objects import GenericUUID, Money 9 | from seedwork.infrastructure.repository import InMemoryRepository 10 | from seedwork.tests.application.test_utils import FakeEventPublisher 11 | 12 | 13 | @pytest.mark.unit 14 | @pytest.mark.asyncio 15 | async def test_create_listing_draft(): 16 | # arrange 17 | listing_id = GenericUUID(int=1) 18 | command = CreateListingDraftCommand( 19 | listing_id=listing_id, 20 | title="foo", 21 | description="bar", 22 | ask_price=Money(1), 23 | seller_id=Seller.next_id(), 24 | ) 25 | publish = FakeEventPublisher() 26 | repository = InMemoryRepository() 27 | 28 | # act 29 | await create_listing_draft(command, repository, publish) 30 | 31 | # assert 32 | assert repository.get_by_id(listing_id).title == "foo" 33 | assert publish.contains("ListingDraftCreatedEvent") 34 | -------------------------------------------------------------------------------- /src/modules/catalog/application/command/create_listing_draft.py: -------------------------------------------------------------------------------- 1 | from lato import Command 2 | 3 | from modules.catalog.application import catalog_module 4 | from modules.catalog.domain.entities import Listing 5 | from modules.catalog.domain.events import ListingDraftCreatedEvent 6 | from modules.catalog.domain.repositories import ListingRepository 7 | from seedwork.domain.value_objects import GenericUUID, Money 8 | 9 | 10 | class CreateListingDraftCommand(Command): 11 | """A command for creating new listing in draft state""" 12 | 13 | listing_id: GenericUUID 14 | title: str 15 | description: str 16 | ask_price: Money 17 | seller_id: GenericUUID 18 | 19 | 20 | @catalog_module.handler(CreateListingDraftCommand) 21 | async def create_listing_draft( 22 | command: CreateListingDraftCommand, repository: ListingRepository, publish 23 | ): 24 | listing = Listing( 25 | id=command.listing_id, 26 | title=command.title, 27 | description=command.description, 28 | ask_price=command.ask_price, 29 | seller_id=command.seller_id, 30 | ) 31 | repository.add(listing) 32 | publish(ListingDraftCreatedEvent(listing_id=listing.id)) 33 | -------------------------------------------------------------------------------- /src/modules/catalog/application/command/publish_listing_draft.py: -------------------------------------------------------------------------------- 1 | from modules.catalog.application import catalog_module 2 | from modules.catalog.domain.entities import Listing 3 | from modules.catalog.domain.repositories import ListingRepository 4 | from modules.catalog.domain.rules import OnlyListingOwnerCanPublishListing 5 | from modules.catalog.domain.value_objects import ListingId, SellerId 6 | from seedwork.application.commands import Command 7 | from seedwork.domain.mixins import check_rule 8 | 9 | 10 | class PublishListingDraftCommand(Command): 11 | """A command for publishing a draft of a listing""" 12 | 13 | listing_id: ListingId # a listing to be published 14 | seller_id: SellerId # a seller, who is publishing a listing 15 | 16 | 17 | @catalog_module.handler(PublishListingDraftCommand) 18 | async def publish_listing_draft( 19 | command: PublishListingDraftCommand, 20 | listing_repository: ListingRepository, 21 | ): 22 | listing: Listing = listing_repository.get_by_id(command.listing_id) 23 | 24 | check_rule( 25 | OnlyListingOwnerCanPublishListing( 26 | listing_seller_id=listing.seller_id, current_seller_id=command.seller_id 27 | ) 28 | ) 29 | listing.publish() 30 | -------------------------------------------------------------------------------- /src/modules/catalog/tests/domain/test_entities.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from modules.catalog.domain.entities import Listing, Seller 4 | from modules.catalog.domain.value_objects import ListingStatus 5 | from seedwork.domain.exceptions import BusinessRuleValidationException 6 | from seedwork.domain.value_objects import Money 7 | 8 | 9 | @pytest.mark.unit 10 | def test_seller_publishes_listing_happy_path(): 11 | seller = Seller(id=Seller.next_id()) 12 | listing = Listing( 13 | id=Listing.next_id(), 14 | title="Tiny dragon", 15 | description="Tiny dragon for sale", 16 | ask_price=Money(1), 17 | seller_id=seller.id, 18 | ) 19 | 20 | seller.publish_listing(listing) 21 | 22 | assert listing.status == ListingStatus.PUBLISHED 23 | 24 | 25 | @pytest.mark.unit 26 | def test_seller_fails_to_publish_listing_with_zero_price(): 27 | seller = Seller(id=Seller.next_id()) 28 | listing = Listing( 29 | id=Listing.next_id(), 30 | title="Tiny dragon", 31 | description="Tiny dragon for sale", 32 | ask_price=Money(0), 33 | seller_id=seller.id, 34 | ) 35 | 36 | with pytest.raises(BusinessRuleValidationException): 37 | seller.publish_listing(listing) 38 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | services: 18 | postgres: 19 | image: postgres:latest 20 | env: 21 | POSTGRES_USER: postgres 22 | POSTGRES_PASSWORD: password 23 | POSTGRES_DB: test_db 24 | ports: 25 | - 5432:5432 26 | options: >- 27 | --health-cmd pg_isready 28 | --health-interval 10s 29 | --health-timeout 5s 30 | --health-retries 5 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | - name: Set up Python 3.10 35 | uses: actions/setup-python@v3 36 | with: 37 | python-version: "3.10" 38 | - name: Install Poetry 39 | run: | 40 | pip install poetry 41 | poetry self update 42 | - name: Install dependencies 43 | run: poetry install 44 | - name: Run all tests 45 | run: | 46 | poetry run pytest src 47 | env: 48 | # The hostname used to communicate with the PostgreSQL service container 49 | DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db 50 | -------------------------------------------------------------------------------- /src/seedwork/domain/repositories.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Generic, TypeVar 3 | 4 | from seedwork.domain.entities import Entity as DomainEntity 5 | from seedwork.domain.value_objects import GenericUUID 6 | 7 | Entity = TypeVar("Entity", bound=DomainEntity) 8 | EntityId = TypeVar("EntityId", bound=GenericUUID) 9 | 10 | 11 | class GenericRepository(Generic[EntityId, Entity], metaclass=abc.ABCMeta): 12 | """An interface for a generic repository""" 13 | 14 | @abc.abstractmethod 15 | def add(self, entity: Entity): 16 | raise NotImplementedError() 17 | 18 | @abc.abstractmethod 19 | def remove(self, entity: Entity): 20 | raise NotImplementedError() 21 | 22 | @abc.abstractmethod 23 | def get_by_id(self, id: EntityId) -> Entity: 24 | raise NotImplementedError() 25 | 26 | @abc.abstractmethod 27 | def persist(self, entity: Entity): 28 | raise NotImplementedError() 29 | 30 | @abc.abstractmethod 31 | def persist_all(self): 32 | raise NotImplementedError() 33 | 34 | @abc.abstractmethod 35 | def collect_events(self): 36 | raise NotImplementedError() 37 | 38 | def __getitem__(self, index) -> Entity: 39 | return self.get_by_id(index) 40 | 41 | @staticmethod 42 | def next_id() -> EntityId: 43 | return GenericUUID.next_id() 44 | -------------------------------------------------------------------------------- /src/seedwork/tests/infrastructure/test_data_mapper.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from uuid import UUID 3 | 4 | import pytest 5 | 6 | from seedwork.domain.entities import Entity 7 | from seedwork.infrastructure.data_mapper import JSONDataMapper 8 | 9 | 10 | @dataclass 11 | class PersonEntity(Entity): 12 | """Person entity""" 13 | 14 | name: str 15 | 16 | 17 | class PersonJSONDataMapper(JSONDataMapper): 18 | entity_class = PersonEntity 19 | model_class = dict 20 | 21 | 22 | @pytest.mark.unit 23 | def test_data_mapper_maps_entity_to_json(): 24 | mapper = PersonJSONDataMapper() 25 | entity_instance = PersonEntity( 26 | id=UUID("12345678123456781234567812345678"), name="Bob" 27 | ) 28 | 29 | actual = mapper.entity_to_model(entity_instance) 30 | 31 | expected = {"id": "12345678-1234-5678-1234-567812345678", "data": {"name": "Bob"}} 32 | 33 | assert actual == expected 34 | 35 | 36 | @pytest.mark.unit 37 | def test_data_mapper_maps_json_to_entity(): 38 | mapper = PersonJSONDataMapper() 39 | model_instance = { 40 | "id": "12345678-1234-5678-1234-567812345678", 41 | "data": {"name": "Bob"}, 42 | } 43 | 44 | actual = mapper.model_to_entity(model_instance) 45 | 46 | expected = PersonEntity(id=UUID("12345678123456781234567812345678"), name="Bob") 47 | 48 | assert actual == expected 49 | -------------------------------------------------------------------------------- /src/seedwork/infrastructure/data_mapper.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Generic, TypeVar 3 | 4 | from seedwork.domain.entities import Entity, GenericUUID 5 | 6 | MapperEntity = TypeVar("MapperEntity", bound=Entity) 7 | MapperModel = TypeVar("MapperModel", bound=Any) 8 | 9 | 10 | class DataMapper(Generic[MapperEntity, MapperModel], ABC): 11 | entity_class: type[MapperEntity] 12 | model_class: type[MapperModel] 13 | 14 | @abstractmethod 15 | def model_to_entity(self, instance: MapperModel) -> MapperEntity: 16 | raise NotImplementedError() 17 | 18 | @abstractmethod 19 | def entity_to_model(self, entity: MapperEntity) -> MapperModel: 20 | raise NotImplementedError() 21 | 22 | 23 | class JSONDataMapper(DataMapper): 24 | def model_to_entity(self, instance: MapperModel) -> MapperEntity: 25 | entity_id = GenericUUID(instance.get("id")) 26 | entity_dict = { 27 | "id": entity_id, 28 | **instance["data"], 29 | } 30 | return self.entity_class(**entity_dict) 31 | 32 | def entity_to_model(self, entity: MapperEntity) -> MapperModel: 33 | data = dict(**entity.__dict__) 34 | entity_id = str(data.pop("id")) 35 | return self.model_class( 36 | **{ 37 | "id": entity_id, 38 | "data": data, 39 | } 40 | ) 41 | -------------------------------------------------------------------------------- /src/modules/catalog/application/command/delete_listing_draft.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from modules.catalog.application import catalog_module 4 | from modules.catalog.domain.entities import Listing 5 | from modules.catalog.domain.events import ListingDraftDeletedEvent 6 | from modules.catalog.domain.repositories import ListingRepository 7 | from modules.catalog.domain.rules import ( 8 | OnlyListingOwnerCanDeleteListing, 9 | PublishedListingMustNotBeDeleted, 10 | ) 11 | from seedwork.application.command_handlers import CommandResult 12 | from lato import Command, TransactionContext 13 | from seedwork.domain.mixins import check_rule 14 | from seedwork.domain.value_objects import GenericUUID 15 | 16 | 17 | class DeleteListingDraftCommand(Command): 18 | """A command for deleting a listing""" 19 | 20 | listing_id: GenericUUID 21 | seller_id: GenericUUID 22 | 23 | 24 | @catalog_module.handler(DeleteListingDraftCommand) 25 | def delete_listing_draft( 26 | command: DeleteListingDraftCommand, repository: ListingRepository, publish 27 | ): 28 | listing: Listing = repository.get_by_id(command.listing_id) 29 | check_rule( 30 | OnlyListingOwnerCanDeleteListing( 31 | listing_seller_id=listing.seller_id, current_seller_id=command.seller_id 32 | ) 33 | ) 34 | check_rule(PublishedListingMustNotBeDeleted(status=listing.status)) 35 | repository.remove(listing) 36 | publish(ListingDraftDeletedEvent(listing_id=listing.id)) 37 | -------------------------------------------------------------------------------- /src/seedwork/application/command_handlers.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from dataclasses import dataclass, field 3 | from typing import Any, Optional 4 | 5 | from seedwork.domain.type_hints import DomainEvent 6 | from seedwork.domain.value_objects import GenericUUID 7 | 8 | 9 | @dataclass 10 | class CommandResult: 11 | entity_id: Optional[GenericUUID] = None 12 | payload: Any = None 13 | events: list[DomainEvent] = field(default_factory=list) 14 | errors: list[Any] = field(default_factory=list) 15 | 16 | def has_errors(self): 17 | return len(self.errors) > 0 18 | 19 | def add_error(self, message, exception, exception_info): 20 | self.errors.append((message, exception, exception_info)) 21 | 22 | def is_success(self) -> bool: 23 | return not self.has_errors() 24 | 25 | @classmethod 26 | def failure(cls, message="Failure", exception=None) -> "CommandResult": 27 | """Creates a failed result""" 28 | exception_info = sys.exc_info() 29 | result = cls() 30 | result.add_error(message, exception, exception_info) 31 | return result 32 | 33 | @classmethod 34 | def success( 35 | cls, entity_id=None, payload=None, event=None, events=None 36 | ) -> "CommandResult": 37 | """Creates a successful result""" 38 | if events is None: 39 | events = [] 40 | if event: 41 | events.append(event) 42 | return cls(entity_id=entity_id, payload=payload, events=events) 43 | -------------------------------------------------------------------------------- /docs/architecture_decision_log/006_error_handling_during_operation_execution.md: -------------------------------------------------------------------------------- 1 | # 4. Error handling during operation execution 2 | 3 | Date: 2021-05-25 4 | 5 | ## Status 6 | 7 | Pending 8 | 9 | ## Context 10 | 11 | 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). 12 | 13 | ## Possible solutions 14 | 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. 15 | 16 | Pros: 17 | - can throw exception from anywhere 18 | 19 | Cons: 20 | - non-linear program flow can become a mess really quickly because it’s hard to trace all existing connections between throw and catch statements. 21 | 22 | 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. 23 | 24 | Pros: 25 | - can return multiple errors within one operation 26 | 27 | Cons: 28 | - ... 29 | 30 | ## Decision 31 | 32 | TBA 33 | 34 | 35 | ## Consequences 36 | 37 | .... -------------------------------------------------------------------------------- /src/seedwork/domain/value_objects.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from dataclasses import dataclass 3 | from typing import Any 4 | 5 | from pydantic import GetCoreSchemaHandler 6 | 7 | 8 | class GenericUUID(uuid.UUID): 9 | @classmethod 10 | def next_id(cls): 11 | return cls(int=uuid.uuid4().int) 12 | 13 | @classmethod 14 | def __get_pydantic_core_schema__( 15 | cls, source_type: Any, handler: GetCoreSchemaHandler 16 | ): 17 | return handler.generate_schema(uuid.UUID) 18 | 19 | 20 | class ValueObject: 21 | """ 22 | Base class for value objects 23 | """ 24 | 25 | 26 | # @functools.total_ordering # type: ignore 27 | @dataclass(frozen=True) 28 | class Money(ValueObject): 29 | amount: int = 0 30 | currency: str = "USD" 31 | 32 | def _check_currency(self, other): 33 | if self.currency != other.currency: 34 | raise ValueError("Cannot compare money of different currencies") 35 | 36 | def __eq__(self, other): 37 | self._check_currency(other) 38 | return self.amount == other.amount 39 | 40 | def __lt__(self, other): 41 | self._check_currency(other) 42 | return self.amount < other.amount 43 | 44 | def __add__(self, other): 45 | self._check_currency(other) 46 | return Money(self.amount + other.amount, currency=self.currency) 47 | 48 | def __repr__(self) -> str: 49 | return f"{self.amount}{self.currency}" 50 | 51 | 52 | class Email(str): 53 | def __new__(cls, email): 54 | if "@" not in email: 55 | raise ValueError("Invalid email address") 56 | return super().__new__(cls, email) 57 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "python-ddd" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Przemysław Górecki "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.10.0" 9 | black = "^21.5b1" 10 | uvicorn = "^0.14.0" 11 | starlette-context = "^0.3.3" 12 | SQLAlchemy = "^1.4.22" 13 | dependency-injector = "^4.35.2" 14 | colorlog = "^5.0.1" 15 | python-json-logger = "^2.0.2" 16 | alembic = "^1.7.3" 17 | sqlalchemy-json = "^0.4.0" 18 | typed-ast = "^1.4.3" 19 | python-multipart = "^0.0.5" 20 | psycopg2-binary = "^2.9.2" 21 | freezegun = "^1.1.0" 22 | SQLAlchemy-Utils = "^0.38.3" 23 | pre-commit = "^2.20.0" 24 | click = "8.0.4" 25 | httpx = "^0.23.1" 26 | requests = "^2.28.1" 27 | bcrypt = "^4.0.1" 28 | mypy = "^1.4.1" 29 | fastapi = "^0.110.0" 30 | pydantic-settings = "^2.2.1" 31 | lato = "^0.12.0" 32 | 33 | [tool.poetry.dev-dependencies] 34 | poethepoet = "^0.10.0" 35 | pytest-cov = "^2.12.1" 36 | vulture = "^2.7" 37 | 38 | [build-system] 39 | requires = ["poetry-core>=1.0.0"] 40 | build-backend = "poetry.core.masonry.api" 41 | 42 | 43 | [tool.poe.tasks] 44 | test = { shell = "DATABASE_URL=postgresql://postgres:password@localhost:5433/postgres pytest src" } 45 | test_domain = "pytest -k domain" 46 | test_infrastructure = "pytest -k infrastructure" 47 | test_application = "pytest -k application" 48 | test_unit = "pytest -m unit" 49 | test_integration = "pytest -m 'not unit'" 50 | test_coverage = "pytest --cov=src --cov-report=html" 51 | start = { shell = "uvicorn src.api.main:app --reload" } 52 | start_cli = { shell = "cd src && python -m cli" } 53 | compose_up = "docker-compose -f docker-compose.dev.yml up" 54 | 55 | [tool.black] 56 | exclude = 'tmp' 57 | 58 | [tool.mypy] 59 | mypy_path = "src" 60 | 61 | -------------------------------------------------------------------------------- /src/seedwork/tests/infrastructure/test_repository.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from seedwork.domain.entities import Entity 6 | from seedwork.domain.exceptions import EntityNotFoundException 7 | from seedwork.infrastructure.repository import InMemoryRepository 8 | 9 | 10 | @dataclass 11 | class Person(Entity): 12 | first_name: str 13 | last_name: str 14 | 15 | 16 | @pytest.mark.unit 17 | def test_InMemoryRepository_persist_one(): 18 | # arrange 19 | person = Person(id=Person.next_id(), first_name="John", last_name="Doe") 20 | repository = InMemoryRepository() 21 | 22 | # act 23 | repository.add(person) 24 | 25 | # assert 26 | assert repository.get_by_id(person.id) == person 27 | 28 | 29 | @pytest.mark.unit 30 | def test_InMemoryRepository_persist_two(): 31 | # arrange 32 | person1 = Person(id=Person.next_id(), first_name="John", last_name="Doe") 33 | person2 = Person(id=Person.next_id(), first_name="Mary", last_name="Doe") 34 | repository = InMemoryRepository() 35 | 36 | # act 37 | repository.add(person1) 38 | repository.add(person2) 39 | 40 | # assert 41 | assert repository.get_by_id(person1.id) == person1 42 | assert repository.get_by_id(person2.id) == person2 43 | 44 | 45 | @pytest.mark.unit 46 | def test_InMemoryRepository_get_by_id_raises_exception(): 47 | repository = InMemoryRepository() 48 | with pytest.raises(EntityNotFoundException): 49 | repository.get_by_id(Person.next_id()) 50 | 51 | 52 | @pytest.mark.unit 53 | def test_InMemoryRepository_remove_by_id_raises_exception(): 54 | repository = InMemoryRepository() 55 | with pytest.raises(EntityNotFoundException): 56 | repository.remove_by_id(Person.next_id()) 57 | -------------------------------------------------------------------------------- /src/conftest.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | from fastapi.testclient import TestClient 5 | from sqlalchemy import create_engine 6 | from sqlalchemy.orm import Session 7 | 8 | from api.main import app as fastapi_instance 9 | from config.api_config import ApiConfig 10 | from modules.iam.application.services import IamService 11 | from seedwork.infrastructure.database import Base 12 | 13 | 14 | @pytest.fixture 15 | def engine(): 16 | config = ApiConfig() 17 | eng = create_engine(config.DATABASE_URL, echo=config.DATABASE_ECHO) 18 | 19 | with eng.begin() as connection: 20 | Base.metadata.drop_all(connection) 21 | Base.metadata.create_all(connection) 22 | return eng 23 | 24 | 25 | @pytest.fixture 26 | def db_session(engine): 27 | with Session(engine) as session: 28 | yield session 29 | 30 | 31 | @pytest.fixture 32 | def api(): 33 | return fastapi_instance 34 | 35 | 36 | @pytest.fixture 37 | def api_client(api, app): 38 | client = TestClient(api) 39 | return client 40 | 41 | 42 | @pytest.fixture 43 | def authenticated_api_client(api, app): 44 | access_token = uuid.uuid4() 45 | with app.transaction_context() as ctx: 46 | iam: IamService = ctx[IamService] 47 | current_user = iam.create_user( 48 | uuid.UUID(int=1), 49 | email="user1@example.com", 50 | password="password", 51 | access_token=str(access_token), 52 | is_superuser=False, 53 | ) 54 | headers = {"Authorization": f"bearer {access_token}"} 55 | client = TestClient(api, headers=headers) 56 | client.current_user = current_user 57 | return client 58 | 59 | 60 | @pytest.fixture 61 | def app(api, db_session): 62 | app = api.container.application() 63 | return app 64 | -------------------------------------------------------------------------------- /src/modules/iam/tests/test_iam_service.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from modules.iam.application.services import IamService 4 | from seedwork.domain.value_objects import GenericUUID 5 | 6 | 7 | @pytest.mark.integration 8 | def test_create_user_with_duplicated_email_raises_exception(app): 9 | # arrange 10 | with app.transaction_context() as ctx: 11 | iam_service = ctx[IamService] 12 | iam_service.create_user( 13 | user_id=GenericUUID(int=1), 14 | email="user1@example.com", 15 | password="password", 16 | access_token="token", 17 | ) 18 | 19 | # assert 20 | with pytest.raises(ValueError): 21 | # act 22 | with app.transaction_context() as ctx: 23 | iam_service = ctx[IamService] 24 | iam_service.create_user( 25 | user_id=GenericUUID(int=2), 26 | email="user2@example.com", 27 | password="password", 28 | access_token="token", 29 | ) 30 | 31 | 32 | @pytest.mark.integration 33 | def test_create_user_with_duplicated_access_token_raises_exception(app): 34 | # arrange 35 | with app.transaction_context() as ctx: 36 | ctx[IamService].create_user( 37 | user_id=GenericUUID(int=1), 38 | email="user1@example.com", 39 | password="password", 40 | access_token="token", 41 | ) 42 | 43 | # assert 44 | with pytest.raises(ValueError): 45 | # act 46 | with app.transaction_context() as ctx: 47 | ctx[IamService].create_user( 48 | user_id=GenericUUID(int=2), 49 | email="user2@example.com", 50 | password="password", 51 | access_token="token", 52 | ) 53 | -------------------------------------------------------------------------------- /src/modules/iam/application/services.py: -------------------------------------------------------------------------------- 1 | import bcrypt 2 | 3 | from modules.iam.application.exceptions import InvalidCredentialsException 4 | from modules.iam.domain.entities import User 5 | from seedwork.domain.value_objects import Email 6 | 7 | 8 | class IamService: 9 | def __init__(self, user_repository): 10 | self.user_repository = user_repository 11 | 12 | def create_user( 13 | self, user_id, email, password, access_token, is_superuser=False 14 | ) -> User: 15 | user = self.user_repository.get_by_email(email) 16 | if user: 17 | raise ValueError(f"User with email {email} already exists") 18 | 19 | user = self.user_repository.get_by_access_token(access_token) 20 | if user: 21 | raise ValueError(f"User with access_token {access_token} already exists") 22 | 23 | password_hash = bcrypt.hashpw(password.encode("UTF-8"), bcrypt.gensalt()) 24 | user = User( 25 | id=user_id, 26 | email=Email(email), 27 | password_hash=password_hash.decode("UTF-8"), 28 | access_token=access_token, 29 | is_superuser=is_superuser, 30 | ) 31 | self.user_repository.add(user) 32 | return user 33 | 34 | def authenticate_with_name_and_password(self, name, password) -> User: 35 | user = self.user_repository.get_by_email(name) 36 | if not user: 37 | raise InvalidCredentialsException() 38 | 39 | password_match = bcrypt.checkpw( 40 | password.encode("UTF-8"), user.password_hash.encode("UTF-8") 41 | ) 42 | if not password_match: 43 | raise InvalidCredentialsException() 44 | 45 | return user 46 | 47 | def find_user_by_access_token(self, access_token: str) -> User: 48 | user = self.user_repository.get_by_access_token(access_token) 49 | return user 50 | -------------------------------------------------------------------------------- /src/modules/bidding/domain/rules.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from pydantic import Field 4 | 5 | from seedwork.domain.rules import BusinessRule 6 | from seedwork.domain.value_objects import Money 7 | 8 | 9 | class PriceOfPlacedBidMustBeGreaterOrEqualThanNextMinimumPrice(BusinessRule): 10 | __message = "Placed bid must be greater or equal than {next_minimum_price}" 11 | 12 | current_price: Money 13 | next_minimum_price: Money 14 | 15 | def is_broken(self) -> bool: 16 | return self.current_price < self.next_minimum_price 17 | 18 | def get_message(self) -> str: 19 | return self.__message.format(next_minimum_price=self.next_minimum_price) 20 | 21 | 22 | class BidCanBeRetracted(BusinessRule): 23 | __message = "Bid cannot be retracted" 24 | 25 | listing_ends_at: datetime 26 | bid_placed_at: datetime 27 | now: datetime = Field(default_factory=datetime.utcnow) 28 | 29 | def is_broken(self) -> bool: 30 | time_left_in_listing = self.now - self.listing_ends_at 31 | time_since_placed = self.now - self.bid_placed_at 32 | less_than_12_hours_before_bidding_ends = time_left_in_listing < timedelta( 33 | hours=12 34 | ) 35 | less_than_1_hour_since_bid_was_placed = time_since_placed < timedelta(hours=1) 36 | 37 | return ( 38 | less_than_12_hours_before_bidding_ends 39 | and less_than_1_hour_since_bid_was_placed 40 | ) 41 | 42 | 43 | class ListingCanBeCancelled(BusinessRule): 44 | __message = "Listing cannot be cancelled" 45 | 46 | time_left_in_listing: timedelta 47 | no_bids_were_placed: int 48 | 49 | def is_broken(self) -> bool: 50 | can_be_cancelled = self.time_left_in_listing > timedelta(hours=12) or ( 51 | self.time_left_in_listing <= timedelta(hours=12) 52 | and self.no_bids_were_placed 53 | ) 54 | return not can_be_cancelled 55 | -------------------------------------------------------------------------------- /src/modules/catalog/domain/entities.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from modules.catalog.domain.events import ( 4 | DomainEvent, 5 | ListingDraftUpdatedEvent, 6 | ListingPublishedEvent, 7 | ) 8 | from modules.catalog.domain.rules import ( 9 | ListingAskPriceMustBeGreaterThanZero, 10 | ListingMustBeDraft, 11 | ) 12 | from seedwork.domain.entities import AggregateRoot 13 | from seedwork.domain.value_objects import GenericUUID, Money 14 | 15 | from .value_objects import ListingStatus 16 | 17 | 18 | @dataclass(kw_only=True) 19 | class Listing(AggregateRoot): 20 | title: str 21 | description: str 22 | ask_price: Money 23 | seller_id: GenericUUID 24 | status = ListingStatus.DRAFT 25 | 26 | def change_main_attributes(self, title: str, description: str, ask_price: Money): 27 | self.title = title 28 | self.description = description 29 | self.ask_price = ask_price 30 | self.register_event(ListingDraftUpdatedEvent(listing_id=self.id)) 31 | 32 | def publish(self): 33 | """Instantly publish listing for sale""" 34 | self.check_rule(ListingMustBeDraft(status=self.status)) 35 | self.check_rule(ListingAskPriceMustBeGreaterThanZero(ask_price=self.ask_price)) 36 | self.status = ListingStatus.PUBLISHED 37 | self.register_event( 38 | ListingPublishedEvent( 39 | listing_id=self.id, ask_price=self.ask_price, seller_id=self.seller_id 40 | ) 41 | ) 42 | 43 | 44 | @dataclass 45 | class Seller(AggregateRoot): 46 | id: GenericUUID 47 | is_new: bool = True 48 | currently_published_listings_count: int = 0 49 | 50 | def publish_listing(self, listing) -> list[DomainEvent]: 51 | self.check_rule( 52 | ListingAskPriceMustBeGreaterThanZero(ask_price=listing.ask_price) 53 | ) 54 | # self.check_rule(ListingMustBeInDraftState(listing.status)) 55 | # self.check_rule(SellerMustBeEligibleForAddingNextListing(self)) 56 | return listing.publish() 57 | -------------------------------------------------------------------------------- /src/modules/catalog/tests/application/test_update_listing_draft.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from modules.catalog.application.command.update_listing_draft import ( 4 | UpdateListingDraftCommand, 5 | update_listing_draft, 6 | ) 7 | from modules.catalog.domain.entities import Listing 8 | from seedwork.domain.value_objects import GenericUUID, Money 9 | from seedwork.infrastructure.repository import InMemoryRepository 10 | 11 | 12 | @pytest.mark.unit 13 | def test_update_listing_draft(): 14 | # arrange 15 | repository = InMemoryRepository() 16 | listing = Listing( 17 | id=Listing.next_id(), 18 | title="Tiny dragon", 19 | description="Tiny dragon for sale", 20 | ask_price=Money(1), 21 | seller_id=GenericUUID.next_id(), 22 | ) 23 | repository.add(listing) 24 | 25 | command = UpdateListingDraftCommand( 26 | listing_id=listing.id, 27 | title="Tiny golden dragon", 28 | description=listing.description, 29 | ask_price=listing.ask_price, 30 | modify_user_id=listing.seller_id, 31 | ) 32 | 33 | # act 34 | update_listing_draft(command, repository) 35 | 36 | # assert 37 | assert listing.title == "Tiny golden dragon" 38 | 39 | 40 | @pytest.mark.skip( 41 | "UpdateListingDraftCommand with optional fields is not yet implemented" 42 | ) 43 | @pytest.mark.unit 44 | def test_partially_update_listing_draft(): 45 | # arrange 46 | repository = InMemoryRepository() 47 | listing = Listing( 48 | id=Listing.next_id(), 49 | title="Tiny dragon", 50 | description="Tiny dragon for sale", 51 | ask_price=Money(1), 52 | seller_id=GenericUUID.next_id(), 53 | ) 54 | repository.add(listing) 55 | 56 | # act 57 | command = UpdateListingDraftCommand( 58 | # only 2 fields should be updated, but all are required in a command 59 | listing_id=listing.id, 60 | title="Tiny golden dragon", 61 | ) 62 | update_listing_draft(command, repository) 63 | 64 | # assert 65 | assert repository.get_by_id(listing.id).title == "Tiny golden dragon" 66 | -------------------------------------------------------------------------------- /src/api/routers/iam.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, status 4 | from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm 5 | from pydantic import BaseModel 6 | 7 | from api.dependencies import TransactionContext, get_transaction_context 8 | from config.container import inject 9 | from modules.iam.application.exceptions import InvalidCredentialsException 10 | from modules.iam.application.services import IamService 11 | 12 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 13 | router = APIRouter() 14 | 15 | 16 | class UserResponse(BaseModel): 17 | id: str 18 | username: str 19 | access_token: str 20 | 21 | 22 | class LoginResponse(BaseModel): 23 | access_token: str 24 | token_type: str 25 | 26 | 27 | # @router.get("/token", tags=["iam"]) 28 | # async def get_token(ctx: Annotated[TransactionContext, Depends(get_transaction_context_for_public_route)]): 29 | # return ctx.current_user.access_token 30 | 31 | 32 | @router.post("/token", tags=["iam"]) 33 | @inject 34 | async def login( 35 | ctx: Annotated[TransactionContext, Depends(get_transaction_context)], 36 | form_data: OAuth2PasswordRequestForm = Depends(), 37 | ) -> LoginResponse: 38 | try: 39 | iam_service = ctx[IamService] 40 | user = iam_service.authenticate_with_name_and_password( 41 | form_data.username, form_data.password 42 | ) 43 | except InvalidCredentialsException: 44 | # TODO: automatically map application exceptions to HTTP exceptions 45 | raise HTTPException( 46 | status_code=status.HTTP_400_BAD_REQUEST, 47 | detail="Incorrect username or password", 48 | ) 49 | 50 | return LoginResponse(access_token=user.access_token, token_type="bearer") 51 | 52 | 53 | @router.get("/users/me", tags=["iam"]) 54 | async def get_users_me( 55 | ctx: Annotated[TransactionContext, Depends(get_transaction_context)], 56 | ) -> UserResponse: 57 | user = ctx.current_user 58 | return UserResponse( 59 | id=str(user.id), username=user.username, access_token=user.access_token 60 | ) 61 | -------------------------------------------------------------------------------- /src/api/tests/test_bidding.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from modules.catalog.application.command import ( 4 | CreateListingDraftCommand, 5 | PublishListingDraftCommand, 6 | ) 7 | from seedwork.domain.value_objects import GenericUUID, Money 8 | from seedwork.infrastructure.logging import logger 9 | 10 | 11 | async def setup_app_for_bidding_tests(app, listing_id, seller_id, bidder_id): 12 | logger.info("Adding users") 13 | with app.transaction_context() as ctx: 14 | iam_service = ctx["iam_service"] 15 | 16 | iam_service.create_user( 17 | user_id=seller_id, 18 | email="seller@example.com", 19 | password="password", 20 | access_token="token1", 21 | ) 22 | ctx["logger"].debug(f"Added seller: {seller_id}") 23 | 24 | iam_service.create_user( 25 | user_id=bidder_id, 26 | email="bidder@example.com", 27 | password="password", 28 | access_token="token2", 29 | ) 30 | ctx["logger"].debug(f"Added bidder: {bidder_id}") 31 | 32 | await app.execute_async( 33 | CreateListingDraftCommand( 34 | listing_id=listing_id, 35 | title="Foo", 36 | description="Bar", 37 | ask_price=Money(10), 38 | seller_id=seller_id, 39 | ) 40 | ) 41 | logger.info(f"Created listing draft: {listing_id}") 42 | 43 | await app.execute_async( 44 | PublishListingDraftCommand(listing_id=listing_id, seller_id=seller_id) 45 | ) 46 | logger.info(f"Published listing draft {listing_id} by seller {seller_id}") 47 | 48 | logger.info("test setup complete") 49 | 50 | 51 | @pytest.mark.integration 52 | @pytest.mark.asyncio 53 | async def test_place_bid(app, api_client): 54 | listing_id = GenericUUID(int=1) 55 | seller_id = GenericUUID(int=2) 56 | bidder_id = GenericUUID(int=3) 57 | await setup_app_for_bidding_tests(app, listing_id, seller_id, bidder_id) 58 | 59 | url = f"/bidding/{listing_id}/place_bid" 60 | 61 | response = api_client.post(url, json={"bidder_id": str(bidder_id), "amount": 11}) 62 | json = response.json() 63 | assert response.status_code == 200 64 | -------------------------------------------------------------------------------- /src/modules/catalog/tests/application/test_delete_listing_draft.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from modules.catalog.application.command.delete_listing_draft import ( 4 | DeleteListingDraftCommand, 5 | delete_listing_draft, 6 | ) 7 | from modules.catalog.domain.entities import Listing 8 | from modules.catalog.domain.events import ListingDraftDeletedEvent 9 | from modules.catalog.infrastructure.listing_repository import ( 10 | PostgresJsonListingRepository, 11 | ) 12 | from seedwork.domain.value_objects import GenericUUID, Money 13 | from seedwork.infrastructure.repository import InMemoryRepository 14 | from seedwork.tests.application.test_utils import FakeEventPublisher 15 | 16 | @pytest.mark.unit 17 | def test_delete_listing_draft(): 18 | # arrange 19 | seller_id = GenericUUID.next_id() 20 | repository = InMemoryRepository() 21 | listing_id = Listing.next_id() 22 | listing = Listing( 23 | id=listing_id, 24 | title="Tiny dragon", 25 | description="Tiny dragon for sale", 26 | ask_price=Money(1), 27 | seller_id=seller_id, 28 | ) 29 | repository.add(listing) 30 | publish = FakeEventPublisher() 31 | 32 | command = DeleteListingDraftCommand( 33 | listing_id=listing.id, 34 | seller_id=seller_id, 35 | ) 36 | 37 | # act 38 | delete_listing_draft(command, repository, publish) 39 | 40 | # assert 41 | assert publish.contains(ListingDraftDeletedEvent) 42 | 43 | 44 | @pytest.mark.integration 45 | def test_delete_listing_draft_removes_from_database(db_session): 46 | seller_id = GenericUUID.next_id() 47 | repository = PostgresJsonListingRepository(db_session=db_session) 48 | listing = Listing( 49 | id=Listing.next_id(), 50 | title="Tiny dragon", 51 | description="Tiny dragon for sale", 52 | ask_price=Money(1), 53 | seller_id=seller_id, 54 | ) 55 | repository.add(listing) 56 | publish = FakeEventPublisher() 57 | 58 | command = DeleteListingDraftCommand( 59 | listing_id=listing.id, 60 | seller_id=seller_id, 61 | ) 62 | 63 | # act 64 | delete_listing_draft(command, repository, publish) 65 | 66 | # assert 67 | assert repository.count() == 0 68 | -------------------------------------------------------------------------------- /src/modules/catalog/domain/rules.py: -------------------------------------------------------------------------------- 1 | from seedwork.domain.rules import BusinessRule 2 | from seedwork.domain.value_objects import Money 3 | 4 | from .value_objects import ListingId, ListingStatus, SellerId 5 | 6 | # import modules.catalog.domain.entities as entities 7 | 8 | 9 | class ListingMustBeInDraftState(BusinessRule): 10 | __message = "Listing status must be draft" 11 | 12 | listing_status: ListingStatus 13 | 14 | def is_broken(self) -> bool: 15 | return self.listing_status != ListingStatus.DRAFT 16 | 17 | 18 | class ListingAskPriceMustBeGreaterThanZero(BusinessRule): 19 | __message = "Listing price must be greater that zero" 20 | 21 | ask_price: Money 22 | 23 | def is_broken(self) -> bool: 24 | return self.ask_price.amount <= 0 25 | 26 | 27 | class ListingMustBeDraft(BusinessRule): 28 | __message = "Listing must be in draft state" 29 | 30 | status: str 31 | 32 | def is_broken(self) -> bool: 33 | return self.status != ListingStatus.DRAFT 34 | 35 | 36 | class SellerMustBeEligibleForAddingNextListing(BusinessRule): 37 | __message = "Seller is not eligible for adding new listing" 38 | 39 | is_new: bool 40 | currently_published_listings_count: int 41 | 42 | def is_broken(self) -> bool: 43 | return self.is_new and self.currently_published_listings_count > 0 44 | 45 | 46 | class PublishedListingMustNotBeDeleted(BusinessRule): 47 | __message = "A published listing can not be deleted" 48 | 49 | status: str 50 | 51 | def is_broken(self) -> bool: 52 | return self.status == ListingStatus.PUBLISHED 53 | 54 | 55 | class OnlyListingOwnerCanPublishListing(BusinessRule): 56 | __message = "Only listing owner can publish a listing" 57 | 58 | listing_seller_id: ListingId 59 | current_seller_id: SellerId 60 | 61 | def is_broken(self) -> bool: 62 | return self.listing_seller_id != self.current_seller_id 63 | 64 | 65 | class OnlyListingOwnerCanDeleteListing(BusinessRule): 66 | __message = "Only listing owner can delete a listing" 67 | 68 | listing_seller_id: ListingId 69 | current_seller_id: SellerId 70 | 71 | def is_broken(self) -> bool: 72 | return self.listing_seller_id != self.current_seller_id 73 | -------------------------------------------------------------------------------- /src/api/tests/test_login.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from modules.iam.application.services import IamService 4 | from seedwork.domain.value_objects import GenericUUID 5 | 6 | 7 | @pytest.mark.integration 8 | def test_login_with_api_token(app, api_client): 9 | # arrange 10 | with app.transaction_context() as ctx: 11 | iam_service = ctx[IamService] 12 | iam_service.create_user( 13 | user_id=GenericUUID(int=1), 14 | email="admin@example.com", 15 | password="admin", 16 | access_token="token", 17 | is_superuser=True, 18 | ) 19 | 20 | # act 21 | response = api_client.post( 22 | "/token", data={"username": "admin@example.com", "password": "admin"} 23 | ) 24 | 25 | # assert 26 | assert response.status_code == 200 27 | assert response.json()["access_token"] == "token" 28 | 29 | 30 | @pytest.mark.integration 31 | def test_login_with_invalid_username_returns_400(app, api_client): 32 | # arrange 33 | with app.transaction_context() as ctx: 34 | iam_service = ctx[IamService] 35 | iam_service.create_user( 36 | user_id=GenericUUID(int=1), 37 | email="admin@example.com", 38 | password="admin", 39 | access_token="token", 40 | is_superuser=True, 41 | ) 42 | 43 | # act 44 | response = api_client.post( 45 | "/token", data={"username": "john@example.com", "password": "password"} 46 | ) 47 | 48 | # assert 49 | assert response.status_code == 400 50 | 51 | 52 | @pytest.mark.integration 53 | def test_login_with_invalid_password_returns_400(app, api_client): 54 | # arrange 55 | with app.transaction_context() as ctx: 56 | iam_service = ctx[IamService] 57 | iam_service.create_user( 58 | user_id=GenericUUID(int=1), 59 | email="admin@example.com", 60 | password="admin", 61 | access_token="token", 62 | is_superuser=True, 63 | ) 64 | 65 | # act 66 | response = api_client.post( 67 | "/token", 68 | data={"username": "admin@example.com", "password": "incorrect_password"}, 69 | ) 70 | 71 | # assert 72 | assert response.status_code == 400 73 | -------------------------------------------------------------------------------- /docs/architecture_decision_log/004_divide_system_into_2_modules copy.md: -------------------------------------------------------------------------------- 1 | # 4. Divide the system into 4 modules 2 | 3 | Date: 2021-05-25 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | The "Auction" domain contains 3 main subdomains: Bidding (core domain), Catalog (supporting subdomain), and User Access (generic domain). 12 | 13 | Since we decided to use Modular Monolith, all subdomains should be implemented as modules within a single system. 14 | 15 | ## Possible solutions 16 | 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. 17 | 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. 18 | 19 | ## Decision 20 | 21 | Solution 2. 22 | 23 | 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. 24 | 25 | ## Consequences 26 | - We can implement each module/Bounded Context independently. 27 | - We need to set clear boundaries between modules and communication strategy between modules (and implement them) 28 | - We need to define the API of each module 29 | - The API/GUI layer needs to know about all of the modules 30 | - We need to create shared libraries/classes to limit boilerplate code which will be the same in all modules 31 | - Complexity of the whole solution will increase 32 | - Complexity of each module will decrease 33 | - We will have clear separation of concerns 34 | - In addition to the application, we must divide the data 35 | - We can delegate development of particular module to defined team, work should be done without any conflicts on codebase -------------------------------------------------------------------------------- /src/modules/catalog/infrastructure/listing_repository.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from sqlalchemy.dialects.postgresql import JSONB 4 | from sqlalchemy.sql.schema import Column 5 | from sqlalchemy_json import mutable_json_type 6 | from sqlalchemy_utils import UUIDType 7 | 8 | from modules.catalog.domain.entities import Listing 9 | from modules.catalog.domain.repositories import ListingRepository 10 | from seedwork.domain.value_objects import GenericUUID, Money 11 | from seedwork.infrastructure.data_mapper import DataMapper 12 | from seedwork.infrastructure.database import Base 13 | from seedwork.infrastructure.repository import SqlAlchemyGenericRepository 14 | 15 | """ 16 | References: 17 | "Introduction to SQLAlchemy 2020 (Tutorial)" by: Mike Bayer 18 | https://youtu.be/sO7FFPNvX2s?t=7214 19 | """ 20 | 21 | 22 | class ListingModel(Base): 23 | """Data model for listing domain object""" 24 | 25 | __tablename__ = "catalog_listing" 26 | id = Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4) 27 | data = Column(mutable_json_type(dbtype=JSONB, nested=True)) 28 | 29 | 30 | class ListingDataMapper(DataMapper[Listing, ListingModel]): 31 | def model_to_entity(self, instance: ListingModel) -> Listing: 32 | d = instance.data 33 | return Listing( 34 | id=instance.id, 35 | title=d["title"], 36 | description=d["description"], 37 | ask_price=Money(**d["ask_price"]), 38 | seller_id=GenericUUID(d["seller_id"]), 39 | ) 40 | 41 | def entity_to_model(self, entity: Listing) -> ListingModel: 42 | return ListingModel( 43 | id=entity.id, 44 | data={ 45 | "title": entity.title, 46 | "description": entity.description, 47 | "ask_price": { 48 | "amount": entity.ask_price.amount, 49 | "currency": entity.ask_price.currency, 50 | }, 51 | "seller_id": str(entity.seller_id), 52 | "status": entity.status, 53 | }, 54 | ) 55 | 56 | 57 | class PostgresJsonListingRepository(ListingRepository, SqlAlchemyGenericRepository): 58 | """Listing repository implementation""" 59 | 60 | mapper_class = ListingDataMapper 61 | model_class = ListingModel 62 | -------------------------------------------------------------------------------- /src/seedwork/tests/application/test_application_with_outbox.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from seedwork.application import Application 6 | from seedwork.application.command_handlers import CommandResult 7 | from seedwork.application.commands import Command 8 | from seedwork.application.events import EventResult, IntegrationEvent 9 | from seedwork.domain.events import DomainEvent 10 | 11 | 12 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 13 | @pytest.mark.unit 14 | def test_command_execution_returns_integration_events(): 15 | """ 16 | In this test, we want to verify that the application stores integration events in the outbox. 17 | """ 18 | 19 | @dataclass 20 | class CompleteOrder(Command): 21 | order_id: int 22 | buyer_email: str 23 | 24 | class OrderCompleted(DomainEvent): 25 | order_id: int 26 | 27 | class NotifyBuyerOfOrderCompletion(IntegrationEvent): 28 | order_id: int 29 | buyer_email: str 30 | 31 | class PrepareOrderForShipping(IntegrationEvent): 32 | order_id: int 33 | 34 | outbox = [] 35 | app = Application(outbox=outbox) 36 | 37 | @app.command_handler 38 | def complete_order(command: CompleteOrder): 39 | domain_event = OrderCompleted( 40 | order_id=command.order_id, buyer_email=command.buyer_email 41 | ) 42 | integration_event = NotifyBuyerOfOrderCompletion( 43 | order_id=command.order_id, buyer_email=command.buyer_email 44 | ) 45 | return CommandResult.success(events=[domain_event, integration_event]) 46 | 47 | @app.domain_event_handler 48 | def on_order_completed(event: OrderCompleted): 49 | integration_event = PrepareOrderForShipping(order_id=event.order_id) 50 | return EventResult.success(event=integration_event) 51 | 52 | @app.on_exit_transaction_context 53 | def on_exit_transaction_context(ctx, exc_type, exc_val, exc_tb): 54 | outbox = ctx.dependency_provider["outbox"] 55 | if exc_type is None: 56 | outbox.extend(ctx.integration_events) 57 | 58 | with app.transaction_context() as ctx: 59 | ctx.execute( 60 | CompleteOrder(order_id=1, buyer_email="john.doe@example.com") 61 | ) 62 | 63 | assert len(outbox) == 2 64 | -------------------------------------------------------------------------------- /src/modules/bidding/README.md: -------------------------------------------------------------------------------- 1 | # Bidding bounded context 2 | 3 | Bidding is competitively offering a price that the bidder or the person offering a bid is willing to pay for a commodity this commodity can be anything, cars, bikes, properties, etc. The price offered is called a bid, the person offering the price is called the bidder and the entire phenomenon is known as bidding. 4 | https://www.educba.com/bidding-vs-auction/ 5 | 6 | ## Bidding process 7 | 8 | As a Buyer, you can place a bid, which must be greater than the current price + 1 USD and which sets the highest price you are willing to pay for an item. 9 | System will let you know (by email) if someone outbids you, and you can decide if you want to increase your maximum limit. 10 | Sometimes you can be automatically outbid (if some other buyer sets his maximum limit higher that yours). 11 | 12 | For example: 13 | 1. Alice wants to sell X. She sets the ask price for this item as 10 USD. 14 | 2. Bob wants to place a bid on X. The minimum amount is 10 USD (the ask price), and he places the bid of 15 USD. As the only bidder, he is the winner and the current price for X is 10 USD. 15 | 3. Alice and Bob are notified by email. 16 | 4. Charlie places his bid, but now the minimum price he can bid is 11 USD, so he decides to bid 12 USD. As this is not enough to outbid Bob, he is not the winner, and the price is increased to 12 USD. 17 | 5. Charlie places another bid, this time with the amount of 20 USD. As this is more than Bob's maximum limit, Charlie is the winner and the price is increased to 16 USD. 18 | 6. Alice is notified of new winner and price for X. Bob is notified by email that he is outbid. 19 | 7. Charlie decides to increase his maximum limit to 25 USD by bidding again with the increased amount. 20 | 8. Now if Bob wants to win the bidding, he must place a bid of at least 26 USD. 21 | 22 | 23 | ## Ubiquitous language 24 | 25 | **Listing** - a product for sale 26 | 27 | **Bidding** - a competitive process of offering a price for listing of a commodity for sale 28 | 29 | **Seller** - the person selling a listing 30 | 31 | **Bidder** - the person offering the price is called the bidder 32 | 33 | **Bid** - a maximum price offered by a bidder to purchase a listing 34 | 35 | 36 | ## User stories: 37 | 38 | - [x] As a bidder, I want to place a bid. 39 | 40 | - [x] As a bidder, I want to retract a bid. 41 | - 42 | - [ ] As a seller, I want to cancel my listing immediately. -------------------------------------------------------------------------------- /src/alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config, pool 4 | 5 | from alembic import context 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | 15 | # add your model's MetaData object here 16 | # for 'autogenerate' support 17 | from modules.catalog.infrastructure.listing_repository import CatalogListingModel 18 | 19 | # from myapp import mymodel 20 | # target_metadata = mymodel.Base.metadata 21 | target_metadata = [CatalogListingModel.metadata] 22 | 23 | # other values from the config, defined by the needs of env.py, 24 | # can be acquired: 25 | # my_important_option = config.get_main_option("my_important_option") 26 | # ... etc. 27 | 28 | 29 | def run_migrations_offline(): 30 | """Run migrations in 'offline' mode. 31 | 32 | This configures the context with just a URL 33 | and not an Engine, though an Engine is acceptable 34 | here as well. By skipping the Engine creation 35 | we don't even need a DBAPI to be available. 36 | 37 | Calls to context.execute() here emit the given string to the 38 | script output. 39 | 40 | """ 41 | url = config.get_main_option("sqlalchemy.url") 42 | context.configure( 43 | url=url, 44 | target_metadata=target_metadata, 45 | literal_binds=True, 46 | dialect_opts={"paramstyle": "named"}, 47 | ) 48 | 49 | with context.begin_transaction(): 50 | context.run_migrations() 51 | 52 | 53 | def run_migrations_online(): 54 | """Run migrations in 'online' mode. 55 | 56 | In this scenario we need to create an Engine 57 | and associate a connection with the context. 58 | 59 | """ 60 | connectable = engine_from_config( 61 | config.get_section(config.config_ini_section), 62 | prefix="sqlalchemy.", 63 | poolclass=pool.NullPool, 64 | ) 65 | 66 | with connectable.connect() as connection: 67 | context.configure(connection=connection, target_metadata=target_metadata) 68 | 69 | with context.begin_transaction(): 70 | context.run_migrations() 71 | 72 | 73 | if context.is_offline_mode(): 74 | run_migrations_offline() 75 | else: 76 | run_migrations_online() 77 | -------------------------------------------------------------------------------- /src/modules/catalog/tests/application/test_publish_listing.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from modules.catalog.application.command.publish_listing_draft import ( 4 | PublishListingDraftCommand, 5 | publish_listing_draft, 6 | ) 7 | from modules.catalog.domain.entities import Listing, Seller 8 | from modules.catalog.domain.value_objects import ListingStatus 9 | from seedwork.domain.exceptions import BusinessRuleValidationException 10 | from seedwork.domain.value_objects import Money 11 | from seedwork.infrastructure.repository import InMemoryRepository 12 | 13 | 14 | @pytest.mark.unit 15 | @pytest.mark.asyncio 16 | async def test_publish_listing(): 17 | # arrange 18 | seller_repository = InMemoryRepository() 19 | seller = Seller(id=Seller.next_id()) 20 | seller_repository.add(seller) 21 | 22 | listing_repository = InMemoryRepository() 23 | listing = Listing( 24 | id=Listing.next_id(), 25 | title="Tiny dragon", 26 | description="Tiny dragon for sale", 27 | ask_price=Money(1), 28 | seller_id=seller.id, 29 | ) 30 | listing_repository.add(listing) 31 | 32 | command = PublishListingDraftCommand( 33 | listing_id=listing.id, 34 | seller_id=seller.id, 35 | ) 36 | 37 | # act 38 | await publish_listing_draft( 39 | command, 40 | listing_repository=listing_repository, 41 | ) 42 | 43 | # assert 44 | assert listing.status == ListingStatus.PUBLISHED 45 | 46 | 47 | @pytest.mark.unit 48 | @pytest.mark.asyncio 49 | async def test_publish_listing_and_break_business_rule(): 50 | # arrange 51 | seller_repository = InMemoryRepository() 52 | seller = Seller(id=Seller.next_id()) 53 | seller_repository.add(seller) 54 | 55 | listing_repository = InMemoryRepository() 56 | listing = Listing( 57 | id=Listing.next_id(), 58 | title="Tiny dragon", 59 | description="Tiny dragon for sale", 60 | ask_price=Money(0), # this will break the rule 61 | seller_id=seller.id, 62 | ) 63 | listing_repository.add(listing) 64 | 65 | command = PublishListingDraftCommand( 66 | listing_id=listing.id, 67 | seller_id=seller.id, 68 | ) 69 | 70 | # act 71 | 72 | # assert 73 | with pytest.raises(BusinessRuleValidationException): 74 | await publish_listing_draft( 75 | command, 76 | listing_repository=listing_repository, 77 | ) 78 | -------------------------------------------------------------------------------- /docs/example.cml: -------------------------------------------------------------------------------- 1 | /* Example Context Map written with 'ContextMapper DSL' */ 2 | ContextMap InsuranceContextMap { 3 | type = SYSTEM_LANDSCAPE 4 | state = TO_BE 5 | /* Add bounded contexts to this context map: */ 6 | contains CustomerManagementContext 7 | contains CustomerSelfServiceContext 8 | contains PrintingContext 9 | contains PolicyManagementContext 10 | contains RiskManagementContext 11 | contains DebtCollection 12 | 13 | /* Define the context relationships: */ 14 | CustomerSelfServiceContext <- CustomerManagementContext 15 | 16 | CustomerManagementContext <- PrintingContext 17 | 18 | PrintingContext -> PolicyManagementContext 19 | 20 | RiskManagementContext <-> PolicyManagementContext 21 | 22 | PolicyManagementContext <- CustomerManagementContext 23 | 24 | DebtCollection <- PrintingContext 25 | 26 | PolicyManagementContext <-> DebtCollection 27 | } 28 | 29 | /* Bounded Context Definitions */ 30 | BoundedContext CustomerManagementContext implements CustomerManagementDomain 31 | 32 | BoundedContext CustomerSelfServiceContext implements CustomerManagementDomain 33 | 34 | BoundedContext PrintingContext implements PrintingDomain 35 | 36 | BoundedContext PolicyManagementContext implements PolicyManagementDomain 37 | 38 | BoundedContext RiskManagementContext implements RiskManagementDomain 39 | 40 | BoundedContext DebtCollection implements DebtsDomain 41 | 42 | /* Domain & Subdomain Definitions */ 43 | Domain InsuranceDomain { 44 | Subdomain CustomerManagementDomain { 45 | type = CORE_DOMAIN 46 | domainVisionStatement = "Subdomain managing everything customer-related." 47 | } 48 | 49 | Subdomain PolicyManagementDomain { 50 | type = CORE_DOMAIN 51 | domainVisionStatement = "Subdomain managing contracts and policies." 52 | } 53 | 54 | Subdomain PrintingDomain { 55 | type = SUPPORTING_DOMAIN 56 | domainVisionStatement = "Service (external system) to solve printing for all kinds of documents (debts, policies, etc.)" 57 | } 58 | 59 | Subdomain RiskManagementDomain { 60 | type = GENERIC_SUBDOMAIN 61 | domainVisionStatement = "Subdomain supporting everything which relates to risk management." 62 | } 63 | 64 | Subdomain DebtsDomain { 65 | type = GENERIC_SUBDOMAIN 66 | domainVisionStatement = "Subomain including everything related to the incoming money (debts, dunning, etc.)" 67 | } 68 | 69 | } 70 | 71 | -------------------------------------------------------------------------------- /src/cli/__main__.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from config.container import TopLevelContainer 4 | from modules.catalog.application.command import CreateListingDraftCommand 5 | from modules.catalog.application.query import GetAllListings 6 | from modules.catalog.domain.repositories import ListingRepository 7 | from modules.catalog.infrastructure.listing_repository import Base 8 | from seedwork.domain.value_objects import Money 9 | from seedwork.infrastructure.logging import LoggerFactory, logger 10 | 11 | # a sample command line script to print all listings 12 | # run with "cd src && python -m cli" 13 | 14 | # configure logger prior to first usage 15 | LoggerFactory.configure(logger_name="cli") 16 | 17 | container = TopLevelContainer() 18 | container.config.from_dict( 19 | dict( 20 | # DATABASE_URL="sqlite+pysqlite:///:memory:", 21 | DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres", 22 | DATABASE_ECHO=False, 23 | DEBUG=True, 24 | ) 25 | ) 26 | 27 | # let's create the database schema 28 | engine = container.db_engine() 29 | Base.metadata.create_all(engine) 30 | 31 | # let's create a new application instance 32 | app = container.application() 33 | 34 | 35 | # let's query the listings, this method implicitly creates a transaction context and then executes a query 36 | # see `get_all_listings` query handler in `src/modules/catalog/application/query/get_all_listings.py` 37 | query_result = app.execute_query(GetAllListings()) 38 | 39 | # now let's print the listings 40 | listings = query_result.payload 41 | print("Listings:") 42 | for listing in listings: 43 | print(f"{listing['id']} - {listing['title']}") 44 | 45 | # now we are explicitly creating a transaction context, this time we want to execute a command 46 | with app.transaction_context() as ctx: 47 | # see `create_listing_draft` command handler in `src/modules/catalog/application/command/create_listing_draft.py` 48 | ctx.execute( 49 | CreateListingDraftCommand( 50 | listing_id=uuid.uuid4(), 51 | title="First listing", 52 | description="...", 53 | ask_price=Money(100), 54 | seller_id=uuid.UUID(int=1), 55 | ) 56 | ) 57 | 58 | # use transaction context to access any dependency (i.e a repository, a service, etc.) 59 | with app.transaction_context() as ctx: 60 | listing_repository = ctx.get_service(ListingRepository) 61 | listing_count = listing_repository.count() 62 | logger.info(f"There are {listing_count} listings in the database") 63 | -------------------------------------------------------------------------------- /src/modules/iam/infrastructure/repository.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from sqlalchemy import Boolean, String 4 | from sqlalchemy.dialects.postgresql import UUID 5 | from sqlalchemy.exc import NoResultFound 6 | from sqlalchemy.sql.schema import Column 7 | 8 | from modules.iam.application.repository import UserRepository 9 | from modules.iam.application.services import User 10 | from seedwork.domain.value_objects import Email 11 | from seedwork.infrastructure.database import Base 12 | from seedwork.infrastructure.json_data_mapper import JSONDataMapper 13 | from seedwork.infrastructure.repository import SqlAlchemyGenericRepository 14 | 15 | 16 | class UserModel(Base): 17 | __tablename__ = "user" 18 | id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 19 | email = Column(String(255), unique=True, nullable=False) 20 | password = Column(String(255)) 21 | access_token = Column(String(255), unique=True, nullable=False) 22 | is_superuser = Column(Boolean(), nullable=False) 23 | 24 | 25 | class UserDataMapper(JSONDataMapper): 26 | def model_to_entity(self, instance: UserModel) -> User: 27 | return User( 28 | id=instance.id, 29 | email=instance.email, 30 | password_hash=instance.password, 31 | access_token=instance.access_token, 32 | is_superuser=instance.is_superuser, 33 | ) 34 | 35 | def entity_to_model(self, entity: User) -> UserModel: 36 | return UserModel( 37 | id=entity.id, 38 | email=entity.email, 39 | password=entity.password_hash, 40 | access_token=entity.access_token, 41 | is_superuser=entity.is_superuser, 42 | ) 43 | 44 | 45 | class PostgresJsonUserRepository(SqlAlchemyGenericRepository, UserRepository): 46 | """Listing repository implementation""" 47 | 48 | mapper_class = UserDataMapper 49 | model_class = UserModel 50 | 51 | def get_by_access_token(self, access_token: str) -> User | None: 52 | try: 53 | instance = ( 54 | self._session.query(UserModel) 55 | .filter_by(access_token=access_token) 56 | .one() 57 | ) 58 | return self._get_entity(instance) 59 | except NoResultFound: 60 | return None 61 | 62 | def get_by_email(self, email: Email) -> User | None: 63 | try: 64 | instance = self._session.query(UserModel).filter_by(email=email).one() 65 | return self._get_entity(instance) 66 | except NoResultFound: 67 | return None 68 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | aaaa() 2 | 3 | 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config, pool 7 | 8 | from alembic import context 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | fileConfig(config.config_file_name) 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | from src.modules.catalog.infrastructure.persistence import ListingMetadata 21 | 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | target_metadata = [ListingMetadata] 25 | 26 | # other values from the config, defined by the needs of env.py, 27 | # can be acquired: 28 | # my_important_option = config.get_main_option("my_important_option") 29 | # ... etc. 30 | section = config.config_ini_section 31 | config.set_section_option( 32 | section, "DATABASE_URL", "postgresql://postgres:password@localhost:5432/postgres" 33 | ) 34 | 35 | 36 | def run_migrations_offline(): 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure( 50 | url=url, 51 | target_metadata=target_metadata, 52 | literal_binds=True, 53 | dialect_opts={"paramstyle": "named"}, 54 | ) 55 | 56 | with context.begin_transaction(): 57 | context.run_migrations() 58 | 59 | 60 | def run_migrations_online(): 61 | """Run migrations in 'online' mode. 62 | 63 | In this scenario we need to create an Engine 64 | and associate a connection with the context. 65 | 66 | """ 67 | connectable = engine_from_config( 68 | config.get_section(config.config_ini_section), 69 | prefix="sqlalchemy.", 70 | poolclass=pool.NullPool, 71 | ) 72 | 73 | with connectable.connect() as connection: 74 | context.configure(connection=connection, target_metadata=target_metadata) 75 | 76 | with context.begin_transaction(): 77 | context.run_migrations() 78 | 79 | 80 | if context.is_offline_mode(): 81 | run_migrations_offline() 82 | else: 83 | run_migrations_online() 84 | -------------------------------------------------------------------------------- /src/seedwork/application/events.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from dataclasses import dataclass, field 3 | from typing import Any 4 | 5 | from pydantic import BaseModel 6 | 7 | from seedwork.domain.type_hints import DomainEvent 8 | from seedwork.domain.value_objects import GenericUUID 9 | 10 | 11 | class EventId(GenericUUID): 12 | """Unique identifier of an event""" 13 | 14 | 15 | class IntegrationEvent(BaseModel): 16 | """ 17 | Integration events are used to communicate between modules/system via inbox-outbox pattern. 18 | They are created in a domain event handler and then saved in an outbox for further delivery. 19 | As a result, integration events are handled asynchronously. 20 | """ 21 | 22 | 23 | @dataclass 24 | class EventResult: 25 | """ 26 | Result of event execution (success or failure) by an event handler. 27 | """ 28 | 29 | event_id: EventId = field(default_factory=EventId.next_id) 30 | payload: Any = None 31 | command: Any = ( 32 | None # command to be executed as a result of this event (experimental) 33 | ) 34 | events: list[DomainEvent] = field(default_factory=list) 35 | errors: list[Any] = field(default_factory=list) 36 | 37 | def has_errors(self) -> bool: 38 | """Returns True if an event execution failed""" 39 | return len(self.errors) > 0 40 | 41 | def is_success(self) -> bool: 42 | """Returns True if an event was successfully executed""" 43 | return not self.has_errors() 44 | 45 | def __hash__(self): 46 | return id(self) 47 | 48 | @classmethod 49 | def failure(cls, message="Failure", exception=None) -> "EventResult": 50 | """Creates a failed result""" 51 | exception_info = sys.exc_info() 52 | errors = [(message, exception, exception_info)] 53 | result = cls(errors=errors) 54 | return result 55 | 56 | @classmethod 57 | def success( 58 | cls, event_id=None, payload=None, command=None, event=None, events=None 59 | ) -> "EventResult": 60 | """Creates a successful result""" 61 | if events is None: 62 | events = [] 63 | if event: 64 | events.append(event) 65 | return cls(event_id=event_id, payload=payload, command=command, events=events) 66 | 67 | 68 | class EventResultSet(set): 69 | """For now just aa fancy name for a set""" 70 | 71 | def is_success(self): 72 | return all([r.is_success() for r in self]) 73 | 74 | @property 75 | def events(self): 76 | all_events = [] 77 | for event in self: 78 | all_events.extend(event.events) 79 | return all_events 80 | 81 | @property 82 | def commands(self): 83 | all_commands = [event.command for event in self if event.command] 84 | return all_commands 85 | -------------------------------------------------------------------------------- /src/api/routers/bidding.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends 4 | from lato import Application 5 | 6 | from api.dependencies import get_application 7 | from api.models.bidding import BiddingResponse, PlaceBidRequest 8 | from config.container import inject 9 | from modules.bidding.application.command import PlaceBidCommand, RetractBidCommand 10 | from modules.bidding.application.query.get_bidding_details import GetBiddingDetails 11 | 12 | router = APIRouter() 13 | 14 | """ 15 | Inspired by https://developer.ebay.com/api-docs/buy/offer/types/api:Bidding 16 | """ 17 | 18 | 19 | @router.get("/bidding/{listing_id}", tags=["bidding"], response_model=BiddingResponse) 20 | @inject 21 | async def get_bidding_details_of_listing( 22 | listing_id, app: Annotated[Application, Depends(get_application)] 23 | ): 24 | """ 25 | Shows listing details 26 | """ 27 | query = GetBiddingDetails(listing_id=listing_id) 28 | result = await app.execute_async(query) 29 | return BiddingResponse( 30 | listing_id=result.id, 31 | auction_end_date=result.ends_at, 32 | bids=result.bids, 33 | ) 34 | 35 | 36 | @router.post( 37 | "/bidding/{listing_id}/place_bid", tags=["bidding"], response_model=BiddingResponse 38 | ) 39 | @inject 40 | async def place_bid( 41 | listing_id, 42 | request_body: PlaceBidRequest, 43 | app: Annotated[Application, Depends(get_application)], 44 | ): 45 | """ 46 | Places a bid on a listing 47 | """ 48 | # TODO: get bidder from current user 49 | 50 | command = PlaceBidCommand( 51 | listing_id=listing_id, 52 | bidder_id=request_body.bidder_id, 53 | amount=request_body.amount, 54 | ) 55 | await app.execute_async(command) 56 | # execute_async, or execute? 57 | 58 | query = GetBiddingDetails(listing_id=listing_id) 59 | result = await app.execute_async(query) 60 | return BiddingResponse( 61 | listing_id=result.id, 62 | auction_end_date=result.ends_at, 63 | bids=result.bids, 64 | ) 65 | 66 | 67 | @router.post( 68 | "/bidding/{listing_id}/retract_bid", 69 | tags=["bidding"], 70 | response_model=BiddingResponse, 71 | ) 72 | @inject 73 | async def retract_bid( 74 | listing_id, app: Annotated[Application, Depends(get_application)] 75 | ): 76 | """ 77 | Retracts a bid from a listing 78 | """ 79 | command = RetractBidCommand( 80 | listing_id=listing_id, 81 | bidder_id="", 82 | ) 83 | app.execute(command) 84 | 85 | query = GetBiddingDetails(listing_id=listing_id) 86 | query_result = app.execute_query(query) 87 | payload = query_result.payload 88 | return BiddingResponse( 89 | listing_id=str(payload.id), 90 | auction_end_date=payload.ends_at, 91 | bids=payload.bids, 92 | ) 93 | -------------------------------------------------------------------------------- /diary.md: -------------------------------------------------------------------------------- 1 | ## Seedwork 2 | 3 | 4 | ## Architecture of Catalog module (2021-05-25) 5 | 6 | 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? 7 | 8 | > 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] 9 | 10 | 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: 11 | 12 | 1. "The typical entry point for this in DDD is an Application Service. Application services orchestrate calls to repositories and domain objects" [2][3]. 13 | 14 | 15 | Maybe we could implementing using a generic CRUD handler? 16 | 17 | 18 | ## Handling commands 19 | 20 | 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: 21 | 22 | ``` 23 | def get_route_controller(request, module): 24 | module.execute_query(MyQuery( 25 | foo=request.GET.foo, 26 | bar=request.GET.bar, 27 | )) 28 | return Response(HTTP_200_OK) 29 | 30 | 31 | def post_route_controller(request, module): 32 | result = module.execute(MyCommand( 33 | foo=request.POST.foo, 34 | bar=request.POST.bar, 35 | )) 36 | return Response(HTTP_200_OK) 37 | ``` 38 | 39 | In this case `execute_command` is responsible for passing a command to an appriopriate command handler. 40 | 41 | 42 | Keep in mind this is a happy path and bad things can happen along a way. In particular: 43 | - creating command can fail: i.e. command can have incorrect params, or some params can be missing (command param validation) [400] 44 | - command execution can fail due to numerous reasons: 45 | - an object that we want act upon may not exist [404] 46 | - a user may not have permissions to perform certain command [403] 47 | - business rule may fail [400] 48 | - application level policy may fail, i.e. too many requests issued by the same user [429] 49 | - .... 50 | 51 | 52 | 53 | For example, query or command can be invalid, an , user may 54 | 55 | 56 | References: 57 | 58 | - [1] https://martinfowler.com/bliki/CQRS.html 59 | - [2] https://softwareengineering.stackexchange.com/questions/302187/crud-operations-in-ddd 60 | - [3] https://lostechies.com/jimmybogard/2008/08/21/services-in-domain-driven-design/ 61 | 62 | 63 | 64 | 65 | 66 | 67 | ## Other references 68 | 69 | - https://github.com/VaughnVernon/IDDD_Samples/tree/master -------------------------------------------------------------------------------- /src/seedwork/tests/application/test_application_and_one_module_linear.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from seedwork.application import Application 6 | from seedwork.application.command_handlers import CommandResult 7 | from seedwork.application.commands import Command 8 | from seedwork.application.events import EventResult 9 | from seedwork.domain.events import DomainEvent 10 | 11 | 12 | @dataclass 13 | class CompleteOrderCommand(Command): 14 | order_id: str 15 | 16 | 17 | class OrderCompletedEvent(DomainEvent): 18 | order_id: str 19 | 20 | 21 | class PaymentProcessedEvent(DomainEvent): 22 | order_id: str 23 | 24 | 25 | class OrderShippedEvent(DomainEvent): 26 | order_id: str 27 | 28 | 29 | app = Application() 30 | 31 | 32 | @app.command_handler 33 | def complete_order(command: CompleteOrderCommand, history): 34 | history.append(f"completing {command.order_id}") 35 | return CommandResult.success( 36 | payload=None, event=OrderCompletedEvent(order_id=command.order_id) 37 | ) 38 | 39 | 40 | @app.domain_event_handler 41 | def when_order_is_completed_process_payment_policy(event: OrderCompletedEvent, history): 42 | history.append(f"processing payment for {event.order_id}") 43 | return EventResult.success( 44 | payload=None, event=PaymentProcessedEvent(order_id=event.order_id) 45 | ) 46 | 47 | 48 | @app.domain_event_handler 49 | def when_payment_is_processed_ship_order_policy( 50 | event: PaymentProcessedEvent, 51 | history, 52 | ): 53 | history.append(f"shipping order for {event.order_id}") 54 | return EventResult.success(event=OrderShippedEvent(order_id=event.order_id)) 55 | 56 | 57 | @app.domain_event_handler 58 | def when_order_is_shipped_sit_and_relax_policy(event: OrderShippedEvent, history): 59 | history.append(f"done with {event.order_id}") 60 | return EventResult.success() 61 | 62 | 63 | @app.on_enter_transaction_context 64 | def on_enter_transaction_context(ctx): 65 | """Prepare dependencies, begin transaction""" 66 | ctx.dependency_provider["outbox"] = [] 67 | 68 | 69 | @app.on_exit_transaction_context 70 | def on_exit_transaction_context(ctx, exc_type, exc_val, exc_tb): 71 | """Save events in outbox, End transaction""" 72 | 73 | 74 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 75 | @pytest.mark.integration 76 | def test_mono_module_command_linear_flow(): 77 | global app 78 | """This tests the linear code flow: 79 | CompleteOrderCommand → OrderCompletedEvent → when_order_is_completed_process_payment_policy → 80 | → ProcessPaymentCommand → PaymentProcessedEvent → when_payment_is_processed_ship_order_policy → 81 | → ShipOrderCommand → OrderShippedEvent → when_order_is_shipped_sit_and_relax_policy 82 | """ 83 | history = [] 84 | with app.transaction_context(history=history) as ctx: 85 | ctx.execute(CompleteOrderCommand(order_id="order1")) 86 | 87 | assert history == [ 88 | "completing order1", 89 | "processing payment for order1", 90 | "shipping order for order1", 91 | "done with order1", 92 | ] 93 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to migrations/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" 39 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. Valid values are: 43 | # 44 | # version_path_separator = : 45 | # version_path_separator = ; 46 | # version_path_separator = space 47 | version_path_separator = os # default: use os.pathsep 48 | 49 | # the output encoding used when revision files 50 | # are written from script.py.mako 51 | # output_encoding = utf-8 52 | 53 | sqlalchemy.url = %(DATABASE_URL) 54 | 55 | 56 | [post_write_hooks] 57 | # post_write_hooks defines scripts or Python functions that are run 58 | # on newly generated revision scripts. See the documentation for further 59 | # detail and examples 60 | 61 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 62 | # hooks = black 63 | # black.type = console_scripts 64 | # black.entrypoint = black 65 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 66 | 67 | # Logging configuration 68 | [loggers] 69 | keys = root,sqlalchemy,alembic 70 | 71 | [handlers] 72 | keys = console 73 | 74 | [formatters] 75 | keys = generic 76 | 77 | [logger_root] 78 | level = WARN 79 | handlers = console 80 | qualname = 81 | 82 | [logger_sqlalchemy] 83 | level = WARN 84 | handlers = 85 | qualname = sqlalchemy.engine 86 | 87 | [logger_alembic] 88 | level = INFO 89 | handlers = 90 | qualname = alembic 91 | 92 | [handler_console] 93 | class = StreamHandler 94 | args = (sys.stderr,) 95 | level = NOTSET 96 | formatter = generic 97 | 98 | [formatter_generic] 99 | format = %(levelname)-5.5s [%(name)s] %(message)s 100 | datefmt = %H:%M:%S 101 | -------------------------------------------------------------------------------- /src/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to alembic/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" 39 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. Valid values are: 43 | # 44 | # version_path_separator = : 45 | # version_path_separator = ; 46 | # version_path_separator = space 47 | version_path_separator = os # default: use os.pathsep 48 | 49 | # the output encoding used when revision files 50 | # are written from script.py.mako 51 | # output_encoding = utf-8 52 | 53 | sqlalchemy.url = postgresql://postgres:password@localhost/postgres 54 | 55 | 56 | [post_write_hooks] 57 | # post_write_hooks defines scripts or Python functions that are run 58 | # on newly generated revision scripts. See the documentation for further 59 | # detail and examples 60 | 61 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 62 | # hooks = black 63 | # black.type = console_scripts 64 | # black.entrypoint = black 65 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 66 | 67 | # Logging configuration 68 | [loggers] 69 | keys = root,sqlalchemy,alembic 70 | 71 | [handlers] 72 | keys = console 73 | 74 | [formatters] 75 | keys = generic 76 | 77 | [logger_root] 78 | level = WARN 79 | handlers = console 80 | qualname = 81 | 82 | [logger_sqlalchemy] 83 | level = WARN 84 | handlers = 85 | qualname = sqlalchemy.engine 86 | 87 | [logger_alembic] 88 | level = INFO 89 | handlers = 90 | qualname = alembic 91 | 92 | [handler_console] 93 | class = StreamHandler 94 | args = (sys.stderr,) 95 | level = NOTSET 96 | formatter = generic 97 | 98 | [formatter_generic] 99 | format = %(levelname)-5.5s [%(name)s] %(message)s 100 | datefmt = %H:%M:%S 101 | -------------------------------------------------------------------------------- /src/api/main.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from fastapi import FastAPI, Request 4 | from fastapi.responses import JSONResponse 5 | from pydantic import ValidationError 6 | 7 | from api.dependencies import oauth2_scheme # noqa 8 | from api.routers import bidding, catalog, diagnostics, iam 9 | from config.api_config import ApiConfig 10 | from config.container import ApplicationContainer 11 | from seedwork.domain.exceptions import DomainException, EntityNotFoundException 12 | from seedwork.infrastructure.database import Base 13 | from seedwork.infrastructure.logging import LoggerFactory, logger 14 | 15 | # configure logger prior to first usage 16 | LoggerFactory.configure(logger_name="api") 17 | 18 | # dependency injection container 19 | config = ApiConfig() 20 | container = ApplicationContainer(config=config) 21 | db_engine = container.db_engine() 22 | logger.info(f"using db engine {db_engine}, creating tables") 23 | Base.metadata.create_all(db_engine) 24 | logger.info("setup complete") 25 | 26 | app = FastAPI(debug=config.DEBUG) 27 | 28 | app.include_router(catalog.router) 29 | app.include_router(bidding.router) 30 | app.include_router(iam.router) 31 | app.include_router(diagnostics.router) 32 | app.container = container # type: ignore 33 | 34 | 35 | @app.exception_handler(ValidationError) 36 | async def pydantic_validation_exception_handler(request: Request, exc: ValidationError): 37 | return JSONResponse( 38 | status_code=422, 39 | content={ 40 | "detail": exc.errors(), 41 | }, 42 | ) 43 | 44 | 45 | # startup 46 | 47 | try: 48 | import uuid 49 | 50 | from modules.iam.application.services import IamService 51 | 52 | with container.application().transaction_context() as ctx: 53 | iam_service = ctx[IamService] 54 | iam_service.create_user( 55 | user_id=uuid.UUID(int=1), 56 | email="user1@example.com", 57 | password="password", 58 | access_token="token", 59 | ) 60 | except ValueError as e: 61 | ... 62 | 63 | 64 | @app.exception_handler(DomainException) 65 | async def domain_exception_handler(request: Request, exc: DomainException): 66 | if container.config.DEBUG: 67 | raise exc 68 | 69 | return JSONResponse( 70 | status_code=500, 71 | content={"message": f"Oops! {exc} did something. There goes a rainbow..."}, 72 | ) 73 | 74 | 75 | @app.exception_handler(EntityNotFoundException) 76 | async def entity_not_found_exception_handler( 77 | request: Request, exc: EntityNotFoundException 78 | ): 79 | return JSONResponse( 80 | status_code=404, 81 | content={ 82 | "message": f"Entity {exc.kwargs} not found in {exc.repository.__class__.__name__}" 83 | }, 84 | ) 85 | 86 | 87 | @app.middleware("http") 88 | async def add_lato_application(request: Request, call_next): 89 | request.state.lato_application = container.application() 90 | return await call_next(request) 91 | 92 | 93 | @app.middleware("http") 94 | async def add_process_time(request: Request, call_next): 95 | start_time = time.time() 96 | try: 97 | response = await call_next(request) 98 | process_time = time.time() - start_time 99 | response.headers["X-Process-Time"] = str(process_time) 100 | return response 101 | finally: 102 | pass 103 | 104 | 105 | @app.get("/") 106 | async def root(): 107 | return {"info": "Online auctions API. See /docs for documentation"} 108 | -------------------------------------------------------------------------------- /src/modules/bidding/infrastructure/listing_repository.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | 4 | from sqlalchemy.dialects.postgresql import JSONB 5 | from sqlalchemy.sql.schema import Column 6 | from sqlalchemy_json import mutable_json_type 7 | from sqlalchemy_utils import UUIDType 8 | 9 | from modules.bidding.domain.entities import Bid, Bidder, Listing, Money, Seller 10 | from modules.bidding.domain.repositories import ListingRepository 11 | from seedwork.domain.value_objects import GenericUUID 12 | from seedwork.infrastructure.database import Base 13 | from seedwork.infrastructure.repository import SqlAlchemyGenericRepository 14 | 15 | """ 16 | References: 17 | "Introduction to SQLAlchemy 2020 (Tutorial)" by: Mike Bayer 18 | https://youtu.be/sO7FFPNvX2s?t=7214 19 | """ 20 | 21 | 22 | class ListingModel(Base): 23 | """Data model for listing domain object in the bidding context""" 24 | 25 | __tablename__ = "bidding_listing" 26 | id = Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4) 27 | data = Column(mutable_json_type(dbtype=JSONB, nested=True)) 28 | 29 | 30 | def serialize_money(money: Money) -> dict: 31 | return { 32 | "amount": money.amount, 33 | "currency": money.currency, 34 | } 35 | 36 | 37 | def serialize_id(value: GenericUUID) -> str: 38 | return str(value) 39 | 40 | 41 | def deserialize_id(value: str) -> GenericUUID: 42 | if isinstance(value, uuid.UUID): 43 | return GenericUUID(value.hex) 44 | return GenericUUID(value) 45 | 46 | 47 | def deserialize_money(data: dict) -> Money: 48 | return Money(data["amount"], currency=data["currency"]) 49 | 50 | 51 | def serialize_datetime(value: datetime.datetime) -> str: 52 | return value.isoformat() 53 | 54 | 55 | def deserialize_datetime(value: str) -> datetime.datetime: 56 | return datetime.datetime.fromisoformat(value) 57 | 58 | 59 | def serialize_bid(bid: Bid) -> dict: 60 | return { 61 | "bidder_id": serialize_id(bid.bidder.id), 62 | "max_price": serialize_money(bid.max_price), 63 | "placed_at": serialize_datetime(bid.placed_at), 64 | } 65 | 66 | 67 | def deserialize_bid(data: dict) -> Bid: 68 | return Bid( 69 | bidder=Bidder(id=deserialize_id(data["bidder_id"])), 70 | max_price=deserialize_money(data["max_price"]), 71 | placed_at=deserialize_datetime(data["placed_at"]), 72 | ) 73 | 74 | 75 | class ListingDataMapper: 76 | def model_to_entity(self, instance: ListingModel) -> Listing: 77 | d = instance.data 78 | return Listing( 79 | id=deserialize_id(instance.id), 80 | seller=Seller(id=deserialize_id(d["seller_id"])), 81 | ask_price=deserialize_money(d["ask_price"]), 82 | starts_at=deserialize_datetime(d["starts_at"]), 83 | ends_at=deserialize_datetime(d["ends_at"]), 84 | ) 85 | 86 | def entity_to_model(self, entity: Listing) -> ListingModel: 87 | return ListingModel( 88 | id=entity.id, 89 | data={ 90 | "starts_at": serialize_datetime(entity.starts_at), 91 | "ends_at": serialize_datetime(entity.ends_at), 92 | "ask_price": serialize_money(entity.ask_price), 93 | "seller_id": serialize_id(entity.seller.id), 94 | "bids": [serialize_bid(b) for b in entity.bids], 95 | }, 96 | ) 97 | 98 | 99 | class PostgresJsonListingRepository(SqlAlchemyGenericRepository, ListingRepository): 100 | """Listing repository implementation""" 101 | 102 | mapper_class = ListingDataMapper 103 | model_class = ListingModel 104 | -------------------------------------------------------------------------------- /src/modules/bidding/tests/infrastructure/test_listing_repository.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import uuid 3 | 4 | import pytest 5 | 6 | from modules.bidding.domain.entities import Bid, Bidder, Listing, Money, Seller 7 | from modules.bidding.infrastructure.listing_repository import ( 8 | ListingDataMapper, 9 | ListingModel, 10 | PostgresJsonListingRepository, 11 | ) 12 | from seedwork.domain.value_objects import GenericUUID 13 | 14 | 15 | @pytest.mark.integration 16 | def test_listing_repo_is_empty(db_session): 17 | repo = PostgresJsonListingRepository(db_session=db_session) 18 | assert repo.count() == 0 19 | 20 | 21 | @pytest.mark.unit 22 | def test_listing_data_mapper_maps_entity_to_model(): 23 | listing = Listing( 24 | id=GenericUUID(int=1), 25 | seller=Seller(id=GenericUUID(int=2)), 26 | ask_price=Money(100, "PLN"), 27 | starts_at=datetime.datetime(2020, 12, 1), 28 | ends_at=datetime.datetime(2020, 12, 31), 29 | bids=[ 30 | Bid( 31 | max_price=Money(200, "PLN"), 32 | bidder=Bidder(id=GenericUUID(int=3)), 33 | placed_at=datetime.datetime(2020, 12, 30), 34 | ) 35 | ], 36 | ) 37 | mapper = ListingDataMapper() 38 | 39 | actual = mapper.entity_to_model(listing) 40 | 41 | expected = ListingModel( 42 | id=GenericUUID(int=1), 43 | data={ 44 | "seller_id": "00000000-0000-0000-0000-000000000002", 45 | "ask_price": { 46 | "amount": 100, 47 | "currency": "PLN", 48 | }, 49 | "starts_at": "2020-12-01T00:00:00", 50 | "ends_at": "2020-12-31T00:00:00", 51 | "bids": [ 52 | { 53 | "max_price": { 54 | "amount": 200, 55 | "currency": "PLN", 56 | }, 57 | "bidder_id": "00000000-0000-0000-0000-000000000003", 58 | "placed_at": "2020-12-30T00:00:00", 59 | } 60 | ], 61 | }, 62 | ) 63 | assert actual.id == expected.id 64 | assert actual.data == expected.data 65 | 66 | 67 | @pytest.mark.unit 68 | def test_listing_data_mapper_maps_model_to_entity(): 69 | instance = ListingModel( 70 | id=GenericUUID(int=1), 71 | data={ 72 | "seller_id": "00000000-0000-0000-0000-000000000002", 73 | "ask_price": { 74 | "amount": 100, 75 | "currency": "PLN", 76 | }, 77 | "starts_at": "2020-12-01T00:00:00", 78 | "ends_at": "2020-12-31T00:00:00", 79 | }, 80 | ) 81 | mapper = ListingDataMapper() 82 | 83 | actual = mapper.model_to_entity(instance) 84 | 85 | expected = Listing( 86 | id=GenericUUID(int=1), 87 | seller=Seller(id=GenericUUID("00000000000000000000000000000002")), 88 | ask_price=Money(100, "PLN"), 89 | starts_at=datetime.datetime(2020, 12, 1), 90 | ends_at=datetime.datetime(2020, 12, 31), 91 | ) 92 | assert actual == expected 93 | 94 | 95 | @pytest.mark.integration 96 | def test_listing_persistence(db_session): 97 | original = Listing( 98 | id=Listing.next_id(), 99 | seller=Seller(id=uuid.uuid4()), 100 | ask_price=Money(100, "PLN"), 101 | starts_at=datetime.datetime(2020, 12, 1), 102 | ends_at=datetime.datetime(2020, 12, 31), 103 | ) 104 | repository = PostgresJsonListingRepository(db_session=db_session) 105 | 106 | repository.add(original) 107 | repository.persist_all() 108 | 109 | repository = PostgresJsonListingRepository(db_session=db_session) 110 | persisted = repository.get_by_id(original.id) 111 | 112 | assert original == persisted 113 | -------------------------------------------------------------------------------- /src/api/routers/catalog.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Depends 4 | 5 | from api.dependencies import Application, User, get_application, get_authenticated_user 6 | from api.models.catalog import ListingIndexModel, ListingReadModel, ListingWriteModel 7 | from config.container import inject 8 | from modules.catalog.application.command import ( 9 | CreateListingDraftCommand, 10 | DeleteListingDraftCommand, 11 | PublishListingDraftCommand, 12 | ) 13 | from modules.catalog.application.query.get_all_listings import GetAllListings 14 | from modules.catalog.application.query.get_listing_details import GetListingDetails 15 | from seedwork.domain.value_objects import GenericUUID, Money 16 | 17 | """ 18 | Inspired by https://developer.ebay.com/api-docs/sell/inventory/resources/offer/methods/createOffer 19 | """ 20 | 21 | router = APIRouter() 22 | 23 | 24 | @router.get("/catalog", tags=["catalog"], response_model=ListingIndexModel) 25 | async def get_all_listings(app: Annotated[Application, Depends(get_application)]): 26 | """ 27 | Shows all published listings in the catalog 28 | """ 29 | query = GetAllListings() 30 | result = await app.execute_async(query) 31 | return dict(data=result) 32 | 33 | 34 | @router.get("/catalog/{listing_id}", tags=["catalog"], response_model=ListingReadModel) 35 | @inject 36 | async def get_listing_details( 37 | listing_id, app: Annotated[Application, Depends(get_application)] 38 | ): 39 | """ 40 | Shows listing details 41 | """ 42 | query = GetListingDetails(listing_id=listing_id) 43 | query_result = await app.execute_async(query) 44 | return dict(data=query_result.payload) 45 | 46 | 47 | @router.post( 48 | "/catalog", tags=["catalog"], status_code=201, response_model=ListingReadModel 49 | ) 50 | @inject 51 | async def create_listing( 52 | request_body: ListingWriteModel, 53 | app: Annotated[Application, Depends(get_application)], 54 | current_user: Annotated[User, Depends(get_authenticated_user)], 55 | ): 56 | """ 57 | Creates a new listing 58 | """ 59 | command = CreateListingDraftCommand( 60 | listing_id=GenericUUID.next_id(), 61 | title=request_body.title, 62 | description=request_body.description, 63 | ask_price=Money(request_body.ask_price_amount, request_body.ask_price_currency), 64 | seller_id=current_user.id, 65 | ) 66 | app.execute(command) 67 | 68 | query = GetListingDetails(listing_id=command.listing_id) 69 | query_result = app.execute_query(query) 70 | return dict(query_result.payload) 71 | 72 | 73 | @router.delete( 74 | "/catalog/{listing_id}", tags=["catalog"], status_code=204, response_model=None 75 | ) 76 | @inject 77 | async def delete_listing( 78 | listing_id, 79 | app: Annotated[Application, Depends(get_application)], 80 | current_user: Annotated[User, Depends(get_authenticated_user)], 81 | ): 82 | """ 83 | Deletes a listing 84 | """ 85 | command = DeleteListingDraftCommand( 86 | listing_id=listing_id, 87 | seller_id=current_user.id, 88 | ) 89 | await app.execute_async(command) 90 | 91 | 92 | @router.post( 93 | "/catalog/{listing_id}/publish", 94 | tags=["catalog"], 95 | status_code=200, 96 | response_model=ListingReadModel, 97 | ) 98 | @inject 99 | async def publish_listing( 100 | listing_id: GenericUUID, 101 | app: Annotated[Application, Depends(get_application)], 102 | current_user: Annotated[User, Depends(get_authenticated_user)], 103 | ): 104 | """ 105 | Publishes a listing 106 | """ 107 | command = PublishListingDraftCommand( 108 | listing_id=listing_id, 109 | seller_id=current_user.id, 110 | ) 111 | await app.execute_async(command) 112 | 113 | query = GetListingDetails(listing_id=listing_id) 114 | response = await app.execute_async(query) 115 | return response 116 | -------------------------------------------------------------------------------- /src/seedwork/tests/application/test_application_and_one_module_branching.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from seedwork.application import Application 6 | from seedwork.application.command_handlers import CommandResult 7 | from seedwork.application.commands import Command 8 | from seedwork.application.events import EventResult 9 | from seedwork.domain.events import DomainEvent 10 | 11 | 12 | @dataclass 13 | class CompleteOrderCommand(Command): 14 | order_id: str 15 | 16 | 17 | class OrderCompletedEvent(DomainEvent): 18 | order_id: str 19 | 20 | 21 | class PaymentProcessedEvent(DomainEvent): 22 | order_id: str 23 | 24 | 25 | class OrderShippedEvent(DomainEvent): 26 | order_id: str 27 | 28 | 29 | def create_app(): 30 | app = Application() 31 | 32 | @app.command_handler 33 | def complete_order(command: CompleteOrderCommand, history): 34 | history.append(f"completing {command.order_id}") 35 | return CommandResult.success( 36 | payload=None, event=OrderCompletedEvent(order_id=command.order_id) 37 | ) 38 | 39 | @app.domain_event_handler 40 | def when_order_is_completed_process_payment_policy( 41 | event: OrderCompletedEvent, history 42 | ): 43 | history.append( 44 | f"starting when_order_is_completed_process_payment_policy for {event.order_id}" 45 | ) 46 | ... 47 | return EventResult.success(event=PaymentProcessedEvent(order_id=event.order_id)) 48 | 49 | @app.domain_event_handler 50 | def when_order_is_completed_ship_order_policy(event: OrderCompletedEvent, history): 51 | history.append( 52 | f"starting when_order_is_completed_ship_order_policy for {event.order_id}" 53 | ) 54 | ... 55 | return EventResult.success(event=OrderShippedEvent(order_id=event.order_id)) 56 | 57 | @app.domain_event_handler 58 | def when_payment_is_processed_open_champagne_policy( 59 | event: PaymentProcessedEvent, history 60 | ): 61 | history.append( 62 | f"starting when_payment_is_processed_open_champagne_policy for {event.order_id}" 63 | ) 64 | return EventResult.success() 65 | 66 | @app.domain_event_handler 67 | def when_order_is_shipped_sit_and_relax_policy(event: OrderShippedEvent, history): 68 | history.append( 69 | f"starting when_order_is_shipped_sit_and_relax_policy for {event.order_id}" 70 | ) 71 | return EventResult.success() 72 | 73 | return app 74 | 75 | 76 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 77 | @pytest.mark.integration 78 | def test_mono_module_command_branching_flow(): 79 | """This tests the branching code flow: 80 | complete_order 81 | ↓ 82 | OrderCompletedEvent 83 | ↓ ↓ 84 | when_order_is_completed_process_payment_policy when_order_is_completed_ship_order_policy 85 | ↓ ↓ 86 | PaymentProcessedEvent OrderShippedEvent 87 | ↓ ↓ 88 | when_payment_is_processed_ship_order_policy when_order_is_shipped_sit_and_relax_policy 89 | """ 90 | app = create_app() 91 | history = [] 92 | with app.transaction_context(history=history) as ctx: 93 | ctx.execute(CompleteOrderCommand(order_id="order1")) 94 | 95 | assert history == [ 96 | "completing order1", 97 | "starting when_order_is_completed_process_payment_policy for order1", 98 | "starting when_order_is_completed_ship_order_policy for order1", 99 | "starting when_payment_is_processed_open_champagne_policy for order1", 100 | "starting when_order_is_shipped_sit_and_relax_policy for order1", 101 | ] 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Domain Driven Design (DDD) example project 2 | 3 | [![Build Status](https://github.com/pgorecki/python-ddd/actions/workflows/pytest.yml/badge.svg?branch=main)](https://github.com/pgorecki/python-ddd/actions/workflows/pytest.yml) 4 | [![codecov](https://codecov.io/gh/pgorecki/python-ddd/branch/master/graph/badge.svg)](https://codecov.io/gh/pgorecki/python-ddd) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | 7 | Disclaimer: this is a work in progress project, stay tuned for updates (*). 8 | 9 | (*) *This project is is accompanied by my blog https://dddinpython.com/ where I introduce some of the concepts and patterns implemented this repository.* 10 | 11 | ## The goal of this project 12 | 13 | AUCTION APPLICATION 14 | 15 | The goal is to implement an automatic bidding system using DDD tactical patterns, 16 | described here: https://www.ebay.co.uk/pages/help/buy/bidding-overview.html 17 | 18 | ## Domain 19 | 20 | Online Auctions domain was selected for the purpose of this project, which is loosely based on Ebay bidding system. 21 | 22 | The main reason for selecting this domain is general familiarity and limited complexity - many people understand how internet bidding work and the whole concept is not difficult to understand (or at least it's much simpler that healthcare or banking domains). On the other hand it's not simply a CRUD system - there are some business rules that must be implemented in the system. 23 | 24 | ### Domain description 25 | 26 | This is a summary of the information we got from a domain expert about how the auction system should work. 27 | 28 | #### Selling 29 | 30 | When you want to sell an item, the first step in getting your item in front of buyers is creating a `Listing`. For now we only focus on selling through auctions (https://www.ebay.com/help/selling/listings/auction-format?id=4110), but in the future we may consider selling with "Buy It Now" (https://www.ebay.com/help/selling/listings/selling-buy-now?id=4109) or in some other ways. When presenting an `Listing` for sale (which we call `publishing` in the Catalog), seller must provide a `Listing` duration and initial price. Also it is also possible to schedule a `Listing` publication (https://www.ebay.com/help/selling/listings/selecting-listing-duration?id=4652). 31 | 32 | You can cancel your listing when bidding takes place, but only under certain circumstances: if time left in listing < 12 hrs you can cancel your listing only if no bids were places (but we might change it in the future). 33 | 34 | If you are a new seller (you never sold an item before), you can list only one `Item` in the `Catalog` at a time. 35 | 36 | #### Buying 37 | 38 | When `Listing` is selled through auction `Bidding` takes place. As a `Buyer`, you can place a bid, which must be greated than the current price + 1 USD and which sets the the highest price you are willing to pay for an item. System will let you know if someone outbids you and you can decide if you want to increase your maximum limit. Sometimes you can can be automatically outbidded (if some other buyer sets his maximum limit higher that yours) - see https://www.ebay.com/help/buying/bidding/automatic-bidding?id=4014. 39 | 40 | After a fixed time since the bidding was started a bidding ends and the `Winner` is announced. 41 | 42 | #### Payments 43 | 44 | At this point, payments are out of scope for this project. 45 | 46 | #### Users 47 | 48 | Each user can be a `Seller` or a `Buyer` (or both). User priveledges can be elevated to a `Staff member` or `Administrator`. 49 | 50 | 51 | ## Event storming 52 | 53 | Event storming technique was used to discover the business domain and the most important business processes. 54 | 55 | ### Listing draft management 56 | 57 | ![](docs/images/draft_management.png) 58 | 59 | ### Publishing Listing to Catalog 60 | 61 | ![](docs/images/publishing_to_catalog.png) 62 | 63 | ### Bidding process 64 | 65 | ![](docs/images/bidding_process.png) 66 | 67 | 68 | ## Context Map 69 | 70 | `Lising`, `Bidding` and `Payment` bounded contexts were identified as a result of event storming. Relationship between these bounded contexts is presented in the following context map. 71 | 72 | ![](docs/images/auctions_ContextMap.png) 73 | 74 | Since `Payment` context will be provided by a 3rd party payments provider (via REST API), the downstream context (`Bidding`) must conform to whatever the upstream provides. 75 | 76 | 77 | ## How to run this project 78 | 79 | `poetry shell` 80 | 81 | `poetry install` 82 | 83 | `poe compose_up` - run in a separate shell to start the database 84 | 85 | `poe start` 86 | 87 | `poe test` 88 | -------------------------------------------------------------------------------- /src/modules/catalog/tests/infrastructure/test_listing_repository.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy.orm import Session 3 | 4 | from modules.catalog.domain.entities import Listing, Money 5 | from modules.catalog.infrastructure.listing_repository import ( 6 | ListingDataMapper, 7 | ListingModel, 8 | PostgresJsonListingRepository, 9 | ) 10 | from seedwork.domain.value_objects import GenericUUID 11 | 12 | 13 | @pytest.mark.integration 14 | def test_listing_repo_is_empty(db_session): 15 | repo = PostgresJsonListingRepository(db_session=db_session) 16 | assert repo.count() == 0 17 | 18 | 19 | @pytest.mark.integration 20 | def test_sqlalchemy_repo_is_adding(engine): 21 | with Session(engine) as session: 22 | repo = PostgresJsonListingRepository(db_session=session) 23 | listing = Listing( 24 | id=GenericUUID(int=1), 25 | title="Foo", 26 | description="...", 27 | ask_price=Money(10), 28 | seller_id=GenericUUID(int=1), 29 | ) 30 | repo.add(listing) 31 | session.commit() 32 | 33 | with Session(engine) as session: 34 | repo = PostgresJsonListingRepository(db_session=session) 35 | listing = repo.get_by_id(GenericUUID(int=1)) 36 | assert listing is not None 37 | 38 | 39 | @pytest.mark.integration 40 | def test_sqlalchemy_repo_is_persisting_chages(engine): 41 | with Session(engine) as session: 42 | repo = PostgresJsonListingRepository(db_session=session) 43 | listing = Listing( 44 | id=GenericUUID(int=1), 45 | title="Foo", 46 | description="...", 47 | ask_price=Money(10), 48 | seller_id=GenericUUID(int=1), 49 | ) 50 | repo.add(listing) 51 | session.commit() 52 | 53 | with Session(engine) as session: 54 | repo = PostgresJsonListingRepository(db_session=session) 55 | listing = repo.get_by_id(GenericUUID(int=1)) 56 | listing.title = "Bar" 57 | repo.persist_all() 58 | session.commit() 59 | 60 | with Session(engine) as session: 61 | repo = PostgresJsonListingRepository(db_session=session) 62 | listing = repo.get_by_id(GenericUUID(int=1)) 63 | assert listing.title == "Bar" 64 | 65 | 66 | @pytest.mark.unit 67 | def test_listing_data_mapper_maps_entity_to_model(): 68 | listing = Listing( 69 | id=GenericUUID("00000000000000000000000000000001"), 70 | title="Foo", 71 | description="...", 72 | ask_price=Money(10), 73 | seller_id=GenericUUID("00000000000000000000000000000002"), 74 | ) 75 | mapper = ListingDataMapper() 76 | 77 | actual = mapper.entity_to_model(listing) 78 | 79 | expected = ListingModel( 80 | id=GenericUUID("00000000000000000000000000000001"), 81 | data={ 82 | "title": "Foo", 83 | "description": "...", 84 | "ask_price": { 85 | "amount": 10, 86 | "currency": "USD", 87 | }, 88 | "seller_id": "00000000-0000-0000-0000-000000000002", 89 | "status": "draft", 90 | }, 91 | ) 92 | assert actual.id == expected.id 93 | assert actual.data == expected.data 94 | 95 | 96 | @pytest.mark.unit 97 | def test_listing_data_mapper_maps_model_to_entity(): 98 | instance = ListingModel( 99 | id=GenericUUID("00000000000000000000000000000001"), 100 | data={ 101 | "title": "Foo", 102 | "description": "...", 103 | "ask_price": { 104 | "amount": 10, 105 | "currency": "USD", 106 | }, 107 | "seller_id": "00000000-0000-0000-0000-000000000002", 108 | "status": "draft", 109 | }, 110 | ) 111 | mapper = ListingDataMapper() 112 | 113 | actual = mapper.model_to_entity(instance) 114 | 115 | expected = Listing( 116 | id=GenericUUID("00000000000000000000000000000001"), 117 | title="Foo", 118 | description="...", 119 | ask_price=Money(10), 120 | seller_id=GenericUUID("00000000000000000000000000000002"), 121 | ) 122 | assert actual == expected 123 | 124 | 125 | @pytest.mark.integration 126 | def test_listing_persistence(db_session): 127 | original = Listing( 128 | id=Listing.next_id(), 129 | ask_price=Money(1), 130 | title="red dragon", 131 | description="", 132 | seller_id=GenericUUID.next_id(), 133 | ) 134 | repository = PostgresJsonListingRepository(db_session=db_session) 135 | 136 | repository.add(original) 137 | repository.persist_all() 138 | 139 | repository = PostgresJsonListingRepository(db_session=db_session) 140 | persisted = repository.get_by_id(original.id) 141 | 142 | assert original == persisted 143 | -------------------------------------------------------------------------------- /src/seedwork/tests/application/test_application.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from seedwork.application import Application 6 | from seedwork.application.command_handlers import CommandResult 7 | from seedwork.application.commands import Command 8 | from seedwork.domain.events import DomainEvent 9 | 10 | 11 | @dataclass 12 | class SendPing(Command): 13 | pass 14 | 15 | 16 | class PingSent(DomainEvent): 17 | pass 18 | 19 | 20 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 21 | @pytest.mark.unit 22 | def test_application_config(): 23 | app = Application("TestApp", 0.1) 24 | 25 | assert app.name == "TestApp" 26 | assert app.version == 0.1 27 | 28 | 29 | 30 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 31 | @pytest.mark.unit 32 | def test_application_handles_command(): 33 | app = Application() 34 | 35 | @app.command_handler 36 | def handle_ping(command: SendPing): 37 | ... 38 | 39 | assert app.get_command_handler(SendPing()) is handle_ping 40 | 41 | 42 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 43 | @pytest.mark.unit 44 | def test_application_handles_domain_event(): 45 | app = Application() 46 | 47 | @app.domain_event_handler 48 | def handle_ping_sent(event: PingSent): 49 | ... 50 | 51 | assert app.get_event_handlers(PingSent()) == [handle_ping_sent] 52 | 53 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 54 | @pytest.mark.unit 55 | def test_app_parameters_injection(): 56 | app = Application(correlation_id=1) 57 | 58 | @app.command_handler 59 | def handle_ping(command: SendPing, correlation_id): 60 | return CommandResult.success(payload=correlation_id) 61 | 62 | result = app.execute(SendPing()) 63 | assert result.payload == 1 64 | 65 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 66 | @pytest.mark.unit 67 | def test_transaction_context_parameter_injection(): 68 | app = Application() 69 | 70 | @app.command_handler 71 | def handle_ping(command: SendPing, correlation_id): 72 | return CommandResult.success(payload=correlation_id) 73 | 74 | with app.transaction_context(correlation_id=1) as ctx: 75 | result = ctx.execute(SendPing()) 76 | assert result.payload == 1 77 | 78 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 79 | @pytest.mark.unit 80 | def test_transaction_context_parameter_override(): 81 | app = Application(correlation_id=1) 82 | 83 | @app.command_handler 84 | def handle_ping(command: SendPing, correlation_id): 85 | return CommandResult.success(payload=correlation_id) 86 | 87 | with app.transaction_context(correlation_id=2) as ctx: 88 | result = ctx.execute(SendPing()) 89 | assert result.payload == 2 90 | 91 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 92 | @pytest.mark.unit 93 | def test_transaction_context_enter_exit(): 94 | app = Application(correlation_id=1) 95 | 96 | @app.on_enter_transaction_context 97 | def on_enter_transaction_context(ctx): 98 | ctx.entered = True 99 | 100 | @app.on_exit_transaction_context 101 | def on_exit_transaction_context(ctx, exc_type, exc_val, exc_tb): 102 | ctx.exited = True 103 | 104 | @app.command_handler 105 | def handle_ping(command: SendPing, correlation_id): 106 | return CommandResult.success(payload=correlation_id) 107 | 108 | with app.transaction_context() as ctx: 109 | ... 110 | 111 | assert ctx.entered 112 | assert ctx.exited 113 | 114 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 115 | @pytest.mark.unit 116 | def test_transaction_context_middleware(): 117 | app = Application(trace=[]) 118 | 119 | @app.transaction_middleware 120 | def middleware1(ctx, next, command=None, query=None, event=None): 121 | ctx.dependency_provider["trace"].append("middleware1") 122 | return next() 123 | 124 | @app.transaction_middleware 125 | def middleware1(ctx, next, command=None, query=None, event=None): 126 | ctx.dependency_provider["trace"].append("middleware2") 127 | return next() 128 | 129 | @app.command_handler 130 | def handle_ping(command: SendPing): 131 | return CommandResult.success() 132 | 133 | with app.transaction_context() as ctx: 134 | ctx.execute(SendPing()) 135 | 136 | assert app.dependency_provider["trace"] == ["middleware1", "middleware2"] 137 | 138 | @pytest.mark.skip(reason="seedwork Application deprecated by lato") 139 | @pytest.mark.unit 140 | def test_missing_dependency(): 141 | app = Application() 142 | 143 | @app.command_handler 144 | def handle_ping(command: SendPing, missing_dependency): 145 | return CommandResult.success(payload=missing_dependency) 146 | 147 | with pytest.raises(TypeError): 148 | app.execute(SendPing()) 149 | -------------------------------------------------------------------------------- /src/api/tests/test_catalog.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from modules.catalog.application.command import ( 4 | CreateListingDraftCommand, 5 | PublishListingDraftCommand, 6 | ) 7 | from seedwork.domain.value_objects import GenericUUID, Money 8 | 9 | 10 | @pytest.mark.integration 11 | def test_empty_catalog_list(api_client): 12 | response = api_client.get("/catalog") 13 | assert response.status_code == 200 14 | assert response.json() == {"data": []} 15 | 16 | 17 | @pytest.mark.integration 18 | @pytest.mark.asyncio 19 | async def test_catalog_list_with_one_item(app, api_client): 20 | # arrange 21 | await app.execute_async( 22 | CreateListingDraftCommand( 23 | listing_id=GenericUUID(int=1), 24 | title="Foo", 25 | description="Bar", 26 | ask_price=Money(10), 27 | seller_id=GenericUUID(int=2), 28 | ) 29 | ) 30 | 31 | # act 32 | response = api_client.get("/catalog") 33 | 34 | # assert 35 | assert response.status_code == 200 36 | response_data = response.json()["data"] 37 | assert len(response_data) == 1 38 | assert response.json() == { 39 | "data": [ 40 | { 41 | "id": str(GenericUUID(int=1)), 42 | "title": "Foo", 43 | "description": "Bar", 44 | "ask_price_amount": 10.0, 45 | "ask_price_currency": "USD", 46 | } 47 | ] 48 | } 49 | 50 | 51 | @pytest.mark.integration 52 | @pytest.mark.asyncio 53 | async def test_catalog_list_with_two_items(app, api_client): 54 | # arrange 55 | await app.execute_async( 56 | CreateListingDraftCommand( 57 | listing_id=GenericUUID(int=1), 58 | title="Foo #1", 59 | description="Bar", 60 | ask_price=Money(10), 61 | seller_id=GenericUUID(int=2), 62 | ) 63 | ) 64 | await app.execute_async( 65 | CreateListingDraftCommand( 66 | listing_id=GenericUUID(int=2), 67 | title="Foo #2", 68 | description="Bar", 69 | ask_price=Money(10), 70 | seller_id=GenericUUID(int=2), 71 | ) 72 | ) 73 | 74 | # act 75 | response = api_client.get("/catalog") 76 | 77 | # assert 78 | assert response.status_code == 200 79 | response_data = response.json()["data"] 80 | assert len(response_data) == 2 81 | 82 | 83 | def test_catalog_create_draft_fails_due_to_incomplete_data( 84 | api, authenticated_api_client 85 | ): 86 | response = authenticated_api_client.post("/catalog") 87 | assert response.status_code == 422 88 | 89 | 90 | @pytest.mark.integration 91 | @pytest.mark.asyncio 92 | async def test_catalog_delete_draft(app, authenticated_api_client): 93 | current_user = authenticated_api_client.current_user 94 | await app.execute_async( 95 | CreateListingDraftCommand( 96 | listing_id=GenericUUID(int=1), 97 | title="Listing to be deleted", 98 | description="...", 99 | ask_price=Money(10), 100 | seller_id=current_user.id, 101 | ) 102 | ) 103 | 104 | response = authenticated_api_client.delete(f"/catalog/{str(GenericUUID(int=1))}") 105 | 106 | assert response.status_code == 204 107 | 108 | 109 | @pytest.mark.integration 110 | def test_catalog_delete_non_existing_draft_returns_404(authenticated_api_client): 111 | listing_id = GenericUUID(int=1) 112 | response = authenticated_api_client.delete(f"/catalog/{listing_id}") 113 | assert response.status_code == 404 114 | 115 | 116 | @pytest.mark.integration 117 | @pytest.mark.asyncio 118 | async def test_catalog_publish_listing_draft(app, authenticated_api_client): 119 | # arrange 120 | current_user = authenticated_api_client.current_user 121 | listing_id = GenericUUID(int=1) 122 | await app.execute_async( 123 | CreateListingDraftCommand( 124 | listing_id=listing_id, 125 | title="Listing to be published", 126 | description="...", 127 | ask_price=Money(10), 128 | seller_id=current_user.id, 129 | ) 130 | ) 131 | 132 | # act 133 | response = authenticated_api_client.post(f"/catalog/{listing_id}/publish") 134 | 135 | # assert that the listing was published 136 | assert response.status_code == 200 137 | 138 | 139 | @pytest.mark.asyncio 140 | async def test_published_listing_appears_in_biddings(app, authenticated_api_client): 141 | # arrange 142 | listing_id = GenericUUID(int=1) 143 | current_user = authenticated_api_client.current_user 144 | await app.execute_async( 145 | CreateListingDraftCommand( 146 | listing_id=listing_id, 147 | title="Listing to be published", 148 | description="...", 149 | ask_price=Money(10), 150 | seller_id=current_user.id, 151 | ) 152 | ) 153 | await app.execute_async( 154 | PublishListingDraftCommand( 155 | listing_id=listing_id, 156 | seller_id=current_user.id, 157 | ) 158 | ) 159 | 160 | url = f"/bidding/{listing_id}" 161 | response = authenticated_api_client.get(url) 162 | assert response.status_code == 200 163 | -------------------------------------------------------------------------------- /src/seedwork/tests/infrastructure/test_sqlalchemy_repository.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from dataclasses import dataclass 3 | 4 | import pytest 5 | from sqlalchemy import Column, String 6 | from sqlalchemy.orm import Session 7 | from sqlalchemy_utils import UUIDType 8 | 9 | from seedwork.domain.entities import Entity 10 | from seedwork.domain.exceptions import EntityNotFoundException 11 | from seedwork.infrastructure.data_mapper import DataMapper 12 | from seedwork.infrastructure.database import Base 13 | from seedwork.infrastructure.repository import SqlAlchemyGenericRepository 14 | 15 | 16 | @dataclass 17 | class Person(Entity): 18 | """Domain object""" 19 | 20 | first_name: str 21 | last_name: str 22 | 23 | 24 | class PersonModel(Base): 25 | """Data model for a domain object""" 26 | 27 | __tablename__ = "person" 28 | id = Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4) 29 | first_name = Column(String) 30 | last_name = Column(String) 31 | 32 | 33 | class PersonDataMapper(DataMapper): 34 | def model_to_entity(self, instance: PersonModel) -> Person: 35 | return Person( 36 | id=instance.id, 37 | first_name=instance.first_name, 38 | last_name=instance.last_name, 39 | ) 40 | 41 | def entity_to_model(self, entity: Person) -> PersonModel: 42 | return PersonModel( 43 | id=entity.id, 44 | first_name=entity.first_name, 45 | last_name=entity.last_name, 46 | ) 47 | 48 | 49 | class PersonSqlAlchemyRepository(SqlAlchemyGenericRepository): 50 | mapper_class = PersonDataMapper 51 | model_class = PersonModel 52 | 53 | 54 | @pytest.mark.integration 55 | def test_sqlalchemy_repository_persist(db_session): 56 | # arrange 57 | person = Person(id=Person.next_id(), first_name="John", last_name="Doe") 58 | repository = PersonSqlAlchemyRepository(db_session=db_session) 59 | 60 | # act 61 | repository.add(person) 62 | 63 | # assert 64 | assert repository.count() == 1 65 | 66 | 67 | @pytest.mark.integration 68 | def test_sqlalchemy_repository_get_by_id(engine): 69 | # arrange 70 | person_id = Person.next_id() 71 | 72 | with Session(engine) as db_session: 73 | person1 = Person(id=person_id, first_name="John", last_name="Doe") 74 | repository1 = PersonSqlAlchemyRepository(db_session=db_session) 75 | repository1.add(person1) 76 | db_session.commit() 77 | 78 | # act - in separate session 79 | with Session(engine) as db_session: 80 | repository2 = PersonSqlAlchemyRepository(db_session=db_session) 81 | person2 = repository2.get_by_id(person_id) 82 | 83 | # assert 84 | assert person1 == person2 85 | 86 | 87 | @pytest.mark.integration 88 | def test_sqlalchemy_repository_update(engine): 89 | # arrange 90 | person_id = Person.next_id() 91 | 92 | with Session(engine) as db_session: 93 | person = Person(id=person_id, first_name="John", last_name="Doe") 94 | repository = PersonSqlAlchemyRepository(db_session=db_session) 95 | repository.add(person) 96 | db_session.commit() 97 | 98 | # act 99 | with Session(engine) as db_session: 100 | repository = PersonSqlAlchemyRepository(db_session=db_session) 101 | person = repository.get_by_id(person_id) 102 | person.first_name = "Johnny" 103 | repository.persist_all() 104 | db_session.commit() 105 | 106 | with Session(engine) as db_session: 107 | repository = PersonSqlAlchemyRepository(db_session=db_session) 108 | person = repository.get_by_id(person_id) 109 | assert person.first_name == "Johnny" 110 | 111 | 112 | @pytest.mark.integration 113 | def test_sqlalchemy_repository_remove_by_id(engine): 114 | # arrange 115 | person_id = Person.next_id() 116 | 117 | with Session(engine) as db_session: 118 | person = Person(id=person_id, first_name="John", last_name="Doe") 119 | repository = PersonSqlAlchemyRepository(db_session=db_session) 120 | repository.add(person) 121 | db_session.commit() 122 | 123 | # act 124 | with Session(engine) as db_session: 125 | repository = PersonSqlAlchemyRepository(db_session=db_session) 126 | repository.remove_by_id(person_id) 127 | db_session.commit() 128 | 129 | # assert 130 | with Session(engine) as db_session: 131 | repository = PersonSqlAlchemyRepository(db_session=db_session) 132 | assert repository.count() == 0 133 | 134 | 135 | @pytest.mark.integration 136 | def test_sqlalchemy_repository_remove(engine): 137 | # arrange 138 | person_id = Person.next_id() 139 | 140 | with Session(engine) as db_session: 141 | person = Person(id=person_id, first_name="John", last_name="Doe") 142 | repository = PersonSqlAlchemyRepository(db_session=db_session) 143 | repository.add(person) 144 | db_session.commit() 145 | 146 | # act 147 | with Session(engine) as db_session: 148 | repository = PersonSqlAlchemyRepository(db_session=db_session) 149 | person = repository.get_by_id(person_id) 150 | repository.remove(person) 151 | db_session.commit() 152 | 153 | # assert 154 | with Session(engine) as db_session: 155 | repository = PersonSqlAlchemyRepository(db_session=db_session) 156 | assert repository.count() == 0 157 | 158 | 159 | @pytest.mark.integration 160 | def test_sqlalchemy_repository_get_by_id_raises_exception(db_session): 161 | repository = PersonSqlAlchemyRepository(db_session=db_session) 162 | with pytest.raises(EntityNotFoundException): 163 | repository.get_by_id(Person.next_id()) 164 | 165 | 166 | @pytest.mark.integration 167 | def test_sqlalchemy_repository_remove_by_id_raises_exception(db_session): 168 | repository = PersonSqlAlchemyRepository(db_session=db_session) 169 | with pytest.raises(EntityNotFoundException): 170 | repository.remove_by_id(Person.next_id()) 171 | -------------------------------------------------------------------------------- /src/seedwork/infrastructure/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | from contextvars import ContextVar 4 | from datetime import datetime 5 | from logging import Logger 6 | from logging.config import dictConfig 7 | 8 | from pythonjsonlogger import jsonlogger 9 | 10 | from seedwork.utils.functional import SimpleLazyObject 11 | 12 | correlation_id: ContextVar[uuid.UUID] = ContextVar( 13 | "correlation_id", default=uuid.UUID("00000000-0000-0000-0000-000000000000") 14 | ) 15 | 16 | 17 | class RequestContextFilter(logging.Filter): 18 | """ "Provides correlation id parameter for the logger""" 19 | 20 | def __init__(self, name: str, correlation_id) -> None: 21 | super().__init__(name=name) 22 | self.correlation_id = correlation_id 23 | 24 | def filter(self, record): 25 | record.correlation_id = self.correlation_id.get() 26 | return True 27 | 28 | 29 | class ElkJsonFormatter(jsonlogger.JsonFormatter): 30 | """ 31 | ELK stack-compatibile formatter 32 | """ 33 | 34 | def add_fields(self, log_record, record, message_dict): 35 | super(ElkJsonFormatter, self).add_fields(log_record, record, message_dict) 36 | log_record["@timestamp"] = datetime.now().isoformat() 37 | log_record["level"] = record.levelname 38 | log_record["logger"] = record.name 39 | 40 | 41 | class LoggerFactory: 42 | _configured = False 43 | 44 | @classmethod 45 | def configure( 46 | cls, 47 | logger_name="app", 48 | log_filename="./logs.json", 49 | correlation_id=correlation_id, 50 | ): 51 | cls.logger_name = logger_name 52 | cls.log_filename = log_filename 53 | cls.correlation_id = correlation_id 54 | cls._configured = True 55 | 56 | @classmethod 57 | def create_logger(cls): 58 | """ 59 | Returns a logger instance, based on a configuration options 60 | """ 61 | if not cls._configured: 62 | cls.configure() 63 | logging_config = { 64 | "version": 1, 65 | "disable_existing_loggers": False, 66 | "formatters": { 67 | "default": { 68 | # exact format is not important, this is the minimum information 69 | "format": "%(asctime)s %(name)-12s %(levelname)-8s %(correlation_id)s %(message)s", 70 | }, 71 | "colored": { 72 | "()": "colorlog.ColoredFormatter", 73 | "format": "%(log_color)s%(asctime)s %(name)-12s %(levelname)-8s %(correlation_id)s %(message)s", 74 | "log_colors": { 75 | "DEBUG": "white", 76 | "INFO": "green", 77 | "WARNING": "yellow", 78 | "ERROR": "red", 79 | "CRITICAL": "red,bold", 80 | }, 81 | }, 82 | "colored_db": { 83 | "()": "colorlog.ColoredFormatter", 84 | "format": "%(log_color)s%(asctime)s %(name)-12s %(levelname)-8s %(correlation_id)s %(message)s", 85 | "log_colors": { 86 | "DEBUG": "purple", 87 | "INFO": "green", 88 | "WARNING": "yellow", 89 | "ERROR": "red", 90 | "CRITICAL": "red,bold", 91 | }, 92 | }, 93 | "json_formatter": { 94 | "()": "seedwork.infrastructure.logging.ElkJsonFormatter", 95 | }, 96 | }, 97 | "handlers": { 98 | # console logs to stderr 99 | "console": { 100 | "class": "logging.StreamHandler", 101 | "formatter": "default", 102 | }, 103 | "colored_console": { 104 | "class": "colorlog.StreamHandler", 105 | "formatter": "colored", 106 | }, 107 | "colored_console_db": { 108 | "class": "colorlog.StreamHandler", 109 | "formatter": "colored_db", 110 | }, 111 | "file_handler": { 112 | "class": "logging.handlers.RotatingFileHandler", 113 | "filename": cls.log_filename, 114 | "formatter": "json_formatter", 115 | } 116 | if cls.log_filename 117 | else None, 118 | # Add Handler for Sentry for `warning` and above 119 | # 'sentry': { 120 | # 'level': 'WARNING', 121 | # 'class': 'raven.contrib.django.raven_compat.handlers.SentryHandler', 122 | # }, 123 | }, 124 | "loggers": { 125 | cls.logger_name: { 126 | "level": "DEBUG", 127 | "handlers": ["colored_console", "file_handler"], # , 'sentry'], 128 | }, 129 | # Prevent noisy modules from logging to Sentry 130 | "noisy_module": { 131 | "level": "ERROR", 132 | "handlers": ["console"], 133 | "propagate": False, 134 | }, 135 | }, 136 | } 137 | 138 | dictConfig(logging_config) 139 | logger = logging.getLogger(name=cls.logger_name) 140 | logger.correlation_id = cls.correlation_id 141 | logger.addFilter( 142 | RequestContextFilter( 143 | name=cls.logger_name, correlation_id=cls.correlation_id 144 | ) 145 | ) 146 | return logger 147 | 148 | 149 | """ 150 | We are making logger globally available, but to make it configurable logger lazy-evaluated. 151 | Use `LoggerFactory.configure()` to configure the logger prior to its usage 152 | """ 153 | logger: Logger = SimpleLazyObject(LoggerFactory.create_logger) # type: ignore 154 | -------------------------------------------------------------------------------- /src/modules/bidding/domain/entities.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime, timedelta 3 | from typing import Optional 4 | 5 | from modules.bidding.domain.events import ( 6 | BidWasPlaced, 7 | BidWasRetracted, 8 | HighestBidderWasOutbid, 9 | ListingWasCancelled, 10 | ) 11 | from modules.bidding.domain.rules import ( 12 | BidCanBeRetracted, 13 | ListingCanBeCancelled, 14 | PriceOfPlacedBidMustBeGreaterOrEqualThanNextMinimumPrice, 15 | ) 16 | from modules.bidding.domain.value_objects import Bid, Bidder, Seller 17 | from seedwork.domain.entities import AggregateRoot 18 | from seedwork.domain.events import DomainEvent 19 | from seedwork.domain.exceptions import DomainException 20 | from seedwork.domain.value_objects import GenericUUID, Money 21 | 22 | 23 | class BidderIsNotBiddingListing(DomainException): 24 | ... 25 | 26 | 27 | class BidCannotBeRetracted(DomainException): 28 | ... 29 | 30 | 31 | class ListingCannotBeCancelled(DomainException): 32 | ... 33 | 34 | 35 | @dataclass(kw_only=True) 36 | class Listing(AggregateRoot[GenericUUID]): 37 | seller: Seller 38 | ask_price: Money 39 | starts_at: datetime 40 | ends_at: datetime 41 | bids: list[Bid] = field(default_factory=list) 42 | 43 | # public queries 44 | @property 45 | def current_price(self) -> Money: 46 | """The current price is the price buyers are competing against""" 47 | if len(self.bids) < 2: 48 | return self.ask_price 49 | 50 | sorted_prices = sorted([bid.max_price for bid in self.bids], reverse=True) 51 | return sorted_prices[1] 52 | 53 | @property 54 | def next_minimum_price(self) -> Money: 55 | return self.current_price + Money(1, currency=self.ask_price.currency) 56 | 57 | # public commands 58 | def place_bid(self, bid: Bid): 59 | """Public method""" 60 | self.check_rule( 61 | PriceOfPlacedBidMustBeGreaterOrEqualThanNextMinimumPrice( 62 | current_price=bid.max_price, next_minimum_price=self.next_minimum_price 63 | ) 64 | ) 65 | 66 | previous_winner_id = self.highest_bid.bidder.id if self.highest_bid else None 67 | current_winner_id = bid.bidder.id 68 | 69 | if self.has_bid_placed_by(bidder=bid.bidder): 70 | self._update_bid(bid) 71 | else: 72 | self._add_bid(bid) 73 | 74 | self.register_event( 75 | BidWasPlaced( 76 | listing_id=self.id, 77 | bidder_id=bid.bidder.id, 78 | ) 79 | ) 80 | 81 | # if there was previous winner... 82 | if previous_winner_id is not None and previous_winner_id != current_winner_id: 83 | self.register_event( 84 | HighestBidderWasOutbid( 85 | listing_id=self.id, 86 | outbid_bidder_id=previous_winner_id, 87 | ) 88 | ) 89 | 90 | def retract_bid_of(self, bidder: Bidder): 91 | """Public method""" 92 | bid = self.get_bid_of(bidder) 93 | self.check_rule( 94 | BidCanBeRetracted(listing_ends_at=self.ends_at, bid_placed_at=bid.placed_at) 95 | ) 96 | 97 | self._remove_bid_of(bidder=bidder) 98 | self.register_event( 99 | BidWasRetracted( 100 | listing_id=self.id, 101 | retracting_bidder_id=bidder.id, 102 | winning_bidder_id=self.highest_bid.bidder.id 103 | if self.highest_bid 104 | else None, 105 | ) 106 | ) 107 | 108 | def cancel(self): 109 | """ 110 | Seller can cancel a listing (end a listing early). Listing must be eligible to cancelled, 111 | depending on time left and if bids have been placed. 112 | """ 113 | self.check_rule( 114 | ListingCanBeCancelled( 115 | time_left_in_listing=self.time_left_in_listing, 116 | no_bids_were_placed=len(self.bids) == 0, 117 | ) 118 | ) 119 | self.ends_at = datetime.utcnow() 120 | self.register_event(ListingWasCancelled(listing_id=self.id)) 121 | 122 | def end(self) -> DomainEvent: 123 | """ 124 | Ends listing. 125 | """ 126 | raise NotImplementedError() 127 | 128 | # public queries 129 | def get_bid_of(self, bidder: Bidder) -> Bid: 130 | try: 131 | bid = next(filter(lambda bid: bid.bidder == bidder, self.bids)) 132 | except StopIteration as e: 133 | raise BidderIsNotBiddingListing() from e 134 | return bid 135 | 136 | def has_bid_placed_by(self, bidder: Bidder) -> bool: 137 | """Checks if listing has a bid placed by a bidder""" 138 | try: 139 | self.get_bid_of(bidder=bidder) 140 | except BidderIsNotBiddingListing: 141 | return False 142 | return True 143 | 144 | @property 145 | def highest_bid(self) -> Optional[Bid]: 146 | try: 147 | highest_bid = max(self.bids, key=lambda bid: bid.max_price) 148 | except ValueError: 149 | # nobody is bidding 150 | return None 151 | return highest_bid 152 | 153 | @property 154 | def time_left_in_listing(self): 155 | now = datetime.utcnow() 156 | zero_seconds = timedelta() 157 | return max(self.ends_at - now, zero_seconds) 158 | 159 | # private commands and queries 160 | def _add_bid(self, bid: Bid): 161 | assert not self.has_bid_placed_by( 162 | bidder=bid.bidder 163 | ), "Only one bid of a bidder is allowed" 164 | self.bids.append(bid) 165 | 166 | def _update_bid(self, bid: Bid): 167 | self.bids = [ 168 | bid if bid.bidder == existing.bidder else existing for existing in self.bids 169 | ] 170 | 171 | def _remove_bid_of(self, bidder: Bidder): 172 | self.bids = [bid for bid in self.bids if bid.bidder != bidder] 173 | -------------------------------------------------------------------------------- /src/seedwork/infrastructure/repository.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from seedwork.domain.entities import Entity 6 | from seedwork.domain.events import DomainEvent 7 | from seedwork.domain.exceptions import EntityNotFoundException 8 | from seedwork.domain.repositories import GenericRepository 9 | from seedwork.domain.value_objects import GenericUUID 10 | from seedwork.infrastructure.data_mapper import DataMapper 11 | from seedwork.infrastructure.database import Base 12 | 13 | 14 | class InMemoryRepository(GenericRepository[GenericUUID, Entity]): 15 | def __init__(self) -> None: 16 | self.objects: dict[Any, Any] = {} 17 | 18 | def get_by_id(self, entity_id: GenericUUID) -> Entity: 19 | try: 20 | return self.objects[entity_id] 21 | except KeyError: 22 | raise EntityNotFoundException(repository=self, entity_id=entity_id) 23 | 24 | def remove_by_id(self, entity_id: GenericUUID): 25 | try: 26 | del self.objects[entity_id] 27 | except KeyError: 28 | raise EntityNotFoundException(repository=self, entity_id=entity_id) 29 | 30 | def add(self, entity: Entity): 31 | assert issubclass(entity.__class__, Entity) 32 | self.objects[entity.id] = entity 33 | 34 | def remove(self, entity: Entity): 35 | del self.objects[entity.id] 36 | 37 | def count(self): 38 | return len(self.objects) 39 | 40 | def persist(self, entity: Entity): 41 | ... 42 | 43 | def persist_all(self): 44 | ... 45 | 46 | def collect_events(self) -> list[DomainEvent]: 47 | events = [] 48 | for entity in self.objects.values(): 49 | events.extend(entity.collect_events()) 50 | return events 51 | 52 | 53 | # a sentinel value for keeping track of entities removed from the repository 54 | class Removed: 55 | def __repr__(self): 56 | return "" 57 | 58 | def __str__(self): 59 | return "" 60 | 61 | 62 | REMOVED = Removed() 63 | 64 | 65 | class SqlAlchemyGenericRepository(GenericRepository[GenericUUID, Entity]): 66 | mapper_class: type[DataMapper[Entity, Base]] 67 | model_class: type[Entity] 68 | 69 | def __init__(self, db_session: Session, identity_map=None): 70 | self._session = db_session 71 | self._identity_map = identity_map or dict() 72 | 73 | def add(self, entity: Entity): 74 | self._identity_map[entity.id] = entity 75 | instance = self.map_entity_to_model(entity) 76 | self._session.add(instance) 77 | 78 | def remove(self, entity: Entity): 79 | self._check_not_removed(entity.id) 80 | self._identity_map[entity.id] = REMOVED 81 | instance = self._session.query(self.get_model_class()).get(entity.id) 82 | self._session.delete(instance) 83 | 84 | def remove_by_id(self, entity_id: GenericUUID): 85 | self._check_not_removed(entity_id) 86 | self._identity_map[entity_id] = REMOVED 87 | instance = self._session.query(self.get_model_class()).get(entity_id) 88 | if instance is None: 89 | raise EntityNotFoundException(repository=self, entity_id=entity_id) 90 | self._session.delete(instance) 91 | 92 | def get_by_id(self, entity_id: GenericUUID): 93 | instance = self._session.query(self.get_model_class()).get(entity_id) 94 | if instance is None: 95 | raise EntityNotFoundException(repository=self, entity_id=entity_id) 96 | return self._get_entity(instance) 97 | 98 | def persist(self, entity: Entity): 99 | """ 100 | Persists all the changes made to the entity. 101 | Basically, entity is mapped to a model instance using a data_mapper, and then added to sqlalchemy session. 102 | """ 103 | self._check_not_removed(entity.id) 104 | assert ( 105 | entity.id in self._identity_map 106 | ), "Cannon persist entity which is unknown to the repo. Did you forget to call repo.add() for this entity?" 107 | instance = self.map_entity_to_model(entity) 108 | merged = self._session.merge(instance) 109 | self._session.add(merged) 110 | 111 | def persist_all(self): 112 | """Persists all changes made to entities known to the repository (present in the identity map).""" 113 | for entity in self._identity_map.values(): 114 | if entity is not REMOVED: 115 | self.persist(entity) 116 | 117 | def collect_events(self): 118 | """Collects all events from entities known to the repository (present in the identity map).""" 119 | events = [] 120 | for entity in self._identity_map.values(): 121 | if entity is not REMOVED: 122 | events.extend(entity.collect_events()) 123 | return events 124 | 125 | @property 126 | def data_mapper(self): 127 | return self.mapper_class() 128 | 129 | def count(self) -> int: 130 | return self._session.query(self.model_class).count() 131 | 132 | def map_entity_to_model(self, entity: Entity): 133 | assert self.mapper_class, ( 134 | f"No data_mapper attribute in {self.__class__.__name__}. " 135 | "Make sure to include `mapper_class = MyDataMapper` in the Repository class." 136 | ) 137 | 138 | return self.data_mapper.entity_to_model(entity) 139 | 140 | def map_model_to_entity(self, instance) -> Entity: 141 | assert self.data_mapper 142 | return self.data_mapper.model_to_entity(instance) 143 | 144 | def get_model_class(self): 145 | assert self.model_class is not None, ( 146 | f"No model_class attribute in in {self.__class__.__name__}. " 147 | "Make sure to include `model_class = MyModel` in the class." 148 | ) 149 | return self.model_class 150 | 151 | def _get_entity(self, instance): 152 | if instance is None: 153 | return None 154 | entity = self.map_model_to_entity(instance) 155 | self._check_not_removed(entity.id) 156 | 157 | if entity.id in self._identity_map: 158 | return self._identity_map[entity.id] 159 | 160 | self._identity_map[entity.id] = entity 161 | return entity 162 | 163 | def _check_not_removed(self, entity_id): 164 | assert ( 165 | self._identity_map.get(entity_id, None) is not REMOVED 166 | ), f"Entity {entity_id} already removed" 167 | -------------------------------------------------------------------------------- /src/modules/bidding/tests/domain/test_bidding.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | 5 | from modules.bidding.domain.entities import Listing 6 | from modules.bidding.domain.value_objects import Bid, Bidder, Money, Seller 7 | from seedwork.domain.exceptions import BusinessRuleValidationException 8 | from seedwork.domain.value_objects import GenericUUID 9 | 10 | 11 | @pytest.mark.unit 12 | def test_listing_initial_price(): 13 | seller = Seller(id=GenericUUID.next_id()) 14 | listing = Listing( 15 | id=Listing.next_id(), 16 | seller=seller, 17 | ask_price=Money(10), 18 | starts_at=datetime.utcnow(), 19 | ends_at=datetime.utcnow(), 20 | ) 21 | assert listing.highest_bid is None 22 | 23 | 24 | @pytest.mark.unit 25 | def test_place_one_bid(): 26 | now = datetime.utcnow() 27 | seller = Seller(id=GenericUUID.next_id()) 28 | bidder = Bidder(id=GenericUUID.next_id()) 29 | bid = Bid(max_price=Money(20), bidder=bidder, placed_at=now) 30 | listing = Listing( 31 | id=Listing.next_id(), 32 | seller=seller, 33 | ask_price=Money(10), 34 | starts_at=datetime.utcnow(), 35 | ends_at=datetime.utcnow(), 36 | ) 37 | listing.place_bid(bid) 38 | assert listing.highest_bid == Bid(max_price=Money(20), bidder=bidder, placed_at=now) 39 | assert listing.current_price == Money(10) 40 | 41 | 42 | @pytest.mark.unit 43 | def test_place_two_bids_second_buyer_outbids(): 44 | now = datetime.utcnow() 45 | seller = Seller(id=GenericUUID(int=1)) 46 | bidder1 = Bidder(id=GenericUUID(int=2)) 47 | bidder2 = Bidder(id=GenericUUID(int=3)) 48 | listing = Listing( 49 | id=GenericUUID(int=4), 50 | seller=seller, 51 | ask_price=Money(10), 52 | starts_at=datetime.utcnow(), 53 | ends_at=datetime.utcnow(), 54 | ) 55 | assert listing.current_price == Money(10) 56 | assert listing.next_minimum_price == Money(11) 57 | 58 | # bidder1 places a bid 59 | listing.place_bid(Bid(bidder=bidder1, max_price=Money(15), placed_at=now)) 60 | assert listing.current_price == Money(10) 61 | assert listing.next_minimum_price == Money(11) 62 | 63 | # bidder2 successfully outbids bidder1 64 | listing.place_bid(Bid(bidder=bidder2, max_price=Money(30), placed_at=now)) 65 | assert listing.current_price == Money(15) 66 | assert listing.next_minimum_price == Money(16) 67 | assert listing.highest_bid == Bid(Money(30), bidder=bidder2, placed_at=now) 68 | 69 | 70 | @pytest.mark.unit 71 | def test_place_two_bids_second_buyer_fails_to_outbid(): 72 | now = datetime.utcnow() 73 | seller = Seller(id=GenericUUID(int=1)) 74 | bidder1 = Bidder(id=GenericUUID(int=2)) 75 | bidder2 = Bidder(id=GenericUUID(int=3)) 76 | listing = Listing( 77 | id=GenericUUID(int=4), 78 | seller=seller, 79 | ask_price=Money(10), 80 | starts_at=datetime.utcnow(), 81 | ends_at=datetime.utcnow(), 82 | ) 83 | 84 | # bidder1 places a bid 85 | listing.place_bid(Bid(bidder=bidder1, max_price=Money(30), placed_at=now)) 86 | assert listing.current_price == Money(10) 87 | assert listing.next_minimum_price == Money(11) 88 | 89 | # bidder2 tries to outbid bidder1... 90 | listing.place_bid(Bid(bidder=bidder2, max_price=Money(20), placed_at=now)) 91 | 92 | # ...but he fails. bidder1 is still a winner, but current price changes 93 | assert listing.highest_bid == Bid(Money(30), bidder=bidder1, placed_at=now) 94 | assert listing.current_price == Money(20) 95 | 96 | 97 | @pytest.mark.unit 98 | def test_place_two_bids_second_buyer_fails_to_outbid_with_same_amount(): 99 | now = datetime.utcnow() 100 | seller = Seller(id=GenericUUID(int=1)) 101 | bidder1 = Bidder(id=GenericUUID(int=2)) 102 | bidder2 = Bidder(id=GenericUUID(int=3)) 103 | listing = Listing( 104 | id=GenericUUID(int=4), 105 | seller=seller, 106 | ask_price=Money(10), 107 | starts_at=datetime.utcnow(), 108 | ends_at=datetime.utcnow(), 109 | ) 110 | listing.place_bid(Bid(bidder=bidder1, max_price=Money(30), placed_at=now)) 111 | listing.place_bid(Bid(bidder=bidder2, max_price=Money(30), placed_at=now)) 112 | assert listing.highest_bid == Bid(Money(30), bidder=bidder1, placed_at=now) 113 | assert listing.current_price == Money(30) 114 | 115 | 116 | @pytest.mark.unit 117 | def test_place_two_bids_by_same_bidder(): 118 | now = datetime.utcnow() 119 | seller = Seller(id=GenericUUID.next_id()) 120 | bidder = Bidder(id=GenericUUID.next_id()) 121 | listing = Listing( 122 | id=Listing.next_id(), 123 | seller=seller, 124 | ask_price=Money(10), 125 | starts_at=datetime.utcnow(), 126 | ends_at=datetime.utcnow(), 127 | ) 128 | listing.place_bid(Bid(max_price=Money(20), bidder=bidder, placed_at=now)) 129 | listing.place_bid(Bid(max_price=Money(30), bidder=bidder, placed_at=now)) 130 | 131 | assert len(listing.bids) == 1 132 | assert listing.highest_bid == Bid(max_price=Money(30), bidder=bidder, placed_at=now) 133 | assert listing.current_price == Money(10) 134 | 135 | 136 | @pytest.mark.unit 137 | def test_cannot_place_bid_if_listing_ended(): 138 | seller = Seller(id=GenericUUID.next_id()) 139 | bidder = Bidder(id=GenericUUID.next_id()) 140 | listing = Listing( 141 | id=Listing.next_id(), 142 | seller=seller, 143 | ask_price=Money(10), 144 | starts_at=datetime.utcnow(), 145 | ends_at=datetime.utcnow(), 146 | ) 147 | bid = Bid( 148 | max_price=Money(10), 149 | bidder=bidder, 150 | placed_at=datetime.utcnow() + timedelta(seconds=1), 151 | ) 152 | with pytest.raises( 153 | BusinessRuleValidationException, 154 | match="PriceOfPlacedBidMustBeGreaterOrEqualThanNextMinimumPrice", 155 | ): 156 | listing.place_bid(bid) 157 | 158 | 159 | @pytest.mark.unit 160 | def test_retract_bid(): 161 | seller = Seller(id=GenericUUID.next_id()) 162 | bidder = Bidder(id=GenericUUID.next_id()) 163 | listing = Listing( 164 | id=Listing.next_id(), 165 | seller=seller, 166 | ask_price=Money(10), 167 | starts_at=datetime.utcnow(), 168 | ends_at=datetime.utcnow(), 169 | ) 170 | bid = Bid( 171 | max_price=Money(100), 172 | bidder=bidder, 173 | placed_at=datetime.utcnow() - timedelta(seconds=1), 174 | ) 175 | listing.place_bid(bid) 176 | with pytest.raises(BusinessRuleValidationException, match="BidCanBeRetracted"): 177 | listing.retract_bid_of(bidder=bidder) 178 | 179 | 180 | @pytest.mark.unit 181 | def test_cancel_listing(): 182 | now = datetime.utcnow() 183 | seller = Seller(id=GenericUUID.next_id()) 184 | listing = Listing( 185 | id=Listing.next_id(), 186 | seller=seller, 187 | ask_price=Money(10), 188 | starts_at=now, 189 | ends_at=now + timedelta(days=10), 190 | ) 191 | 192 | listing.cancel() 193 | 194 | assert listing.time_left_in_listing == timedelta() 195 | 196 | 197 | @pytest.mark.unit 198 | def test_can_cancel_listing_with_bids(): 199 | now = datetime.utcnow() 200 | seller = Seller(id=GenericUUID.next_id()) 201 | bidder = Bidder(id=GenericUUID.next_id()) 202 | listing = Listing( 203 | id=Listing.next_id(), 204 | seller=seller, 205 | ask_price=Money(10), 206 | starts_at=now, 207 | ends_at=now + timedelta(days=10), 208 | ) 209 | bid = Bid( 210 | max_price=Money(100), 211 | bidder=bidder, 212 | placed_at=now, 213 | ) 214 | listing.place_bid(bid) 215 | 216 | listing.cancel() 217 | 218 | assert listing.time_left_in_listing == timedelta() 219 | 220 | 221 | @pytest.mark.unit 222 | def test_cannot_cancel_listing_with_bids(): 223 | now = datetime.utcnow() 224 | seller = Seller(id=GenericUUID.next_id()) 225 | bidder = Bidder(id=GenericUUID.next_id()) 226 | listing = Listing( 227 | id=Listing.next_id(), 228 | seller=seller, 229 | ask_price=Money(10), 230 | starts_at=now, 231 | ends_at=now + timedelta(hours=1), 232 | ) 233 | bid = Bid( 234 | max_price=Money(100), 235 | bidder=bidder, 236 | placed_at=now, 237 | ) 238 | listing.place_bid(bid) 239 | 240 | with pytest.raises(BusinessRuleValidationException, match="ListingCanBeCancelled"): 241 | listing.cancel() 242 | --------------------------------------------------------------------------------