├── .github └── workflows │ └── python_ci.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── README.md └── src ├── adapters ├── __init__.py ├── exceptions.py └── repositories │ ├── __init__.py │ └── auction_repository │ ├── __init__.py │ ├── in_memory_repository.py │ └── mongodb_repository.py ├── domain ├── __init__.py ├── entities │ ├── __init__.py │ ├── auction.py │ ├── bid.py │ └── item.py └── value_objects │ ├── __init__.py │ └── price.py ├── drivers ├── __init__.py └── rest │ ├── __init__.py │ ├── dependencies.py │ ├── exception_handlers.py │ ├── main.py │ └── routers │ ├── __init__.py │ ├── auction.py │ └── schema.py ├── ports ├── __init__.py └── repositories │ ├── __init__.py │ └── auction_repository.py ├── pyproject.toml ├── requirements.txt ├── tests ├── __init__.py ├── conftest.py ├── integration │ ├── __init__.py │ ├── drivers │ │ ├── auction_submit_bid_test.py │ │ └── conftest.py │ └── repositories │ │ └── mongodb_auction_repository_test.py ├── unit │ ├── __init__.py │ └── submit_bid_use_case_test.py └── utils.py └── use_cases ├── __init__.py ├── exceptions.py └── submit_bid_use_case.py /.github/workflows/python_ci.yaml: -------------------------------------------------------------------------------- 1 | name: Python CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | defaults: 15 | run: 16 | working-directory: 'src/' 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Set up Python 3.13 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: 3.13 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements.txt 30 | 31 | - name: Run ruff 32 | run: ruff check . 33 | 34 | - name: Run mypy 35 | run: mypy . 36 | 37 | test: 38 | runs-on: ubuntu-latest 39 | defaults: 40 | run: 41 | working-directory: 'src/' 42 | services: 43 | mongodb: 44 | image: mongo:8.0.4 45 | ports: 46 | - 27017:27017 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | 51 | - name: Set up Python 3.13 52 | uses: actions/setup-python@v5 53 | with: 54 | python-version: 3.13 55 | 56 | - name: Install dependencies 57 | run: | 58 | python -m pip install --upgrade pip 59 | pip install -r requirements.txt 60 | 61 | - name: Run tests with coverage 62 | run: | 63 | coverage run -m pytest tests/unit 64 | coverage report 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 156 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 157 | 158 | # User-specific stuff 159 | .idea/**/workspace.xml 160 | .idea/**/tasks.xml 161 | .idea/**/usage.statistics.xml 162 | .idea/**/dictionaries 163 | .idea/**/shelf 164 | 165 | # AWS User-specific 166 | .idea/**/aws.xml 167 | 168 | # Generated files 169 | .idea/**/contentModel.xml 170 | 171 | # Sensitive or high-churn files 172 | .idea/**/dataSources/ 173 | .idea/**/dataSources.ids 174 | .idea/**/dataSources.local.xml 175 | .idea/**/sqlDataSources.xml 176 | .idea/**/dynamic.xml 177 | .idea/**/uiDesigner.xml 178 | .idea/**/dbnavigator.xml 179 | 180 | # Gradle 181 | .idea/**/gradle.xml 182 | .idea/**/libraries 183 | 184 | # Gradle and Maven with auto-import 185 | # When using Gradle or Maven with auto-import, you should exclude module files, 186 | # since they will be recreated, and may cause churn. Uncomment if using 187 | # auto-import. 188 | # .idea/artifacts 189 | # .idea/compiler.xml 190 | # .idea/jarRepositories.xml 191 | # .idea/modules.xml 192 | # .idea/*.iml 193 | # .idea/modules 194 | # *.iml 195 | # *.ipr 196 | 197 | # CMake 198 | cmake-build-*/ 199 | 200 | # Mongo Explorer plugin 201 | .idea/**/mongoSettings.xml 202 | 203 | # File-based project format 204 | *.iws 205 | 206 | # IntelliJ 207 | out/ 208 | 209 | # mpeltonen/sbt-idea plugin 210 | .idea_modules/ 211 | 212 | # JIRA plugin 213 | atlassian-ide-plugin.xml 214 | 215 | # Cursive Clojure plugin 216 | .idea/replstate.xml 217 | 218 | # SonarLint plugin 219 | .idea/sonarlint/ 220 | 221 | # Crashlytics plugin (for Android Studio and IntelliJ) 222 | com_crashlytics_export_strings.xml 223 | crashlytics.properties 224 | crashlytics-build.properties 225 | fabric.properties 226 | 227 | # Editor-based Rest Client 228 | .idea/httpRequests 229 | 230 | # Android studio 3.1+ serialized cache file 231 | .idea/caches/build_file_checksums.ser 232 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-toml 7 | - id: check-yaml 8 | args: 9 | - --unsafe 10 | - id: end-of-file-fixer 11 | - id: trailing-whitespace 12 | - repo: https://github.com/charliermarsh/ruff-pre-commit 13 | rev: v0.9.3 14 | hooks: 15 | - id: ruff 16 | args: 17 | - --fix 18 | - id: ruff-format 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clean Architecture on Python with FastAPI 2 | 3 | 4 | CI 5 | 6 | 7 | This repository serves as an illustrative example of implementing Clean Architecture 8 | principles in a Python application using FastAPI. It accompanies the Medium article 9 | [“Clean Architecture with Python”](https://medium.com/@shaliamekh/clean-architecture-with-python-d62712fd8d4f). 10 | 11 | ## Local setup 12 | 13 | Create a virtual environment and install dependencies 14 | 15 | ```shell 16 | python3 -m venv venv && venv/bin/pip install -r src/requirements.txt 17 | ``` 18 | Launch a mongodb instance using Docker 19 | ```shell 20 | docker run --rm -d -p 27017:27017 --name=mongo-auctions mongo:8.0.4 21 | ``` 22 | 23 | Run tests 24 | ```shell 25 | venv/bin/pytest src/tests 26 | ``` 27 | 28 | Start FastAPI application 29 | 30 | ```shell 31 | venv/bin/fastapi dev src/drivers/rest/main.py 32 | ``` 33 | 34 | ## Clean up 35 | 36 | Remove the mongodb container 37 | ```shell 38 | docker rm -f mongo-auctions 39 | ``` 40 | -------------------------------------------------------------------------------- /src/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/adapters/__init__.py -------------------------------------------------------------------------------- /src/adapters/exceptions.py: -------------------------------------------------------------------------------- 1 | class ExternalError(Exception): 2 | pass 3 | 4 | 5 | class DatabaseError(ExternalError): 6 | def __init__(self, error: Exception): 7 | self.error = error 8 | 9 | def __str__(self) -> str: 10 | return str(self.error) 11 | -------------------------------------------------------------------------------- /src/adapters/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/adapters/repositories/__init__.py -------------------------------------------------------------------------------- /src/adapters/repositories/auction_repository/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/adapters/repositories/auction_repository/__init__.py -------------------------------------------------------------------------------- /src/adapters/repositories/auction_repository/in_memory_repository.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from domain.entities.auction import Auction 4 | from domain.entities.bid import Bid 5 | from ports.repositories.auction_repository import AuctionRepository 6 | 7 | 8 | class InMemoryAuctionRepository(AuctionRepository): 9 | auctions: list[Auction] = [] 10 | 11 | async def get(self, **filters: Any) -> Auction | None: 12 | for auction in self.auctions: 13 | if (f := filters.get("id")) and f == auction.id: 14 | return auction 15 | return None 16 | 17 | async def add_bid(self, bid: Bid) -> bool: 18 | for auction in self.auctions: 19 | if auction.id == bid.auction_id: 20 | auction.bids.append(bid) 21 | return True 22 | return False 23 | -------------------------------------------------------------------------------- /src/adapters/repositories/auction_repository/mongodb_repository.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from typing import Any 3 | from uuid import UUID 4 | 5 | from bson import Binary 6 | from motor.motor_asyncio import AsyncIOMotorClient 7 | 8 | from adapters.exceptions import DatabaseError 9 | from domain.entities.auction import Auction 10 | from domain.entities.bid import Bid 11 | from domain.entities.item import Item 12 | from domain.value_objects.price import Price 13 | from ports.repositories.auction_repository import AuctionRepository 14 | 15 | 16 | class MongoAuctionRepository(AuctionRepository): 17 | def __init__(self, client: AsyncIOMotorClient): # type: ignore 18 | self.collection = client.auctions.auction 19 | 20 | async def get(self, **filters: Any) -> Auction | None: 21 | filters = self.__get_filters(filters) 22 | try: 23 | document = await self.collection.find_one(filters) 24 | return self.__to_auction_entity(document) if document else None 25 | except Exception as e: 26 | raise DatabaseError(e) from e 27 | 28 | async def add_bid(self, bid: Bid) -> bool: 29 | try: 30 | r = await self.collection.update_one( 31 | {"_id": Binary.from_uuid(bid.auction_id)}, 32 | {"$push": {"bids": self.__bid_to_doc(bid)}}, 33 | ) 34 | return bool(r.modified_count) 35 | except Exception as e: 36 | raise DatabaseError(e) from e 37 | 38 | async def add(self, auction: Auction) -> None: 39 | try: 40 | auction_doc = self.__auction_to_doc(auction) 41 | await self.collection.insert_one(auction_doc) 42 | except Exception as e: 43 | raise DatabaseError(e) from e 44 | 45 | @staticmethod 46 | def __get_filters(filters_args: dict[str, Any]) -> dict[str, Any]: 47 | filters = {} 48 | if f := filters_args.get("id"): 49 | filters["_id"] = Binary.from_uuid(f) 50 | return filters 51 | 52 | def __auction_to_doc(self, auction: Auction) -> dict[str, Any]: 53 | return { 54 | "_id": Binary.from_uuid(auction.id), 55 | "item": { 56 | "_id": Binary.from_uuid(auction.item.id), 57 | "name": auction.item.name, 58 | "description": auction.item.description, 59 | }, 60 | "seller_id": Binary.from_uuid(auction.seller_id), 61 | "start_date": auction.start_date.isoformat(), 62 | "end_date": auction.end_date.isoformat(), 63 | "start_price": { 64 | "value": auction.start_price.value, 65 | "currency": auction.start_price.currency, 66 | }, 67 | "bids": [self.__bid_to_doc(bid) for bid in auction.bids], 68 | } 69 | 70 | @staticmethod 71 | def __bid_to_doc(bid: Bid) -> dict[str, Any]: 72 | return { 73 | "bidder_id": Binary.from_uuid(bid.bidder_id), 74 | "price": {"value": bid.price.value, "currency": bid.price.currency}, 75 | "auction_id": Binary.from_uuid(bid.auction_id), 76 | "_id": Binary.from_uuid(bid.id), 77 | "created_at": bid.created_at.isoformat(), 78 | } 79 | 80 | def __to_auction_entity(self, obj: dict[str, Any]) -> Auction: 81 | return Auction( 82 | id=UUID(bytes=obj["_id"]), 83 | item=Item( 84 | id=UUID(bytes=obj["item"]["_id"]), 85 | name=obj["item"]["name"], 86 | description=obj["item"]["description"], 87 | ), 88 | seller_id=UUID(bytes=obj["seller_id"]), 89 | start_date=date.fromisoformat(obj["start_date"]), 90 | end_date=date.fromisoformat(obj["end_date"]), 91 | start_price=Price( 92 | value=obj["start_price"]["value"], 93 | currency=obj["start_price"]["currency"], 94 | ), 95 | bids=[self.__to_bid_entity(bid) for bid in obj["bids"]], 96 | ) 97 | 98 | @staticmethod 99 | def __to_bid_entity(bid: dict[str, Any]) -> Bid: 100 | return Bid( 101 | bidder_id=UUID(bytes=bid["bidder_id"]), 102 | price=Price(value=bid["price"]["value"], currency=bid["price"]["currency"]), 103 | auction_id=UUID(bytes=bid["auction_id"]), 104 | id=UUID(bytes=bid["_id"]), 105 | created_at=datetime.fromisoformat(bid["created_at"]), 106 | ) 107 | -------------------------------------------------------------------------------- /src/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/domain/__init__.py -------------------------------------------------------------------------------- /src/domain/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/domain/entities/__init__.py -------------------------------------------------------------------------------- /src/domain/entities/auction.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import date 3 | from uuid import UUID, uuid4 4 | 5 | from domain.entities.bid import Bid 6 | from domain.entities.item import Item 7 | from domain.value_objects.price import Price 8 | 9 | 10 | @dataclass 11 | class Auction: 12 | item: Item 13 | seller_id: UUID 14 | start_date: date 15 | end_date: date 16 | start_price: Price 17 | bids: list[Bid] = field(default_factory=list) 18 | id: UUID = field(default_factory=uuid4) 19 | 20 | @property 21 | def is_active(self) -> bool: 22 | return self.start_date <= date.today() <= self.end_date 23 | 24 | @property 25 | def minimal_bid_price(self) -> Price: 26 | return max( 27 | [bid.price for bid in self.bids] + [self.start_price], key=lambda x: x.value 28 | ) 29 | -------------------------------------------------------------------------------- /src/domain/entities/bid.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from uuid import UUID, uuid4 4 | 5 | from domain.value_objects.price import Price 6 | 7 | 8 | @dataclass 9 | class Bid: 10 | bidder_id: UUID 11 | price: Price 12 | auction_id: UUID 13 | id: UUID = field(default_factory=uuid4) 14 | created_at: datetime = field(default_factory=datetime.now) 15 | -------------------------------------------------------------------------------- /src/domain/entities/item.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from uuid import UUID, uuid4 3 | 4 | 5 | @dataclass 6 | class Item: 7 | name: str 8 | description: str 9 | id: UUID = field(default_factory=uuid4) 10 | -------------------------------------------------------------------------------- /src/domain/value_objects/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/domain/value_objects/__init__.py -------------------------------------------------------------------------------- /src/domain/value_objects/price.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import StrEnum 3 | 4 | 5 | class CurrencyOption(StrEnum): 6 | euro = "EUR" 7 | dollar = "USD" 8 | 9 | 10 | @dataclass(frozen=True) 11 | class Price: 12 | value: float 13 | currency: CurrencyOption 14 | -------------------------------------------------------------------------------- /src/drivers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/drivers/__init__.py -------------------------------------------------------------------------------- /src/drivers/rest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/drivers/rest/__init__.py -------------------------------------------------------------------------------- /src/drivers/rest/dependencies.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import Annotated 3 | 4 | from fastapi import Depends 5 | from motor.motor_asyncio import AsyncIOMotorClient 6 | 7 | from adapters.repositories.auction_repository.mongodb_repository import ( 8 | MongoAuctionRepository, 9 | ) 10 | from ports.repositories.auction_repository import AuctionRepository 11 | from use_cases.submit_bid_use_case import SubmitBidUseCase 12 | 13 | 14 | @lru_cache 15 | def get_mongo_client() -> AsyncIOMotorClient: # type: ignore 16 | return AsyncIOMotorClient("mongodb://localhost:27017") 17 | 18 | 19 | def get_auction_repository( 20 | mongo_client: Annotated[AsyncIOMotorClient, Depends(get_mongo_client)], # type: ignore 21 | ) -> AuctionRepository: 22 | return MongoAuctionRepository(mongo_client) 23 | 24 | 25 | def get_submit_bid_use_case( 26 | auction_repository: Annotated[AuctionRepository, Depends(get_auction_repository)], 27 | ) -> SubmitBidUseCase: 28 | return SubmitBidUseCase(auction_repository) 29 | -------------------------------------------------------------------------------- /src/drivers/rest/exception_handlers.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request, status 2 | from fastapi.responses import JSONResponse 3 | 4 | from adapters.exceptions import ExternalError 5 | from use_cases.exceptions import ( 6 | AuctionNotActiveError, 7 | AuctionNotFoundError, 8 | LowBidError, 9 | ) 10 | 11 | 12 | def exception_container(app: FastAPI) -> None: 13 | @app.exception_handler(AuctionNotFoundError) 14 | async def auction_not_found_exception_handler( 15 | request: Request, exc: AuctionNotFoundError 16 | ) -> JSONResponse: 17 | return JSONResponse( 18 | status_code=status.HTTP_404_NOT_FOUND, content={"message": str(exc)} 19 | ) 20 | 21 | @app.exception_handler(AuctionNotActiveError) 22 | async def auction_not_active_exception_handler( 23 | request: Request, exc: AuctionNotActiveError 24 | ) -> JSONResponse: 25 | return JSONResponse( 26 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 27 | content={"message": str(exc)}, 28 | ) 29 | 30 | @app.exception_handler(LowBidError) 31 | async def low_bid_exception_handler( 32 | request: Request, exc: LowBidError 33 | ) -> JSONResponse: 34 | return JSONResponse( 35 | status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, 36 | content={"message": str(exc)}, 37 | ) 38 | 39 | @app.exception_handler(ExternalError) 40 | async def external_exception_handler( 41 | request: Request, exc: ExternalError 42 | ) -> JSONResponse: 43 | return JSONResponse( 44 | status_code=status.HTTP_400_BAD_REQUEST, 45 | content={"message": "Something went wrong. Please try again"}, 46 | ) 47 | -------------------------------------------------------------------------------- /src/drivers/rest/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | 3 | from drivers.rest.exception_handlers import exception_container 4 | from drivers.rest.routers import auction 5 | 6 | app = FastAPI() 7 | 8 | app.include_router(auction.router) 9 | 10 | exception_container(app) 11 | -------------------------------------------------------------------------------- /src/drivers/rest/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/drivers/rest/routers/__init__.py -------------------------------------------------------------------------------- /src/drivers/rest/routers/auction.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | from uuid import UUID 3 | 4 | from fastapi import APIRouter, Depends, status 5 | 6 | from drivers.rest.dependencies import get_submit_bid_use_case 7 | from drivers.rest.routers.schema import BidInput 8 | from use_cases.submit_bid_use_case import SubmitBidUseCase 9 | 10 | router = APIRouter(prefix="/auctions") 11 | 12 | 13 | @router.post("/{auction_id}/bids", status_code=status.HTTP_204_NO_CONTENT) 14 | async def submit_bid( 15 | auction_id: UUID, 16 | data: BidInput, 17 | use_case: Annotated[SubmitBidUseCase, Depends(get_submit_bid_use_case)], 18 | ) -> None: 19 | await use_case(data.to_entity(auction_id)) 20 | -------------------------------------------------------------------------------- /src/drivers/rest/routers/schema.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from pydantic import BaseModel 4 | 5 | from domain.entities.bid import Bid 6 | from domain.value_objects.price import CurrencyOption, Price 7 | 8 | 9 | class PriceInput(BaseModel): 10 | value: float 11 | currency: CurrencyOption 12 | 13 | def to_entity(self) -> Price: 14 | return Price(value=self.value, currency=self.currency) 15 | 16 | 17 | class BidInput(BaseModel): 18 | bidder_id: UUID 19 | price: PriceInput 20 | 21 | def to_entity(self, auction_id: UUID) -> Bid: 22 | return Bid( 23 | auction_id=auction_id, 24 | bidder_id=self.bidder_id, 25 | price=self.price.to_entity(), 26 | ) 27 | -------------------------------------------------------------------------------- /src/ports/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/ports/__init__.py -------------------------------------------------------------------------------- /src/ports/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/ports/repositories/__init__.py -------------------------------------------------------------------------------- /src/ports/repositories/auction_repository.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any 3 | 4 | from domain.entities.auction import Auction 5 | from domain.entities.bid import Bid 6 | 7 | 8 | class AuctionRepository(ABC): 9 | @abstractmethod 10 | async def get(self, **filters: Any) -> Auction | None: 11 | pass 12 | 13 | @abstractmethod 14 | async def add_bid(self, bid: Bid) -> bool: 15 | pass 16 | -------------------------------------------------------------------------------- /src/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | asyncio_mode = "auto" 3 | asyncio_default_fixture_loop_scope = "session" 4 | 5 | [tool.mypy] 6 | strict = true 7 | 8 | [[tool.mypy.overrides]] 9 | module = "tests.*" 10 | disallow_incomplete_defs = false 11 | disallow_untyped_defs = false 12 | 13 | [tool.ruff.format] 14 | skip-magic-trailing-comma = true 15 | 16 | [tool.ruff.lint] 17 | select = [ 18 | "E", # pycodestyle errors 19 | "W", # pycodestyle warnings 20 | "F", # pyflakes 21 | "I", # isort 22 | "B", # flake8-bugbear 23 | "C4", # flake8-comprehensions 24 | "SIM", # flake8-simplify 25 | "T201", # flake8-print 26 | "UP", # pyupgrade 27 | ] 28 | ignore = [ 29 | "E501", # line too long, handled by black 30 | ] 31 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | coverage==7.6.10 2 | fastapi[standard]==0.115.7 3 | httpx==0.28.1 4 | motor==3.6.1 5 | mypy==1.14.1 6 | pytest-asyncio==0.25.2 7 | pytest==8.3.4 8 | ruff==0.9.3 9 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/tests/__init__.py -------------------------------------------------------------------------------- /src/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from motor.motor_asyncio import AsyncIOMotorClient 3 | from pytest_asyncio import is_async_test 4 | 5 | 6 | def pytest_collection_modifyitems(items): 7 | pytest_asyncio_tests = (item for item in items if is_async_test(item)) 8 | session_scope_marker = pytest.mark.asyncio(loop_scope="session") 9 | for async_test in pytest_asyncio_tests: 10 | async_test.add_marker(session_scope_marker, append=False) 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def client(): 15 | return AsyncIOMotorClient("mongodb://localhost:27017") 16 | -------------------------------------------------------------------------------- /src/tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/tests/integration/__init__.py -------------------------------------------------------------------------------- /src/tests/integration/drivers/auction_submit_bid_test.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Any 3 | 4 | import pytest 5 | from fastapi import status 6 | from httpx import AsyncClient 7 | 8 | from adapters.exceptions import ExternalError 9 | from domain.value_objects.price import CurrencyOption, Price 10 | from drivers.rest.dependencies import get_submit_bid_use_case 11 | from drivers.rest.main import app 12 | from use_cases.exceptions import ( 13 | AuctionNotActiveError, 14 | AuctionNotFoundError, 15 | LowBidError, 16 | ) 17 | 18 | auction_id = uuid.uuid4() 19 | payload = {"bidder_id": str(uuid.uuid4()), "price": {"value": 90, "currency": "EUR"}} 20 | url = f"/auctions/{str(auction_id)}/bids" 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "exception, status_code", 25 | ( 26 | (AuctionNotFoundError(auction_id), status.HTTP_404_NOT_FOUND), 27 | (AuctionNotActiveError(auction_id), status.HTTP_422_UNPROCESSABLE_ENTITY), 28 | ( 29 | LowBidError(Price(value=10, currency=CurrencyOption.euro)), 30 | status.HTTP_422_UNPROCESSABLE_ENTITY, 31 | ), 32 | ), 33 | ) 34 | async def test_submit_bid_auction_with_exceptions( 35 | async_client: AsyncClient, exception: Exception, status_code: int 36 | ): 37 | class MockUseCase: 38 | async def __call__(self, *args: Any, **kwargs: Any) -> None: 39 | raise exception 40 | 41 | app.dependency_overrides[get_submit_bid_use_case] = MockUseCase 42 | response = await async_client.post(url, json=payload) 43 | assert response.status_code == status_code 44 | assert response.json() == {"message": str(exception)} 45 | 46 | 47 | async def test_submit_bid_auction_not_found(async_client: AsyncClient): 48 | exception = ExternalError 49 | 50 | class MockUseCase: 51 | async def __call__(self, *args: Any, **kwargs: Any) -> None: 52 | raise exception 53 | 54 | app.dependency_overrides[get_submit_bid_use_case] = MockUseCase 55 | response = await async_client.post(url, json=payload) 56 | assert response.status_code == status.HTTP_400_BAD_REQUEST 57 | assert response.json() == {"message": "Something went wrong. Please try again"} 58 | 59 | 60 | async def test_submit_bid_success(async_client: AsyncClient): 61 | class MockUseCase: 62 | async def __call__(self, *args: Any, **kwargs: Any) -> None: 63 | pass 64 | 65 | app.dependency_overrides[get_submit_bid_use_case] = MockUseCase 66 | response = await async_client.post(url, json=payload) 67 | assert response.status_code == status.HTTP_204_NO_CONTENT 68 | -------------------------------------------------------------------------------- /src/tests/integration/drivers/conftest.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncGenerator 2 | 3 | import pytest 4 | from httpx import ASGITransport, AsyncClient 5 | 6 | from drivers.rest.main import app 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | async def async_client() -> AsyncGenerator[AsyncClient, None]: 11 | transport = ASGITransport(app=app) 12 | async with AsyncClient(transport=transport, base_url="http://test") as client: 13 | yield client 14 | -------------------------------------------------------------------------------- /src/tests/integration/repositories/mongodb_auction_repository_test.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | import pytest 4 | from motor.motor_asyncio import AsyncIOMotorClient 5 | 6 | from adapters.repositories.auction_repository.mongodb_repository import ( 7 | MongoAuctionRepository, 8 | ) 9 | from tests.utils import create_auction, create_bid 10 | 11 | 12 | @pytest.fixture 13 | async def auction_repository(client: AsyncIOMotorClient): # type: ignore 14 | repo = MongoAuctionRepository(client) 15 | yield repo 16 | await repo.collection.drop() 17 | 18 | 19 | async def test_get_by_id_success(auction_repository: MongoAuctionRepository): 20 | auction = create_auction() 21 | await auction_repository.add(auction) 22 | assert await auction_repository.get(id=auction.id) == auction 23 | 24 | 25 | async def test_get_by_id_not_found(auction_repository: MongoAuctionRepository): 26 | assert await auction_repository.get(id=uuid4()) is None 27 | 28 | 29 | async def test_add_bid_success(auction_repository: MongoAuctionRepository): 30 | auction = create_auction() 31 | await auction_repository.add(auction) 32 | bid = create_bid(auction_id=auction.id) 33 | assert await auction_repository.add_bid(bid) is True 34 | 35 | 36 | async def test_add_bid_auction_not_found(auction_repository: MongoAuctionRepository): 37 | bid = create_bid(auction_id=uuid4()) 38 | assert await auction_repository.add_bid(bid) is False 39 | -------------------------------------------------------------------------------- /src/tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/tests/unit/__init__.py -------------------------------------------------------------------------------- /src/tests/unit/submit_bid_use_case_test.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import date, timedelta 3 | 4 | import pytest 5 | 6 | from adapters.repositories.auction_repository.in_memory_repository import ( 7 | InMemoryAuctionRepository, 8 | ) 9 | from ports.repositories.auction_repository import AuctionRepository 10 | from tests.utils import create_auction, create_bid 11 | from use_cases.exceptions import ( 12 | AuctionNotActiveError, 13 | AuctionNotFoundError, 14 | LowBidError, 15 | ) 16 | from use_cases.submit_bid_use_case import SubmitBidUseCase 17 | 18 | 19 | @pytest.fixture 20 | def auctions_repository() -> AuctionRepository: 21 | return InMemoryAuctionRepository() 22 | 23 | 24 | @pytest.fixture 25 | def submit_bid_use_case(auctions_repository: AuctionRepository) -> SubmitBidUseCase: 26 | return SubmitBidUseCase(auctions_repository) 27 | 28 | 29 | async def test_auction_not_found(submit_bid_use_case: SubmitBidUseCase): 30 | bid = create_bid(auction_id=uuid.uuid4()) 31 | with pytest.raises(AuctionNotFoundError): 32 | await submit_bid_use_case(bid) 33 | 34 | 35 | async def test_auction_not_active( 36 | auctions_repository: InMemoryAuctionRepository, 37 | submit_bid_use_case: SubmitBidUseCase, 38 | ): 39 | auction = create_auction(end_date=date.today() - timedelta(days=1)) 40 | auctions_repository.auctions.append(auction) 41 | bid = create_bid(auction_id=auction.id) 42 | with pytest.raises(AuctionNotActiveError): 43 | await submit_bid_use_case(bid) 44 | 45 | 46 | async def test_bid_price_lower_than_start_price( 47 | auctions_repository: InMemoryAuctionRepository, 48 | submit_bid_use_case: SubmitBidUseCase, 49 | ): 50 | auction = create_auction(start_price_value=10) 51 | auctions_repository.auctions.append(auction) 52 | bid = create_bid(auction_id=auction.id, price_value=9) 53 | with pytest.raises(LowBidError): 54 | await submit_bid_use_case(bid) 55 | 56 | 57 | async def test_bid_price_lower_than_highest_bid( 58 | auctions_repository: InMemoryAuctionRepository, 59 | submit_bid_use_case: SubmitBidUseCase, 60 | ): 61 | auction = create_auction(bids=[create_bid(price_value=20)]) 62 | auctions_repository.auctions.append(auction) 63 | bid = create_bid(auction_id=auction.id, price_value=19) 64 | with pytest.raises(LowBidError): 65 | await submit_bid_use_case(bid) 66 | 67 | 68 | async def test_bid_successfully_added( 69 | auctions_repository: InMemoryAuctionRepository, 70 | submit_bid_use_case: SubmitBidUseCase, 71 | ): 72 | auction = create_auction() 73 | auctions_repository.auctions.append(auction) 74 | bid = create_bid(auction_id=auction.id) 75 | await submit_bid_use_case(bid) 76 | result = await auctions_repository.get(id=auction.id) 77 | assert result is not None and bid in result.bids 78 | -------------------------------------------------------------------------------- /src/tests/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | from uuid import UUID, uuid4 3 | 4 | from domain.entities.auction import Auction 5 | from domain.entities.bid import Bid 6 | from domain.entities.item import Item 7 | from domain.value_objects.price import CurrencyOption, Price 8 | 9 | 10 | def create_bid( 11 | auction_id: UUID | None = None, 12 | price_value: float = 100.0, 13 | price_currency: CurrencyOption = CurrencyOption.euro, 14 | ) -> Bid: 15 | return Bid( 16 | bidder_id=uuid4(), 17 | price=Price(value=price_value, currency=price_currency), 18 | auction_id=auction_id if auction_id else uuid4(), 19 | ) 20 | 21 | 22 | def create_auction( 23 | bids: list[Bid] | None = None, 24 | start_date: date | None = None, 25 | end_date: date | None = None, 26 | start_price_value: int = 10, 27 | ) -> Auction: 28 | return Auction( 29 | item=Item(name="Test item", description="Test item description"), 30 | seller_id=uuid4(), 31 | start_date=start_date if start_date else date.today() - timedelta(days=7), 32 | end_date=end_date if end_date else date.today() + timedelta(days=7), 33 | start_price=Price(value=start_price_value, currency=CurrencyOption.euro), 34 | bids=bids if bids else [], 35 | ) 36 | -------------------------------------------------------------------------------- /src/use_cases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaliamekh/clean-architecture-fastapi/758da335e359ee58804c15a0d0459d80ddd7a596/src/use_cases/__init__.py -------------------------------------------------------------------------------- /src/use_cases/exceptions.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from domain.value_objects.price import Price 4 | 5 | 6 | class AuctionNotFoundError(Exception): 7 | def __init__(self, auction_id: UUID): 8 | self.auction_id = auction_id 9 | 10 | def __str__(self) -> str: 11 | return f"Auction not found: {self.auction_id}" 12 | 13 | 14 | class AuctionNotActiveError(Exception): 15 | def __init__(self, auction_id: UUID): 16 | self.auction_id = auction_id 17 | 18 | def __str__(self) -> str: 19 | return f"Auction is not active: {self.auction_id}" 20 | 21 | 22 | class LowBidError(Exception): 23 | def __init__(self, min_price: Price): 24 | self.min_price = min_price 25 | 26 | def __str__(self) -> str: 27 | return f"New bids for the auction should be higher than {self.min_price.value} {self.min_price.currency}" 28 | -------------------------------------------------------------------------------- /src/use_cases/submit_bid_use_case.py: -------------------------------------------------------------------------------- 1 | from domain.entities.bid import Bid 2 | from ports.repositories.auction_repository import AuctionRepository 3 | from use_cases.exceptions import ( 4 | AuctionNotActiveError, 5 | AuctionNotFoundError, 6 | LowBidError, 7 | ) 8 | 9 | 10 | class SubmitBidUseCase: 11 | def __init__(self, auction_repository: AuctionRepository): 12 | self._auction_repository = auction_repository 13 | 14 | async def __call__(self, bid: Bid) -> None: 15 | auction = await self._auction_repository.get(id=bid.auction_id) 16 | if not auction: 17 | raise AuctionNotFoundError(bid.auction_id) 18 | if not auction.is_active: 19 | raise AuctionNotActiveError(auction.id) 20 | if bid.price.value <= auction.minimal_bid_price.value: 21 | raise LowBidError(auction.minimal_bid_price) 22 | await self._auction_repository.add_bid(bid) 23 | --------------------------------------------------------------------------------