├── .github └── workflows │ └── python-app.yml ├── Dockerfile ├── README.md ├── auctioning_platform ├── .gitignore ├── Makefile ├── config.ini ├── itca │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ └── auctions │ │ │ ├── __init__.py │ │ │ ├── auction.py │ │ │ ├── bid.py │ │ │ └── blueprint.py │ ├── auctions │ │ ├── __init__.py │ │ ├── app │ │ │ ├── __init__.py │ │ │ ├── ports │ │ │ │ ├── __init__.py │ │ │ │ └── payments.py │ │ │ ├── queries │ │ │ │ ├── __init__.py │ │ │ │ └── auction_details.py │ │ │ ├── repositories │ │ │ │ ├── __init__.py │ │ │ │ ├── auctions.py │ │ │ │ └── auctions_descriptors.py │ │ │ └── use_cases │ │ │ │ ├── __init__.py │ │ │ │ ├── finalizing_auction.py │ │ │ │ ├── placing_bid.py │ │ │ │ └── starting_auction.py │ │ └── domain │ │ │ ├── __init__.py │ │ │ ├── entities │ │ │ ├── __init__.py │ │ │ ├── auction.py │ │ │ └── auction_descriptor.py │ │ │ ├── events │ │ │ ├── __init__.py │ │ │ ├── auction_ended.py │ │ │ └── bidder_has_been_overbid.py │ │ │ ├── exceptions │ │ │ ├── __init__.py │ │ │ └── bid_on_ended_auction.py │ │ │ └── value_objects │ │ │ ├── __init__.py │ │ │ ├── auction_id.py │ │ │ ├── bid_id.py │ │ │ └── bidder_id.py │ ├── auctions_infra │ │ ├── __init__.py │ │ ├── adapters │ │ │ ├── __init__.py │ │ │ └── payments.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── auction.py │ │ │ ├── auction_descriptor.py │ │ │ └── bid.py │ │ ├── queries │ │ │ ├── __init__.py │ │ │ └── auction_details.py │ │ ├── read_models │ │ │ ├── __init__.py │ │ │ └── auction_details.py │ │ └── repositories │ │ │ ├── __init__.py │ │ │ ├── auctions.py │ │ │ └── auctions_descriptors.py │ ├── customer_relationship │ │ ├── __init__.py │ │ └── facade.py │ ├── db │ │ ├── __init__.py │ │ ├── base.py │ │ ├── guid.py │ │ ├── jsonb.py │ │ └── migrations │ │ │ ├── env.py │ │ │ ├── script.py.mako │ │ │ └── versions │ │ │ ├── 2743a62623ef_add_table_for_paying_for_won_item_.py │ │ │ ├── 4810e499a4f1_add_payments_table.py │ │ │ ├── 4de7a165a4d2_create_auction_descriptor.py │ │ │ ├── 7ee5422d729d_create_view_for_auction_read_model.py │ │ │ ├── 83b8b82bd255_add_tables_for_event_sourcing_snapshots.py │ │ │ ├── 873d26933b37_add_current_price_to_auction_model.py │ │ │ ├── aa67d02455c9_create_auction_and_bid_models.py │ │ │ ├── c1523f4b8ca8_create_model_for_outbox.py │ │ │ └── f76a3a08cc3b_add_tables_for_event_sourcing_models.py │ ├── event_sourcing │ │ ├── __init__.py │ │ ├── aggregate_changes.py │ │ ├── event.py │ │ ├── event_store.py │ │ ├── event_stream.py │ │ ├── models.py │ │ ├── projection.py │ │ └── sqlalchemy_event_store.py │ ├── foundation │ │ ├── __init__.py │ │ ├── domain_exception.py │ │ ├── event.py │ │ ├── event_bus.py │ │ ├── money.py │ │ ├── serde.py │ │ └── unit_of_work.py │ ├── main │ │ ├── __init__.py │ │ └── event_bus.py │ ├── payments │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ ├── consumer.py │ │ │ ├── exceptions.py │ │ │ ├── requests.py │ │ │ └── responses.py │ │ ├── config.py │ │ ├── dao.py │ │ ├── events │ │ │ ├── __init__.py │ │ │ ├── payment_captured.py │ │ │ ├── payment_charged.py │ │ │ ├── payment_failed.py │ │ │ ├── payment_overdue.py │ │ │ └── payment_started.py │ │ ├── facade.py │ │ └── models.py │ ├── processes │ │ ├── __init__.py │ │ ├── exceptions.py │ │ ├── locking.py │ │ └── paying_for_won_auction │ │ │ ├── __init__.py │ │ │ ├── process_manager.py │ │ │ ├── repository.py │ │ │ ├── sql_alchemy_repository.py │ │ │ └── state.py │ ├── shipping │ │ ├── __init__.py │ │ ├── app │ │ │ ├── __init__.py │ │ │ └── repositories │ │ │ │ ├── __init__.py │ │ │ │ └── orders.py │ │ └── domain │ │ │ ├── __init__.py │ │ │ ├── aggregates │ │ │ ├── __init__.py │ │ │ └── order.py │ │ │ ├── entities │ │ │ ├── __init__.py │ │ │ └── order.py │ │ │ ├── events │ │ │ ├── __init__.py │ │ │ └── consignment_shipped.py │ │ │ └── value_objects │ │ │ ├── __init__.py │ │ │ ├── customer_id.py │ │ │ ├── order_id.py │ │ │ └── product_id.py │ ├── shipping_infra │ │ ├── __init__.py │ │ ├── projections │ │ │ ├── __init__.py │ │ │ └── order_summary.py │ │ └── repositories │ │ │ ├── __init__.py │ │ │ └── orders.py │ └── tasks │ │ ├── __init__.py │ │ ├── app.py │ │ ├── celery_injector.py │ │ ├── cli.py │ │ └── outbox │ │ ├── __init__.py │ │ ├── model.py │ │ └── tasks.py ├── poetry.lock ├── pyproject.toml ├── setup.cfg ├── test_config.ini └── tests │ ├── __init__.py │ ├── auctions │ ├── __init__.py │ ├── acceptance │ │ ├── __init__.py │ │ └── test_placing_bid.py │ ├── app │ │ ├── __init__.py │ │ └── use_cases │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ ├── test_finalizing_auction.py │ │ │ └── test_placing_bid.py │ ├── domain │ │ ├── __init__.py │ │ └── entities │ │ │ ├── __init__.py │ │ │ └── test_auction.py │ └── factories.py │ ├── auctions_infra │ ├── __init__.py │ ├── adapters │ │ ├── __init__.py │ │ ├── bripe_failure.yml │ │ ├── bripe_success.yml │ │ └── test_payments.py │ ├── conftest.py │ └── repositories │ │ ├── __init__.py │ │ └── test_auctions.py │ ├── conftest.py │ ├── doubles │ ├── __init__.py │ ├── in_memory_auctions_repo.py │ └── test_in_memory_auctions_repo.py │ ├── payments │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── bripe_charge_then_capture.yml │ │ └── test_consumer.py │ └── test_facade.py │ ├── processes │ ├── __init__.py │ └── paying_for_won_auction │ │ ├── __init__.py │ │ └── test_process_manager.py │ └── shipping │ ├── __init__.py │ ├── domain │ ├── __init__.py │ └── aggregates │ │ ├── __init__.py │ │ └── test_order.py │ └── infra │ ├── __init__.py │ └── test_persistence.py ├── diagrams ├── 10_1_test_pyramid.drawio ├── 10_1_test_pyramid.drawio.png ├── 10_2_test_pyramid.drawio ├── 10_2_test_pyramid.drawio.png ├── 10_3_ice_cream_cone.drawio ├── 10_3_ice_cream_cone.drawio.png ├── 10_4_kite.drawio ├── 10_4_kite.drawio.png ├── 2_1_diagram.drawio ├── 2_1_diagram.drawio.png ├── 3_1_ref_sequence.puml ├── 3_1_ref_sequence2.png ├── 3_1_referencyjna.png ├── 3_2_ref_sequence.png ├── 4_1_queries_ref_sequence.png ├── 4_1_queries_ref_sequence.puml ├── 6_1_separate_stacks.drawio ├── 6_1_separate_stacks.drawio.png ├── 6_2_separate_stacks_separate_database.drawio ├── 6_2_separate_stacks_separate_database.drawio.png ├── 6_3_separate_stacks_no_queries.drawio ├── 6_3_separate_stacks_no_queries.drawio.png ├── 8_1_payment_sequence.png ├── 8_1_payment_sequence.puml ├── 8_2_using_stored_token.png ├── 8_2_using_stored_token.puml ├── 8_3_sender_event_bus_listener.png ├── 8_3_sender_event_bus_listener.puml ├── 8_4_unit_of_work.png ├── 8_4_unit_of_work.puml ├── 9_1_modularity.drawio ├── 9_1_modularity.drawio.png ├── 9_2_modularity.drawio ├── 9_2_modularity.drawio.png ├── 9_3_modularity.drawio ├── 9_3_modularity.drawio.png ├── 9_4_components_in_app.drawio ├── 9_4_components_in_app.drawio.png └── Reference_diagram_circle.drawio └── extra ├── bripe_fake.py ├── classical_mapping.py ├── config.py ├── cqrs.py ├── db.sqlite ├── declarative_mapping_with_dataclasses.py ├── event_bus_draft.py ├── events_example.py ├── use_case_reads.py └── white_box_testing.py /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | name: Implementing The Clean Architecture checks 2 | 3 | on: 4 | push: 5 | branches: [ trunk ] 6 | pull_request: 7 | branches: [ trunk ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | defaults: 14 | run: 15 | working-directory: ./auctioning_platform 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python 3.9 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.9 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip poetry 26 | poetry install 27 | - name: Run linters 28 | run: | 29 | poetry run make check 30 | - name: Test with pytest 31 | run: | 32 | poetry run pytest 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | 3 | RUN mkdir /app 4 | WORKDIR /app 5 | COPY requirements.txt /app/ 6 | 7 | RUN pip install -r requirements.txt 8 | 9 | ENV FLASK_APP=itca.api:app 10 | ENTRYPOINT ["flask", "run", "-h", "0.0.0.0"] 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Implementing the Clean Architecture 2 | This repository is meant to be a reference of many useful patterns when one implements projects using the Clean Architecture or Domain-Driven Design. 3 | 4 | It also happens to be an example project for my book under the same title. 5 | 6 | EN [Implementing the Clean Architecture @ Leanpub](https://leanpub.com/implementing-the-clean-architecture) 7 | 8 | PL (also printed!) [Implementowanie Czystej Architektury w Pythonie](https://helion.pl/ksiazki/implementowanie-czystej-architektury-w-pythonie-sebastian-buczynski,imczar.htm#format/d) 9 | 10 | ## Let's talk about it on Discord 11 | [![Join our Discord server!](https://invidget.switchblade.xyz/cDyDKv2VsY)](http://discord.gg/cDyDKv2VsY) 12 | 13 | # Table of contents 14 | - [Diagrams](#diagrams) (coming soon) 15 | - Big Picture Event Storming 16 | - Context Map 17 | - C4 18 | - [Tactical Patterns](#tactical-patterns) (coming soon) 19 | - [Components Integration Patterns](#components-integration-patterns) (coming soon) 20 | - [Using with popular tools](#using-with-popular-tools) (more coming soon) 21 | 22 | # Diagrams 23 | Coming soon 24 | 25 | # Tactical Patterns 26 | Coming soon 27 | 28 | # Components Integration Patterns 29 | ## Dependency Injection 30 | - [Assemble components to configure IoC Container (main)](https://github.com/Enforcer/implementing-the-clean-architecture/blob/trunk/auctioning_platform/itca/main/__init__.py#L18) 31 | - Nesting Injector's modules - [nested](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/processes/paying_for_won_auction/__init__.py#L21) and [component-level one](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/processes/__init__.py#L10) that's included in top-level's assemble 32 | 33 | ## Integration via Direct component's API call 34 | [e.g. Process Manager directly calls Customer Relationship Facade](https://github.com/Enforcer/implementing-the-clean-architecture/blob/trunk/auctioning_platform/itca/processes/paying_for_won_auction/process_manager.py#L48) 35 | 36 | ## Integration via Port / Adapter 37 | - [Port in one component](https://github.com/Enforcer/implementing-the-clean-architecture/blob/trunk/auctioning_platform/itca/auctions/app/ports/payments.py) 38 | - [Adapter in the other](https://github.com/Enforcer/implementing-the-clean-architecture/blob/trunk/auctioning_platform/itca/auctions_infra/adapters/payments.py) 39 | 40 | ## Integration via Events 41 | ### Groundwork 42 | - [Event Bus (mediator) interface](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/foundation/event_bus.py#L11) 43 | - [Injector-based Event Bus](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/foundation/event_bus.py#L65) 44 | - [Handling of synchronous listeners](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/foundation/event_bus.py#L79) 45 | - [Handling of asynchronous listeners](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/foundation/event_bus.py#L93) 46 | - [Interface for a function used to run listener asynchronously](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/foundation/event_bus.py#L61) 47 | - [Implementation of function used to run listeners asynchronously](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/main/event_bus.py#L19) 48 | 49 | ### Example 50 | - One component [defines an Event](https://github.com/Enforcer/implementing-the-clean-architecture/blob/trunk/auctioning_platform/itca/auctions/domain/events/bidder_has_been_overbid.py) and [publishes it](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/auctions/app/use_cases/placing_bid.py#L59) 51 | - [Another component subscribes to it using DI](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/customer_relationship/__init__.py#L34) 52 | - [Another component handles the Event](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/customer_relationship/__init__.py#L18) 53 | 54 | ## Integration via Process Manager 55 | - [Process Manager itself (multilistener with keeping state)](https://github.com/Enforcer/implementing-the-clean-architecture/blob/trunk/auctioning_platform/itca/processes/paying_for_won_auction/process_manager.py) 56 | - [Subscriptions to Events using DI](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/processes/paying_for_won_auction/__init__.py#L22) 57 | - [Handling Events using singledispatchmethod](https://github.com/Enforcer/implementing-the-clean-architecture/blob/6e1c28b51ddde9d55944c8d52397806299e38099/auctioning_platform/itca/processes/paying_for_won_auction/process_manager.py#L28) 58 | 59 | # Using with popular tools 60 | ## Celery 61 | - [Creating instance as usual after building IoC Container, then setting it on an attribute](https://github.com/Enforcer/implementing-the-clean-architecture/blob/trunk/auctioning_platform/itca/tasks/cli.py) 62 | - Custom integration of Celery and Injector (coming soon) 63 | 64 | ## More coming soon 65 | -------------------------------------------------------------------------------- /auctioning_platform/.gitignore: -------------------------------------------------------------------------------- 1 | database.sqlite 2 | 3 | -------------------------------------------------------------------------------- /auctioning_platform/Makefile: -------------------------------------------------------------------------------- 1 | 2 | .PHONY: lint 3 | lint: 4 | isort itca tests 5 | black itca tests 6 | flake8 itca tests 7 | mypy --ignore-missing-imports itca tests 8 | 9 | .PHONY: check 10 | check: 11 | isort --check itca tests 12 | black --check itca tests 13 | flake8 itca tests 14 | mypy --ignore-missing-imports itca tests 15 | 16 | -------------------------------------------------------------------------------- /auctioning_platform/config.ini: -------------------------------------------------------------------------------- 1 | [database] 2 | url = sqlite:///database.sqlite 3 | 4 | [redis] 5 | url = redis://localhost:6379/0 6 | 7 | [bripe] 8 | username = test 9 | password = test 10 | 11 | [alembic] 12 | script_location = itca/db/migrations 13 | prepend_sys_path = . 14 | version_path_separator = os 15 | 16 | [post_write_hooks] 17 | hooks=isort,black 18 | 19 | isort.type=console_scripts 20 | isort.entrypoint=isort 21 | 22 | black.type=console_scripts 23 | black.entrypoint=black 24 | 25 | -------------------------------------------------------------------------------- /auctioning_platform/itca/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/api/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import Flask 4 | from flask_injector import FlaskInjector 5 | 6 | from itca.api.auctions import AuctionsApi 7 | from itca.api.auctions.blueprint import auctions_blueprint 8 | from itca.main import assemble 9 | 10 | 11 | def create_app() -> Flask: 12 | app = Flask(__name__) 13 | app.register_blueprint(auctions_blueprint, url_prefix="/auctions") 14 | 15 | config_path = os.environ.get("CONFIG_PATH", "config.ini") 16 | FlaskInjector( 17 | app, 18 | modules=[AuctionsApi()], 19 | injector=assemble(config_path=config_path), 20 | ) 21 | 22 | return app 23 | -------------------------------------------------------------------------------- /auctioning_platform/itca/api/auctions/__init__.py: -------------------------------------------------------------------------------- 1 | import flask_injector 2 | import injector 3 | 4 | from itca.api.auctions.auction import auction # noqa: F401 5 | from itca.api.auctions.bid import PlacingBidWebPresenter 6 | from itca.auctions import PlacingBidOutputBoundary 7 | 8 | 9 | class AuctionsApi(injector.Module): 10 | @flask_injector.request 11 | @injector.provider 12 | def placing_bid_output_boundary(self) -> PlacingBidOutputBoundary: 13 | return PlacingBidWebPresenter() 14 | -------------------------------------------------------------------------------- /auctioning_platform/itca/api/auctions/auction.py: -------------------------------------------------------------------------------- 1 | from flask import Response, jsonify 2 | from sqlalchemy import select 3 | from sqlalchemy.orm import Session 4 | 5 | from itca.api.auctions.blueprint import auctions_blueprint 6 | from itca.auctions import AuctionDetails 7 | from itca.auctions_infra import auction_read_model 8 | from itca.foundation.serde import converter 9 | 10 | 11 | @auctions_blueprint.get("/") 12 | def auction( 13 | auction_id: int, 14 | auction_details: AuctionDetails, 15 | ) -> Response: 16 | data = auction_details.query(auction_id=auction_id) 17 | return jsonify(converter.unstructure(data)) 18 | 19 | 20 | @auctions_blueprint.get("/read_model/") 21 | def auction_via_read_model( 22 | auction_id: int, 23 | session: Session, 24 | ) -> Response: 25 | stmt = select(auction_read_model).filter( 26 | auction_read_model.c.id == auction_id 27 | ) 28 | row = session.execute(stmt).first() 29 | return jsonify(dict(row)) 30 | -------------------------------------------------------------------------------- /auctioning_platform/itca/api/auctions/bid.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from flask import Response, abort, jsonify, make_response, request 4 | 5 | from itca.api.auctions.blueprint import auctions_blueprint 6 | from itca.auctions import ( 7 | PlacingBid, 8 | PlacingBidInputDto, 9 | PlacingBidOutputBoundary, 10 | PlacingBidOutputDto, 11 | ) 12 | from itca.foundation.serde import converter 13 | 14 | 15 | class current_user: 16 | """Little fake until Flask-Login is in place""" 17 | 18 | is_authenticated: bool = True 19 | id = 44 20 | 21 | 22 | class PlacingBidWebPresenter(PlacingBidOutputBoundary): 23 | _response: Response 24 | 25 | def present(self, output_dto: PlacingBidOutputDto) -> None: 26 | if output_dto.is_winning: 27 | message = "Hooray! You are a winner" 28 | else: 29 | message = ( 30 | f"Your bid is too low. " 31 | f"Current price is {output_dto.current_price}" 32 | ) 33 | self._response = make_response(jsonify({"message": message})) 34 | 35 | def get_presented_value(self) -> Any: 36 | return self._response 37 | 38 | 39 | @auctions_blueprint.post("//bids") 40 | def place_bid( 41 | auction_id: int, 42 | placing_bid_uc: PlacingBid, 43 | presenter: PlacingBidOutputBoundary, 44 | ) -> Response: 45 | if not current_user.is_authenticated: 46 | abort(403) 47 | 48 | input_dto = converter.structure( 49 | { 50 | **request.json, # type: ignore 51 | **{"auction_id": auction_id, "bidder_id": current_user.id}, 52 | }, 53 | PlacingBidInputDto, 54 | ) 55 | placing_bid_uc.execute(input_dto) 56 | return presenter.get_presented_value() 57 | -------------------------------------------------------------------------------- /auctioning_platform/itca/api/auctions/blueprint.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | auctions_blueprint = Blueprint("auctions", __name__) 4 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/__init__.py: -------------------------------------------------------------------------------- 1 | import injector 2 | 3 | from itca.auctions.app.queries.auction_details import ( 4 | AuctionDetails, 5 | AuctionDetailsDto, 6 | ) 7 | from itca.auctions.app.repositories.auctions import AuctionsRepository 8 | from itca.auctions.app.repositories.auctions_descriptors import ( 9 | AuctionsDescriptorsRepository, 10 | ) 11 | from itca.auctions.app.use_cases.finalizing_auction import ( 12 | FinalizingAuction, 13 | FinalizingAuctionInputDto, 14 | ) 15 | from itca.auctions.app.use_cases.placing_bid import ( 16 | PlacingBid, 17 | PlacingBidInputDto, 18 | PlacingBidOutputBoundary, 19 | PlacingBidOutputDto, 20 | ) 21 | from itca.auctions.app.use_cases.starting_auction import ( 22 | StartingAuction, 23 | StartingAuctionInputDto, 24 | ) 25 | from itca.auctions.domain.events.auction_ended import AuctionEnded 26 | from itca.auctions.domain.events.bidder_has_been_overbid import ( 27 | BidderHasBeenOverbid, 28 | ) 29 | from itca.auctions.domain.value_objects.auction_id import AuctionId 30 | from itca.foundation.event_bus import EventBus 31 | 32 | __all__ = [ 33 | # Module 34 | "Auctions", 35 | # Use Cases 36 | "PlacingBid", 37 | "FinalizingAuction", 38 | "StartingAuction", 39 | # Queries 40 | "AuctionDetails", 41 | # DTOs 42 | "PlacingBidInputDto", 43 | "PlacingBidOutputDto", 44 | "AuctionDetailsDto", 45 | "FinalizingAuctionInputDto", 46 | "StartingAuctionInputDto", 47 | # Output Boundaries 48 | "PlacingBidOutputBoundary", 49 | # Repositories 50 | "AuctionsRepository", 51 | "AuctionsDescriptorsRepository", 52 | # Types 53 | "AuctionId", 54 | # Events 55 | "BidderHasBeenOverbid", 56 | "AuctionEnded", 57 | ] 58 | 59 | 60 | class Auctions(injector.Module): 61 | @injector.provider 62 | def placing_bid( 63 | self, 64 | output_boundary: PlacingBidOutputBoundary, 65 | auctions_repo: AuctionsRepository, 66 | ) -> PlacingBid: 67 | return PlacingBid( 68 | output_boundary=output_boundary, 69 | auctions_repo=auctions_repo, 70 | event_bus=EventBus(), 71 | ) 72 | 73 | @injector.provider 74 | def starting_auction( 75 | self, 76 | auctions_repo: AuctionsRepository, 77 | auctions_descriptors_repo: AuctionsDescriptorsRepository, 78 | ) -> StartingAuction: 79 | return StartingAuction( 80 | auctions_repo=auctions_repo, 81 | auctions_descriptors_repo=auctions_descriptors_repo, 82 | ) 83 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions/app/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/ports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions/app/ports/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/ports/payments.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from itca.auctions.domain.value_objects.bidder_id import BidderId 4 | from itca.foundation.money import Money 5 | 6 | 7 | class PaymentFailed(Exception): 8 | pass 9 | 10 | 11 | class NotEnoughFunds(PaymentFailed): 12 | pass 13 | 14 | 15 | class PaymentsTemporarilyUnavailable(PaymentFailed): 16 | pass 17 | 18 | 19 | CardId = int 20 | 21 | 22 | class Payments(abc.ABC): 23 | @abc.abstractmethod 24 | def pay(self, token: str, amount: Money) -> None: 25 | pass 26 | 27 | @abc.abstractmethod 28 | def pay_with_selected_card( 29 | self, bidder_id: BidderId, card_id: CardId, amount: Money 30 | ) -> None: 31 | pass 32 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/queries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions/app/queries/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/queries/auction_details.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from attr import define 4 | 5 | from itca.auctions.domain.value_objects.auction_id import AuctionId 6 | from itca.foundation.money import Money 7 | 8 | 9 | @define(frozen=True) 10 | class AuctionDetailsDto: 11 | @define(frozen=True) 12 | class TopBidder: 13 | anonymized_name: str 14 | bid_amount: Money 15 | 16 | auction_id: AuctionId 17 | title: str 18 | current_price: Money 19 | starting_price: Money 20 | top_bidders: list[TopBidder] 21 | 22 | 23 | class AuctionDetails(abc.ABC): 24 | class NotFound(Exception): 25 | pass 26 | 27 | @abc.abstractmethod 28 | def query(self, auction_id: AuctionId) -> AuctionDetailsDto: 29 | pass 30 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions/app/repositories/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/repositories/auctions.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from itca.auctions.domain.entities.auction import Auction 4 | from itca.auctions.domain.value_objects.auction_id import AuctionId 5 | 6 | 7 | class AuctionsRepository(abc.ABC): 8 | @abc.abstractmethod 9 | def get(self, auction_id: AuctionId) -> Auction: 10 | pass 11 | 12 | @abc.abstractmethod 13 | def save(self, auction: Auction) -> None: 14 | pass 15 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/repositories/auctions_descriptors.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from itca.auctions.domain.entities.auction_descriptor import AuctionDescriptor 4 | from itca.auctions.domain.value_objects.auction_id import AuctionId 5 | 6 | 7 | class AuctionsDescriptorsRepository(abc.ABC): 8 | class NotFound(Exception): 9 | pass 10 | 11 | @abc.abstractmethod 12 | def get(self, auction_id: AuctionId) -> AuctionDescriptor: 13 | pass 14 | 15 | @abc.abstractmethod 16 | def add(self, descriptor: AuctionDescriptor) -> None: 17 | pass 18 | 19 | @abc.abstractmethod 20 | def delete(self, descriptor: AuctionDescriptor) -> None: 21 | pass 22 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/use_cases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions/app/use_cases/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/use_cases/finalizing_auction.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | 3 | from itca.auctions import AuctionsRepository 4 | from itca.auctions.app.ports.payments import Payments 5 | from itca.auctions.domain.value_objects.auction_id import AuctionId 6 | from itca.auctions.domain.value_objects.bidder_id import BidderId 7 | 8 | 9 | @define(frozen=True) 10 | class FinalizingAuctionInputDto: 11 | auction_id: AuctionId 12 | bidder_id: BidderId 13 | payment_token: str 14 | 15 | 16 | @define 17 | class FinalizingAuction: 18 | _auctions_repo: AuctionsRepository 19 | _payments: Payments 20 | 21 | def execute(self, input_dto: FinalizingAuctionInputDto) -> None: 22 | auction = self._auctions_repo.get(input_dto.auction_id) 23 | auction.finalize(input_dto.bidder_id) 24 | self._payments.pay( 25 | token=input_dto.payment_token, amount=auction.current_price 26 | ) 27 | self._auctions_repo.save(auction) 28 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/use_cases/placing_bid.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from dataclasses import dataclass 3 | from typing import Any 4 | 5 | from attr import define 6 | 7 | from itca.auctions.app.repositories.auctions import AuctionsRepository 8 | from itca.auctions.domain.value_objects.auction_id import AuctionId 9 | from itca.auctions.domain.value_objects.bidder_id import BidderId 10 | from itca.foundation.event_bus import EventBus 11 | from itca.foundation.money import Money 12 | 13 | 14 | @dataclass(frozen=True) 15 | class PlacingBidOutputDto: 16 | is_winning: bool 17 | current_price: Money 18 | 19 | 20 | class PlacingBidOutputBoundary(abc.ABC): 21 | @abc.abstractmethod 22 | def present(self, dto: PlacingBidOutputDto) -> None: 23 | pass 24 | 25 | @abc.abstractmethod 26 | def get_presented_value(self) -> Any: 27 | pass 28 | 29 | 30 | @dataclass(frozen=True) 31 | class PlacingBidInputDto: 32 | bidder_id: BidderId 33 | auction_id: AuctionId 34 | amount: Money 35 | 36 | 37 | @define 38 | class PlacingBid: 39 | _output_boundary: PlacingBidOutputBoundary 40 | _auctions_repo: AuctionsRepository 41 | _event_bus: EventBus 42 | 43 | def execute(self, input_dto: PlacingBidInputDto) -> None: 44 | auction = self._auctions_repo.get(input_dto.auction_id) 45 | events = auction.place_bid( 46 | bidder_id=input_dto.bidder_id, amount=input_dto.amount 47 | ) 48 | self._auctions_repo.save(auction) 49 | 50 | is_winning = input_dto.bidder_id == auction.winner 51 | self._output_boundary.present( 52 | PlacingBidOutputDto( 53 | is_winning=is_winning, 54 | current_price=auction.current_price, 55 | ) 56 | ) 57 | 58 | for event in events: 59 | self._event_bus.publish(event) 60 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/app/use_cases/starting_auction.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from attr import define 4 | 5 | from itca.auctions import AuctionsRepository 6 | from itca.auctions.app.repositories.auctions_descriptors import ( 7 | AuctionsDescriptorsRepository, 8 | ) 9 | from itca.auctions.domain.entities.auction import Auction 10 | from itca.auctions.domain.entities.auction_descriptor import AuctionDescriptor 11 | from itca.foundation.money import Money 12 | 13 | 14 | @define(frozen=True) 15 | class StartingAuctionInputDto: 16 | stating_price: Money 17 | end_time: datetime 18 | title: str 19 | description: str 20 | 21 | 22 | @define 23 | class StartingAuction: 24 | _auctions_repo: AuctionsRepository 25 | _auctions_descriptors_repo: AuctionsDescriptorsRepository 26 | 27 | def execute(self, dto: StartingAuctionInputDto) -> None: 28 | descriptor = AuctionDescriptor( 29 | title=dto.title, 30 | description=dto.description, 31 | ) 32 | self._auctions_descriptors_repo.add(descriptor) 33 | self._auctions_repo.save( 34 | Auction( 35 | id=descriptor.id, 36 | starting_price=dto.stating_price, 37 | bids=[], 38 | ends_at=dto.end_time, 39 | ) 40 | ) 41 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions/domain/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions/domain/entities/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/entities/auction.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from attr import define 5 | 6 | from itca.auctions.domain.events.bidder_has_been_overbid import ( 7 | BidderHasBeenOverbid, 8 | ) 9 | from itca.auctions.domain.exceptions.bid_on_ended_auction import ( 10 | BidOnEndedAuction, 11 | ) 12 | from itca.auctions.domain.value_objects.auction_id import AuctionId 13 | from itca.auctions.domain.value_objects.bid_id import BidId 14 | from itca.auctions.domain.value_objects.bidder_id import BidderId 15 | from itca.foundation.event import Event 16 | from itca.foundation.money import Money 17 | 18 | 19 | @define 20 | class Bid: 21 | id: Optional[BidId] 22 | bidder_id: BidderId 23 | amount: Money 24 | 25 | 26 | @define 27 | class Auction: 28 | _id: AuctionId 29 | _starting_price: Money 30 | _bids: list[Bid] 31 | _ends_at: datetime 32 | 33 | def __attrs_post_init__(self) -> None: 34 | self._bids.sort(key=lambda bid: bid.amount) 35 | 36 | @property 37 | def id(self) -> AuctionId: 38 | return self._id 39 | 40 | def place_bid(self, bidder_id: BidderId, amount: Money) -> list[Event]: 41 | events: list[Event] = [] 42 | self._ensure_not_ended() 43 | 44 | if amount > self.current_price: 45 | if self._bids: 46 | events.append( 47 | BidderHasBeenOverbid( 48 | auction_id=self.id, 49 | bidder_id=self._highest_bid.bidder_id, 50 | old_price=self._highest_bid.amount, 51 | new_price=amount, 52 | ) 53 | ) 54 | 55 | new_bid = Bid( 56 | id=None, 57 | bidder_id=bidder_id, 58 | amount=amount, 59 | ) 60 | self._bids.append(new_bid) 61 | 62 | return events 63 | 64 | def _ensure_not_ended(self) -> None: 65 | if datetime.now() > self._ends_at: 66 | raise BidOnEndedAuction 67 | 68 | @property 69 | def current_price(self) -> Money: 70 | if not self._bids: 71 | return self._starting_price 72 | else: 73 | return self._highest_bid.amount 74 | 75 | @property 76 | def winner(self) -> Optional[BidderId]: 77 | if not self._bids: 78 | return None 79 | return self._highest_bid.bidder_id 80 | 81 | @property 82 | def _highest_bid(self) -> Bid: 83 | return self._bids[-1] 84 | 85 | def finalize(self, bidder_id: BidderId) -> None: 86 | pass 87 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/entities/auction_descriptor.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | from itca.auctions.domain.value_objects.auction_id import AuctionId 4 | 5 | 6 | @attr.s(auto_attribs=True) 7 | class AuctionDescriptor: 8 | id: AuctionId = attr.ib(init=False) 9 | title: str 10 | description: str 11 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/events/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions/domain/events/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/events/auction_ended.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | 3 | from itca.auctions.domain.value_objects.auction_id import AuctionId 4 | from itca.auctions.domain.value_objects.bidder_id import BidderId 5 | from itca.foundation.event import Event 6 | from itca.foundation.money import Money 7 | 8 | 9 | @define(frozen=True) 10 | class AuctionEnded(Event): 11 | auction_id: AuctionId 12 | winner_id: BidderId 13 | price: Money 14 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/events/bidder_has_been_overbid.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | 3 | from itca.auctions.domain.value_objects.auction_id import AuctionId 4 | from itca.auctions.domain.value_objects.bidder_id import BidderId 5 | from itca.foundation.event import Event 6 | from itca.foundation.money import Money 7 | 8 | 9 | @define(frozen=True) 10 | class BidderHasBeenOverbid(Event): 11 | auction_id: AuctionId 12 | bidder_id: BidderId 13 | old_price: Money 14 | new_price: Money 15 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/exceptions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions/domain/exceptions/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/exceptions/bid_on_ended_auction.py: -------------------------------------------------------------------------------- 1 | from itca.foundation.domain_exception import DomainException 2 | 3 | 4 | class BidOnEndedAuction(DomainException): 5 | pass 6 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/value_objects/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions/domain/value_objects/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/value_objects/auction_id.py: -------------------------------------------------------------------------------- 1 | AuctionId = int 2 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/value_objects/bid_id.py: -------------------------------------------------------------------------------- 1 | BidId = int 2 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions/domain/value_objects/bidder_id.py: -------------------------------------------------------------------------------- 1 | BidderId = int 2 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/__init__.py: -------------------------------------------------------------------------------- 1 | import injector 2 | from sqlalchemy.orm import Session 3 | 4 | from itca.auctions import ( 5 | AuctionDetails, 6 | AuctionsDescriptorsRepository, 7 | AuctionsRepository, 8 | ) 9 | from itca.auctions.domain.entities.auction_descriptor import AuctionDescriptor 10 | from itca.auctions_infra.models import auction_descriptors 11 | from itca.auctions_infra.queries.auction_details import SqlAlchemyAuctionDetails 12 | from itca.auctions_infra.read_models.auction_details import auction_read_model 13 | from itca.auctions_infra.repositories.auctions import ( 14 | SqlAlchemyAuctionsRepository, 15 | ) 16 | from itca.auctions_infra.repositories.auctions_descriptors import ( 17 | SqlAlchemyAuctionsDescriptorsRepository, 18 | ) 19 | from itca.db import mapper_registry 20 | 21 | __all__ = [ 22 | # Module 23 | "AuctionsInfra", 24 | # Read models 25 | "auction_read_model", 26 | ] 27 | 28 | 29 | class AuctionsInfra(injector.Module): 30 | def configure(self, binder: injector.Binder) -> None: 31 | mapper_registry.map_imperatively( 32 | AuctionDescriptor, 33 | auction_descriptors, 34 | ) 35 | 36 | @injector.provider 37 | def auctions_repo(self, session: Session) -> AuctionsRepository: 38 | return SqlAlchemyAuctionsRepository(session=session) 39 | 40 | @injector.provider 41 | def auctions_descriptors_repo( 42 | self, session: Session 43 | ) -> AuctionsDescriptorsRepository: 44 | return SqlAlchemyAuctionsDescriptorsRepository(session=session) 45 | 46 | @injector.provider 47 | def auction_details(self, session: Session) -> AuctionDetails: 48 | return SqlAlchemyAuctionDetails(session) 49 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions_infra/adapters/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/adapters/payments.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from attr import define 3 | from yarl import URL 4 | 5 | from itca.auctions.app.ports.payments import CardId, PaymentFailed, Payments 6 | from itca.auctions.domain.value_objects.bidder_id import BidderId 7 | from itca.foundation.money import Money 8 | 9 | 10 | @define 11 | class CardDto: 12 | id: CardId 13 | last_4_digits: str 14 | processing_network: str 15 | 16 | 17 | CustomerId = int 18 | 19 | 20 | class BripePayments(Payments): 21 | def __init__(self, username: str, password: str, base_url: str) -> None: 22 | self._basic_auth = (username, password) 23 | self._base_url = base_url 24 | 25 | def pay(self, token: str, amount: Money) -> None: 26 | response = requests.post( 27 | url=str(URL(self._base_url) / "api/v1/charge"), 28 | auth=self._basic_auth, 29 | json={ 30 | "card_token": token, 31 | "currency": amount.currency.iso_code, 32 | "amount": int(amount.amount * 100), 33 | }, 34 | ) 35 | if not response.ok: 36 | raise PaymentFailed 37 | 38 | def pay_with_selected_card( 39 | self, bidder_id: BidderId, card_id: CardId, amount: Money 40 | ) -> None: 41 | ... 42 | 43 | def list_of_remembered_cards( 44 | self, customer_id: CustomerId 45 | ) -> list[CardDto]: 46 | ... 47 | 48 | def remember_card(self, token: str) -> None: 49 | ... 50 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/models/__init__.py: -------------------------------------------------------------------------------- 1 | from itca.auctions_infra.models.auction import Auction 2 | from itca.auctions_infra.models.auction_descriptor import ( 3 | AuctionDescriptor, 4 | auction_descriptors, 5 | ) 6 | from itca.auctions_infra.models.bid import Bid 7 | 8 | __all__ = [ 9 | "Auction", 10 | "AuctionDescriptor", 11 | "auction_descriptors", 12 | "Bid", 13 | ] 14 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/models/auction.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, Column, DateTime, Integer 2 | 3 | from itca.db import JSONB, Base 4 | 5 | 6 | class Auction(Base): 7 | __tablename__ = "auctions" 8 | 9 | id = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True) 10 | starting_price = Column(JSONB(), nullable=False) 11 | current_price = Column(JSONB(), nullable=False) 12 | ends_at = Column(DateTime(), nullable=False) 13 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/models/auction_descriptor.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, Column, Integer, String, Table 2 | 3 | from itca.db import Base, metadata 4 | 5 | auction_descriptors = Table( 6 | "auctions_descriptors", 7 | metadata, 8 | Column( 9 | "id", 10 | BigInteger().with_variant(Integer, "sqlite"), 11 | primary_key=True, 12 | ), 13 | Column("title", String(), nullable=False), 14 | Column("description", String(), nullable=False), 15 | ) 16 | 17 | 18 | class AuctionDescriptor(Base): 19 | __table__ = auction_descriptors 20 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/models/bid.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, Column, ForeignKey, Integer 2 | 3 | from itca.db import JSONB, Base 4 | 5 | 6 | class Bid(Base): 7 | __tablename__ = "bids" 8 | 9 | id = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True) 10 | amount = Column(JSONB(), nullable=False) 11 | bidder_id = Column(BigInteger(), nullable=False) 12 | auction_id = Column(BigInteger(), ForeignKey("auctions.id"), nullable=False) 13 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/queries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions_infra/queries/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/queries/auction_details.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | from sqlalchemy import select 3 | from sqlalchemy.exc import NoResultFound 4 | from sqlalchemy.orm import Session 5 | 6 | from itca.auctions import AuctionDetails, AuctionDetailsDto 7 | from itca.auctions.domain.value_objects.auction_id import AuctionId 8 | from itca.auctions_infra.models import Auction, AuctionDescriptor, Bid 9 | from itca.foundation.money import Money 10 | from itca.foundation.serde import converter 11 | 12 | 13 | @define 14 | class SqlAlchemyAuctionDetails(AuctionDetails): 15 | _session: Session 16 | 17 | def query(self, auction_id: AuctionId) -> AuctionDetailsDto: 18 | auction_stmt = ( 19 | select(Auction, AuctionDescriptor) 20 | .join( 21 | AuctionDescriptor, 22 | AuctionDescriptor.id == Auction.id, # type: ignore 23 | ) 24 | .filter(Auction.id == auction_id) 25 | ) 26 | try: 27 | auction, descriptor = self._session.execute(auction_stmt).one() 28 | except NoResultFound: 29 | raise AuctionDetails.NotFound 30 | 31 | top_bids_stmt = ( 32 | select(Bid) 33 | .filter(Bid.auction_id == auction_id) 34 | .order_by(Bid.id.desc()) 35 | .limit(3) 36 | ) 37 | top_bids: list[Bid] = self._session.execute(top_bids_stmt).scalars() 38 | 39 | return AuctionDetailsDto( 40 | auction_id=auction.id, 41 | title=descriptor.title, 42 | current_price=converter.structure(auction.current_price, Money), 43 | starting_price=converter.structure(auction.starting_price, Money), 44 | top_bidders=[ 45 | AuctionDetailsDto.TopBidder( 46 | anonymized_name=f"Bidder #{bid.bidder_id}", 47 | bid_amount=converter.structure(bid.amount, Money), 48 | ) 49 | for bid in top_bids 50 | ], 51 | ) 52 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/read_models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions_infra/read_models/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/read_models/auction_details.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, Column, String, Table 2 | 3 | from itca.db import JSONB, metadata 4 | 5 | auction_read_model = Table( 6 | "auction_read_model", 7 | metadata, 8 | Column("id", BigInteger()), 9 | Column("current_price", JSONB()), 10 | Column("starting_price", JSONB()), 11 | Column("title", String()), 12 | Column("description", String()), 13 | ) 14 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/auctions_infra/repositories/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/repositories/auctions.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | from sqlalchemy import select 3 | from sqlalchemy.orm import Session 4 | 5 | from itca.auctions.app.repositories.auctions import AuctionsRepository 6 | from itca.auctions.domain.entities.auction import Auction 7 | from itca.auctions.domain.value_objects.auction_id import AuctionId 8 | from itca.auctions_infra.models.auction import Auction as AuctionModel 9 | from itca.auctions_infra.models.bid import Bid as BidModel 10 | from itca.foundation.serde import converter 11 | 12 | 13 | @define 14 | class SqlAlchemyAuctionsRepository(AuctionsRepository): 15 | _session: Session 16 | 17 | def get(self, auction_id: AuctionId) -> Auction: 18 | bids = self._session.execute( 19 | select(BidModel.__table__).where(BidModel.auction_id == auction_id) 20 | ) 21 | auction_row = self._session.execute( 22 | select(AuctionModel.__table__).where(AuctionModel.id == auction_id) 23 | ).one() 24 | 25 | auction_vars = { 26 | f"_{key}": value for key, value in dict(auction_row).items() 27 | } 28 | return converter.structure( 29 | {"_bids": bids, **auction_vars}, 30 | Auction, 31 | ) 32 | 33 | def save(self, auction: Auction) -> None: 34 | dict_repr = converter.unstructure(auction) 35 | self._session.merge( 36 | AuctionModel( 37 | id=auction.id, 38 | starting_price=dict_repr["_starting_price"], 39 | current_price=converter.unstructure(auction.current_price), 40 | ends_at=dict_repr["_ends_at"], 41 | ) 42 | ) 43 | for bid in dict_repr["_bids"]: 44 | self._session.merge( 45 | BidModel( 46 | id=bid["id"], 47 | amount=bid["amount"], 48 | bidder_id=bid["bidder_id"], 49 | auction_id=auction.id, 50 | ) 51 | ) 52 | self._session.flush() 53 | -------------------------------------------------------------------------------- /auctioning_platform/itca/auctions_infra/repositories/auctions_descriptors.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | from sqlalchemy.orm import Session 3 | 4 | from itca.auctions import AuctionId, AuctionsDescriptorsRepository 5 | from itca.auctions.domain.entities.auction_descriptor import AuctionDescriptor 6 | 7 | 8 | @define 9 | class SqlAlchemyAuctionsDescriptorsRepository(AuctionsDescriptorsRepository): 10 | _session: Session 11 | 12 | def get(self, auction_id: AuctionId) -> AuctionDescriptor: 13 | raise NotImplementedError 14 | 15 | def add(self, descriptor: AuctionDescriptor) -> None: 16 | self._session.add(descriptor) 17 | self._session.flush([descriptor]) 18 | 19 | def delete(self, descriptor: AuctionDescriptor) -> None: 20 | raise NotImplementedError 21 | -------------------------------------------------------------------------------- /auctioning_platform/itca/customer_relationship/__init__.py: -------------------------------------------------------------------------------- 1 | import injector 2 | from attr import define 3 | 4 | from itca.auctions import AuctionDetails, BidderHasBeenOverbid 5 | from itca.customer_relationship.facade import CustomerRelationshipFacade 6 | from itca.foundation.event_bus import ( 7 | AsyncEventListenerProvider, 8 | AsyncListener, 9 | Listener, 10 | ) 11 | 12 | 13 | @define 14 | class OnBidderHasBeenOverbid(Listener[BidderHasBeenOverbid]): # type: ignore 15 | _facade: CustomerRelationshipFacade 16 | _auction_details: AuctionDetails 17 | 18 | def __call__(self, event: BidderHasBeenOverbid) -> None: 19 | auction_dto = self._auction_details.query(event.auction_id) 20 | self._facade.notify_about_overbid( 21 | customer_id=event.bidder_id, 22 | auction_id=event.auction_id, 23 | auction_title=auction_dto.title, 24 | new_price=event.new_price, 25 | ) 26 | 27 | 28 | class CustomerRelationship(injector.Module): 29 | @injector.provider 30 | def facade(self) -> CustomerRelationshipFacade: 31 | return CustomerRelationshipFacade() 32 | 33 | def configure(self, binder: injector.Binder) -> None: 34 | binder.multibind( 35 | AsyncListener[BidderHasBeenOverbid], 36 | to=AsyncEventListenerProvider(OnBidderHasBeenOverbid), 37 | ) 38 | 39 | @injector.provider 40 | def on_bidder_has_been_overbid( 41 | self, 42 | customer_relationship_facade: CustomerRelationshipFacade, 43 | auction_details: AuctionDetails, 44 | ) -> OnBidderHasBeenOverbid: 45 | return OnBidderHasBeenOverbid( 46 | facade=customer_relationship_facade, 47 | auction_details=auction_details, 48 | ) 49 | -------------------------------------------------------------------------------- /auctioning_platform/itca/customer_relationship/facade.py: -------------------------------------------------------------------------------- 1 | from itca.foundation.money import Money 2 | 3 | 4 | class CustomerRelationshipFacade: 5 | def register_customer(self, customer_id: int, email: str) -> None: 6 | ... 7 | 8 | def unregister_customer(self, customer_id: int) -> None: 9 | ... 10 | 11 | def update_contact_info(self, customer_id: int, email: str) -> None: 12 | ... 13 | 14 | def notify_about_overbid( 15 | self, 16 | customer_id: int, 17 | auction_id: int, 18 | auction_title: str, 19 | new_price: Money, 20 | ) -> None: 21 | print( 22 | f"Hey, you - #{customer_id}!" 23 | f"You've been overbid on auction #{auction_id} '{auction_title}'" 24 | f"New price is {new_price}." 25 | ) 26 | 27 | def notify_about_winning_auction( 28 | self, 29 | customer_id: int, 30 | auction_id: int, 31 | auction_title: str, 32 | amount: Money, 33 | ) -> None: 34 | print( 35 | f"Hey, you - #{customer_id}!" 36 | f"You've won the auction #{auction_id} '{auction_title}'" 37 | f"Now, you owe us {amount}." 38 | ) 39 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | import injector 4 | from attr import attrib, define 5 | from sqlalchemy import create_engine 6 | from sqlalchemy.engine import Connection, Engine 7 | from sqlalchemy.orm import Session, sessionmaker 8 | 9 | from itca.db.base import Base, mapper_registry, metadata 10 | from itca.db.guid import GUID 11 | from itca.db.jsonb import JSONB 12 | 13 | __all__ = [ 14 | # Module 15 | "Db", 16 | # SQLAlchemy registry objects & Base class for models 17 | "Base", 18 | "metadata", 19 | "mapper_registry", 20 | # Fields 21 | "JSONB", 22 | "GUID", 23 | ] 24 | 25 | 26 | @define(repr=False) 27 | class Db(injector.Module): 28 | _url: str 29 | _engine: Engine = attrib(init=False) 30 | _session_factory: Callable[[], Session] = attrib(init=False) 31 | 32 | def configure(self, _binder: injector.Binder) -> None: 33 | self._engine = create_engine(self._url, future=True) 34 | self._session_factory = sessionmaker(bind=self._engine, future=True) 35 | 36 | @injector.singleton 37 | @injector.provider 38 | def engine(self) -> Engine: 39 | return self._engine 40 | 41 | @injector.threadlocal 42 | @injector.provider 43 | def session(self) -> Session: 44 | return self._session_factory() 45 | 46 | @injector.threadlocal 47 | @injector.provider 48 | def connection(self, current_session: Session) -> Connection: 49 | return current_session.connection() 50 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from sqlalchemy import MetaData, Table 4 | from sqlalchemy.orm import as_declarative, registry 5 | 6 | metadata = MetaData() 7 | mapper_registry = registry(metadata=metadata) 8 | 9 | 10 | @as_declarative(metadata=metadata) 11 | class Base: 12 | __table__: Table 13 | 14 | def __init__(self, *args: Any, **kwargs: Any) -> None: 15 | ... 16 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/guid.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Any, Optional 3 | 4 | from sqlalchemy.dialects.postgresql import UUID 5 | from sqlalchemy.types import CHAR, TypeDecorator 6 | 7 | 8 | class GUID(TypeDecorator): 9 | """Platform-independent GUID type. 10 | 11 | Uses PostgreSQL's UUID type, otherwise uses 12 | CHAR(32), storing as stringified hex values. 13 | 14 | Source: https://docs.sqlalchemy.org/en/13/core/ 15 | custom_types.html#backend-agnostic-guid-type 16 | """ 17 | 18 | impl = CHAR 19 | 20 | def load_dialect_impl(self, dialect: Any) -> Any: 21 | if dialect.name == "postgresql": 22 | return dialect.type_descriptor(UUID()) 23 | else: 24 | return dialect.type_descriptor(CHAR(32)) 25 | 26 | def process_bind_param(self, value: Any, dialect: Any) -> Optional[str]: 27 | if value is None: 28 | return value 29 | elif dialect.name == "postgresql": 30 | return str(value) 31 | else: 32 | if not isinstance(value, uuid.UUID): 33 | return "%.32x" % uuid.UUID(value).int 34 | else: 35 | # hexstring 36 | return "%.32x" % value.int 37 | 38 | def process_result_value( 39 | self, value: Any, dialect: Any 40 | ) -> Optional[uuid.UUID]: 41 | if value is None: 42 | return value 43 | else: 44 | if not isinstance(value, uuid.UUID): 45 | value = uuid.UUID(value) 46 | return value 47 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/jsonb.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Optional 3 | 4 | from sqlalchemy.dialects.postgresql import JSONB as PostgresJSONB 5 | from sqlalchemy.types import Text, TypeDecorator 6 | 7 | 8 | class JSONB(TypeDecorator): 9 | impl = Text 10 | 11 | def load_dialect_impl(self, dialect: Any) -> Any: 12 | if dialect.name == "postgresql": 13 | return dialect.type_descriptor(PostgresJSONB()) 14 | else: 15 | return dialect.type_descriptor(Text()) 16 | 17 | def process_bind_param(self, value: Any, dialect: Any) -> Optional[Any]: 18 | if value is None: 19 | return value 20 | elif dialect.name == "postgresql": 21 | return value 22 | else: 23 | return json.dumps(value) 24 | 25 | def process_result_value(self, value: Any, dialect: Any) -> Optional[Any]: 26 | if value is None: 27 | return value 28 | else: 29 | return json.loads(value) 30 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/migrations/env.py: -------------------------------------------------------------------------------- 1 | from alembic import context 2 | from sqlalchemy.engine import Engine 3 | 4 | from itca.db.base import metadata 5 | from itca.main import assemble 6 | 7 | config = context.config 8 | 9 | target_metadata = metadata 10 | 11 | 12 | def run_migrations_offline(): 13 | """Run migrations in 'offline' mode. 14 | 15 | This configures the context with just a URL 16 | and not an Engine, though an Engine is acceptable 17 | here as well. By skipping the Engine creation 18 | we don't even need a DBAPI to be available. 19 | 20 | Calls to context.execute() here emit the given string to the 21 | script output. 22 | 23 | """ 24 | container = assemble(config.config_file_name) 25 | engine = container.get(Engine) 26 | context.configure( 27 | url=engine.url, 28 | target_metadata=target_metadata, 29 | literal_binds=True, 30 | dialect_opts={"paramstyle": "named"}, 31 | compare_type=True, 32 | ) 33 | 34 | with context.begin_transaction(): 35 | context.run_migrations() 36 | 37 | 38 | def run_migrations_online(): 39 | """Run migrations in 'online' mode. 40 | 41 | In this scenario we need to create an Engine 42 | and associate a connection with the context. 43 | 44 | """ 45 | container = assemble(config.config_file_name) 46 | connectable = container.get(Engine) 47 | 48 | with connectable.connect() as connection: 49 | context.configure( 50 | connection=connection, 51 | target_metadata=target_metadata, 52 | compare_type=True, 53 | ) 54 | 55 | with context.begin_transaction(): 56 | context.run_migrations() 57 | 58 | 59 | if context.is_offline_mode(): 60 | run_migrations_offline() 61 | else: 62 | run_migrations_online() 63 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/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 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/migrations/versions/2743a62623ef_add_table_for_paying_for_won_item_.py: -------------------------------------------------------------------------------- 1 | """Add table for paying for won item process manager state 2 | 3 | Revision ID: 2743a62623ef 4 | Revises: 4810e499a4f1 5 | Create Date: 2021-09-25 19:27:57.129047 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | from itca.db.guid import GUID 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "2743a62623ef" 15 | down_revision = "4810e499a4f1" 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 | "paying_for_won_auction_pm", 24 | sa.Column( 25 | "auction_id", 26 | sa.BigInteger().with_variant(sa.Integer(), "sqlite"), 27 | nullable=False, 28 | ), 29 | sa.Column("winner_id", sa.BigInteger(), nullable=False), 30 | sa.Column("winning_bid_currency", sa.String(length=3), nullable=False), 31 | sa.Column("winning_bid_amount", sa.Numeric(), nullable=False), 32 | sa.Column("payment_uuid", GUID(), nullable=True), 33 | sa.Column( 34 | "payment_finished_at", sa.DateTime(timezone=True), nullable=True 35 | ), 36 | sa.Column( 37 | "shipment_started_at", sa.DateTime(timezone=True), nullable=True 38 | ), 39 | sa.Column( 40 | "shipment_sent_at", sa.DateTime(timezone=True), nullable=True 41 | ), 42 | sa.PrimaryKeyConstraint("auction_id"), 43 | ) 44 | # ### end Alembic commands ### 45 | 46 | 47 | def downgrade(): 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | op.drop_table("paying_for_won_auction_pm") 50 | # ### end Alembic commands ### 51 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/migrations/versions/4810e499a4f1_add_payments_table.py: -------------------------------------------------------------------------------- 1 | """Add payments table 2 | 3 | Revision ID: 4810e499a4f1 4 | Revises: c1523f4b8ca8 5 | Create Date: 2021-09-24 00:03:51.327406 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "4810e499a4f1" 13 | down_revision = "c1523f4b8ca8" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "payments", 22 | sa.Column("uuid", sa.String(length=32), nullable=False), 23 | sa.Column("customer_id", sa.Integer(), nullable=False), 24 | sa.Column("amount", sa.Integer(), nullable=False), 25 | sa.Column("currency", sa.String(length=5), nullable=False), 26 | sa.Column("description", sa.String(), nullable=False), 27 | sa.Column("status", sa.String(length=32), nullable=False), 28 | sa.Column("charge_id", sa.String(length=32), nullable=True), 29 | sa.PrimaryKeyConstraint("uuid"), 30 | ) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_table("payments") 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/migrations/versions/4de7a165a4d2_create_auction_descriptor.py: -------------------------------------------------------------------------------- 1 | """Create auction descriptor 2 | 3 | Revision ID: 4de7a165a4d2 4 | Revises: aa67d02455c9 5 | Create Date: 2021-09-18 21:10:37.746825 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "4de7a165a4d2" 13 | down_revision = "aa67d02455c9" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "auctions_descriptors", 22 | sa.Column( 23 | "id", 24 | sa.BigInteger().with_variant(sa.Integer(), "sqlite"), 25 | nullable=False, 26 | ), 27 | sa.Column("title", sa.String(), nullable=False), 28 | sa.Column("description", sa.String(), nullable=False), 29 | sa.PrimaryKeyConstraint("id"), 30 | ) 31 | op.add_column( 32 | "auctions", sa.Column("ends_at", sa.DateTime(), nullable=False) 33 | ) 34 | # ### end Alembic commands ### 35 | 36 | 37 | def downgrade(): 38 | # ### commands auto generated by Alembic - please adjust! ### 39 | op.drop_column("auctions", "ends_at") 40 | op.drop_table("auctions_descriptors") 41 | # ### end Alembic commands ### 42 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/migrations/versions/7ee5422d729d_create_view_for_auction_read_model.py: -------------------------------------------------------------------------------- 1 | """Create view for auction read model 2 | 3 | Revision ID: 7ee5422d729d 4 | Revises: 873d26933b37 5 | Create Date: 2021-09-18 21:49:49.017835 6 | 7 | """ 8 | from alembic import op 9 | 10 | # revision identifiers, used by Alembic. 11 | revision = "7ee5422d729d" 12 | down_revision = "873d26933b37" 13 | branch_labels = None 14 | depends_on = None 15 | 16 | 17 | def upgrade(): 18 | op.execute( 19 | """ 20 | CREATE VIEW auction_read_model AS 21 | SELECT 22 | auctions.id, 23 | auctions.current_price, 24 | auctions.starting_price, 25 | auctions_descriptors.title, 26 | auctions_descriptors.description 27 | FROM auctions 28 | JOIN auctions_descriptors 29 | ON auctions.id = auctions_descriptors.id 30 | """ 31 | ) 32 | 33 | 34 | def downgrade(): 35 | op.execute("DROP VIEW auction_read_model") 36 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/migrations/versions/83b8b82bd255_add_tables_for_event_sourcing_snapshots.py: -------------------------------------------------------------------------------- 1 | """Add tables for event sourcing snapshots 2 | 3 | Revision ID: 83b8b82bd255 4 | Revises: f76a3a08cc3b 5 | Create Date: 2021-10-02 14:01:45.035375 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | from itca.db import GUID, JSONB 13 | 14 | revision = "83b8b82bd255" 15 | down_revision = "f76a3a08cc3b" 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 | "snapshots", 24 | sa.Column("uuid", GUID(), nullable=False), 25 | sa.Column("aggregate_uuid", GUID(), nullable=False), 26 | sa.Column("name", sa.String(length=50), nullable=False), 27 | sa.Column("data", JSONB(), nullable=False), 28 | sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), 29 | sa.Column("version", sa.BigInteger(), nullable=False), 30 | sa.PrimaryKeyConstraint("uuid"), 31 | ) 32 | op.create_index( 33 | "ix_snapshots_aggregate_version", 34 | "snapshots", 35 | ["aggregate_uuid", "version"], 36 | unique=False, 37 | ) 38 | # ### end Alembic commands ### 39 | 40 | 41 | def downgrade(): 42 | # ### commands auto generated by Alembic - please adjust! ### 43 | op.drop_index("ix_snapshots_aggregate_version", table_name="snapshots") 44 | op.drop_table("snapshots") 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/migrations/versions/873d26933b37_add_current_price_to_auction_model.py: -------------------------------------------------------------------------------- 1 | """Add current price to auction model 2 | 3 | Revision ID: 873d26933b37 4 | Revises: 4de7a165a4d2 5 | Create Date: 2021-09-18 21:19:52.103056 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | from itca.db.jsonb import JSONB 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "873d26933b37" 15 | down_revision = "4de7a165a4d2" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.add_column( 23 | "auctions", 24 | sa.Column("current_price", JSONB(), nullable=False), 25 | ) 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.drop_column("auctions", "current_price") 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/migrations/versions/aa67d02455c9_create_auction_and_bid_models.py: -------------------------------------------------------------------------------- 1 | """Create auction and bid models 2 | 3 | Revision ID: aa67d02455c9 4 | Revises: 5 | Create Date: 2021-09-17 23:21:44.125886 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | from itca.db.jsonb import JSONB 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "aa67d02455c9" 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 | "auctions", 24 | sa.Column( 25 | "id", 26 | sa.BigInteger().with_variant(sa.Integer(), "sqlite"), 27 | nullable=False, 28 | ), 29 | sa.Column("starting_price", JSONB(), nullable=False), 30 | sa.PrimaryKeyConstraint("id"), 31 | ) 32 | op.create_table( 33 | "bids", 34 | sa.Column( 35 | "id", 36 | sa.BigInteger().with_variant(sa.Integer(), "sqlite"), 37 | nullable=False, 38 | ), 39 | sa.Column("amount", JSONB(), nullable=False), 40 | sa.Column("bidder_id", sa.BigInteger(), nullable=False), 41 | sa.Column("auction_id", sa.BigInteger(), nullable=False), 42 | sa.ForeignKeyConstraint( 43 | ["auction_id"], 44 | ["auctions.id"], 45 | ), 46 | sa.PrimaryKeyConstraint("id"), 47 | ) 48 | # ### end Alembic commands ### 49 | 50 | 51 | def downgrade(): 52 | # ### commands auto generated by Alembic - please adjust! ### 53 | op.drop_table("bids") 54 | op.drop_table("auctions") 55 | # ### end Alembic commands ### 56 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/migrations/versions/c1523f4b8ca8_create_model_for_outbox.py: -------------------------------------------------------------------------------- 1 | """Create model for outbox 2 | 3 | Revision ID: c1523f4b8ca8 4 | Revises: 7ee5422d729d 5 | Create Date: 2021-09-23 22:07:47.445258 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | from itca.db import JSONB 13 | 14 | revision = "c1523f4b8ca8" 15 | down_revision = "7ee5422d729d" 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 | "outbox_messages", 24 | sa.Column( 25 | "id", 26 | sa.BigInteger().with_variant(sa.Integer(), "sqlite"), 27 | nullable=False, 28 | ), 29 | sa.Column("listener", sa.String(), nullable=False), 30 | sa.Column("event", sa.String(), nullable=False), 31 | sa.Column("event_payload", JSONB(), nullable=False), 32 | sa.PrimaryKeyConstraint("id"), 33 | ) 34 | # ### end Alembic commands ### 35 | 36 | 37 | def downgrade(): 38 | # ### commands auto generated by Alembic - please adjust! ### 39 | op.drop_table("outbox_messages") 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /auctioning_platform/itca/db/migrations/versions/f76a3a08cc3b_add_tables_for_event_sourcing_models.py: -------------------------------------------------------------------------------- 1 | """Add tables for event sourcing models 2 | 3 | Revision ID: f76a3a08cc3b 4 | Revises: 2743a62623ef 5 | Create Date: 2021-09-30 21:44:12.582449 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | from itca.db import GUID, JSONB 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "f76a3a08cc3b" 15 | down_revision = "2743a62623ef" 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 | "aggregates", 24 | sa.Column("uuid", GUID(), nullable=False), 25 | sa.Column("version", sa.BigInteger(), nullable=False), 26 | sa.PrimaryKeyConstraint("uuid"), 27 | ) 28 | op.create_table( 29 | "events", 30 | sa.Column("uuid", GUID(), nullable=False), 31 | sa.Column("aggregate_uuid", GUID(), nullable=False), 32 | sa.Column("name", sa.String(length=50), nullable=False), 33 | sa.Column("data", JSONB(), nullable=False), 34 | sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), 35 | sa.Column("version", sa.BigInteger(), nullable=False), 36 | sa.PrimaryKeyConstraint("uuid"), 37 | ) 38 | op.create_index( 39 | "ix_events_aggregate_version", 40 | "events", 41 | ["aggregate_uuid", "version"], 42 | unique=False, 43 | ) 44 | # ### end Alembic commands ### 45 | 46 | 47 | def downgrade(): 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | op.drop_index("ix_events_aggregate_version", table_name="events") 50 | op.drop_table("events") 51 | op.drop_table("aggregates") 52 | # ### end Alembic commands ### 53 | -------------------------------------------------------------------------------- /auctioning_platform/itca/event_sourcing/__init__.py: -------------------------------------------------------------------------------- 1 | import injector 2 | from sqlalchemy.orm import Session 3 | 4 | from itca.event_sourcing.event_store import EventStore 5 | from itca.event_sourcing.projection import Projection, SynchronousProjection 6 | from itca.event_sourcing.sqlalchemy_event_store import SqlAlchemyEventStore 7 | 8 | __all__ = [ 9 | # Module 10 | "EventSourcing", 11 | # Interfaces 12 | "EventStore", 13 | "Projection", 14 | "SynchronousProjection", 15 | ] 16 | 17 | 18 | class EventSourcing(injector.Module): 19 | @injector.provider 20 | def event_store( 21 | self, 22 | session: Session, 23 | synchronous_projections: list[SynchronousProjection], 24 | ) -> EventStore: 25 | return SqlAlchemyEventStore( 26 | session=session, 27 | synchronous_projections=synchronous_projections, 28 | ) 29 | -------------------------------------------------------------------------------- /auctioning_platform/itca/event_sourcing/aggregate_changes.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from attr import define 4 | 5 | from itca.event_sourcing.event import EsEvent 6 | 7 | 8 | @define(frozen=True) 9 | class AggregateChanges: 10 | aggregate_uuid: UUID 11 | events: list[EsEvent] 12 | expected_version: int 13 | -------------------------------------------------------------------------------- /auctioning_platform/itca/event_sourcing/event.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import ClassVar, Type 3 | from uuid import UUID 4 | 5 | from attr import define 6 | 7 | 8 | @define(frozen=True) 9 | class EsEvent: 10 | _subclasses: ClassVar[dict[str, Type]] = {} 11 | 12 | uuid: UUID 13 | aggregate_uuid: UUID 14 | created_at: datetime 15 | version: int 16 | 17 | def __init_subclass__(cls) -> None: 18 | cls._subclasses[cls.__name__] = cls 19 | 20 | @classmethod 21 | def subclass_for_name(cls, name: str) -> Type["EsEvent"]: 22 | return cls._subclasses[name] 23 | -------------------------------------------------------------------------------- /auctioning_platform/itca/event_sourcing/event_store.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from uuid import UUID 3 | 4 | from itca.event_sourcing.aggregate_changes import AggregateChanges 5 | from itca.event_sourcing.event import EsEvent 6 | from itca.event_sourcing.event_stream import EventStream 7 | 8 | 9 | class EventStore(abc.ABC): 10 | class NotFound(Exception): 11 | pass 12 | 13 | class NoEventsToAppend(Exception): 14 | pass 15 | 16 | class ConcurrentStreamWriteError(RuntimeError): 17 | pass 18 | 19 | @abc.abstractmethod 20 | def load_stream(self, aggregate_uuid: UUID) -> EventStream: 21 | pass 22 | 23 | @abc.abstractmethod 24 | def append_to_stream(self, changes: AggregateChanges) -> None: 25 | pass 26 | 27 | @abc.abstractmethod 28 | def save_snapshot(self, snapshot: EsEvent) -> None: 29 | pass 30 | -------------------------------------------------------------------------------- /auctioning_platform/itca/event_sourcing/event_stream.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from attr import define 4 | 5 | from itca.event_sourcing.event import EsEvent 6 | 7 | 8 | @define(frozen=True) 9 | class EventStream: 10 | uuid: UUID 11 | events: list[EsEvent] 12 | version: int 13 | -------------------------------------------------------------------------------- /auctioning_platform/itca/event_sourcing/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, Column, DateTime, Index, String 2 | 3 | from itca.db import GUID, JSONB, Base 4 | 5 | 6 | class Aggregate(Base): 7 | __tablename__ = "aggregates" 8 | 9 | uuid = Column(GUID(), primary_key=True) 10 | version = Column(BigInteger(), nullable=False) 11 | 12 | 13 | class Event(Base): 14 | __tablename__ = "events" 15 | __table_args__ = ( 16 | Index( 17 | "ix_events_aggregate_version", 18 | "aggregate_uuid", 19 | "version", 20 | ), 21 | ) 22 | uuid = Column(GUID, primary_key=True) 23 | aggregate_uuid = Column(GUID(), nullable=False) 24 | name = Column(String(50), nullable=False) 25 | data = Column(JSONB(), nullable=False) 26 | created_at = Column(DateTime(timezone=True), nullable=False) 27 | version = Column(BigInteger(), nullable=False) 28 | 29 | 30 | class Snapshot(Base): 31 | __tablename__ = "snapshots" 32 | __table_args__ = ( 33 | Index( 34 | "ix_snapshots_aggregate_version", 35 | "aggregate_uuid", 36 | "version", 37 | ), 38 | ) 39 | uuid = Column(GUID, primary_key=True) 40 | aggregate_uuid = Column(GUID(), nullable=False) 41 | name = Column(String(50), nullable=False) 42 | data = Column(JSONB(), nullable=False) 43 | created_at = Column(DateTime(timezone=True), nullable=False) 44 | version = Column(BigInteger(), nullable=False) 45 | -------------------------------------------------------------------------------- /auctioning_platform/itca/event_sourcing/projection.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from abc import ABC 3 | 4 | from itca.event_sourcing.event import EsEvent 5 | 6 | 7 | class Projection(abc.ABC): 8 | @abc.abstractmethod 9 | def __call__(self, events: list[EsEvent]) -> None: 10 | pass 11 | 12 | 13 | class SynchronousProjection(Projection, ABC): 14 | pass 15 | -------------------------------------------------------------------------------- /auctioning_platform/itca/foundation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/foundation/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/foundation/domain_exception.py: -------------------------------------------------------------------------------- 1 | class DomainException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /auctioning_platform/itca/foundation/event.py: -------------------------------------------------------------------------------- 1 | class Event: 2 | pass 3 | -------------------------------------------------------------------------------- /auctioning_platform/itca/foundation/event_bus.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Type, TypeVar, cast 2 | 3 | from attr import define 4 | from injector import Injector, Provider, UnknownProvider, UnsatisfiedRequirement 5 | 6 | from itca.foundation.event import Event 7 | 8 | TEvent = TypeVar("TEvent") 9 | 10 | 11 | class EventBus: 12 | def publish(self, event: Event) -> None: 13 | pass 14 | 15 | 16 | class Listener(List[TEvent]): 17 | """Simple generic used to associate listeners with events using DI. 18 | 19 | e.g Listener[BidderHasBeenOverbid]. 20 | """ 21 | 22 | def __call__(self, event: TEvent) -> None: 23 | raise NotImplementedError 24 | 25 | 26 | class AsyncListener(List[TEvent]): 27 | """An async counterpart of Listener[Event].""" 28 | 29 | def __call__(self, event: TEvent) -> None: 30 | raise NotImplementedError 31 | 32 | 33 | class EventListenerProvider(Provider): 34 | """Useful for configuring bind for event listeners. 35 | 36 | Using DI for dispatching events to listeners requires ability to bind 37 | multiple listeners to a single key (Listener[Event]). 38 | """ 39 | 40 | def __init__(self, cls: Type[TEvent]) -> None: 41 | self._cls = cls 42 | 43 | def get(self, injector: Injector) -> list[TEvent]: 44 | return [injector.create_object(self._cls)] 45 | 46 | 47 | class AsyncEventListenerProvider(Provider): 48 | """An async counterpart of EventListenerProvider. 49 | 50 | In async, one does not need to actually construct the instance. 51 | It is enough to obtain class itself. 52 | """ 53 | 54 | def __init__(self, cls: Type[TEvent]) -> None: 55 | self._cls = cls 56 | 57 | def get(self, _injector: Injector) -> list[Type[TEvent]]: 58 | return [self._cls] 59 | 60 | 61 | RunAsyncHandler = Callable[[Type[AsyncListener[TEvent]], TEvent], None] 62 | 63 | 64 | @define 65 | class InjectorEventBus(EventBus): 66 | _container: Injector 67 | _run_async_handler: RunAsyncHandler 68 | 69 | def publish(self, event: Event) -> None: 70 | event_cls = type(event) 71 | try: 72 | sync_listeners = self._container.get( 73 | Listener[event_cls] # type: ignore 74 | ) 75 | except (UnsatisfiedRequirement, UnknownProvider): 76 | pass 77 | else: 78 | assert isinstance(sync_listeners, list) 79 | for listener in cast(List[Listener], sync_listeners): 80 | listener(event) 81 | 82 | try: 83 | async_handlers = self._container.get( 84 | AsyncListener[event_cls] # type: ignore 85 | ) 86 | except (UnsatisfiedRequirement, UnknownProvider): 87 | pass 88 | else: 89 | assert isinstance(async_handlers, list) 90 | for async_handler in cast( 91 | List[Type[AsyncListener]], async_handlers 92 | ): 93 | self._run_async_handler(async_handler, event) 94 | -------------------------------------------------------------------------------- /auctioning_platform/itca/foundation/money.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from decimal import Decimal, DecimalException 3 | from functools import total_ordering 4 | from typing import Any, ClassVar, Tuple, Type 5 | 6 | __all__ = [ 7 | "Money", 8 | "Currency", 9 | "USD", 10 | "EUR", 11 | ] 12 | 13 | 14 | class Currency: 15 | decimal_precision: ClassVar[int] 16 | iso_code: ClassVar[str] 17 | __subclasses: ClassVar[dict[str, Type["Currency"]]] = {} 18 | 19 | def __init_subclass__(cls, **kwargs: Any) -> None: 20 | cls.__subclasses[cls.__name__] = cls 21 | 22 | @classmethod 23 | def from_code(cls, name: str) -> Type["Currency"]: 24 | return cls.__subclasses[name] 25 | 26 | 27 | class USD(Currency): 28 | decimal_precision = 2 29 | iso_code = "USD" 30 | 31 | 32 | class EUR(Currency): 33 | decimal_precision = 2 34 | iso_code = "EUR" 35 | 36 | 37 | @total_ordering 38 | class Money: 39 | def __init__(self, currency: Type[Currency], amount: Any) -> None: 40 | if not inspect.isclass(currency) or not issubclass(currency, Currency): 41 | raise ValueError(f"{currency} is not a subclass of Currency!") 42 | try: 43 | decimal_amount = Decimal(amount).normalize() 44 | except DecimalException: 45 | raise ValueError(f'"{amount}" is not a valid amount!') 46 | else: 47 | decimal_tuple = decimal_amount.as_tuple() 48 | if decimal_tuple.sign: 49 | raise ValueError("amount must not be negative!") 50 | elif -decimal_tuple.exponent > currency.decimal_precision: 51 | raise ValueError( 52 | f"given amount has invalid precision! It should have " 53 | f"no more than {currency.decimal_precision} decimal places!" 54 | ) 55 | 56 | self._currency = currency 57 | self._amount = decimal_amount 58 | 59 | @property 60 | def currency(self) -> Type[Currency]: 61 | return self._currency 62 | 63 | @property 64 | def amount(self) -> Decimal: 65 | return self._amount 66 | 67 | def __eq__(self, other: Any) -> bool: 68 | if not isinstance(other, Money): 69 | raise TypeError 70 | return self.currency == other.currency and self.amount == other.amount 71 | 72 | def __lt__(self, other: Any) -> bool: 73 | if not isinstance(other, Money): 74 | raise TypeError( 75 | f"'<' not supported between instances " 76 | f"of 'Money' and '{other.__class__.__name__}'" 77 | ) 78 | elif self.currency != other.currency: 79 | raise TypeError("Can not compare money in different currencies!") 80 | else: 81 | return self.amount < other.amount 82 | 83 | def __mul__(self, other: Any) -> "Money": 84 | assert isinstance(other, int) 85 | return Money(self.currency, self.amount * other) 86 | 87 | def __add__(self, other: Any) -> "Money": 88 | assert isinstance(other, Money) 89 | assert self.currency == other.currency 90 | return Money(self.currency, self.amount + other.amount) 91 | 92 | def __repr__(self) -> str: 93 | return ( 94 | f"<{self.__class__.__name__}" 95 | f"({self.currency.__name__}, '{self.amount}')>" 96 | ) 97 | 98 | def __composite_values__(self) -> Tuple[str, Decimal]: 99 | return self.currency.__name__, self.amount 100 | -------------------------------------------------------------------------------- /auctioning_platform/itca/foundation/serde.py: -------------------------------------------------------------------------------- 1 | """Serialization / Deserialization""" 2 | from datetime import datetime 3 | from uuid import UUID 4 | 5 | import cattr 6 | 7 | from itca.foundation.money import Currency, Money 8 | 9 | converter = cattr.Converter() 10 | converter.register_unstructure_hook( # type: ignore 11 | Money, 12 | lambda money: { 13 | "currency": money.currency.__name__, # type: ignore 14 | "amount": str(money.amount), # type: ignore 15 | }, 16 | ) 17 | converter.register_unstructure_hook(UUID, str) 18 | 19 | converter.register_structure_hook( 20 | Money, 21 | lambda money_dict, _: Money( 22 | Currency.from_code(money_dict["currency"]), money_dict["amount"] 23 | ), 24 | ) 25 | converter.register_structure_hook( 26 | datetime, lambda datetime_raw, _: datetime_raw 27 | ) 28 | converter.register_structure_hook( 29 | UUID, 30 | lambda uuid_raw, _: UUID(uuid_raw) 31 | if not isinstance(uuid_raw, UUID) 32 | else uuid_raw, 33 | ) 34 | -------------------------------------------------------------------------------- /auctioning_platform/itca/foundation/unit_of_work.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Callable 3 | 4 | 5 | class UnitOfWork(abc.ABC): 6 | @abc.abstractmethod 7 | def begin(self) -> None: 8 | pass 9 | 10 | @abc.abstractmethod 11 | def rollback(self) -> None: 12 | pass 13 | 14 | @abc.abstractmethod 15 | def commit(self) -> None: 16 | pass 17 | 18 | @abc.abstractmethod 19 | def register_callback_after_commit( 20 | self, callback: Callable[[], None] 21 | ) -> None: 22 | pass 23 | -------------------------------------------------------------------------------- /auctioning_platform/itca/main/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from configparser import ConfigParser 3 | from pathlib import Path 4 | 5 | from injector import Injector 6 | 7 | from itca.auctions import Auctions 8 | from itca.auctions_infra import AuctionsInfra 9 | from itca.customer_relationship import CustomerRelationship 10 | from itca.db import Db 11 | from itca.event_sourcing import EventSourcing 12 | from itca.main.event_bus import EventBusModule 13 | from itca.payments import Payments 14 | from itca.processes import Processes 15 | from itca.shipping_infra import ShippingInfra 16 | 17 | 18 | def assemble(config_path: str = "config.ini") -> Injector: 19 | config = read_config(config_path) 20 | configure_loggers() 21 | 22 | return Injector( 23 | [ 24 | Db(url=config["database"]["url"]), 25 | EventBusModule(), 26 | EventSourcing(), 27 | Auctions(), 28 | AuctionsInfra(), 29 | ShippingInfra(), 30 | Payments( 31 | username=config["bripe"]["username"], 32 | password=config["bripe"]["password"], 33 | ), 34 | CustomerRelationship(), 35 | Processes(redis_url=config["redis"]["url"]), 36 | ], 37 | auto_bind=False, 38 | ) 39 | 40 | 41 | def read_config(config_path: str) -> ConfigParser: 42 | assert Path(config_path).is_file(), f"Config {config_path} doesn't exist!" 43 | config = ConfigParser() 44 | config.read(config_path) 45 | return config 46 | 47 | 48 | def configure_loggers() -> None: 49 | logging.getLogger("itca.processes.locking").setLevel(logging.DEBUG) 50 | -------------------------------------------------------------------------------- /auctioning_platform/itca/main/event_bus.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | import injector 4 | from sqlalchemy.orm import Session 5 | 6 | from itca.foundation.event_bus import ( 7 | AsyncListener, 8 | Event, 9 | EventBus, 10 | InjectorEventBus, 11 | ) 12 | from itca.foundation.serde import converter 13 | from itca.tasks.outbox.model import OutboxMessage 14 | 15 | 16 | class EventBusModule(injector.Module): 17 | @injector.provider 18 | def event_bus(self, container: injector.Injector) -> EventBus: 19 | def run_async(listener: Type[AsyncListener], event: Event) -> None: 20 | session = container.get(Session) 21 | session.add( 22 | OutboxMessage( 23 | listener=f"{listener.__module__}.{listener.__name__}", 24 | event=f"{type(event).__module__}.{type(event).__name__}", 25 | event_payload=converter.unstructure(event), 26 | ) 27 | ) 28 | 29 | return InjectorEventBus(container, run_async) 30 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/__init__.py: -------------------------------------------------------------------------------- 1 | import injector 2 | from attr import define 3 | from sqlalchemy.engine import Connection 4 | 5 | from itca.foundation.event_bus import ( 6 | AsyncEventListenerProvider, 7 | AsyncListener, 8 | EventBus, 9 | Listener, 10 | ) 11 | from itca.payments.config import PaymentsConfig 12 | from itca.payments.events import ( 13 | PaymentCaptured, 14 | PaymentCharged, 15 | PaymentFailed, 16 | PaymentStarted, 17 | ) 18 | from itca.payments.facade import PaymentsFacade 19 | 20 | __all__ = [ 21 | # Module 22 | "Payments", 23 | # Facade 24 | "PaymentsFacade", 25 | # Events 26 | "PaymentStarted", 27 | "PaymentCharged", 28 | "PaymentCaptured", 29 | "PaymentFailed", 30 | ] 31 | 32 | 33 | @define 34 | class OnPaymentCharged(Listener[PaymentCharged]): # type: ignore 35 | _facade: PaymentsFacade 36 | 37 | def __call__(self, event: PaymentCharged) -> None: 38 | self._facade.capture(event.payment_uuid, event.customer_id) 39 | 40 | 41 | class Payments(injector.Module): 42 | def __init__(self, username: str, password: str) -> None: 43 | self._config = PaymentsConfig(username, password) 44 | 45 | @injector.provider 46 | def facade( 47 | self, 48 | connection: Connection, 49 | event_bus: EventBus, 50 | ) -> PaymentsFacade: 51 | return PaymentsFacade(self._config, connection, event_bus) 52 | 53 | def configure(self, binder: injector.Binder) -> None: 54 | binder.multibind( 55 | AsyncListener[PaymentCharged], 56 | to=AsyncEventListenerProvider(OnPaymentCharged), 57 | ) 58 | 59 | @injector.provider 60 | def on_payment_charged( 61 | self, payments_facade: PaymentsFacade 62 | ) -> OnPaymentCharged: 63 | return OnPaymentCharged(facade=payments_facade) 64 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/api/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "ApiConsumer", 3 | "PaymentFailedError", 4 | ] 5 | 6 | from itca.payments.api.consumer import ApiConsumer 7 | from itca.payments.api.exceptions import PaymentFailedError 8 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/api/consumer.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Type, TypeVar 2 | 3 | import requests 4 | 5 | from itca.foundation.money import Money 6 | from itca.payments.api.exceptions import PaymentFailedError 7 | from itca.payments.api.requests import CaptureRequest, ChargeRequest, Request 8 | from itca.payments.api.responses import CaptureResponse, ChargeResponse 9 | 10 | ResponseCls = TypeVar("ResponseCls") 11 | 12 | 13 | class ApiConsumer: 14 | def __init__(self, login: str, password: str) -> None: 15 | self._basic_auth = login, password 16 | 17 | def charge(self, card_token: str, amount: Money) -> str: 18 | currency, converted_amount = self._get_iso_code_and_amount(amount) 19 | request = ChargeRequest(card_token, currency, str(converted_amount)) 20 | response = self._execute_request(request, ChargeResponse) 21 | return response.charge_uuid 22 | 23 | def capture(self, charge_id: str) -> None: 24 | request = CaptureRequest(charge_id) 25 | self._execute_request(request, CaptureResponse) 26 | 27 | def _execute_request( 28 | self, request: Request, response_cls: Type[ResponseCls] 29 | ) -> ResponseCls: 30 | response = requests.post( 31 | request.url, auth=self._basic_auth, json=request.to_params() 32 | ) 33 | if not response.ok: 34 | raise PaymentFailedError 35 | else: 36 | return response_cls.from_dict(response.json()) # type: ignore 37 | 38 | def _get_iso_code_and_amount(self, money_amount: Money) -> Tuple[str, int]: 39 | return money_amount.currency.iso_code, int(money_amount.amount * 100) 40 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/api/exceptions.py: -------------------------------------------------------------------------------- 1 | class PaymentFailedError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/api/requests.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, fields 2 | 3 | 4 | @dataclass 5 | class Request: 6 | url = "http://localhost:5050/api" 7 | method = "GET" 8 | 9 | def to_params(self) -> dict: 10 | return { 11 | field.name: getattr(self, field.name) 12 | for field in fields(self) 13 | if not field.name.startswith("_") 14 | } 15 | 16 | 17 | @dataclass 18 | class ChargeRequest(Request): 19 | card_token: str 20 | currency: str 21 | amount: str 22 | url = f"{Request.url}/v1/charge" 23 | method = "POST" 24 | 25 | 26 | @dataclass 27 | class CaptureRequest(Request): 28 | _capture_id: str 29 | method = "POST" 30 | 31 | @property 32 | def url(self) -> str: # type: ignore 33 | return f"{Request.url}/v1/charges/{self._capture_id}/capture" 34 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/api/responses.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, fields 2 | 3 | 4 | @dataclass 5 | class Response: 6 | @classmethod 7 | def from_dict(cls, data: dict) -> "Response": 8 | cls_fields = fields(cls) 9 | matching_data = {field.name: data[field.name] for field in cls_fields} 10 | return cls(**matching_data) # type: ignore 11 | 12 | 13 | @dataclass 14 | class ChargeResponse(Response): 15 | charge_uuid: str 16 | success: bool 17 | 18 | 19 | @dataclass 20 | class CaptureResponse(Response): 21 | pass 22 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/config.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | 3 | 4 | @define(repr=False) 5 | class PaymentsConfig: 6 | username: str 7 | password: str 8 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/dao.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import Any, List, Optional 4 | from uuid import UUID 5 | 6 | from sqlalchemy.engine import Connection 7 | 8 | from itca.foundation.money import USD, Money 9 | from itca.payments.models import payments 10 | 11 | 12 | class PaymentStatus(Enum): 13 | NEW = "NEW" 14 | CHARGED = "CHARGED" 15 | CAPTURED = "CAPTURED" 16 | FAILED = "FAILED" 17 | TIMED_OUT = "TIMED_OUT" 18 | 19 | 20 | @dataclass 21 | class PaymentDto: 22 | id: UUID 23 | amount: Money 24 | description: str 25 | status: str 26 | 27 | @classmethod 28 | def from_row(cls, row: Any) -> "PaymentDto": 29 | return PaymentDto( 30 | UUID(row.uuid), 31 | Money(USD, row.amount / 100), 32 | row.description, 33 | row.status, 34 | ) 35 | 36 | 37 | def start_new_payment( 38 | payment_uuid: UUID, 39 | customer_id: int, 40 | amount: Money, 41 | description: str, 42 | conn: Connection, 43 | ) -> None: 44 | conn.execute( 45 | payments.insert( 46 | { 47 | "uuid": str(payment_uuid), 48 | "customer_id": customer_id, 49 | "amount": int(amount.amount) * 100, 50 | "currency": amount.currency.iso_code, 51 | "description": description, 52 | "status": PaymentStatus.NEW.value, 53 | } 54 | ) 55 | ) 56 | 57 | 58 | def get_pending_payments( 59 | customer_id: int, conn: Connection 60 | ) -> List[PaymentDto]: 61 | rows = conn.execute( 62 | payments.select( 63 | (payments.c.customer_id == customer_id) 64 | & (payments.c.status == PaymentStatus.NEW.value) 65 | ) 66 | ).fetchall() 67 | return [PaymentDto.from_row(row) for row in rows] 68 | 69 | 70 | def get_payment( 71 | payment_uuid: UUID, customer_id: int, conn: Connection 72 | ) -> PaymentDto: 73 | row = conn.execute( 74 | payments.select( 75 | (payments.c.customer_id == customer_id) 76 | & (payments.c.uuid == str(payment_uuid)) 77 | ) 78 | ).first() 79 | return PaymentDto.from_row(row) 80 | 81 | 82 | def get_payment_charge_id( 83 | payment_uuid: UUID, customer_id: int, conn: Connection 84 | ) -> Optional[str]: 85 | row = conn.execute( 86 | payments.select( 87 | (payments.c.customer_id == customer_id) 88 | & (payments.c.uuid == str(payment_uuid)) 89 | ) 90 | ).first() 91 | return str(row.charge_id) if row.charge_id else None 92 | 93 | 94 | def update_payment( 95 | payment_uuid: UUID, customer_id: int, values: dict, conn: Connection 96 | ) -> None: 97 | conn.execute( 98 | payments.update() 99 | .where( 100 | (payments.c.uuid == str(payment_uuid)) 101 | & (payments.c.customer_id == customer_id) 102 | ) 103 | .values(**values) 104 | ) 105 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/events/__init__.py: -------------------------------------------------------------------------------- 1 | from itca.payments.events.payment_captured import PaymentCaptured 2 | from itca.payments.events.payment_charged import PaymentCharged 3 | from itca.payments.events.payment_failed import PaymentFailed 4 | from itca.payments.events.payment_overdue import PaymentOverdue 5 | from itca.payments.events.payment_started import PaymentStarted 6 | 7 | __all__ = [ 8 | "PaymentStarted", 9 | "PaymentCharged", 10 | "PaymentCaptured", 11 | "PaymentFailed", 12 | "PaymentOverdue", 13 | ] 14 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/events/payment_captured.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from attr import define 4 | 5 | from itca.foundation.event import Event 6 | 7 | 8 | @define(frozen=True) 9 | class PaymentCaptured(Event): 10 | payment_uuid: UUID 11 | customer_id: int 12 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/events/payment_charged.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from attr import define 4 | 5 | from itca.foundation.event import Event 6 | 7 | 8 | @define(frozen=True) 9 | class PaymentCharged(Event): 10 | payment_uuid: UUID 11 | customer_id: int 12 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/events/payment_failed.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from attr import define 4 | 5 | from itca.foundation.event import Event 6 | 7 | 8 | @define(frozen=True) 9 | class PaymentFailed(Event): 10 | payment_uuid: UUID 11 | customer_id: int 12 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/events/payment_overdue.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from uuid import UUID 3 | 4 | from attr import define 5 | 6 | from itca.foundation.event import Event 7 | 8 | 9 | @define(frozen=True) 10 | class PaymentOverdue(Event): 11 | payment_uuid: UUID 12 | customer_id: int 13 | due_date: date 14 | days: int 15 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/events/payment_started.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from attr import define 4 | 5 | from itca.foundation.event import Event 6 | 7 | 8 | @define(frozen=True) 9 | class PaymentStarted(Event): 10 | payment_uuid: UUID 11 | customer_id: int 12 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/facade.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from uuid import UUID 3 | 4 | from sqlalchemy.engine import Connection 5 | 6 | from itca.foundation.event_bus import EventBus 7 | from itca.foundation.money import Money 8 | from itca.payments import dao 9 | from itca.payments.api import ApiConsumer, PaymentFailedError 10 | from itca.payments.config import PaymentsConfig 11 | from itca.payments.events import ( 12 | PaymentCaptured, 13 | PaymentCharged, 14 | PaymentFailed, 15 | PaymentStarted, 16 | ) 17 | 18 | 19 | class PaymentsFacade: 20 | def __init__( 21 | self, 22 | config: PaymentsConfig, 23 | connection: Connection, 24 | event_bus: EventBus, 25 | ) -> None: 26 | self._api_consumer = ApiConsumer(config.username, config.password) 27 | self._connection = connection 28 | self._event_bus = event_bus 29 | 30 | def get_pending_payments(self, customer_id: int) -> List[dao.PaymentDto]: 31 | return dao.get_pending_payments(customer_id, self._connection) 32 | 33 | def start_new_payment( 34 | self, 35 | payment_uuid: UUID, 36 | customer_id: int, 37 | amount: Money, 38 | description: str, 39 | ) -> None: 40 | dao.start_new_payment( 41 | payment_uuid, customer_id, amount, description, self._connection 42 | ) 43 | self._event_bus.publish(PaymentStarted(payment_uuid, customer_id)) 44 | 45 | def charge(self, payment_uuid: UUID, customer_id: int, token: str) -> None: 46 | payment = dao.get_payment(payment_uuid, customer_id, self._connection) 47 | if payment.status != dao.PaymentStatus.NEW.value: 48 | raise Exception(f"Can't pay - unexpected status {payment.status}") 49 | 50 | try: 51 | charge_id = self._api_consumer.charge(token, payment.amount) 52 | except PaymentFailedError: 53 | dao.update_payment( 54 | payment_uuid, 55 | customer_id, 56 | {"status": dao.PaymentStatus.FAILED.value}, 57 | self._connection, 58 | ) 59 | self._event_bus.publish(PaymentFailed(payment_uuid, customer_id)) 60 | else: 61 | update_values = { 62 | "status": dao.PaymentStatus.CHARGED.value, 63 | "charge_id": charge_id, 64 | } 65 | dao.update_payment( 66 | payment_uuid, customer_id, update_values, self._connection 67 | ) 68 | self._event_bus.publish(PaymentCharged(payment_uuid, customer_id)) 69 | 70 | def capture(self, payment_uuid: UUID, customer_id: int) -> None: 71 | charge_id = dao.get_payment_charge_id( 72 | payment_uuid, customer_id, self._connection 73 | ) 74 | assert ( 75 | charge_id 76 | ), f"No charge_id available for {payment_uuid}, aborting capture" 77 | self._api_consumer.capture(charge_id) 78 | dao.update_payment( 79 | payment_uuid, 80 | customer_id, 81 | {"status": dao.PaymentStatus.CAPTURED.value}, 82 | self._connection, 83 | ) 84 | self._event_bus.publish(PaymentCaptured(payment_uuid, customer_id)) 85 | -------------------------------------------------------------------------------- /auctioning_platform/itca/payments/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, Table 2 | 3 | from itca.db import metadata 4 | 5 | payments = Table( 6 | "payments", 7 | metadata, 8 | Column("uuid", String(32), primary_key=True), 9 | Column("customer_id", Integer, nullable=False), 10 | Column("amount", Integer, nullable=False), 11 | Column("currency", String(5), nullable=False), 12 | Column("description", String, nullable=False), 13 | Column("status", String(32), nullable=False), 14 | Column("charge_id", String(32), nullable=True), 15 | ) 16 | -------------------------------------------------------------------------------- /auctioning_platform/itca/processes/__init__.py: -------------------------------------------------------------------------------- 1 | import injector 2 | from attr import define 3 | from redis import Redis 4 | 5 | from itca.processes.locking import Lock, RedisLock 6 | from itca.processes.paying_for_won_auction import PayingForWonAuctionModule 7 | 8 | 9 | @define 10 | class Processes(injector.Module): 11 | _redis_url: str 12 | 13 | def configure(self, binder: injector.Binder) -> None: 14 | binder.install(PayingForWonAuctionModule()) 15 | 16 | @injector.provider 17 | def lock(self) -> Lock: 18 | return RedisLock(redis=Redis.from_url(self._redis_url)) 19 | -------------------------------------------------------------------------------- /auctioning_platform/itca/processes/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidRequest(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /auctioning_platform/itca/processes/locking.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import logging 3 | from contextlib import contextmanager 4 | from typing import Iterator 5 | 6 | from attr import define 7 | from pottery import Redlock 8 | from redis import Redis 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Lock(abc.ABC): 14 | class FailedToAcquire(Exception): 15 | pass 16 | 17 | @contextmanager 18 | @abc.abstractmethod 19 | def acquire(self, name: str, expires_after: int, wait_for: int) -> Iterator: 20 | pass 21 | 22 | 23 | @define 24 | class RedisLock(Lock): 25 | _redis: Redis 26 | 27 | @contextmanager 28 | def acquire(self, name: str, expires_after: int, wait_for: int) -> Iterator: 29 | logger.debug( 30 | "Trying to acquire lock %s, to expire after %s, wait for %s", 31 | name, 32 | expires_after, 33 | wait_for, 34 | ) 35 | redlock = Redlock( 36 | key=name, 37 | masters={self._redis}, 38 | auto_release_time=expires_after * 1000, 39 | ) 40 | acquired = False 41 | try: 42 | acquired = redlock.acquire(blocking=True, timeout=wait_for) 43 | if acquired: 44 | logger.debug( 45 | "Lock acquired %s, will hold for %s", 46 | name, 47 | expires_after, 48 | ) 49 | else: 50 | raise Lock.FailedToAcquire 51 | yield 52 | finally: 53 | if acquired: 54 | redlock.release() 55 | logger.debug("Lock released %s", name) 56 | -------------------------------------------------------------------------------- /auctioning_platform/itca/processes/paying_for_won_auction/__init__.py: -------------------------------------------------------------------------------- 1 | import injector 2 | from sqlalchemy.orm import Session 3 | 4 | from itca.auctions import AuctionDetails, AuctionEnded 5 | from itca.customer_relationship import CustomerRelationshipFacade 6 | from itca.foundation.event_bus import AsyncEventListenerProvider, AsyncListener 7 | from itca.payments import PaymentCaptured, PaymentsFacade 8 | from itca.processes.locking import Lock 9 | from itca.processes.paying_for_won_auction.process_manager import ( 10 | PayingForWonAuctionProcess, 11 | ) 12 | from itca.processes.paying_for_won_auction.repository import ( 13 | PayingForWonAuctionStateRepository, 14 | ) 15 | from itca.processes.paying_for_won_auction.sql_alchemy_repository import ( 16 | SqlAStateRepository, 17 | ) 18 | from itca.shipping import ConsignmentShipped 19 | 20 | 21 | class PayingForWonAuctionModule(injector.Module): 22 | def configure(self, binder: injector.Binder) -> None: 23 | binder.multibind( 24 | AsyncListener[AuctionEnded], 25 | to=AsyncEventListenerProvider(PayingForWonAuctionProcess), 26 | ) 27 | binder.multibind( 28 | AsyncListener[PaymentCaptured], 29 | to=AsyncEventListenerProvider(PayingForWonAuctionProcess), 30 | ) 31 | binder.multibind( 32 | AsyncListener[ConsignmentShipped], 33 | to=AsyncEventListenerProvider(PayingForWonAuctionProcess), 34 | ) 35 | 36 | @injector.provider 37 | def paying_for_won_auction_process( 38 | self, 39 | payments: PaymentsFacade, 40 | customer_relationship: CustomerRelationshipFacade, 41 | auction_details: AuctionDetails, 42 | repository: PayingForWonAuctionStateRepository, 43 | locks: Lock, 44 | ) -> PayingForWonAuctionProcess: 45 | return PayingForWonAuctionProcess( 46 | payments=payments, 47 | customer_relationship=customer_relationship, 48 | auction_details=auction_details, 49 | repository=repository, 50 | locks=locks, 51 | ) 52 | 53 | @injector.provider 54 | def repository( 55 | self, session: Session 56 | ) -> PayingForWonAuctionStateRepository: 57 | return SqlAStateRepository(session=session) 58 | -------------------------------------------------------------------------------- /auctioning_platform/itca/processes/paying_for_won_auction/process_manager.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from functools import singledispatchmethod 3 | from typing import Iterator 4 | 5 | from attr import define 6 | 7 | from itca.auctions import AuctionDetails, AuctionEnded 8 | from itca.customer_relationship import CustomerRelationshipFacade 9 | from itca.foundation.event import Event 10 | from itca.payments import PaymentCaptured, PaymentsFacade 11 | from itca.processes.locking import Lock 12 | from itca.processes.paying_for_won_auction.repository import ( 13 | PayingForWonAuctionStateRepository, 14 | ) 15 | from itca.processes.paying_for_won_auction.state import PayingForWonAuctionState 16 | from itca.shipping import ConsignmentShipped 17 | 18 | 19 | @define 20 | class PayingForWonAuctionProcess: 21 | _payments: PaymentsFacade 22 | _customer_relationship: CustomerRelationshipFacade 23 | _auction_details: AuctionDetails 24 | _repository: PayingForWonAuctionStateRepository 25 | _locks: Lock 26 | 27 | @singledispatchmethod 28 | def __call__(self, event: Event) -> None: 29 | raise NotImplementedError(f"Unknown event - {event}") 30 | 31 | @__call__.register 32 | def _handle_auction_ended(self, event: AuctionEnded) -> None: 33 | if not (state := self._repository.get_by_auction(event.auction_id)): 34 | state = PayingForWonAuctionState( 35 | auction_id=event.auction_id, 36 | winner_id=event.winner_id, 37 | winning_bid=event.price, 38 | ) 39 | self._repository.add(state) 40 | 41 | with self._lock(state) as locked_state: 42 | del state # we should not operate on it anymore after lock 43 | locked_state.generate_payment_uuid() 44 | 45 | auction_dto = self._auction_details.query( 46 | auction_id=event.auction_id 47 | ) 48 | self._customer_relationship.notify_about_winning_auction( 49 | customer_id=event.winner_id, 50 | auction_id=event.auction_id, 51 | auction_title=auction_dto.title, 52 | amount=event.price, 53 | ) 54 | self._payments.start_new_payment( 55 | payment_uuid=locked_state.payment_uuid, 56 | customer_id=event.winner_id, 57 | amount=event.price, 58 | description=f"For item won at auction {auction_dto.title}", 59 | ) 60 | 61 | @__call__.register 62 | def _handle_payment_captured(self, event: PaymentCaptured) -> None: 63 | state = self._repository.get_by_payment(event.payment_uuid) 64 | with self._lock(state) as locked_state: 65 | del state 66 | locked_state.finish_payment() 67 | ... 68 | 69 | @__call__.register 70 | def _handle_consignment_shipped(self, event: ConsignmentShipped) -> None: 71 | pass 72 | 73 | @contextmanager 74 | def _lock( 75 | self, state: PayingForWonAuctionState 76 | ) -> Iterator[PayingForWonAuctionState]: 77 | with self._locks.acquire( 78 | name=f"{self.__class__.__name__}_{state.auction_id}", 79 | expires_after=60, 80 | wait_for=1, 81 | ): 82 | from sqlalchemy.exc import InvalidRequestError 83 | 84 | try: 85 | self._repository._session.expire(state) # type: ignore 86 | except InvalidRequestError: 87 | pass # happens when state is not expired yet 88 | yield state 89 | -------------------------------------------------------------------------------- /auctioning_platform/itca/processes/paying_for_won_auction/repository.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from uuid import UUID 3 | 4 | from itca.processes.paying_for_won_auction.state import PayingForWonAuctionState 5 | 6 | 7 | class PayingForWonAuctionStateRepository(abc.ABC): 8 | @abc.abstractmethod 9 | def get_by_auction(self, auction_id: int) -> PayingForWonAuctionState: 10 | pass 11 | 12 | @abc.abstractmethod 13 | def get_by_payment(self, payment_uuid: UUID) -> PayingForWonAuctionState: 14 | pass 15 | 16 | @abc.abstractmethod 17 | def add(self, state: PayingForWonAuctionState) -> None: 18 | pass 19 | -------------------------------------------------------------------------------- /auctioning_platform/itca/processes/paying_for_won_auction/sql_alchemy_repository.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from attr import define 4 | from sqlalchemy import ( 5 | BigInteger, 6 | Column, 7 | DateTime, 8 | Integer, 9 | Numeric, 10 | String, 11 | Table, 12 | ) 13 | from sqlalchemy.orm import Session, composite 14 | 15 | from itca.db import GUID, mapper_registry 16 | from itca.foundation.money import Currency, Money 17 | from itca.processes.paying_for_won_auction import ( 18 | PayingForWonAuctionStateRepository, 19 | ) 20 | from itca.processes.paying_for_won_auction.state import PayingForWonAuctionState 21 | 22 | paying_for_won_auction_pm_table = Table( 23 | "paying_for_won_auction_pm", 24 | mapper_registry.metadata, 25 | Column( 26 | "auction_id", 27 | BigInteger().with_variant(Integer, "sqlite"), 28 | primary_key=True, 29 | ), 30 | Column("winner_id", BigInteger(), nullable=False), 31 | Column("winning_bid_currency", String(3), nullable=False), 32 | Column("winning_bid_amount", Numeric(), nullable=False), 33 | Column("payment_uuid", GUID(), nullable=True), 34 | Column("payment_finished_at", DateTime(timezone=True), nullable=True), 35 | Column("shipment_started_at", DateTime(timezone=True), nullable=True), 36 | Column("shipment_sent_at", DateTime(timezone=True), nullable=True), 37 | ) 38 | 39 | 40 | mapper_registry.map_imperatively( 41 | PayingForWonAuctionState, 42 | paying_for_won_auction_pm_table, 43 | properties={ 44 | "_winning_bid": composite( 45 | lambda currency_code, amount: Money( 46 | Currency.from_code(currency_code), amount 47 | ), 48 | paying_for_won_auction_pm_table.c.winning_bid_currency, 49 | paying_for_won_auction_pm_table.c.winning_bid_amount, 50 | ), 51 | }, 52 | column_prefix="_", 53 | ) 54 | 55 | 56 | @define 57 | class SqlAStateRepository(PayingForWonAuctionStateRepository): 58 | _session: Session 59 | 60 | def get_by_auction(self, auction_id: int) -> PayingForWonAuctionState: 61 | return self._session.query(PayingForWonAuctionState).get(auction_id) 62 | 63 | def get_by_payment(self, payment_uuid: UUID) -> PayingForWonAuctionState: 64 | return ( 65 | self._session.query(PayingForWonAuctionState) 66 | .filter_by(_payment_uuid=payment_uuid) 67 | .one() 68 | ) 69 | 70 | def add(self, state: PayingForWonAuctionState) -> None: 71 | self._session.add(state) 72 | -------------------------------------------------------------------------------- /auctioning_platform/itca/processes/paying_for_won_auction/state.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Optional 3 | from uuid import UUID, uuid4 4 | 5 | import attr 6 | 7 | from itca.foundation.money import Money 8 | from itca.processes.exceptions import InvalidRequest 9 | 10 | 11 | @attr.s(auto_attribs=True) 12 | class PayingForWonAuctionState: 13 | _auction_id: int 14 | _winner_id: int 15 | _winning_bid: Money 16 | _payment_uuid: Optional[UUID] = None 17 | _payment_finished_at: Optional[datetime] = None 18 | _shipment_started_at: Optional[datetime] = None 19 | _shipment_sent_at: Optional[datetime] = None 20 | 21 | def generate_payment_uuid(self) -> None: 22 | if self._payment_uuid is not None: 23 | raise InvalidRequest 24 | self._payment_uuid = uuid4() 25 | 26 | @property 27 | def auction_id(self) -> int: 28 | return self._auction_id 29 | 30 | @property 31 | def payment_uuid(self) -> UUID: 32 | assert self._payment_uuid, "Begin it first!" 33 | return self._payment_uuid 34 | 35 | def finish_payment(self) -> None: 36 | if self._payment_finished_at: 37 | raise InvalidRequest 38 | self._payment_finished_at = datetime.now(tz=timezone.utc) 39 | 40 | def ship_item(self) -> None: 41 | if self._shipment_started_at: 42 | raise InvalidRequest 43 | self._shipment_started_at = datetime.now(tz=timezone.utc) 44 | 45 | def shipment_complete(self) -> None: 46 | if self._shipment_sent_at: 47 | raise InvalidRequest 48 | self._shipment_sent_at = datetime.now(tz=timezone.utc) 49 | -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/__init__.py: -------------------------------------------------------------------------------- 1 | from itca.shipping.app.repositories.orders import OrdersRepository 2 | from itca.shipping.domain.events.consignment_shipped import ConsignmentShipped 3 | 4 | __all__ = [ 5 | # Events 6 | "ConsignmentShipped", 7 | # Repositories 8 | "OrdersRepository", 9 | ] 10 | -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/shipping/app/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/app/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/shipping/app/repositories/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/app/repositories/orders.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from itca.shipping.domain.aggregates.order import Order 4 | from itca.shipping.domain.value_objects.order_id import OrderId 5 | 6 | 7 | class OrdersRepository(abc.ABC): 8 | class NotFound(Exception): 9 | pass 10 | 11 | @abc.abstractmethod 12 | def get(self, order_id: OrderId) -> Order: 13 | pass 14 | 15 | @abc.abstractmethod 16 | def save(self, order: Order) -> None: 17 | pass 18 | -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/shipping/domain/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/domain/aggregates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/shipping/domain/aggregates/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/domain/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/shipping/domain/entities/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/domain/entities/order.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import Optional 3 | from uuid import UUID 4 | 5 | from attr import attrib, define, evolve 6 | 7 | from itca.foundation.money import Money 8 | from itca.shipping.domain.value_objects.customer_id import CustomerId 9 | from itca.shipping.domain.value_objects.product_id import ProductId 10 | 11 | 12 | @define(frozen=True) 13 | class OrderLine: 14 | quantity: int 15 | unit_price: Money 16 | 17 | 18 | class AlreadyPaid(Exception): 19 | pass 20 | 21 | 22 | class CannotChangeOrderLinesOfPaidOrder(Exception): 23 | pass 24 | 25 | 26 | @define 27 | class Order: 28 | _uuid: UUID 29 | _customer_id: CustomerId 30 | _lines: dict[ProductId, OrderLine] = attrib(factory=dict) 31 | _paid_at: Optional[datetime] = None 32 | _shipped_at: Optional[datetime] = None 33 | 34 | def mark_as_paid(self) -> None: 35 | if self._paid_at is not None: 36 | raise AlreadyPaid 37 | 38 | self._paid_at = datetime.now(tz=timezone.utc) 39 | 40 | def add_product( 41 | self, product_id: ProductId, quantity: int, unit_price: Money 42 | ) -> None: 43 | if self._paid_at: 44 | raise CannotChangeOrderLinesOfPaidOrder 45 | 46 | try: 47 | line = self._lines[product_id] 48 | new_line = evolve( 49 | line, quantity=line.quantity + quantity, unit_price=unit_price 50 | ) 51 | except KeyError: 52 | new_line = OrderLine(quantity=quantity, unit_price=unit_price) 53 | 54 | self._lines[product_id] = new_line 55 | -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/domain/events/__init__.py: -------------------------------------------------------------------------------- 1 | from itca.shipping.domain.events.consignment_shipped import ConsignmentShipped 2 | 3 | __all__ = [ 4 | "ConsignmentShipped", 5 | ] 6 | -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/domain/events/consignment_shipped.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from attr import define 4 | 5 | from itca.foundation.event import Event 6 | 7 | 8 | @define(frozen=True) 9 | class ConsignmentShipped(Event): 10 | uuid: UUID 11 | -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/domain/value_objects/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/shipping/domain/value_objects/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/domain/value_objects/customer_id.py: -------------------------------------------------------------------------------- 1 | CustomerId = int 2 | -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/domain/value_objects/order_id.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | OrderId = UUID 4 | -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping/domain/value_objects/product_id.py: -------------------------------------------------------------------------------- 1 | ProductId = int 2 | -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping_infra/__init__.py: -------------------------------------------------------------------------------- 1 | import injector 2 | from sqlalchemy.orm import Session 3 | 4 | from itca.event_sourcing import SynchronousProjection 5 | from itca.shipping_infra.projections.order_summary import OrderSummaryProjection 6 | 7 | __all__ = [ 8 | # Module 9 | "ShippingInfra", 10 | ] 11 | 12 | 13 | class ShippingInfra(injector.Module): 14 | @injector.multiprovider 15 | def sync_order_summary_projection( 16 | self, session: Session 17 | ) -> list[SynchronousProjection]: 18 | return [ 19 | OrderSummaryProjection(session=session), 20 | ] 21 | -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping_infra/projections/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/shipping_infra/projections/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping_infra/projections/order_summary.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from functools import singledispatchmethod 3 | from uuid import UUID 4 | 5 | from attr import define 6 | from sqlalchemy import BigInteger, Column, DateTime, Enum, Integer 7 | from sqlalchemy.orm import Session 8 | 9 | from itca.db import GUID, JSONB, Base 10 | from itca.event_sourcing.event import EsEvent 11 | from itca.event_sourcing.projection import SynchronousProjection 12 | from itca.foundation.money import USD, Money 13 | from itca.foundation.serde import converter 14 | from itca.shipping.domain.aggregates.order import ( 15 | OrderDrafted, 16 | OrderPaid, 17 | ProductAdded, 18 | ) 19 | 20 | 21 | class OrderStatus(enum.Enum): 22 | DRAFT = "DRAFT" 23 | PAID = "PAID" 24 | 25 | 26 | class OrderSummary(Base): 27 | __tablename__ = "order_summaries" 28 | 29 | uuid = Column(GUID(), primary_key=True) 30 | version = Column(BigInteger(), nullable=False) 31 | status = Column(Enum(OrderStatus), nullable=False) 32 | total_quantity = Column(Integer(), nullable=False) 33 | total_price = Column(JSONB(), nullable=False) 34 | updated_at = Column(DateTime(timezone=True), nullable=False) 35 | 36 | def set_updated_at_and_version_for(self, event: EsEvent) -> None: 37 | self.version = event.version 38 | self.updated_at = event.created_at 39 | 40 | 41 | @define 42 | class OrderSummaryProjection(SynchronousProjection): 43 | _session: Session 44 | 45 | def __call__(self, events: list[EsEvent]) -> None: 46 | if not events: 47 | return 48 | 49 | model = self._get_model(events[0].aggregate_uuid) 50 | 51 | for event in events: 52 | self._project(event, model) 53 | model.set_updated_at_and_version_for(event) 54 | 55 | def _get_model(self, aggregate_uuid: UUID) -> OrderSummary: 56 | model = self._session.query(OrderSummary).get(aggregate_uuid) 57 | if not model: 58 | model = OrderSummary(uuid=aggregate_uuid) 59 | self._session.add(model) 60 | 61 | return model 62 | 63 | @singledispatchmethod 64 | def _project(self, event: EsEvent, model: OrderSummary) -> None: 65 | pass 66 | 67 | @_project.register 68 | def _project_drafted( 69 | self, event: OrderDrafted, model: OrderSummary 70 | ) -> None: 71 | model.status = OrderStatus.DRAFT 72 | model.total_quantity = 0 73 | model.total_price = converter.unstructure(Money(USD, 0)) 74 | 75 | @_project.register 76 | def _project_product_added( 77 | self, event: ProductAdded, model: OrderSummary 78 | ) -> None: 79 | model.total_quantity += event.quantity 80 | old_total = converter.structure(model.total_price, Money) 81 | new_total = old_total + (event.unit_price * event.quantity) 82 | model.total_price = converter.unstructure(new_total) 83 | 84 | @_project.register 85 | def _project_paid(self, event: OrderPaid, model: OrderSummary) -> None: 86 | model.status = OrderStatus.PAID 87 | -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping_infra/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/shipping_infra/repositories/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/shipping_infra/repositories/orders.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | 3 | from itca.event_sourcing import EventStore 4 | from itca.shipping import OrdersRepository 5 | from itca.shipping.domain.aggregates.order import Order 6 | from itca.shipping.domain.value_objects.order_id import OrderId 7 | 8 | 9 | @define 10 | class EventStoreOrdersRepository(OrdersRepository): 11 | _event_store: EventStore 12 | 13 | def get(self, order_id: OrderId) -> Order: 14 | try: 15 | stream = self._event_store.load_stream(order_id) 16 | except EventStore.NotFound: 17 | raise OrdersRepository.NotFound 18 | return Order(stream) 19 | 20 | def save(self, order: Order) -> None: 21 | changes = order.changes 22 | self._event_store.append_to_stream(changes) 23 | if changes.expected_version % 100 == 0: 24 | snapshot = order.take_snapshot() 25 | self._event_store.save_snapshot(snapshot) 26 | -------------------------------------------------------------------------------- /auctioning_platform/itca/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/tasks/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/tasks/app.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | 3 | app = Celery("itca", broker="redis://localhost:6379/0") 4 | -------------------------------------------------------------------------------- /auctioning_platform/itca/tasks/celery_injector.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | from injector import Injector 3 | 4 | ATTRIBUTE = "__container__" 5 | 6 | 7 | def install(app: Celery, container: Injector) -> None: 8 | setattr(app, ATTRIBUTE, container) 9 | -------------------------------------------------------------------------------- /auctioning_platform/itca/tasks/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any 3 | 4 | from celery import Celery 5 | 6 | from itca.main import assemble 7 | from itca.tasks import celery_injector 8 | from itca.tasks.app import app 9 | from itca.tasks.outbox.tasks import send_out_from_outbox 10 | 11 | config_path = os.environ.get("CONFIG_PATH", "config.ini") 12 | container = assemble(config_path=config_path) 13 | celery_injector.install(app, container) 14 | 15 | 16 | @app.on_after_configure.connect 17 | def setup_periodic_tasks(sender: Celery, **kwargs: Any) -> None: 18 | sender.add_periodic_task( 19 | 1.0, 20 | send_out_from_outbox.s(), 21 | name="Send out from outbox", 22 | expires=1, 23 | ) 24 | -------------------------------------------------------------------------------- /auctioning_platform/itca/tasks/outbox/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/itca/tasks/outbox/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/itca/tasks/outbox/model.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import BigInteger, Column, Integer, String 2 | 3 | from itca.db import JSONB, Base 4 | 5 | 6 | class OutboxMessage(Base): 7 | __tablename__ = "outbox_messages" 8 | 9 | id = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True) 10 | listener = Column(String(), nullable=False) 11 | event = Column(String(), nullable=False) 12 | event_payload = Column(JSONB(), nullable=False) 13 | -------------------------------------------------------------------------------- /auctioning_platform/itca/tasks/outbox/tasks.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | from typing import Type 4 | 5 | from sqlalchemy import delete, select 6 | from sqlalchemy.orm import Session 7 | 8 | from itca.foundation.serde import converter 9 | from itca.tasks.app import app 10 | from itca.tasks.outbox.model import OutboxMessage 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | @app.task() 16 | def send_out_from_outbox() -> None: 17 | session: Session = app.__container__.get(Session) 18 | with session.begin(): 19 | pending_stmt = ( 20 | select(OutboxMessage).order_by(OutboxMessage.id).limit(100) 21 | ) 22 | 23 | messages: list[OutboxMessage] = ( 24 | session.execute(pending_stmt).scalars().all() 25 | ) 26 | logger.error("Sending out %d messages", len(messages)) 27 | for message in messages: 28 | execute_listener.delay( 29 | message.listener, message.event, message.event_payload 30 | ) 31 | 32 | delete_stmt = delete(OutboxMessage).filter( 33 | OutboxMessage.id.in_([message.id for message in messages]) 34 | ) 35 | session.execute(delete_stmt) 36 | 37 | 38 | @app.task() 39 | def execute_listener( 40 | listener_class_name: str, event_cls_name: str, event_payload: str 41 | ) -> None: 42 | listener_cls = get_cls_by_qualified_name(listener_class_name) 43 | event_cls = get_cls_by_qualified_name(event_cls_name) 44 | event = converter.structure(event_payload, event_cls) 45 | 46 | listener = app.__container__.get(listener_cls) 47 | session = app.__container__.get(Session) 48 | try: 49 | listener(event) 50 | session.commit() 51 | finally: 52 | session.close() 53 | 54 | 55 | def get_cls_by_qualified_name(qualified_name: str) -> Type: 56 | mod_name, class_name = qualified_name.rsplit(".", maxsplit=1) 57 | module = importlib.import_module(mod_name) 58 | return getattr(module, class_name) 59 | -------------------------------------------------------------------------------- /auctioning_platform/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 80 3 | target-version = ['py39'] 4 | 5 | [tool.isort] 6 | profile = "black" 7 | line_length = 80 8 | 9 | [tool.poetry] 10 | name = "auctioning_platform" 11 | version = "1.0.0" 12 | authors = [] 13 | description = "" 14 | license = "MIT" 15 | 16 | [tool.poetry.dependencies] 17 | python = "^3.9" 18 | attrs = "^21.2.0" 19 | Flask = "^2.0.1" 20 | injector = "^0.18.4" 21 | SQLAlchemy = "^1.4.23" 22 | Flask-Injector = "^0.13.0" 23 | cattrs = "^1.8.0" 24 | alembic = "^1.7.3" 25 | marshmallow = "^3.13.0" 26 | requests = "^2.26.0" 27 | yarl = "^1.6.3" 28 | types-requests = "^2.25.6" 29 | psycopg2-binary = "^2.9.1" 30 | celery = {extras = ["redis"], version = "^5.1.2"} 31 | redis = "^3.5.3" 32 | pottery = "^1.3.6" 33 | types-redis = "^3.5.8" 34 | factory-boy = "^3.2.0" 35 | freezegun = "^1.1.0" 36 | types-freezegun = "^1.1.0" 37 | retrying = "^1.3.3" 38 | 39 | [tool.poetry.dev-dependencies] 40 | black = "^21.9b0" 41 | mypy = "^0.910" 42 | pytest = "^6.2.5" 43 | isort = "^5.9.3" 44 | flake8 = "^3.9.2" 45 | vcrpy = "^4.1.1" 46 | 47 | [build-system] 48 | requires = ["poetry-core>=1.0.0"] 49 | build-backend = "poetry.core.masonry.api" 50 | -------------------------------------------------------------------------------- /auctioning_platform/setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max_line_length = 80 3 | 4 | -------------------------------------------------------------------------------- /auctioning_platform/test_config.ini: -------------------------------------------------------------------------------- 1 | [database] 2 | url = sqlite:// 3 | 4 | [redis] 5 | url = redis://localhost:6379/1 6 | 7 | [bripe] 8 | username = test 9 | password = test 10 | -------------------------------------------------------------------------------- /auctioning_platform/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/auctions/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/acceptance/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/auctions/acceptance/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/acceptance/test_placing_bid.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from freezegun import freeze_time 6 | 7 | from itca.auctions import ( 8 | AuctionId, 9 | AuctionsDescriptorsRepository, 10 | AuctionsRepository, 11 | BidderHasBeenOverbid, 12 | PlacingBid, 13 | PlacingBidInputDto, 14 | PlacingBidOutputBoundary, 15 | PlacingBidOutputDto, 16 | StartingAuction, 17 | StartingAuctionInputDto, 18 | ) 19 | from itca.auctions.domain.entities.auction_descriptor import AuctionDescriptor 20 | from itca.auctions.domain.exceptions.bid_on_ended_auction import ( 21 | BidOnEndedAuction, 22 | ) 23 | from itca.foundation.event_bus import EventBus 24 | from itca.foundation.money import USD, Money 25 | from tests.doubles.in_memory_auctions_repo import InMemoryAuctionsRepository 26 | 27 | AUCTION_ID = 1 28 | 29 | 30 | class AuctionsDescriptorsRepositoryFake(AuctionsDescriptorsRepository): 31 | def get(self, auction_id: AuctionId) -> AuctionDescriptor: 32 | raise NotImplementedError 33 | 34 | def add(self, descriptor: AuctionDescriptor) -> None: 35 | descriptor.id = AUCTION_ID 36 | 37 | def delete(self, descriptor: AuctionDescriptor) -> None: 38 | raise NotImplementedError 39 | 40 | 41 | @pytest.fixture() 42 | def starting_auction(auctions_repo: AuctionsRepository) -> StartingAuction: 43 | return StartingAuction( 44 | auctions_repo=auctions_repo, 45 | auctions_descriptors_repo=AuctionsDescriptorsRepositoryFake(), 46 | ) 47 | 48 | 49 | def test_overbidding_emits_events_about_new_winner_and_overbid( 50 | starting_auction: StartingAuction, 51 | placing_bid: PlacingBid, 52 | event_bus: Mock, 53 | ) -> None: 54 | # Arrange 55 | starting_auction.execute( 56 | StartingAuctionInputDto( 57 | stating_price=Money(USD, "1.00"), 58 | end_time=datetime.now() + timedelta(days=3), 59 | title="Yellow Submarine", 60 | description="...", 61 | ) 62 | ) 63 | placing_bid.execute( 64 | PlacingBidInputDto( 65 | bidder_id=1, 66 | auction_id=AUCTION_ID, 67 | amount=Money(USD, "2.00"), 68 | ) 69 | ) 70 | 71 | # Act 72 | event_bus.reset_mock() 73 | placing_bid.execute( 74 | PlacingBidInputDto( 75 | bidder_id=2, 76 | auction_id=AUCTION_ID, 77 | amount=Money(USD, "3.00"), 78 | ) 79 | ) 80 | 81 | # Assert 82 | event_bus.publish.assert_called_once_with( 83 | BidderHasBeenOverbid( 84 | auction_id=AUCTION_ID, 85 | bidder_id=1, 86 | old_price=Money(USD, "2.00"), 87 | new_price=Money(USD, "3.00"), 88 | ) 89 | ) 90 | 91 | 92 | def test_overbidding_returns_winner_and_new_price( 93 | starting_auction: StartingAuction, 94 | placing_bid: PlacingBid, 95 | placing_bid_output_boundary: Mock, 96 | ) -> None: 97 | # Arrange 98 | starting_auction.execute( 99 | StartingAuctionInputDto( 100 | stating_price=Money(USD, "1.00"), 101 | end_time=datetime.now() + timedelta(days=3), 102 | title="Yellow Submarine", 103 | description="...", 104 | ) 105 | ) 106 | placing_bid.execute( 107 | PlacingBidInputDto( 108 | bidder_id=1, 109 | auction_id=AUCTION_ID, 110 | amount=Money(USD, "2.00"), 111 | ) 112 | ) 113 | 114 | # Act 115 | placing_bid_output_boundary.reset_mock() 116 | placing_bid.execute( 117 | PlacingBidInputDto( 118 | bidder_id=2, 119 | auction_id=AUCTION_ID, 120 | amount=Money(USD, "3.00"), 121 | ) 122 | ) 123 | 124 | # Assert 125 | placing_bid_output_boundary.present.assert_called_once_with( 126 | PlacingBidOutputDto( 127 | is_winning=True, 128 | current_price=Money(USD, "3.00"), 129 | ) 130 | ) 131 | 132 | 133 | def test_bidding_on_ended_auction_raises_exception( 134 | starting_auction: StartingAuction, 135 | placing_bid: PlacingBid, 136 | ) -> None: 137 | # Arrange 138 | with freeze_time(datetime.now() - timedelta(days=1)): 139 | starting_auction.execute( 140 | StartingAuctionInputDto( 141 | stating_price=Money(USD, "1.00"), 142 | end_time=datetime.now(), 143 | title="Yellow Submarine", 144 | description="...", 145 | ) 146 | ) 147 | 148 | # Act & Assert 149 | with pytest.raises(BidOnEndedAuction): 150 | placing_bid.execute( 151 | PlacingBidInputDto( 152 | bidder_id=1, 153 | auction_id=AUCTION_ID, 154 | amount=Money(USD, "2.00"), 155 | ) 156 | ) 157 | 158 | 159 | @pytest.fixture() 160 | def placing_bid( 161 | auctions_repo: AuctionsRepository, 162 | event_bus: EventBus, 163 | placing_bid_output_boundary: PlacingBidOutputBoundary, 164 | ) -> PlacingBid: 165 | return PlacingBid( 166 | output_boundary=placing_bid_output_boundary, 167 | auctions_repo=auctions_repo, 168 | event_bus=event_bus, 169 | ) 170 | 171 | 172 | @pytest.fixture() 173 | def auctions_repo() -> AuctionsRepository: 174 | return InMemoryAuctionsRepository() 175 | 176 | 177 | @pytest.fixture() 178 | def event_bus() -> EventBus: 179 | return Mock(spec_set=EventBus) 180 | 181 | 182 | @pytest.fixture() 183 | def placing_bid_output_boundary() -> Mock: 184 | return Mock(spec_set=PlacingBidOutputBoundary) 185 | -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/auctions/app/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/app/use_cases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/auctions/app/use_cases/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/app/use_cases/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from itca.auctions import AuctionsRepository 4 | from tests.doubles.in_memory_auctions_repo import InMemoryAuctionsRepository 5 | 6 | 7 | @pytest.fixture() 8 | def repo() -> AuctionsRepository: 9 | return InMemoryAuctionsRepository() 10 | -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/app/use_cases/test_finalizing_auction.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | 5 | from itca.auctions import ( 6 | AuctionsRepository, 7 | PlacingBid, 8 | PlacingBidInputDto, 9 | PlacingBidOutputBoundary, 10 | ) 11 | from itca.auctions.app.ports.payments import Payments 12 | from itca.auctions.app.use_cases.finalizing_auction import ( 13 | FinalizingAuction, 14 | FinalizingAuctionInputDto, 15 | ) 16 | from itca.foundation.event_bus import EventBus 17 | from itca.foundation.money import USD, Money 18 | from tests.auctions.factories import create_auction 19 | 20 | 21 | def test_calls_payments_with_token_and_current_price( 22 | repo: AuctionsRepository, placing_bid: PlacingBid 23 | ): 24 | auction_id = create_auction(repo=repo, starting_price=Money(USD, "1.00")) 25 | placing_bid.execute( 26 | PlacingBidInputDto( 27 | auction_id=auction_id, bidder_id=1, amount=Money(USD, "2.00") 28 | ) 29 | ) 30 | payments_mock = Mock(Payments) 31 | 32 | uc = FinalizingAuction(payments=payments_mock, auctions_repo=repo) 33 | uc.execute( 34 | FinalizingAuctionInputDto( 35 | auction_id=auction_id, bidder_id=1, payment_token="TOKEN123" 36 | ) 37 | ) 38 | 39 | payments_mock.pay.assert_called_once_with( 40 | token="TOKEN123", amount=Money(USD, "2.00") 41 | ) 42 | 43 | 44 | @pytest.fixture 45 | def placing_bid(repo: AuctionsRepository) -> PlacingBid: 46 | return PlacingBid( 47 | output_boundary=Mock(PlacingBidOutputBoundary), 48 | auctions_repo=repo, 49 | event_bus=Mock(EventBus), 50 | ) 51 | -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/app/use_cases/test_placing_bid.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | 6 | from itca.auctions import AuctionsRepository 7 | from itca.auctions.app.use_cases.placing_bid import ( 8 | PlacingBid, 9 | PlacingBidInputDto, 10 | PlacingBidOutputBoundary, 11 | PlacingBidOutputDto, 12 | ) 13 | from itca.auctions.domain.exceptions.bid_on_ended_auction import ( 14 | BidOnEndedAuction, 15 | ) 16 | from itca.foundation.event_bus import EventBus 17 | from itca.foundation.money import USD, Money 18 | from tests.auctions.factories import create_auction 19 | 20 | 21 | def test_presents_winning_and_10_usd_price_when_higher_bid_placed( 22 | repo: AuctionsRepository, 23 | ) -> None: 24 | output_boundary_mock = Mock(spec_set=PlacingBidOutputBoundary) 25 | event_bus = Mock(spec_set=EventBus) 26 | auction_id = create_auction(repo) 27 | use_case = PlacingBid( 28 | output_boundary=output_boundary_mock, 29 | auctions_repo=repo, 30 | event_bus=event_bus, 31 | ) 32 | 33 | price = Money(USD, "10.00") 34 | input_dto = PlacingBidInputDto( 35 | bidder_id=1, 36 | auction_id=auction_id, 37 | amount=price, 38 | ) 39 | use_case.execute(input_dto) 40 | 41 | output_boundary_mock.present.assert_called_once_with( 42 | PlacingBidOutputDto(is_winning=True, current_price=price) 43 | ) 44 | 45 | 46 | def test_bidding_on_ended_auction_raises_exception( 47 | repo: AuctionsRepository, 48 | ) -> None: 49 | yesterday = datetime.now() - timedelta(days=1) 50 | auction_id = create_auction( 51 | repo, starting_price=Money(USD, "1"), ends_at=yesterday 52 | ) 53 | use_case = PlacingBid( 54 | output_boundary=Mock(PlacingBidOutputBoundary), 55 | auctions_repo=repo, 56 | event_bus=Mock(EventBus), 57 | ) 58 | 59 | with pytest.raises(BidOnEndedAuction): 60 | use_case.execute( 61 | PlacingBidInputDto( 62 | bidder_id=1, auction_id=auction_id, amount=Money(USD, "10") 63 | ) 64 | ) 65 | -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/auctions/domain/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/domain/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/auctions/domain/entities/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/domain/entities/test_auction.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from itca.auctions.domain.events.bidder_has_been_overbid import ( 4 | BidderHasBeenOverbid, 5 | ) 6 | from itca.auctions.domain.exceptions.bid_on_ended_auction import ( 7 | BidOnEndedAuction, 8 | ) 9 | from itca.foundation.money import USD, Money 10 | from tests.auctions.factories import AuctionFactory, build_auction 11 | 12 | 13 | def test_new_auction_has_current_price_equal_to_starting() -> None: 14 | starting_price = Money(USD, "12.99") 15 | auction = build_auction(starting_price=starting_price) 16 | 17 | assert starting_price == auction.current_price 18 | 19 | 20 | def test_placing_bid_on_ended_auction_raises_exception() -> None: 21 | auction = AuctionFactory.build( 22 | starting_price=Money(USD, "1.00"), 23 | ended=True, 24 | ) 25 | 26 | with pytest.raises(BidOnEndedAuction): 27 | auction.place_bid(bidder_id=1, amount=Money(USD, "2.00")) 28 | 29 | 30 | def test_returns_event_upon_overbid() -> None: 31 | auction = build_auction(starting_price=Money(USD, "1.00")) 32 | 33 | auction.place_bid(bidder_id=1, amount=Money(USD, "2.00")) 34 | events = auction.place_bid(bidder_id=2, amount=Money(USD, "3.00")) 35 | 36 | assert ( 37 | BidderHasBeenOverbid( 38 | auction_id=auction.id, 39 | bidder_id=1, 40 | old_price=Money(USD, "2.00"), 41 | new_price=Money(USD, "3.00"), 42 | ) 43 | in events 44 | ) 45 | 46 | 47 | def test_current_price_reflects_the_highest_bid() -> None: 48 | auction = AuctionFactory.build(starting_price=Money(USD, "10.00")) 49 | 50 | auction.place_bid(bidder_id=1, amount=Money(USD, "11.00")) 51 | auction.place_bid(bidder_id=2, amount=Money(USD, "12.00")) 52 | 53 | assert auction.current_price == Money(USD, "12.00") 54 | -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions/factories.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import factory 4 | 5 | from itca.auctions import AuctionsRepository 6 | from itca.auctions.domain.entities.auction import Auction 7 | from itca.auctions.domain.value_objects.auction_id import AuctionId 8 | from itca.foundation.money import USD, Money 9 | 10 | 11 | def create_auction( 12 | repo: AuctionsRepository, 13 | starting_price: Money = Money(USD, "5.00"), 14 | ends_at: datetime = datetime.now() + timedelta(days=3), 15 | ) -> AuctionId: 16 | auction = build_auction(starting_price=starting_price, ends_at=ends_at) 17 | repo.save(auction) 18 | return auction.id 19 | 20 | 21 | def build_auction( 22 | starting_price: Money = Money(USD, "5.00"), 23 | ends_at: datetime = datetime.now() + timedelta(days=3), 24 | ) -> Auction: 25 | auction_id = 2 26 | return Auction( 27 | id=auction_id, 28 | starting_price=starting_price, 29 | bids=[], 30 | ends_at=ends_at, 31 | ) 32 | 33 | 34 | class AuctionFactory(factory.Factory): 35 | class Meta: 36 | model = Auction 37 | 38 | class Params: 39 | ended = False 40 | 41 | id = factory.Sequence(lambda n: n) 42 | bids = factory.List([]) 43 | starting_price = Money(USD, "5.00") 44 | ends_at: datetime = factory.LazyAttribute( 45 | lambda o: datetime.now() + timedelta(days=3) 46 | if not o.ended 47 | else datetime.now() - timedelta(days=1) 48 | ) 49 | -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions_infra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/auctions_infra/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions_infra/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/auctions_infra/adapters/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions_infra/adapters/bripe_failure.yml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"card_token": "", "currency": "EUR", "amount": 500}' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Basic dGVzdDp0ZXN0 11 | Connection: 12 | - keep-alive 13 | Content-Length: 14 | - '52' 15 | Content-Type: 16 | - application/json 17 | User-Agent: 18 | - python-requests/2.26.0 19 | method: POST 20 | uri: http://localhost:5050/api/v1/charge 21 | response: 22 | body: 23 | string: ' 24 | 25 | 400 Bad Request 26 | 27 |

Bad Request

28 | 29 |

The browser (or proxy) sent a request that this server could not understand.

30 | 31 | ' 32 | headers: 33 | Content-Length: 34 | - '192' 35 | Content-Type: 36 | - text/html; charset=utf-8 37 | Date: 38 | - Sat, 18 Sep 2021 13:44:30 GMT 39 | Server: 40 | - Werkzeug/2.0.1 Python/3.9.0 41 | status: 42 | code: 400 43 | message: BAD REQUEST 44 | version: 1 45 | -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions_infra/adapters/bripe_success.yml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"card_token": "IRRELEVANT", "currency": "USD", "amount": 1000}' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Basic dGVzdDp0ZXN0 11 | Connection: 12 | - keep-alive 13 | Content-Length: 14 | - '63' 15 | Content-Type: 16 | - application/json 17 | User-Agent: 18 | - python-requests/2.26.0 19 | method: POST 20 | uri: http://localhost:5050/api/v1/charge 21 | response: 22 | body: 23 | string: '{"charge_uuid":"e78bceb6-c59a-4d6b-aebe-c9eeafbce613","success":true} 24 | 25 | ' 26 | headers: 27 | Content-Length: 28 | - '70' 29 | Content-Type: 30 | - application/json 31 | Date: 32 | - Sat, 18 Sep 2021 13:42:14 GMT 33 | Server: 34 | - Werkzeug/2.0.1 Python/3.9.0 35 | status: 36 | code: 200 37 | message: OK 38 | version: 1 39 | -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions_infra/adapters/test_payments.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import vcr 5 | 6 | from itca.auctions.app.ports.payments import PaymentFailed 7 | from itca.auctions_infra.adapters.payments import BripePayments 8 | from itca.foundation.money import EUR, USD, Money 9 | 10 | 11 | @vcr.use_cassette(str(Path(__file__).parent / "bripe_success.yml")) 12 | def test_doesnt_raise_exception_if_payment_succeeds( 13 | payments: BripePayments, 14 | ) -> None: 15 | try: 16 | payments.pay(token="IRRELEVANT", amount=Money(USD, "10")) 17 | except PaymentFailed: 18 | pytest.fail("Failed when it should succeed!") 19 | 20 | 21 | @vcr.use_cassette(str(Path(__file__).parent / "bripe_failure.yml")) 22 | def test_raises_exception_when_request_fails(payments: BripePayments) -> None: 23 | with pytest.raises(PaymentFailed): 24 | payments.pay(token="", amount=Money(EUR, "5")) 25 | 26 | 27 | @pytest.fixture() 28 | def payments() -> BripePayments: 29 | return BripePayments( 30 | username="test", password="test", base_url="http://localhost:5050/" 31 | ) 32 | -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions_infra/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import create_engine 3 | from sqlalchemy.orm import Session, sessionmaker 4 | 5 | from itca.db import metadata 6 | 7 | 8 | @pytest.fixture() 9 | def session() -> Session: 10 | engine = create_engine("sqlite://", future=True, echo=True) 11 | metadata.create_all(bind=engine) 12 | return sessionmaker(bind=engine, future=True)() 13 | -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions_infra/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/auctions_infra/repositories/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/auctions_infra/repositories/test_auctions.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from itca.auctions.domain.entities.auction import Auction, Bid 6 | from itca.auctions_infra.repositories.auctions import ( 7 | SqlAlchemyAuctionsRepository, 8 | ) 9 | from itca.foundation.money import USD, Money 10 | 11 | 12 | def test_should_get_back_saved_auction(session: Session) -> None: 13 | bids = [ 14 | Bid( 15 | id=1, 16 | bidder_id=1, 17 | amount=Money(USD, "15.99"), 18 | ) 19 | ] 20 | auction = Auction( 21 | id=1, 22 | starting_price=Money(USD, "9.99"), 23 | bids=bids, 24 | ends_at=datetime.now(), 25 | ) 26 | repo = SqlAlchemyAuctionsRepository(session) 27 | 28 | repo.save(auction) 29 | 30 | assert repo.get(auction.id) == auction 31 | -------------------------------------------------------------------------------- /auctioning_platform/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from injector import Injector 3 | from sqlalchemy.engine import Engine 4 | 5 | from itca.db import metadata 6 | from itca.main import assemble 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def container() -> Injector: 11 | ioc_container = assemble("test_config.ini") 12 | metadata.create_all(bind=ioc_container.get(Engine)) 13 | return ioc_container 14 | -------------------------------------------------------------------------------- /auctioning_platform/tests/doubles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/doubles/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/doubles/in_memory_auctions_repo.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from itca.auctions.app.repositories.auctions import AuctionsRepository 4 | from itca.auctions.domain.entities.auction import Auction 5 | from itca.auctions.domain.value_objects.auction_id import AuctionId 6 | 7 | 8 | class InMemoryAuctionsRepository(AuctionsRepository): 9 | def __init__(self) -> None: 10 | self._storage: dict[AuctionId, Auction] = {} # 1 11 | 12 | def get(self, auction_id: AuctionId) -> Auction: 13 | return copy.deepcopy(self._storage[auction_id]) # 2 14 | 15 | def save(self, auction: Auction) -> None: 16 | self._storage[auction.id] = copy.deepcopy(auction) # 3 17 | -------------------------------------------------------------------------------- /auctioning_platform/tests/doubles/test_in_memory_auctions_repo.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from itca.auctions.domain.entities.auction import Auction, Bid 4 | from itca.foundation.money import USD, Money 5 | from tests.doubles.in_memory_auctions_repo import InMemoryAuctionsRepository 6 | 7 | 8 | def test_should_get_back_saved_auction() -> None: 9 | bids = [ 10 | Bid( 11 | id=1, 12 | bidder_id=1, 13 | amount=Money(USD, "15.99"), 14 | ) 15 | ] 16 | auction = Auction( 17 | id=1, 18 | starting_price=Money(USD, "9.99"), 19 | bids=bids, 20 | ends_at=datetime.now(), 21 | ) 22 | repo = InMemoryAuctionsRepository() 23 | 24 | repo.save(auction) 25 | 26 | assert repo.get(auction.id) == auction 27 | -------------------------------------------------------------------------------- /auctioning_platform/tests/payments/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/payments/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/payments/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/payments/api/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/payments/api/bripe_charge_then_capture.yml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"card_token": "irrevelant", "currency": "USD", "amount": "1500"}' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Basic dGVzdDp0ZXN0 11 | Connection: 12 | - keep-alive 13 | Content-Length: 14 | - '65' 15 | Content-Type: 16 | - application/json 17 | User-Agent: 18 | - python-requests/2.26.0 19 | method: POST 20 | uri: http://localhost:5050/api/v1/charge 21 | response: 22 | body: 23 | string: '{"charge_uuid":"f0a10345-ae65-4a6b-9de9-a61f1a3981bd","success":true} 24 | 25 | ' 26 | headers: 27 | Content-Length: 28 | - '70' 29 | Content-Type: 30 | - application/json 31 | Date: 32 | - Wed, 22 Sep 2021 19:54:10 GMT 33 | Server: 34 | - Werkzeug/2.0.1 Python/3.9.0 35 | status: 36 | code: 200 37 | message: OK 38 | - request: 39 | body: '{}' 40 | headers: 41 | Accept: 42 | - '*/*' 43 | Accept-Encoding: 44 | - gzip, deflate 45 | Authorization: 46 | - Basic dGVzdDp0ZXN0 47 | Connection: 48 | - keep-alive 49 | Content-Length: 50 | - '2' 51 | Content-Type: 52 | - application/json 53 | User-Agent: 54 | - python-requests/2.26.0 55 | method: POST 56 | uri: http://localhost:5050/api/v1/charges/f0a10345-ae65-4a6b-9de9-a61f1a3981bd/capture 57 | response: 58 | body: 59 | string: '{"success":true} 60 | 61 | ' 62 | headers: 63 | Content-Length: 64 | - '17' 65 | Content-Type: 66 | - application/json 67 | Date: 68 | - Wed, 22 Sep 2021 19:54:10 GMT 69 | Server: 70 | - Werkzeug/2.0.1 Python/3.9.0 71 | status: 72 | code: 200 73 | message: OK 74 | version: 1 75 | -------------------------------------------------------------------------------- /auctioning_platform/tests/payments/api/test_consumer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import vcr 5 | 6 | from itca.foundation.money import USD, Money 7 | from itca.payments.api import ApiConsumer, PaymentFailedError 8 | 9 | 10 | @vcr.use_cassette(str(Path(__file__).parent / "bripe_charge_then_capture.yml")) 11 | def test_capture_after_charge_succeeds( 12 | api_consumer: ApiConsumer, card_token: str 13 | ) -> None: 14 | charge_id = api_consumer.charge(card_token, Money(USD, "15.00")) 15 | 16 | try: 17 | api_consumer.capture(charge_id) 18 | except PaymentFailedError: 19 | pytest.fail("Should not fail!") 20 | 21 | 22 | @pytest.fixture() 23 | def api_consumer() -> ApiConsumer: 24 | return ApiConsumer("test", "test") 25 | 26 | 27 | @pytest.fixture() 28 | def card_token() -> str: 29 | return "irrevelant" 30 | -------------------------------------------------------------------------------- /auctioning_platform/tests/processes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/processes/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/processes/paying_for_won_auction/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/processes/paying_for_won_auction/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/processes/paying_for_won_auction/test_process_manager.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Iterator 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | from injector import Injector 7 | 8 | from itca.auctions import AuctionDetails, AuctionDetailsDto, AuctionEnded 9 | from itca.customer_relationship import CustomerRelationshipFacade 10 | from itca.foundation.money import USD, Money 11 | from itca.payments import PaymentsFacade 12 | from itca.processes.exceptions import InvalidRequest 13 | from itca.processes.locking import Lock 14 | from itca.processes.paying_for_won_auction import ( 15 | PayingForWonAuctionProcess, 16 | PayingForWonAuctionStateRepository, 17 | ) 18 | 19 | 20 | class AuctionDetailsStub(AuctionDetails): 21 | def query(self, auction_id: int) -> AuctionDetailsDto: 22 | return AuctionDetailsDto( 23 | auction_id=auction_id, 24 | title="Test", 25 | current_price=Money(USD, "9.99"), 26 | starting_price=Money(USD, "1.00"), 27 | top_bidders=[], 28 | ) 29 | 30 | 31 | def test_cannot_be_started_twice_for_the_same_auction( 32 | pm: PayingForWonAuctionProcess, 33 | ) -> None: 34 | event = AuctionEnded(auction_id=1, winner_id=2, price=Money(USD, "9.99")) 35 | pm(event) 36 | 37 | with pytest.raises(InvalidRequest): 38 | pm(event) 39 | 40 | 41 | @pytest.fixture() 42 | def pm(container: Injector) -> PayingForWonAuctionProcess: 43 | repo = container.get(PayingForWonAuctionStateRepository) # type: ignore 44 | return PayingForWonAuctionProcess( 45 | payments=Mock(spec_set=PaymentsFacade), 46 | customer_relationship=Mock(spec_set=CustomerRelationshipFacade), 47 | auction_details=AuctionDetailsStub(), 48 | repository=repo, 49 | locks=DummyLock(), 50 | ) 51 | 52 | 53 | class DummyLock(Lock): 54 | @contextmanager 55 | def acquire(self, name: str, expires_after: int, wait_for: int) -> Iterator: 56 | yield 57 | -------------------------------------------------------------------------------- /auctioning_platform/tests/shipping/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/shipping/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/shipping/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/shipping/domain/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/shipping/domain/aggregates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/shipping/domain/aggregates/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/shipping/domain/aggregates/test_order.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from uuid import uuid4 3 | 4 | import pytest 5 | from freezegun import freeze_time 6 | 7 | from itca.event_sourcing.event_stream import EventStream 8 | from itca.foundation.money import USD, Money 9 | from itca.shipping.domain import events as domain_events 10 | from itca.shipping.domain.aggregates.order import ( 11 | AlreadyPaid, 12 | Order, 13 | OrderLine, 14 | OrderPaid, 15 | ) 16 | 17 | 18 | def test_order_cannot_be_marked_as_paid_twice(order: Order) -> None: 19 | order.mark_as_paid() 20 | 21 | with pytest.raises(AlreadyPaid): 22 | order.mark_as_paid() 23 | 24 | 25 | def test_marking_as_paid_records_order_paid_event(order: Order) -> None: 26 | order.mark_as_paid() 27 | 28 | changes = order.changes 29 | assert len(changes.events) == 1 30 | assert isinstance(changes.events[0], OrderPaid) 31 | 32 | 33 | @freeze_time("2021-07-05 15:00:00") 34 | def test_snapshot_contains_products_and_paid_at(order: Order) -> None: 35 | order.add_product(product_id=1, quantity=1, unit_price=Money(USD, "1.99")) 36 | order.add_product(product_id=1, quantity=2, unit_price=Money(USD, "0.99")) 37 | order.add_product(product_id=2, quantity=1, unit_price=Money(USD, "7.99")) 38 | order.mark_as_paid() 39 | 40 | snapshot = order.take_snapshot() 41 | 42 | assert snapshot.aggregate_uuid == order.uuid 43 | assert snapshot.paid_at == datetime(2021, 7, 5, 15, tzinfo=timezone.utc) 44 | assert snapshot.lines == { 45 | 1: OrderLine(quantity=3, unit_price=Money(USD, "0.99")), 46 | 2: OrderLine(quantity=1, unit_price=Money(USD, "7.99")), 47 | } 48 | 49 | 50 | def test_snapshot_remembers_changes(order: Order) -> None: 51 | order.mark_as_paid() 52 | snapshot = order.take_snapshot() 53 | 54 | order_from_snapshot = Order( 55 | EventStream( 56 | uuid=order.uuid, events=[snapshot], version=snapshot.version 57 | ) 58 | ) 59 | 60 | with pytest.raises(AlreadyPaid): 61 | order_from_snapshot.mark_as_paid() 62 | 63 | 64 | def test_returns_domain_event_order_sent_upon_marking_as_sent( 65 | order: Order, 66 | ) -> None: 67 | order.mark_as_paid() 68 | 69 | returned_events = order.mark_as_sent() 70 | 71 | assert len(returned_events) == 1 72 | assert isinstance(returned_events[0], domain_events.ConsignmentShipped) 73 | 74 | 75 | @pytest.fixture() 76 | def order() -> Order: 77 | new_order = Order.draft(uuid=uuid4()) 78 | changes = new_order.changes 79 | return Order( 80 | EventStream( 81 | uuid=changes.aggregate_uuid, 82 | events=changes.events, 83 | version=1, 84 | ) 85 | ) 86 | -------------------------------------------------------------------------------- /auctioning_platform/tests/shipping/infra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/auctioning_platform/tests/shipping/infra/__init__.py -------------------------------------------------------------------------------- /auctioning_platform/tests/shipping/infra/test_persistence.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | from uuid import UUID, uuid4 3 | 4 | import pytest 5 | from injector import Injector 6 | from retrying import retry 7 | from sqlalchemy.orm import Session 8 | 9 | from itca.event_sourcing import EventStore 10 | from itca.foundation.money import USD, Money 11 | from itca.foundation.serde import converter 12 | from itca.shipping.domain.aggregates.order import AlreadyPaid, Order 13 | from itca.shipping_infra.projections.order_summary import ( 14 | OrderStatus, 15 | OrderSummary, 16 | ) 17 | 18 | 19 | def test_write_then_read(event_store: EventStore) -> None: 20 | uuid = uuid4() 21 | order = Order.draft(uuid=uuid) 22 | order.mark_as_paid() 23 | 24 | event_store.append_to_stream(order.changes) 25 | 26 | stream = event_store.load_stream(uuid) 27 | loaded_order = Order(stream) 28 | 29 | with pytest.raises(AlreadyPaid): 30 | loaded_order.mark_as_paid() 31 | 32 | 33 | def test_raises_concurrent_error_if_detects_race_condition( 34 | event_store: EventStore, 35 | ) -> None: 36 | uuid = uuid4() 37 | order = Order.draft(uuid=uuid) 38 | event_store.append_to_stream(order.changes) 39 | 40 | stream = event_store.load_stream(uuid) 41 | loaded_order = Order(stream) 42 | loaded_order.mark_as_paid() 43 | event_store.append_to_stream(loaded_order.changes) 44 | 45 | with pytest.raises(EventStore.ConcurrentStreamWriteError): 46 | event_store.append_to_stream(loaded_order.changes) 47 | 48 | 49 | def test_retrying_upon_concurrent_stream_write(event_store: EventStore) -> None: 50 | uuid = uuid4() 51 | order = Order.draft(uuid=uuid) 52 | event_store.append_to_stream(order.changes) 53 | 54 | @retry( 55 | retry_on_exception=lambda exc: isinstance( # 1 56 | exc, EventStore.ConcurrentStreamWriteError 57 | ), 58 | stop_max_attempt_number=2, # 2 59 | ) 60 | def execute(aggregate_uuid: UUID) -> None: # 3 61 | stream = event_store.load_stream(aggregate_uuid) 62 | order = Order(stream) 63 | order.mark_as_paid() 64 | event_store.append_to_stream(order.changes) 65 | 66 | with patch.object( 67 | event_store, 68 | "append_to_stream", 69 | wraps=event_store.append_to_stream, 70 | side_effect=[EventStore.ConcurrentStreamWriteError, None], 71 | ) as append_to_stream_mock: 72 | execute(uuid) 73 | 74 | assert len(append_to_stream_mock.mock_calls) == 2 75 | 76 | 77 | def test_loads_state_from_snapshot(event_store: EventStore) -> None: 78 | uuid = uuid4() 79 | order = Order.draft(uuid=uuid) 80 | order.add_product(product_id=1, quantity=2, unit_price=Money(USD, "2.99")) 81 | event_store.append_to_stream(order.changes) 82 | snapshot = order.take_snapshot() 83 | event_store.save_snapshot(snapshot) 84 | 85 | stream = event_store.load_stream(uuid) 86 | 87 | assert len(stream.events) == 1 88 | assert isinstance(stream.events[0], type(snapshot)) 89 | 90 | 91 | def test_projects_order(event_store: EventStore, session: Session) -> None: 92 | order = Order.draft(uuid=uuid4()) 93 | order.add_product(product_id=1, quantity=1, unit_price=Money(USD, "13.99")) 94 | order.add_product(product_id=2, quantity=3, unit_price=Money(USD, "7.50")) 95 | order.mark_as_paid() 96 | 97 | event_store.append_to_stream(order.changes) 98 | 99 | events = order.changes.events 100 | summary: OrderSummary = session.query(OrderSummary).get(order.uuid) 101 | assert summary.version == events[-1].version 102 | assert summary.total_quantity == 4 103 | assert converter.structure(summary.total_price, Money) == Money( 104 | USD, "36.49" 105 | ) 106 | assert summary.status == OrderStatus.PAID 107 | 108 | 109 | @pytest.fixture() 110 | def event_store(container: Injector) -> EventStore: 111 | return container.get(EventStore) # type: ignore 112 | 113 | 114 | @pytest.fixture() 115 | def session(container: Injector) -> Session: 116 | return container.get(Session) 117 | -------------------------------------------------------------------------------- /diagrams/10_1_test_pyramid.drawio: -------------------------------------------------------------------------------- 1 | 7Vhdc6IwFP01PNoRUMRHxXY/pt3ZWXe73ccIEdIGYkMQ7K/fGwhiRGu/nNqZPpl7knvJPTc5uaNhe3HxhaNFdMUCTA2rGxSGPTEsy+xZDvxIZFUhrjuogJCTQC1qgCl5wArsKjQjAU61hYIxKshCB32WJNgXGoY4Z7m+bM6o/tUFCnELmPqIttG/JBCRysIaNPhXTMKo/rLpDKuZGNWLVSZphAKWb0D2uWF7nDFRjeLCw1SSV/NS+V3smV1vjONEPMUBuXdud3yPfl/fxPn35SS7+jXq2FWUJaKZSlhtVqxqBgQnKAmlNc4jIvB0gXw5lUPBAYtETMEyYciZQIKwBMzOsAuACo65wMXeXZtrLuAQYRZjwVewRDnU7K30Y5E3tbC7Cos26rAGkap/uI7cUAQDxdIzGOudOmODg4xZuxizjsaY2yKIsywJsPSROTMuIhayBNFLxhaKmVssxErJAcoE03nDBRE3G+N/MtRZX1mTQkUujVVtJJDMzaax4SXNxq20ar85S8QFigmVgMcyTjCHdH7gXE2qXVpycSoQFyOpO4D4FKUp8Wv4gtB6/xUjOGhpz8Eic0zhxCx1v10lU64/GYGI68Nh93efjjpCCvn5WDltismBOPZ2IEg4xKIVqDxA63RefqaGO26hQ4G6cUCW2mFz7jOpsGWlOmlZqhEsMJ1F0UzCKFS/tF788ig5ownBtymssrw6JKRZRdW/BHC5Yx09gSRimULpjmaEJh85FYFTkd0aXs9w+6USyuFw6Mv8qvgz/vo9PpGVLSmUGnCJZtA1aQKHKAnlq0DxXLrJB4FAUzJScEyCoHxfOIbtoVkZTerPQl648gr2x0Z/oiuU6RySs73Pj2qv1JeapmZTsfaL/963qtM9c11H9VKvlDZzS9lsPQCbz1N8FC0yT70jMA83UeaulsA8WkvQb1H251ubNFwInZxUcHaHPUYZByRhiSR0Ds/qFlTfHh8IgtP9yP3ZVQ29O3nW81/aKgPzLSrX0yvntCvn7Ox+j1Q3p1W3KeZLAux9Fu9g8cxe/32rN2jfuoSIz9IdLp3Ve+eLV0v2aTa86cNq9rG7XdXlfviufavVVU38Z5P7Zk3u47phO1u64T5fEMBs/hSsetTmr1X7/D8= -------------------------------------------------------------------------------- /diagrams/10_1_test_pyramid.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/10_1_test_pyramid.drawio.png -------------------------------------------------------------------------------- /diagrams/10_2_test_pyramid.drawio: -------------------------------------------------------------------------------- 1 | 7Zdbc6MgGIZ/jZedEUyMvWxsu+3OZC/WPd0yQpQpgkswmv31CyueqhmzM3Xai1wJ7/eB8Lwc1PHCrPokUZ7uBCbMgS6uHO/egRCsoK8fRjnVShBsaiGRFNukTojoH2JF16oFxeQwSFRCMEXzoRgLzkmsBhqSUpTDtL1gw7fmKCEjIYoRG6s/KVapnQXcdPoToUnavBn4t3UkQ02ynckhRViUPcl7cLxQCqHqUlaFhBl4DZe63eOZaDswSbi6pAEKXgJ3+xt9+/ErKz8f74vd17sbr+7liFhhJ2wHq04NASUp4ompbcuUKhLlKDahUhuutVRlTNeALkqhkKKC6+rNrasF2zmRilRnRw1aFnoREZERJU86xTZo6Nnls7bVsvPCc62W9nxoRWT9T9qeO0S6YCn9B7HVRye2mSUGp4jBxYiBj04MzC8yMIUMLIZsPUL2/XkMjVRqCOegpHghoWBCaoULboDuKWOvJMRoYgjGGhDR+tbgo/rQu7OBjGJ81g0pCo6JGb7hvxdcPaKMMgMvFIWkukfofiGlDdozHTbJkZ0BeAvnVkPnWkd61vmTx8NCxvkj4yIij1Tju7o3716wfl/3NuNtx6m6WjdvHQzeeeOBC24Z3Y3+bDR09bdYbsSYiQLPXzqXogZ+z2NG9uqN7icwcSHBCbrBYnThiO4O8QKx696Y2hv+cG8sdqrpavf/8C/W+wvzHv4C -------------------------------------------------------------------------------- /diagrams/10_2_test_pyramid.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/10_2_test_pyramid.drawio.png -------------------------------------------------------------------------------- /diagrams/10_3_ice_cream_cone.drawio: -------------------------------------------------------------------------------- 1 | 7Zdbb5swGIZ/DZeVsJ0Setmk7bpJ2cXY6dbCDlg1NnNMIPv1s4s5BaJkWlHVqVfY7+fj8/qEh9ZZ9UHhPN1IQrkHfVJ56M6DECxgYD5WOdRKGC5rIVGMuEKdELHf1Im+UwtG6G5QUEvJNcuHYiyFoLEeaFgpWQ6LbSUf9prjhI6EKMZ8rP5gRKduFnDZ6Y+UJWnTMwhu6kiGm8JuJrsUE1n2JHTvobWSUteprFpTbuE1XOp6Dyei7cAUFfqSCjh8Cv3VL/z1+8+s/LS/KzZfbq9Q3coe88JN2A1WHxoCWjEsEptblSnTNMpxbEOlMdxoqc64yQGTVFJjzaQw2asb3whbzvJHF3QdUaVpdXIGoOViFhSVGdXqYIq4Cg1Jt5RA6PJlZwzynZb2TGlF7BZD0jbd8TIJh+wv8C3eEr7lEB+cwAen8MHZ8IG3hA8cLT80wQ9M8QOz8bse8fv2cUyQVnpIaqeVfKJryaUyipDC0t0yzo8kzFliccaGEDX6yvJj5my8dYGMEXLSGiULQagd/rMZUugHnDFu6a1loZhpEfqfaemC7uiHTeHIzQDM4OTiaCegsZPB5Dkyk4/ByMeIqj0zNP87M1/ePQSuX9e95XgXCqbfrTtv3QK88sYDF9xAphnz2LR0zQsut2LMZUHOX0iXogZBz2NOt/ofT7n+cwhN3edwNpxwhHODRYH5+2Y4vxnaZ8LLbwaT7f4znmO9vzV0/wc= -------------------------------------------------------------------------------- /diagrams/10_3_ice_cream_cone.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/10_3_ice_cream_cone.drawio.png -------------------------------------------------------------------------------- /diagrams/10_4_kite.drawio: -------------------------------------------------------------------------------- 1 | 7ZZNj5swEIZ/TY6psCmBHDfsbreVtofSr6uFJ2CtsaljAumvr1kGCCVRWylp1aoX5HlnbDPPaz4Wflw0rwwr80fNQS6ox5uFf7ugNCKBu7bCoRMCL+yEzAjeSWQUEvEVUPRQrQSH3aTQai2tKKdiqpWC1E40Zoyup2VbLae7liyDmZCkTM7VT4LbHNui4ag/gMjyfmeyWneZgvXF2MkuZ1zXR5J/t/Bjo7XtRkUTg2zZ9Vy6efdnssONGVD2Zyaw6CnyNl/Y+4+fi/rN/rZ6fHez9LtV9kxW2DDerD30BKwRTGVttKlzYSEpWdqmaue303JbSBcRNzTaMiu0cuFy7TlhK0X54CLvOVkpDhwj3BaMheZsP2Sg5E4X6AKsObgSnBCuXuDZwqO19JF0PRpF+uOWH5u0Ro8YHo5sWHzk5waI8BdwvvwtOMllAPa4EB+hfg/0CCD1vDnAQbw4QPI3AYymAOlJgOQUQHI1gMEM4IfXc4TQ2CmqnTX6CWIttXGK0qrFuxVSficxKbKWZ+oQgdM3LUDhXpc3mCgE52e9mb4DtlrZe1YI2fKLdWWEW5F6b6HGJH4NaF+cYAfk8lYOhqCX4SkrVyec9K9l5GpmZAJmLxzOf87Ny9tHguhP+xfOH0Ql7H/zfmweDa748Llw/OV6zh39t/p33wA= -------------------------------------------------------------------------------- /diagrams/10_4_kite.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/10_4_kite.drawio.png -------------------------------------------------------------------------------- /diagrams/2_1_diagram.drawio: -------------------------------------------------------------------------------- 1 | 5Zlbb+MoGIZ/jS8rxWf7Msl0ZivtzowUjUbamxXB2CG1jReTOs6vH7DxmTTdrZtO1KvACwHzPS/YgGauk+MXCrLdXyRAsWYsgqNmftIMQ9c9h/8IpawVx7ZrIaI4kJU6YYNPSIoLqR5wgPJBRUZIzHA2FCFJUwTZQAOUkmJYLSTxsNcMRGgibCCIp+pPHLBdrXqG2+l/IBztmp51x69LEtBUliPJdyAgRU8y7zVzTQlhdSo5rlEsgtfEpf7f5zOl7YNRlLKX/CFN/ik+f03KP6O/9Tv4bf/NKY93lsTzBOKDHLF8WlY2IeDN8GjzzKrYYYY2GYCipODAubZjScxzOk+CPKsRhPiIeK8rSg5pIFJVcRuABc9AkmAo0yGO4zWJCa36MwOAvBCKPzBKHlGvxIEe2oa8ZDp2GY4nRBk69iQZiy+IJIjRkleRpbYluUhjmr7MFx1my5Paroe4FYG0VtS23UWfJySA/wLD/Q1h2MgLLBUMz9iajjMPDGcxhGHZUximo4DRivPD8BQwnJj3uwrwE09GrBp7LW3pWOGdDuq9M8fQgwgqJ9XWs0W45+HoDDna+pSjYSk4tuL8HP2zHLdjZiHhI+9jcv49kKbgLq9eTktegUfp2BU2rXziT5yCHv+6uYktJt0q/IQb4T6Fe6QZa/FYiBY4L6tM2xi+5LvLXdSRMRZku+dOy5+19MXu3tvmYWiobR44W8cWy1VEQYC5xZuylKRoHve7xsj97tT9uqFwfyvO7v5mHVW4/3Vmn8yhZRbjRwD34Dmnn5kUPQd9p6cyA8EjN9rioK1tbbUuIQb1DMApQzREezEJXmQ/Do4NfTb0hWTfN5GUQIyjlGchEn2KWcJtgPl34VIWJDgI4nPG7sy76JtXH5l3Ds+N3py2qfCc6s35dpbTz1pu5gX3IQ0p4EAPj+xAZ1p4X/pEuvHcrGiXyYckixF/LTBQLeOdg7W1qa3M4sLSemYgH9vyjj/6WHTtqeVVX+5vZ3ljYvkVOAGx7QVpCXe3BokSBhgmornqy1DYcCOfXm/y9VZdt99mB+Da7nQ7dlWm5oTpj4cbBnl3LZK+8buRtCYkNxhpa0vz3BsG+l48VcvtdXnaU55lzlAi9jAZogCW+7S8YbJXm6qOPkarOAO7KlrVceQs2xW+k/CXBQaijRMqUjH9fZ/RU1qe/d66LQON/TH0zww7jdHZjqU427muWV5yXJoGS3ELIIIfgzwX0eoTHAetJtqc9Jtc4S3ICsZzkw4Fg3uEaRR7UbIVUWo0imK+DDwNbx9UoZM9fCe4mhfNqbY7hOSYo+jn5EAhkv/q3xaMGhovDZOGGKARYpOGKpLtsF8BV3X8+sHhtgxeDfeSS94arupM9qPD9WeC615yyf+Gy7PddWVdvbv0Ne9/AQ== -------------------------------------------------------------------------------- /diagrams/2_1_diagram.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/2_1_diagram.drawio.png -------------------------------------------------------------------------------- /diagrams/3_1_ref_sequence.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include https://raw.githubusercontent.com/bschwarz/puml-themes/master/themes/materia/puml-theme-materia.puml 3 | skinparam defaultFontSize 24 4 | skinparam dpi 300 5 | 6 | actor Klient as client 7 | participant PlacingBidView as view 8 | participant PlacingBidInputDto as input_dto 9 | participant PlacingBidUseCase as use_case 10 | participant AuctionsDataAccess as data_access 11 | participant Auction as auction 12 | participant PlacingBidOutputDto as output_dto 13 | participant PlacingBidOutputBoundary as output_boundary 14 | 15 | client -> view: post(http_request) 16 | view -> input_dto: << create >> 17 | activate input_dto 18 | 19 | view -> use_case: execute(input_dto) 20 | activate use_case 21 | deactivate input_dto 22 | 23 | use_case -> data_access: get(auction_id) 24 | activate data_access 25 | 26 | data_access -> auction: << create >> 27 | activate auction 28 | 29 | data_access --> use_case: auction 30 | 31 | use_case -> auction: place_bid(bidder_id, amount) 32 | 33 | use_case -> data_access: save(auction) 34 | deactivate data_access 35 | 36 | use_case -> auction: winner << get >> 37 | auction --> use_case: winner_id 38 | use_case -> auction: current_price << get >> 39 | auction --> use_case: current_price 40 | deactivate auction 41 | 42 | use_case -> output_dto: << create >> 43 | activate output_dto 44 | 45 | use_case -> output_boundary: present(output_dto) 46 | activate output_boundary 47 | deactivate use_case 48 | 49 | output_boundary --> client: is a winner & current price 50 | deactivate output_dto 51 | deactivate output_boundary 52 | 53 | @enduml 54 | 55 | -------------------------------------------------------------------------------- /diagrams/3_1_ref_sequence2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/3_1_ref_sequence2.png -------------------------------------------------------------------------------- /diagrams/3_1_referencyjna.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/3_1_referencyjna.png -------------------------------------------------------------------------------- /diagrams/3_2_ref_sequence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/3_2_ref_sequence.png -------------------------------------------------------------------------------- /diagrams/4_1_queries_ref_sequence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/4_1_queries_ref_sequence.png -------------------------------------------------------------------------------- /diagrams/4_1_queries_ref_sequence.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include https://raw.githubusercontent.com/bschwarz/puml-themes/master/themes/materia/puml-theme-materia.puml 3 | skinparam defaultFontSize 24 4 | skinparam dpi 300 5 | 6 | actor Klient as client 7 | participant PlacingBidView as view 8 | participant PlacingBidInputDto as input_dto 9 | participant PlacingBidUseCase as use_case 10 | participant AuctionsDataAccess as data_access 11 | participant Auction as auction 12 | participant GetAuctionQuery as query 13 | 14 | client -> view: post(http_request) 15 | activate view 16 | 17 | view -> input_dto: << create >> 18 | activate input_dto 19 | 20 | view -> use_case: execute(input_dto) 21 | activate use_case 22 | deactivate input_dto 23 | 24 | use_case -> data_access: get(auction_id) 25 | activate data_access 26 | 27 | data_access -> auction: << create >> 28 | activate auction 29 | 30 | data_access --> use_case: auction 31 | 32 | use_case -> auction: place_bid(bidder_id, amount) 33 | 34 | use_case -> data_access: save(auction) 35 | deactivate data_access 36 | deactivate auction 37 | deactivate use_case 38 | 39 | view -> query: get_details(auction_id) 40 | query --> view: auction_details_dto 41 | 42 | @enduml 43 | 44 | -------------------------------------------------------------------------------- /diagrams/6_1_separate_stacks.drawio: -------------------------------------------------------------------------------- 1 | 7VjLctowFP0aZtpFGD/AhmUgIV20nbRZJOlOtYUtLEseWca4X1/JlvwAB2hIMswUNtE9eli559xzDQN7Hm/uGEjCb9SHeGAZ/mZg3wwsyzJMW/yRSFEhjjmugIAhv4LMBnhAf6ACDYVmyIdpZyGnFHOUdEGPEgI93sEAYzTvLltS3H1qAgK4Azx4AO+ij8jnYYVOLLfBv0AUhPrJpjOtZn4DLwoYzYh6HqEEVjMx0Meo/zENgU/zFmTfDuw5o5RXo3gzh1imVWes2rd4Yba+MoOEH7Nhml//NKIQLlcTjGer8Y/HqXFla+bWAGcqGQPLweLEmY/WYhjI4SNgKc+BTC0DMcwpi4BeJp7YWtmz+VOOfBqhzy9tKJPDC81FmU0oL22I6TxEHD4kwJOzuVCfwEIeYxGZYlgnVa71aIw8NU45o1FNply6RBjPKaasfIy9LD8Sp4QrRZqClBnAKCAi8EReIauPam01yk999zYFipU1ZBxuWpCi5A7SGHJWiCVq9srV+lClY6swb3Q4GiksbGnQURhQ0g/qoxsNiIGSwT9JYrTDCfRFuaiQMh7SgBKAbxt01mWtxRDcIP4k4eFYRc96hojrtqZk+KwOgMS/lnUtecAgTSWvElwgXB9MtJE4IlpBzgsVg4xTATUX/UppogWzq4st/l8kNaUZ8+DezCnX44AFkO9d6VQrZWL3yoRBDDhad33qHTgf79hAU/MgwSgC3gpdarVbq2ZPsZqTjy1WZw9xvngKKS6sdVnrs9gPZs063WJ73a3HBQ9asaHGN5t2UNRGrEy6duznrmM328pI75OcL0CMsATmwjmRYNoyvsN8SxDmcW5/iiu7R7vy+Mxc2d1T3IiIdzFROVnEM3Yp8u3XqDN4j5pc3qP6WD1ch5rLt6tDtfWeInGZRjW221XNdEsMlbmoXVt6qK9xSh8Yn08f0DMH+oAxdKddmQ0N41A3KKN7yJDImDSDk1pE+R27reLKqADju1Iv4bdpI1qUR7QR97zaiL55q438AknBAUHgf+8bI+fsXukt+9I3+lh9927g2DITr+gHxxxVC0ufVfnIK3qLCJtfDavlza+y9u1f -------------------------------------------------------------------------------- /diagrams/6_1_separate_stacks.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/6_1_separate_stacks.drawio.png -------------------------------------------------------------------------------- /diagrams/6_2_separate_stacks_separate_database.drawio: -------------------------------------------------------------------------------- 1 | 7VpLd9o4FP41nDOzaI8f2MAykKRdZGY6k0WmsxO2sBVky0eWY8yvH8mS/IaQAiltySbS1cOyvu+7V1d4ZC+izScKkvAP4kM8sgx/M7JvR5ZlGabN/wlLIS2u6UhDQJEvTWZteERbqIyGsmbIh2mrIyMEM5S0jR6JY+ixlg1QSvJ2txXB7acmIIA9w6MHcN/6hHwWSuvUmtT2zxAFoX6y6c5kyxJ464CSLFbPi0kMZUsE9DTqHdMQ+CRvmOy7kb2ghDBZijYLiMW26h2T4+53tFZLpjBmhwyY5Tf/GOsQrp6nGM+fnb+fZsYHWyP3AnCmNmNkuZjPOPfRCy8GovgEaMpyILaWggjmhK6B7saf2Og5MPi3HPlkjX7fNaDcHFZoLMrdhGLRBm/OQ8TgYwI80Zpz9nFbyCLMayYvVpsq+nokQp4qp4ySdQWm6LpCGC8IJrR8jL0q/4SdxEwx0uSgzAFGQcwrHt9XSKupGkON8q9aexMChcoLpAxuGiYFySdIIshowbuo1g8TzQ8lHVtV85qH47GyhQ0OusoGFPWDauqaA7ygaPAmSox7mECfy0VVCWUhCUgM8F1tnbdRayAEN4j9K8wfHVX7qltivtxGk6h+VRPA2L8RuhY4YJCmAldhvEe4mjjWjsTltWfIWKHqIGOEm+qFPhCSaML0edHBfyeoKcmoB/funPJ6DNAAsr09XdlTbOxemlCIAUMvbT91BsydnhuoNQ8SjNbAe0ZXrba1ag6I1Zy+q1gt92ixDupkQE+Dop40VW2o8u2mWSkqTVd6n7QEXzmDelxZ0wMF6vcgQlgYFlyFiGNtGX/CvEMJKXBAmfYdZThWNuU6DvQux3iBycFewLkwL+Du8QI+f0pcXF1A2wUMxet3dgG22QPlXV1AL64f4gEGIv5uB1DWvkCK+I4JjH9ur2DNLssr2Nb35JfZYFfNtdf41WJXTbYrvwSexqn5VQ7l7waKRoeEoJiljZm/CEPDlTpGJ/NxOwnsawPG7QG8INdQc716mWPoP+kFRZ67gkiENpXPtmvGFiSIv7hRNQStbsNpr0DjASwhbsugF95ErEIewDeqIUK+LxUHU7QFy3I+QTe1/3xyZz5ybgdIu5fhfSru9w69GFrd1qg1jZrXHoOx1fjIHzJth1dZexsTa+boLmS1Srkouq7wFPQw+/Soz0woXlHADyTZmmX0enbqXnV8/7OTOT06tv2Udx0HnFLOE0X6Xt+etFkz65BBhkY16gwC1y/aEPh/ICkYiBH41RWtI/AlXYjYV0UPoXp2nbq22IlvUOohU1XE0nNJB3VG1R8fGE6WVO+/VvvB8hTr8Dz45BHmyDz4eM9yvWe5IH6dPg8+kl/j3kHjlUST+N62YL9Mpql/qj5Jpum6iigXnFta19zyW0+iF5Bb6nvcgU8rlvrrhznYyt9W9LVRVnWh3U8q+CqWO7+a4AgmougVGHHcqf066EvJkIdlZag+ZvkrY3waqNmhYHXeGg1OAazbAXZs9YF1B3Cdng1Xu58Q7sVVeukrsF3Fdm9zzwcsr9bfWUnvXH/HZt/9Dw== -------------------------------------------------------------------------------- /diagrams/6_2_separate_stacks_separate_database.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/6_2_separate_stacks_separate_database.drawio.png -------------------------------------------------------------------------------- /diagrams/6_3_separate_stacks_no_queries.drawio: -------------------------------------------------------------------------------- 1 | 7VhLc9owEP41zLSHZPwAA8dAHj20nbY5JDmqtrCFZS0jyxj66yvZkh/YUDokTGaCL2g/SSux37cr2QN3nmweOFpF3yDAdOBYwWbg3g4cx7FsV/4oZFsinj0qgZCToITsGngkf7AGLY1mJMBpa6AAoIKs2qAPjGFftDDEOeTtYQug7VVXKMQd4NFHtIs+kUBEJTpxxjX+BZMwMivb3rTs+Y38OOSQMb0eA4bLngQZN/o/phEKIG9A7t3AnXMAUbaSzRxTFVYTsXLe/Z7easscM3HMhGl+88uKI7xYTiidLUc/n6bWlWuYWyOa6WAMHI9Kj7OArGUzVM0nxFORIxVajhKcA4+RGSZXbIzsmfwpJwHE5PO+CUVwxNZwUUQTq01bsjuPiMCPK+Sr3lyqT2KRSKi0bNmsgqrG+pAQX7dTwSGuyFRDF4TSOVDgxTLuongUDkxoRdqSlBmiJGTS8GVcMa9cNaZaxVPtvUmBZmWNucCbBqQpecCQYMG3cojuvRobfejUcbWZ1zocDjUWNTToaQxp6YeV61oDsqFl8F+SGHY4wYFMF20CFxGEwBC9q9FZm7UGQ3hDxLOCr0faejE9TG630aXMF+0As+BG5bXigaI0Vbwq8J7QyjEzhcST1hILsdU2ygRIqN7oV4CVEUxXFzv87yU1hYz7+GDkdNUTiIdYHBzplSNVYA/KhGOKBFm369QbcD7qlIE659GKkhj5S3LJ1Xau2j3Jak/OmqyOd3Ky9uZJTz71JvW4mdWWbt9umsa2yukq38ethK+KQT2vsMxExfo9SghVwFxmIZFcO9Z3nO9IokxwxIWpHcVxrDFdOo6sLqdUgfHRVWD0zqqAd6AKBHIVtr2UgHYJ6Duvz1wCjN56WSNM3thkSLJYZPzC3u5l6x2wN/nQt61DdX8v4/+urObN7fUqq576A4jcTK0od9xW1HRHKOVxoWc13856HF1bjcfpv2kYv2UIOn4LDVZ/75R7hfuhZblXem8uKM9VkXgVSXVdnV9Gl7Pp2LNp6J3vbJJm/eGpJLv+sOfe/QU= -------------------------------------------------------------------------------- /diagrams/6_3_separate_stacks_no_queries.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/6_3_separate_stacks_no_queries.drawio.png -------------------------------------------------------------------------------- /diagrams/8_1_payment_sequence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/8_1_payment_sequence.png -------------------------------------------------------------------------------- /diagrams/8_1_payment_sequence.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include https://raw.githubusercontent.com/bschwarz/puml-themes/master/themes/materia/puml-theme-materia.puml 3 | skinparam defaultFontSize 24 4 | 5 | actor Licytujący as bidder 6 | participant Frontend as frontend 7 | participant Backend as backend 8 | participant PaymentProvider as provider 9 | 10 | bidder -> frontend: podaje dane karty 11 | frontend -> provider: przesyła dane karty 12 | provider --> frontend: zwraca token 13 | frontend -> backend: przekazuje token do zapisania 14 | 15 | @enduml -------------------------------------------------------------------------------- /diagrams/8_2_using_stored_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/8_2_using_stored_token.png -------------------------------------------------------------------------------- /diagrams/8_2_using_stored_token.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include https://raw.githubusercontent.com/bschwarz/puml-themes/master/themes/materia/puml-theme-materia.puml 3 | skinparam defaultFontSize 24 4 | 5 | actor Licytujący as bidder 6 | participant Frontend as frontend 7 | participant Backend as backend 8 | participant PaymentProvider as provider 9 | 10 | bidder -> frontend: tak, chcę to kupić za 100 PLN! 11 | frontend -> backend 12 | backend -> provider: obciąż token #123456 na 100 PLN 13 | 14 | @enduml -------------------------------------------------------------------------------- /diagrams/8_3_sender_event_bus_listener.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/8_3_sender_event_bus_listener.png -------------------------------------------------------------------------------- /diagrams/8_3_sender_event_bus_listener.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include https://raw.githubusercontent.com/bschwarz/puml-themes/master/themes/materia/puml-theme-materia.puml 3 | skinparam defaultFontSize 24 4 | 5 | actor Klient as client 6 | participant Nadawca as sender 7 | participant Zdarzenie as event 8 | participant "Szyna zdarzeń" as event_bus 9 | participant Listener as listener 10 | 11 | listener -> event_bus: subscribe(event_type) 12 | client -> sender: do_something() 13 | sender --> event: << create >> 14 | sender -> event_bus: publish(event) 15 | event_bus -> listener: handle(event) 16 | 17 | @enduml -------------------------------------------------------------------------------- /diagrams/8_4_unit_of_work.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/8_4_unit_of_work.png -------------------------------------------------------------------------------- /diagrams/8_4_unit_of_work.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | !include https://raw.githubusercontent.com/bschwarz/puml-themes/master/themes/materia/puml-theme-materia.puml 3 | skinparam defaultFontSize 24 4 | 5 | participant Kontekst as context 6 | participant "Jednostka pracy" as uow 7 | participant "Transakcja bazy danych" as db_tx 8 | queue "Kolejka zadań" as q 9 | 10 | activate context 11 | 12 | context -> uow: begin() 13 | activate uow 14 | uow -> db_tx: begin() 15 | activate db_tx 16 | 17 | context -> uow: commit() 18 | uow -> db_tx: commit() 19 | deactivate db_tx 20 | 21 | uow -> uow: run_callbacks() 22 | uow -> q: send_email() 23 | deactivate uow 24 | 25 | deactivate context 26 | 27 | @enduml -------------------------------------------------------------------------------- /diagrams/9_1_modularity.drawio: -------------------------------------------------------------------------------- 1 | 7Vldc6IwFP01PLbDh6A+irb2odvtjA/bPkaIGA2EDaFIf/0mEIQIa+2o1c7UF8nJTUjOPfdyCZo1DjdTCuLlL+JDrJm6v9GsiWaahjFw+J9A8hJxHL0EAop8aVQDM/QOJViZpciHiWLICMEMxSrokSiCHlMwQCnJVLMFwepdYxDAFjDzAG6jf5DPliU6MPs1/gBRsKzubDjDsicElbHcSbIEPskakHWnWWNKCCuvws0YYkFexUs57v4/vduFURixQwb01w+P6zgnz7+fZvpg8ur37elNzyyneQM4lTuWq2V5RQGfhrPNG262RAzOYuCJnow7nGNLFmLeMvglSOLSBQu0gfyubgwpCiGDVIxBUSDt2iuXm3mDlMFNA5I7mULCp6E5N6l6paykqizJcVa7yHKkybLhni0IpCyC7cw1c/xCkvcZIq3vSWSvd3VM9s7J5Cm0N1QZszoY07sY08/FmP1dtXd1TDrXrj1Lpcyw25wZZgdnW/D0nPWvnDPbuT7OBm2KfF53yCahbEkCEgF8V6MuJWnkC1Imukoa3CD20rh+FSa3tmhFfLUvckTRqPtWkLFcFlwgZYRD9X0fCYk/CvSEpNSDe3ZpS/oYoAFk+wylYgQHez1KIQYMvam1WZd3iqEjSkHeMIgJiljSmPlZAI18ZNiqUEyr6ekP7fvWjjDKBdQy2e7kCOUMO6LNwZxdd0GKxdWacv6mpOq4SQpPj7iBYcSbulMUxcBTB4y5ZxGkvOsJZk1TJ5D/IBSBi7taepZnRBvb2sBMV1ATMSQN1MFVqxg2p43J5UT35YYqeCdeePAzNQwo5HsE88JA6F26m1vbrmZPRHbBKIg44HEp8+1ZrkgiiBf7I9kRIt8vYg2DOcQu8NZBEXVjggkt7mstil9nXOwN9Va+2r6zyCVrzdeCrjym3xoDU7r/c4HQUu6N0VOUu5voyGKR8IjdTXMn0G+VE47Rr3W8fkvRVcAzfc9j4MM1H5EK6brj3ENgrzjnnxWsmr4/eA6KmaqngX6OukE/8BnYO9cj0DZ+hHABIbSKocsLoesIomQyiUG0y+6XiWNKQcSfDYIauBJaGLoeItl+MaiO38LqRk6lh44joKP1cGhtfD45dB2kXDovjGKM1sBbgWPivl2xJIySNayKi4hEovRYIIx3oMPrli71qPo6hWZUyZgdKaTXIZmzvU3Z7ROjhsOu0gtFGJfvXoYl2/cgRFhwqqrz9DHfG1ybA9sHWBM+dfTjvQ7vOfq1ea/r0OzSGbvSz0+6No3+lwmGN+sPWuUrY/1Z0Lr7Bw== -------------------------------------------------------------------------------- /diagrams/9_1_modularity.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/9_1_modularity.drawio.png -------------------------------------------------------------------------------- /diagrams/9_2_modularity.drawio: -------------------------------------------------------------------------------- 1 | 7VpNc6M4EP01HDMFCDA5+iPJHLKzqcphJ0cZZKwYI0bIscmvXwkkg4yGOBU7JlU5mW61hPT6davpsgWm690dhfnyHxKj1HLteGeBmeW6jhMG/EdoyloTuHatSCiOpVGjeMSvSCqV2QbHqNAMGSEpw7mujEiWoYhpOkgp2epmC5Lqb81hgjqKxwimXe1/OGbLWhu6o0b/E+Fkqd7sBNf1yBoqY3mSYgljsm2pwI0FppQQVj+td1OUCvAULvW827+M7jdGUcaOmTBa/bxf5SV5+PfXox3OnuKRf3elNvcC0408sdwtKxUEfBmONhcm2yVm6DGHkRjZcodz3ZKtUy45/BEWee2CBd4h/tZJjiheI4aomIOzRNp1dy4P84IoQ7uWSp7kDhG+DC25iRxVMySr1DG2jYtAIJm3bLlnr4SSFsl+5QY5/iDBew+QztcE0vMHh6R7TiRPwT1PRwwYELNNiNnnQgx8Ve4NDklv4NxzPB0yx+ti5rgGzPbK02PmDxwzfzQ4zDy7C1HM6w4pEsqWJCEZTG8a7YSSTRYLUGY2lxqbe0JyCd8zYqyURRTcMKKDi3aY/W49P4mlfvhSmu3kypVQSmFBMnYL1zgViinZUIwo3/gvtJWD8m1OtWgWj0XNxeUohUWBo1p5i1O1h7/6s+BrR6iPZ2FtyCBNEOsxBJKQAtFeflCUQoZf9Erv9L42VQYBXAvmp6yCRJfsbbkl1tS3QnfzjCxBQmmQaOZSMvDoHs55Ga4HVoqTTPiF4849CCYifjCvc8dyYI3juKYZKvArnFfrCQbkBGesAsWfWP7M4PZejnQd3hsRnbDel/ZyS1a7ejaFO2e0Da61iL+SKx3tcLn4gzh6K/nqE8hiUXAiHvJjv6MPpNTQRJmKHgJrzeHBnw1RA1dF5ZUxN3BAvmsGxZcPjPQJuqNapoGiWfXCuVI80NcyhzFa8RkbQc/JtIww3NvRw5n86HODrj5BYGavnuPeuCwqHsq59hku1+sj7wnvbFfr9TcPLsCDw4Lh4jwAxivkwjwY5ylewegZfsTP3CNMd2bBKFmhKUkJv6VmGcnEnbTgBcSB6vgLzcQenV8n4ExwUGMaOOMZKHO2ChN0v6JbDhukF9plBTiq9JQnMLTC3u2/kfLOYBzY/aif8aWzb++Zos8ZWviZGgmXztiKP9/p2nXCoRHG1EW5NGEeCGVftopzw4OMHnZdHBo62+er4kYdgD6x6dM0ep6sVp/H2PThoNPyd1totYqE2EyrpHc3i0BfFL/ZCQLSa292gtT300A6QeAEn/WOHuOB3hI6RT+p5zOuN9g/ufH0Zo3R5VdvVH688XRl//BGjq8lnRN1ntQyKpUdpKjzdaJUI+1COcs6f6N6npJoZbXb1L0lxumS08Da1GrfgypAxjHMRdb4qjWIN/q0GoSLzX9J6vBv/pEDbv4H -------------------------------------------------------------------------------- /diagrams/9_2_modularity.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/9_2_modularity.drawio.png -------------------------------------------------------------------------------- /diagrams/9_3_modularity.drawio: -------------------------------------------------------------------------------- 1 | 7VpLd9o6EP41LNPjN7AMkKSLtM05LO7tUtjCKBjLlUXA/PpK9vghrBBSoLhtVlijGT1mPs18sunZ49X2gaFk8YUGOOpZRrDt2ZOeZZn20BU/UpIVkr41LAQhIwEo1YIp2WEQGiBdkwCniiKnNOIkUYU+jWPsc0WGGKMbVW1OI3XWBIW4JZj6KGpL/yMBXxTSgdWv5Z8xCRflzKYH+1uhUhl2ki5QQDcNkX3Xs8eMUl48rbZjHEnnlX4p7O5f6a0WxnDMjzHoLz8/LpOMPn37OjUGk+9B3324caximBcUrWHHsFqelS4Qwwhvi8ZosyAcTxPky56NCLiQLfgqEi1TPKI0KUIwJ1ssZh0lmJEV5phJGxKHoNdeOWzmBTOOtw0R7OQBUzEMy4RK2esVFoAqG3y8qUNke6CyaISnEiKARViNXHtOPIDz3uNI+890pON0zpPOJT15DuwNVY/ZGo8ZOo8Zl/KY+6dir2uedAdtxwWiHkCTMr6gIY1RdFdLR4yu40C6amKIVq3zSGkCznrGnGdQ3NCaU9XleEv4/9L8kwut742eyRZGzhsZNOY05vdoRSIpGNM1I5iJhX/FG+iE2cx8mDi4lbVQtP0IpSnxC+E9iaK3wpmKsX18CH2QCKSjDkad4Qhx8qIWVl0IwfSJErGWCi2WYShocfZRwBELMQerGghi5yhrqCVSIX19HnO4h0qvr9bVN9a1py8eihXUqKx8cgJQh5oj76GVPMwRz+OptoxkPYvIcv2MxfMuQGyHYyKfK8VQMYOW5jA8opngeGrOiEgYS3AJ8AgY2iOZA4ggUbfQsSJBUJwVnJIdmuXjSRhDMMTg7qjnTjTYPQj0NmoPHutWaqp4IyxJoWa6lGV8MrzSFOJ9A1nrVHirg5bErByAzucp5r39BHcGKJXnt7v11lYPpOm264RpaepEJTx/xe133Geu1zmfuYYuZeXpSZ5xxXnejzUtO27SPBvcCgXTTrZ1p7zOIV81UBNEQ9Ur01s+4awUPLFdlqAAL4XFujd2e6Nx5hNU6bF9S7H1mUZW7MDTZ02VILyBhjz/ga1xidNjHIkE52JAMD+AcAUgtFLC1YFQErjus23hdZYVRkOjXwqk3Y1gBGYlqa3zlmL+VN6jTmTwMY2xQt/lYKkgv7zN8XPxWVi+CwWuINmHFKE0nu86cBrGzGtizGwgrMbbGxgzm/gCqwPgeheOFJwAkpogMS59WzweR2a3cGTpitahC9cm21BZSAZWcen6Fy5a3mtV6d0XLZFXLbdk1b96tdp7p3n5u5SrRUmOiDRB8T5f+G1055GkHMey/wCdUalLJVYXfi5Go/l8cTKjsTUvr38ztdV9Bbg2tb1NIrJE/jM6hbqKkHA1milndInHNKKsriVzUSP2RMfnLB18VICdAzQqZiwNC3Y0kLnctbj9uaMRsE5GoVk57KMYyBkPvTPoWgDbX18mYuj4I3qa6HlG16Kne/t57Yxd4ucjXVtmv2uA0b36vTZgprtM4gU+7OS3DucvenVlOgMFBNWnvwYIXNs8C9ETzfo/OcW9oP5nk333Ew== -------------------------------------------------------------------------------- /diagrams/9_3_modularity.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/9_3_modularity.drawio.png -------------------------------------------------------------------------------- /diagrams/9_4_components_in_app.drawio: -------------------------------------------------------------------------------- 1 | 7VtRl6MmFP41eZyeAFGTx87s7G7P6fb0nHlod196OMoosyoWcZL01xdHiBo3MalJEDt5iVy4CJfvg8sFZ+gh2XziOIu+sIDEMzgPNjP0YQblz0Hyr5RsK8ly6VWCkNOgEoFa8ET/IUo4V9KCBiRvFRSMxYJmbaHP0pT4oiXDnLN1u9gzi9tvzXBIOoInH8dd6R80EJHqBfRq+WdCw0i/GbirKifBurDqSR7hgK0bIvQ4Qw+cMVE9JZsHEpfG03ap9D4eyN01jJNUnKLAEf3O4ZdvIFx+e/nF23y6cz/fqVpecVyoDs+gG8v67p+ZrFYaDPtVhvt3Ubb0/oEVnBIus34j61osn8JKrUgDLChLdUWyRVVdVQFlDLHVFualBikbOZfZ64gK8pRVb11LTElZJJJYpoBq1ZPS1W0nXJDNQaOAnaklRglLiOBbWUQp3KGlGh6FT8dR6XU92kAPYdQYaVfJsAJYuKu7HgP5oIbhjCFZdixEAglJlWRcRCxkKY4fa+l924Z1mV8Zy5TlXogQW8UvXAjWtivZUPFnqf6To1JfGzkfNqrmt8RWJ1LZ3YZSmfzazKvV3lJarxzDjzihcSlow0nDo+zx8RGVBpKaPjliSKimC8xDIvo40EUIJ7HE8Wu7HRcfbdghIC78kj75JYlyktEvS6QFMk2k1TuRLkMkdCKRoEkioYNE+oumzxxbTicETNPJs5BO3ij5tLBhYVp0+JRHNMtoGlrGJDgf28Lk2sKkWxHCOZEQC5OEcA4SwsoFZp8W5hcYADomvCEvQIMVNUfM8kIPybg9L93KHwQRAvqq9/1+kQv5Qt4ICzRyf6BQNV76b5Jlh5SsYpzjjm0hAt3dZ4a3ieyQbbtPbzE228Lhs9lLkWS6POa+llQzGXAG+gFnzHcNjxq2PGrQ41EHOI/eutMLglxw9n0XYgUXnket2MFCOEnETBowZmOHaJKA8SaNmKF7mDfVnznH20aBjFG1Zquafy8FzfVxz/foHBb1KbioRwGAowryoWp0Dfdd7wcwYDFuBpyzqagZ0IpZ1fPnRAgAjO7ioTNFxJx3bmAbYpBRwAyP1r0D5saAcYwCZvhByRi9skk7ZWBuFDHDr3y8I+bWiDEbcu2edieYph0Y2RW0AyvTQTtgzdHcdQ65+6eRS3Lt5HPwoVz7T3vm/YjyvGcD7DpHFa6zAQb/81sZIwXsUHfiJoBdeCYAa+P11skD1ooJFq5M4NXGW6RXxasJfBoJmp+Lz/0I+G3w2b2nl3Hmkzwntp3hw7HtBvQdgtHuy03fMb/qVhyuTvW2jMaHERw5SC4QvJkCSIy4OHfO3i1L0LeI7CvAZd+2c3lU4TqrDprkzYMW5Bs3nexFvdEgpW5l85OcjFrmlOx/h+NdzymRyfpr4Iqp9TfV6PFf -------------------------------------------------------------------------------- /diagrams/9_4_components_in_app.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/diagrams/9_4_components_in_app.drawio.png -------------------------------------------------------------------------------- /diagrams/Reference_diagram_circle.drawio: -------------------------------------------------------------------------------- 1 | 7VxZc6M4EP41fnRKQpyPsZOZrdrZM1s1O48KKDYORl6Mg51fv8IIsEB2yFgcTvyQitVCQnS3vj5oMULT5fZrhFfz36hHgpEGvO0I3Y00TQMQsX8pZZdRoGXrGWUW+R6nlYQH/5VwIuDUje+RtXBhTGkQ+yuR6NIwJG4s0HAU0US87IkG4l1XeEZqhAcXB3Xqd9+L5xnV1qyS/gvxZ/P8ztB0sp5H7D7PIroJ+f1CGpKsZ4nzafgzrufYo8kBCd2P0DSiNM5+LbdTEqR8zTmWjftypLdYckTCuMkAJ7n9GzzPydPCDoLJwvjruwPGmmlm87zgYMOZwZcb73LusHmYIFhjksz9mDyssJv2JEwZGG0eLwPWguxnfUl8lS8kisn2gMSX+JXQJYmjHbuE946hzfnFdWls6pyQlJKBJuDE+aFYCirm+jAr5i8Zw35w3ryLT2BYfNIqXNJlXNKkXNLa45LhKOSSh9dz4ilkWZVnmoRntoxldosc04elV3pNseo80mU80lvkkfU2j/YYvNcW0A1OIavCKKRJdqBUnXS7PVbZw2OVKWMMMGXQBKz2AFyTcMYM2J0nT5Q92yGLzP82NO8Yr/feyi27AJqrbdmZ+hkZ10ralG4in0Ss63eSHF5qztL/96G7wPld2WNkN876zhNTOtMDHyvxDN4PAxUrXFhl0bzURWi2JkALvcEiGsVzOqMhDr5RuuKMWZA43nGHE29iKrKNbP3433T4jcFbPw567rZ85n1jlzdC9jgHg9Lmj8O+cti+lY9LRfQFL/0gJYiKwuWXrRKap7bdmg3kSndM0blFiXE0I/FJjnLfj3iCd1xXiogEOPZfRGdZJmE+9E/q73dUbngrUFnDv2ypfFRFT4plnKE6NpLtfbxMNxHfi2ILbEZTYzSZ7hK2XUHROxOulW/blJnf8COLkARNw4E/C9lvlwmUyR1N0r3oswjklncsfc9L55hEhCEOftzPl+rNKmXLnlHGZGTcVZQFvqVZdU06vb1qGFFEXXxNo8PwRWoawQ0ASK9Yx6z5s5qUX0KfntakHSWx7Bbx5QBdwI1lNAMYKMBLiTYqAIaE3m0aOLP2Y0Dd54z0xU+Ztp+tEwQyLdUIdKaNcaT7ObespQbcl9SJMi2BgpY0VBLQopJ0owPasHQgT10d0YF9hkeh0AflekARGdwAr9e+K2DDyaBAqXdiDEwvjPc6EXFCo9fdZ3Eg8n1zvgMBbhA04OAdBse8BKDQPj5S2MrjmPOQwpHlq67hhnTzKAk3NGCK4YZ2Hnjk66nkQBxxghahxZRlhTtOVv3JbNcKe+T5QD9d/zLSV5ppiKIbQPrKuoYWikILo3FooQ/LMNi5Ijc2DB79LAYh3x9qDIIFTBEAlBqELiyANNro1gJ8J4sU952J69OEsKvu/vkjX8RjVCL/BRgEc3CvM0ypk7jnpOe/NJIvFOUriqOxlsAztSS/4XqFw+M3TDhT01uGNFriQDrTKSz0U+R6Iov1m5BYKGS2JnGdDTS3IO8lIVKbCsdUIpxzt/DXCIcM5T/lsyeH8MUDq/c885DwyxbNmdY/fllDMFC7D2OgoCFGLDrsX8Ky0sCrhbpaqKuFUmWhdh/HQhXFlBzAjP4BzK6/0e8uQV+kVaQ5lp99V99Cnj13LS7wTa2s2vgkQL/ilb/eLFJXwWd/1HNfd3HWPo3XHyTJku8INUU+uq4JW36sJssyhlA6bQdZF/vq8lxdntbN/h1dx6OpPnKc1YineD0c7tz5BZv/asGwqRl9m38H1lg0FPN/rJCnqPhT/HpeeYmfA5o6DYN7OV8vIx9K1caAnMLm8nUG5hQ6Cl6dH7Phiko4mqWjOvYxTVU+pqOykBxZyBGdQU2Jjyk6rt0VduT7qs+s6fvdjyE7Gjbq39HQ+hfqBL/imiAvLAdeO3QG+s8hOadPBKh2FwZVp9N+jadjN3UzBnZSBAEpkJ9yEJJdQlMfwdY+UcIp3z5KnAGoI1t0Bs48VVaZpn37j0C3MekVTt4orxoMnEhdiOvhkuq+UXG4BCJThBFVaWskm7WLoEL20YeO/c9f2XwRDdILLsDTHKPK28r+C2oQ6Pbc4ZBNg+p0JQKoqWFQ/02EM7Xi9LGBgRwyu9AzZs31AoGhOQxSzL86DNV9o8RhKJLKao6XdRFndJu2uMLGsSsHZk7gNW3RCD1Upi2AASsfw9GVBBxaJYqxugo4Cv3v93gqec3U5yIDjv7LIwuX5lixU0/VMfzzwj9bBdRXKa/v0efNBRfw1DTUkn9RtlMNPV6O190Zk1Swl4ExsEMJsmb56erMsJTfBkf3/wM= -------------------------------------------------------------------------------- /extra/bripe_fake.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from uuid import uuid4 3 | 4 | from flask import Flask, request, abort, jsonify 5 | from marshmallow import Schema, fields, ValidationError, validate 6 | 7 | app = Flask(__name__) 8 | 9 | 10 | class ChargePayloadSchema(Schema): 11 | card_token = fields.Str(required=True) 12 | currency = fields.Str( 13 | required=True, validate=validate.OneOf(["USD", "PLN"]) 14 | ) 15 | amount = fields.Int(required=True) 16 | 17 | 18 | @app.post("/api/v1/charge") 19 | def charge() -> Any: 20 | auth = request.authorization 21 | if auth is None: 22 | abort(401) 23 | 24 | if (auth.username, auth.password) != ("test", "test"): 25 | abort(403) 26 | 27 | if not request.is_json: 28 | abort(400) 29 | 30 | try: 31 | ChargePayloadSchema().load(request.json) 32 | except ValidationError as err: 33 | return jsonify(err.messages), 400 34 | 35 | return jsonify({"charge_uuid": str(uuid4()), "success": True}) 36 | 37 | 38 | @app.post("/api/v1/charges//capture") 39 | def capture(charge_uuid: str) -> Any: 40 | return jsonify({"success": bool(charge_uuid)}) 41 | 42 | 43 | app.run(host="127.0.0.1", port=5050) 44 | -------------------------------------------------------------------------------- /extra/classical_mapping.py: -------------------------------------------------------------------------------- 1 | # https://docs.sqlalchemy.org/en/14/orm/mapping_styles.html#imperative-mapping-with-dataclasses-and-attrs 2 | import attr 3 | 4 | from sqlalchemy import ( 5 | Table, 6 | MetaData, 7 | Column, 8 | Integer, 9 | create_engine, 10 | ForeignKey, 11 | Numeric, 12 | String, 13 | ) 14 | from sqlalchemy.orm import registry, sessionmaker, relationship, composite 15 | 16 | from itca.foundation.money import Money, Currency, USD 17 | 18 | metadata = MetaData() 19 | mapper_registry = registry(metadata=metadata) 20 | 21 | AuctionId = int 22 | BidderId = int 23 | 24 | auctions = Table( 25 | "auctions", 26 | metadata, 27 | Column("id", Integer, primary_key=True), 28 | Column("starting_price_amount", Numeric()), 29 | Column("starting_price_currency", String(3)), 30 | ) 31 | 32 | 33 | bids = Table( 34 | "bids", 35 | metadata, 36 | Column("id", Integer, primary_key=True), 37 | Column("auction_id", Integer, ForeignKey("auctions.id")), 38 | Column("amount_amount", Numeric()), 39 | Column("amount_currency", String(3)), 40 | ) 41 | 42 | 43 | @attr.s(auto_attribs=True) 44 | class Bid: 45 | _id: int = attr.ib(init=False) 46 | _bidder_id: BidderId 47 | _amount: Money 48 | 49 | 50 | @attr.s(auto_attribs=True) 51 | class Auction: 52 | _id: int = attr.ib(init=False) 53 | _starting_price: Money 54 | _bids: list[Bid] = attr.ib(factory=list) 55 | 56 | def place_bid(self, bid: Bid) -> None: 57 | ... 58 | self._bids.append(bid) 59 | 60 | 61 | mapper_registry.map_imperatively( 62 | Auction, 63 | auctions, 64 | properties={ 65 | "_bids": relationship(Bid), 66 | "_starting_price": composite( 67 | lambda currency_code, amount: Money( 68 | Currency.from_code(currency_code), amount 69 | ), 70 | auctions.c.starting_price_currency, 71 | auctions.c.starting_price_amount, 72 | ), 73 | }, 74 | column_prefix="_", 75 | ) 76 | mapper_registry.map_imperatively( 77 | Bid, 78 | bids, 79 | properties={ 80 | "_amount": composite( 81 | lambda currency_code, amount: Money( 82 | Currency.from_code(currency_code), amount 83 | ), 84 | bids.c.amount_currency, 85 | bids.c.amount_amount, 86 | ), 87 | }, 88 | column_prefix="_", 89 | ) 90 | 91 | 92 | engine = create_engine("sqlite:///db.sqlite", echo=True) 93 | metadata.drop_all(bind=engine) 94 | metadata.create_all(bind=engine) 95 | Session = sessionmaker(bind=engine) 96 | 97 | 98 | session = Session() 99 | session.query(Auction).all() 100 | some_auction = Auction(starting_price=Money(USD, "0.99")) 101 | some_auction.place_bid(Bid(bidder_id=1, amount=Money(USD, "10.99"))) 102 | 103 | session.add(some_auction) 104 | session.commit() 105 | 106 | 107 | def get_auction() -> Auction: 108 | # .options(raiseload('*')) 109 | user_instance: Auction = session.query(Auction).one() 110 | # assert user_instance.id # i tak mypy sie czepia 111 | return user_instance 112 | 113 | 114 | us_instance = get_auction() 115 | # assert us_instance.id 116 | print(us_instance, us_instance._id + 2) 117 | -------------------------------------------------------------------------------- /extra/config.py: -------------------------------------------------------------------------------- 1 | from itca.auctions.app.ports.payments import Payments 2 | 3 | 4 | class Config(dict): 5 | pass 6 | 7 | 8 | class BripePayments(Payments): 9 | def __init__(self, config: Config) -> None: 10 | self._basic_auth = ( 11 | config["bripe"]["username"], 12 | config["bripe"]["password"], 13 | ) 14 | -------------------------------------------------------------------------------- /extra/cqrs.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar, Any 2 | 3 | import injector 4 | from attr import define 5 | from injector import Injector, Binder 6 | 7 | from itca.auctions import AuctionId 8 | from itca.auctions.domain.value_objects.bidder_id import BidderId 9 | from itca.foundation.money import Money, USD 10 | 11 | T = TypeVar("T") 12 | 13 | 14 | class PlacingBidOutputBoundary: 15 | pass 16 | 17 | 18 | class AuctionsRepository: 19 | pass 20 | 21 | 22 | TCommand = TypeVar("TCommand") # 1 23 | 24 | 25 | class Handler(Generic[TCommand]): # 2 26 | def __call__(self, command: TCommand) -> None: 27 | pass 28 | 29 | 30 | class CommandBus: 31 | def __init__(self, container: Injector) -> None: # 3 32 | self._container = container 33 | 34 | def dispatch(self, command: Any) -> None: 35 | handler = self._container.get(Handler[type(command)]) # 4 36 | handler(command) # 5 37 | 38 | 39 | # PlacingBidInputDto staje się samodzielną Komendą 40 | @define(frozen=True) 41 | class PlaceBid: # 1 42 | bidder_id: BidderId 43 | auction_id: AuctionId 44 | amount: Money 45 | 46 | 47 | @define 48 | class PlaceBidHandler: # 2 49 | _boundary: PlacingBidOutputBoundary 50 | _repo: AuctionsRepository 51 | 52 | def __call__(self, command: PlaceBid) -> None: 53 | ... 54 | 55 | 56 | class Auctions(injector.Module): 57 | @injector.provider 58 | def place_bid_handler( # 3 59 | self, 60 | boundary: PlacingBidOutputBoundary, 61 | repo: AuctionsRepository, 62 | ) -> Handler[PlaceBid]: 63 | return PlaceBidHandler(boundary, repo) 64 | 65 | 66 | c = Injector([Auctions()]) 67 | command_bus = CommandBus(c) 68 | command_bus.dispatch( 69 | PlaceBid(auction_id=1, bidder_id=1, amount=Money(USD, "1")) 70 | ) 71 | -------------------------------------------------------------------------------- /extra/db.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Enforcer/implementing-the-clean-architecture/22902e986775beb15e2b91e0ed1c8abf7709af50/extra/db.sqlite -------------------------------------------------------------------------------- /extra/declarative_mapping_with_dataclasses.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from decimal import Decimal 3 | 4 | from sqlalchemy import Column, ForeignKey, Integer, Numeric 5 | from sqlalchemy.orm import registry, relationship, sessionmaker 6 | 7 | from itca.auctions import AuctionId 8 | from itca.auctions.domain.value_objects.bidder_id import BidderId 9 | 10 | 11 | from sqlalchemy import create_engine, MetaData 12 | 13 | 14 | engine = create_engine("sqlite://", echo=True) 15 | Session = sessionmaker(bind=engine) 16 | 17 | metadata = MetaData() 18 | mapper_registry = registry(metadata=metadata) 19 | 20 | 21 | session = Session() 22 | 23 | 24 | @mapper_registry.mapped 25 | @dataclass 26 | class Bid: 27 | __tablename__ = "bids" 28 | __sa_dataclass_metadata_key__ = "sa" 29 | 30 | id: int = field( 31 | init=False, metadata={"sa": Column(Integer, primary_key=True)} 32 | ) 33 | bidder_id: BidderId = field(metadata={"sa": Column(Integer)}) 34 | amount: Decimal = field(metadata={"sa": Column(Numeric)}) 35 | auction_id: AuctionId = field( 36 | init=False, metadata={"sa": Column(ForeignKey("auctions.id"))} 37 | ) 38 | 39 | 40 | @mapper_registry.mapped 41 | @dataclass 42 | class Auction: 43 | __tablename__ = "auctions" 44 | __sa_dataclass_metadata_key__ = "sa" 45 | 46 | id: int = field( 47 | init=False, metadata={"sa": Column(Integer, primary_key=True)} 48 | ) 49 | starting_price: Decimal = field(metadata={"sa": Column(Numeric)}) 50 | bids: list[Bid] = field( 51 | default_factory=list, metadata={"sa": relationship("Bid")} 52 | ) 53 | 54 | def place_bid(self, bidder_id: BidderId, amount: Decimal) -> None: 55 | ... 56 | self.bids.append(Bid(bidder_id=bidder_id, amount=amount)) 57 | 58 | 59 | metadata.create_all(bind=engine) 60 | 61 | auction = Auction(starting_price=Decimal("10.00")) 62 | session.add(auction) 63 | session.flush() 64 | 65 | auction.place_bid(bidder_id=1, amount=Decimal("15.00")) 66 | session.flush() 67 | -------------------------------------------------------------------------------- /extra/event_bus_draft.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Type 2 | 3 | 4 | class Event: 5 | pass 6 | 7 | 8 | class EventBus: 9 | def emit(self, event: Event) -> None: 10 | ... 11 | 12 | def subscribe( 13 | self, event_cls: Type[Event], listener: Callable[[Event], None] 14 | ) -> None: 15 | ... 16 | -------------------------------------------------------------------------------- /extra/events_example.py: -------------------------------------------------------------------------------- 1 | from extra.event_bus_draft import EventBus 2 | 3 | 4 | def assemble() -> None: # 1 5 | event_bus = EventBus() 6 | setup_dependency_injection(event_bus) 7 | setup_event_subscriptions() 8 | 9 | 10 | def setup_dependency_injection( 11 | event_bus: EventBus, 12 | ) -> None: 13 | def di_config(binder: inject.Binder) -> None: 14 | binder.bind(EventBus, event_bus) # 2 15 | ... 16 | 17 | inject.configure(di_config) 18 | 19 | 20 | def setup_event_subscriptions( 21 | event_bus: EventBus, 22 | ) -> None: 23 | event_bus.subscribe( # 3 24 | BidderHasBeenOverbid, 25 | lambda event: send_email.delay( 26 | event.auction_id, 27 | event.bidder_id, 28 | event.old_price, 29 | event.new_price, 30 | ), 31 | ) 32 | -------------------------------------------------------------------------------- /extra/use_case_reads.py: -------------------------------------------------------------------------------- 1 | from attr import define 2 | 3 | from itca.auctions import AuctionsRepository 4 | from itca.auctions.domain.value_objects.auction_id import AuctionId 5 | from itca.foundation.money import Money 6 | 7 | 8 | @define(frozen=True) 9 | class GettingAuctionDetailsInputDto: 10 | auction_id: AuctionId 11 | 12 | 13 | @define(frozen=True) 14 | class GettingAuctionDetailsOutputDto: 15 | @define(frozen=True) 16 | class TopBidder: 17 | anonymized_name: str 18 | bid_amount: Money 19 | 20 | auction_id: AuctionId 21 | title: str 22 | current_price: Money 23 | starting_price: Money 24 | top_bidders: list[TopBidder] 25 | 26 | 27 | class GettingAuctionDetailsOutputBoundary: 28 | def present(self, dto: GettingAuctionDetailsOutputDto) -> None: 29 | pass 30 | 31 | 32 | class Bidder: 33 | username: str 34 | 35 | 36 | class BiddersRepository: 37 | def get(self, bidder_id) -> Bidder: 38 | pass 39 | 40 | 41 | class AuctionDescriptor: 42 | title: str 43 | 44 | 45 | class AuctionsDescriptorsRepository: 46 | def get(self, auction_id) -> AuctionDescriptor: 47 | pass 48 | 49 | 50 | @define 51 | class GettingAuctionDetails: 52 | _output_boundary: GettingAuctionDetailsOutputBoundary 53 | _auctions_repo: AuctionsRepository 54 | _auctions_descriptors_repo: AuctionsDescriptorsRepository 55 | _bidders_repo: BiddersRepository 56 | 57 | def execute(self, input_dto: GettingAuctionDetailsInputDto) -> None: 58 | auction = self._auctions_repo.get(input_dto.auction_id) # 1 59 | descriptor = self._auctions_descriptors_repo.get( 60 | input_dto.auction_id 61 | ) # 2 62 | top_bids = auction.get_top_bids(count=3) # 3 63 | 64 | top_bidders = [] 65 | for bid in top_bids: 66 | bidder = self._bidders_repo.get(bid.bidder_id) # 4 67 | anonymized_name = f"{bidder.username[0]}..." 68 | top_bidders.append( 69 | GettingAuctionDetailsOutputDto.TopBidder( 70 | anonymized_name, bid.amount 71 | ) 72 | ) 73 | 74 | output_dto = GettingAuctionDetailsOutputDto( # 5 75 | auction_id=auction.id, 76 | title=descriptor.title, 77 | current_price=auction.current_price, 78 | starting_price=auction.starting_price, 79 | top_bidders=top_bidders, 80 | ) 81 | self._output_boundary.present(output_dto) 82 | -------------------------------------------------------------------------------- /extra/white_box_testing.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from attr import define 3 | 4 | from itca.auctions import AuctionId 5 | 6 | 7 | class AuctionAlreadyEnded(Exception): 8 | pass 9 | 10 | 11 | @define 12 | class Auction: 13 | _id: AuctionId 14 | _ended: bool 15 | 16 | def end(self) -> None: 17 | if self._ended: 18 | raise AuctionAlreadyEnded 19 | self._ended = True 20 | 21 | 22 | def test_ending_auction_changes_ended_flag(): 23 | auction = Auction(id=1, ended=False) 24 | 25 | auction.end() 26 | 27 | assert auction._ended 28 | 29 | 30 | def test_auction_cannot_be_ended_twice(): 31 | auction = Auction(id=1, ended=False) 32 | 33 | auction.end() 34 | 35 | with pytest.raises(AuctionAlreadyEnded): 36 | auction.end() 37 | --------------------------------------------------------------------------------