├── .gitignore ├── .idea ├── .gitignore ├── fastapi-hexagonal.iml ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── Dockerfile ├── Makefile ├── README.md ├── app ├── __init__.py ├── application │ ├── __init__.py │ ├── services │ │ ├── __init__.py │ │ └── product.py │ └── validators │ │ ├── __init__.py │ │ └── product.py ├── domain │ ├── __init__.py │ ├── entities │ │ ├── __init__.py │ │ └── product.py │ ├── events │ │ ├── __init__.py │ │ └── product.py │ ├── exceptions.py │ ├── repositories │ │ ├── __init__.py │ │ └── product.py │ └── use_cases │ │ ├── __init__.py │ │ └── product.py ├── infrastructure │ ├── __init__.py │ ├── container.py │ ├── events │ │ ├── __init__.py │ │ └── product.py │ ├── fast_api.py │ ├── handlers │ │ ├── __init__.py │ │ └── products.py │ ├── repositories │ │ ├── __init__.py │ │ └── product.py │ └── schemas │ │ ├── __init__.py │ │ └── product.py ├── main.py └── test │ ├── __init__.py │ ├── data │ ├── __init__.py │ └── product.py │ ├── fastapi │ ├── __init__.py │ └── test_product_api.py │ └── services │ ├── __init__.py │ └── test_product.py ├── architecture.png └── requirements.txt /.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 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/fastapi-hexagonal.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine 2 | 3 | WORKDIR /code 4 | 5 | COPY requirements.txt /requirements.txt 6 | 7 | ADD ./app /code/app 8 | 9 | RUN pip install -r /requirements.txt 10 | 11 | EXPOSE 8000 12 | 13 | CMD ["uvicorn", "app.main:application", "--reload", "--host", "0.0.0.0", "--port", "8000"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMAGE = devlusaja-fastapi:latest 2 | CONTAINER = fastapi_container 3 | 4 | build: 5 | @docker build -t ${IMAGE} . 6 | 7 | up: 8 | @docker run -d --name ${CONTAINER} -p 8000:8000 -v ${PWD}/app:/code/app ${IMAGE} 9 | @make log 10 | 11 | down: 12 | @docker rm -f ${CONTAINER} 13 | 14 | log: 15 | @docker logs -f ${CONTAINER} 16 | 17 | test: 18 | @docker run --rm -it --name ${CONTAINER}-test -v ${PWD}/app:/code/app ${IMAGE} pytest -vv -s -p no:cacheprovider 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hexagonal architecture example with Python 2 | 3 | Example of a microservice designed following the definition of hexagonal architecture. 4 | Depending on the size of our system, there may be more sub-layers within the application, domain or infrastructure. 5 | So it is requested to take it as a base. 6 | 7 | **Let's explain architecture** 8 | 9 | * Principal layers: 10 | * Domain 11 | * Application 12 | * Infrastructure 13 | * Test 14 | 15 | > Domain: 16 | > * In this layer we place all the business logic, such as: 17 | > - Entities with business rules. 18 | > - Interfaces or ports that define the behavior of use cases, repositories or events that are then implemented by the application and infrastructure layers. 19 | > - It is important to highlight that in this layer we should not use 3rd party technologies 20 | > 21 | > Application: 22 | > * In this layer we put all the functionality that is related to our application, for example: 23 | > - Services which implement the use cases defined in the domain layer 24 | > - Data validations that allow verifying if the input data is correct, such as character lengths. 25 | > 26 | > Infrastructure: 27 | > * In this layer we place the input and output adapters, which implement the interfaces defined for the repositories or business events. For example: 28 | > - Repository that stores the data in a database. 29 | > - Events that communicate with some queuing engine. 30 | > - Handlers that serve as the entry point and that call the application services. 31 | > - In this layer we can implement any 3rd party library. 32 | > 33 | > Test: 34 | > * In this directory we will place all the tests of our application. 35 | 36 | ![hexagonal-architecture](architecture.png) 37 | 38 | * Structure 39 | 40 | ```bash 41 | ├── app 42 | │ ├── application 43 | │ │ ├── validators # Validation logic for input data 44 | │ │ └── services # Implement the use cases 45 | │ ├── domain 46 | │ │ ├── entities # Entities with business logic 47 | │ │ ├── events # Interface that defines how an event behaves 48 | │ │ ├── repositories # Interface that defines how a data repository behaves 49 | │ │ ├── use cases # Interface that defines the use cases of the application 50 | │ │ └── exceptions # Domain exceptions 51 | │ ├── infrastructure 52 | │ │ ├── events # Implements the domain event interface 53 | │ │ ├── handlers # Application entry point 54 | │ │ ├── repositories # Implements the domain repository interface 55 | │ │ ├── schemas # Data structures used as input and output 56 | │ │ └── container # Dependency injector 57 | └───└── test 58 | ``` 59 | 60 | **Technologies** 61 | 62 | - python 63 | - fastapi 64 | - dependency-injector 65 | - pytest 66 | - pydantic 67 | - docker 68 | - make 69 | 70 | **Commands** 71 | ```bash 72 | $ make build #Create docker images 73 | ``` 74 | 75 | ```bash 76 | $ make up #Run the docker container 77 | ``` 78 | 79 | ```bash 80 | $ make down #Stop the docker container 81 | ``` 82 | 83 | **References** 84 | 85 | - https://alistair.cockburn.us/hexagonal-architecture/ 86 | - http://wiki.c2.com/?HexagonalArchitecture -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/__init__.py -------------------------------------------------------------------------------- /app/application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/application/__init__.py -------------------------------------------------------------------------------- /app/application/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/application/services/__init__.py -------------------------------------------------------------------------------- /app/application/services/product.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from app.domain.use_cases.product import ProductUseCases 3 | from app.domain.entities.product import ProductEntity 4 | from app.domain.events.product import ProductCreatedEvent, ProductUpdatedEvent 5 | from app.application.validators.product import ProductValidator 6 | from app.domain.repositories.product import ProductRepository 7 | 8 | 9 | class ProductService(ProductUseCases): 10 | 11 | __media_url = 'https://devlusaja.com/{}' 12 | 13 | def __init__(self, product_repository: ProductRepository, 14 | product_created_event: ProductCreatedEvent, 15 | product_updated_event: ProductUpdatedEvent 16 | ): 17 | super().__init__(product_repository, product_created_event, product_updated_event) 18 | 19 | def products_catalog(self) -> List[ProductEntity]: 20 | products = self.product_repository.get_all() 21 | for product in products: 22 | product.image = self.__media_url.format(product.image) 23 | return products 24 | 25 | def product_detail(self, id: str) -> ProductEntity: 26 | product = self.product_repository.get_by_id(id) 27 | product.image = self.__media_url.format(product.image) 28 | return product 29 | 30 | def register_product(self, product: ProductEntity) -> ProductEntity: 31 | ProductValidator.validate_price_is_float(product.price) 32 | ProductValidator.validate_description_len(product.description) 33 | 34 | product = self.product_repository.add(product) 35 | product.image = self.__media_url.format(product.image) 36 | 37 | self.product_created_event.send(product) 38 | 39 | return product 40 | 41 | def update_product(self, product: ProductEntity) -> ProductEntity: 42 | ProductValidator.validate_price_is_float(product.price) 43 | ProductValidator.validate_description_len(product.description) 44 | 45 | product = self.product_repository.update(product) 46 | product.image = self.__media_url.format(product.image) 47 | 48 | self.product_updated_event.send(product) 49 | 50 | return product 51 | -------------------------------------------------------------------------------- /app/application/validators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/application/validators/__init__.py -------------------------------------------------------------------------------- /app/application/validators/product.py: -------------------------------------------------------------------------------- 1 | from app.domain.exceptions import InvalidPrice, InvalidDescription 2 | 3 | 4 | class ProductValidator: 5 | 6 | @staticmethod 7 | def validate_price_is_float(price: float) -> float: 8 | try: 9 | float(price) 10 | except ValueError: 11 | raise InvalidPrice 12 | return float(price) 13 | 14 | @staticmethod 15 | def validate_description_len(description: str) -> None: 16 | if len(description) > 50: 17 | raise InvalidDescription 18 | -------------------------------------------------------------------------------- /app/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/domain/__init__.py -------------------------------------------------------------------------------- /app/domain/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/domain/entities/__init__.py -------------------------------------------------------------------------------- /app/domain/entities/product.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from app.domain.exceptions import PriceIsLessThanOrEqualToZero, StockIsLessThanOrEqualToZero 3 | 4 | 5 | class ProductEntity: 6 | 7 | def __init__(self, uid: str, name: str, description: str, price: float, stock: int, image: str): 8 | self.__validate_price(price) 9 | self.__validate_stock(stock) 10 | 11 | self.id = uid 12 | self.name = name 13 | self.description = description 14 | self.price = price 15 | self.stock = stock 16 | self.image = image 17 | 18 | @staticmethod 19 | def __validate_price(price: float): 20 | if price <= 0: 21 | raise PriceIsLessThanOrEqualToZero 22 | 23 | @staticmethod 24 | def __validate_stock(stock: int): 25 | if stock <= 0: 26 | raise StockIsLessThanOrEqualToZero 27 | 28 | class ProductEntityFactory: 29 | 30 | @staticmethod 31 | def create(id: str|None, name: str, description: str, price: float, stock: int, image: str) -> ProductEntity: 32 | if id is None: 33 | id = uuid.uuid4().__str__() 34 | return ProductEntity(id, name, description, float(price), int(stock), image) 35 | -------------------------------------------------------------------------------- /app/domain/events/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/domain/events/__init__.py -------------------------------------------------------------------------------- /app/domain/events/product.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from app.domain.entities.product import ProductEntity 3 | 4 | class ProductCreatedEvent(ABC): 5 | 6 | @abstractmethod 7 | def send(self, product: ProductEntity) -> bool: 8 | raise NotImplemented 9 | 10 | class ProductUpdatedEvent(ABC): 11 | 12 | @abstractmethod 13 | def send(self, product: ProductEntity) -> bool: 14 | raise NotImplemented 15 | -------------------------------------------------------------------------------- /app/domain/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidDescription(Exception): 2 | def __init__(self): 3 | super().__init__('The description must have less than 50 characters') 4 | 5 | class InvalidPrice(Exception): 6 | def __init__(self): 7 | super().__init__('The price is not a valid number') 8 | 9 | class PriceIsLessThanOrEqualToZero(Exception): 10 | def __init__(self): 11 | super().__init__('The price is less than or equal to zero') 12 | 13 | class StockIsLessThanOrEqualToZero(Exception): 14 | def __init__(self): 15 | super().__init__('The stock is less than or equal to zero') -------------------------------------------------------------------------------- /app/domain/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/domain/repositories/__init__.py -------------------------------------------------------------------------------- /app/domain/repositories/product.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List 3 | 4 | from app.domain.entities.product import ProductEntity 5 | 6 | 7 | class ProductRepository(ABC): 8 | 9 | @abstractmethod 10 | def get_all(self) -> List[ProductEntity]: 11 | raise NotImplemented 12 | 13 | @abstractmethod 14 | def get_by_id(self, id: str) -> ProductEntity: 15 | raise NotImplemented 16 | 17 | @abstractmethod 18 | def add(self, product: ProductEntity) -> ProductEntity: 19 | raise NotImplemented 20 | 21 | @abstractmethod 22 | def update(self, product: ProductEntity) -> ProductEntity: 23 | raise NotImplemented 24 | -------------------------------------------------------------------------------- /app/domain/use_cases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/domain/use_cases/__init__.py -------------------------------------------------------------------------------- /app/domain/use_cases/product.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List 3 | from app.domain.entities.product import ProductEntity 4 | from app.domain.events.product import ProductCreatedEvent, ProductUpdatedEvent 5 | from app.domain.repositories.product import ProductRepository 6 | 7 | 8 | class ProductUseCases(ABC): 9 | 10 | @abstractmethod 11 | def __init__(self, product_repository: ProductRepository, 12 | product_created_event: ProductCreatedEvent, 13 | product_updated_event: ProductUpdatedEvent 14 | ): 15 | self.product_repository = product_repository 16 | self.product_created_event = product_created_event 17 | self.product_updated_event = product_updated_event 18 | 19 | @abstractmethod 20 | def products_catalog(self) -> List[ProductEntity]: 21 | raise NotImplemented 22 | 23 | @abstractmethod 24 | def product_detail(self, id: str) -> ProductEntity: 25 | raise NotImplemented 26 | 27 | @abstractmethod 28 | def register_product(self, product: ProductEntity) -> ProductEntity: 29 | raise NotImplemented 30 | 31 | @abstractmethod 32 | def update_product(self, product: ProductEntity) -> ProductEntity: 33 | raise NotImplemented 34 | -------------------------------------------------------------------------------- /app/infrastructure/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/infrastructure/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/container.py: -------------------------------------------------------------------------------- 1 | from dependency_injector import containers, providers 2 | from app.domain.entities.product import ProductEntityFactory 3 | from app.infrastructure.events.product import ProductCreatedQueueEvent, ProductUpdatedQueueEvent 4 | from app.infrastructure.handlers import Handlers 5 | from app.infrastructure.repositories.product import ProductInMemoryRepository 6 | from app.application.services.product import ProductService 7 | 8 | 9 | class Container(containers.DeclarativeContainer): 10 | 11 | #loads all handlers where @injects are set 12 | wiring_config = containers.WiringConfiguration(modules=Handlers.modules()) 13 | 14 | #Factories 15 | product_factory = providers.Factory(ProductEntityFactory) 16 | 17 | #Repositories 18 | product_repository = providers.Singleton(ProductInMemoryRepository) 19 | 20 | #Events 21 | product_created_event = providers.Factory(ProductCreatedQueueEvent) 22 | product_updated_event = providers.Factory(ProductUpdatedQueueEvent) 23 | 24 | #Services 25 | product_services = providers.Factory(ProductService, product_repository, product_created_event, product_updated_event) 26 | -------------------------------------------------------------------------------- /app/infrastructure/events/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/infrastructure/events/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/events/product.py: -------------------------------------------------------------------------------- 1 | from app.domain.entities.product import ProductEntity 2 | from app.domain.events.product import ProductCreatedEvent, ProductUpdatedEvent 3 | 4 | 5 | class ProductCreatedQueueEvent(ProductCreatedEvent): 6 | 7 | def send(self, product: ProductEntity): 8 | # TODO: Your code here 9 | return True 10 | 11 | class ProductUpdatedQueueEvent(ProductUpdatedEvent): 12 | 13 | def send(self, product: ProductEntity): 14 | # TODO: Your code here 15 | return True 16 | -------------------------------------------------------------------------------- /app/infrastructure/fast_api.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from app.infrastructure.container import Container 3 | from app.infrastructure.handlers import Handlers 4 | 5 | 6 | def create_app(): 7 | fast_api = FastAPI() 8 | fast_api.container = Container() 9 | for handler in Handlers.iterator(): 10 | fast_api.include_router(handler.router) 11 | return fast_api 12 | -------------------------------------------------------------------------------- /app/infrastructure/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from types import ModuleType 3 | from typing import Iterator 4 | 5 | 6 | class Handlers: 7 | handlers_base_path = ('app', 'infrastructure', 'handlers') 8 | ignored = ('__init__.py', '__pycache__') 9 | 10 | @classmethod 11 | def __all_module_names(cls) -> list: 12 | return list( 13 | filter( 14 | lambda module: module not in cls.ignored, os.listdir('/'.join(cls.handlers_base_path)) 15 | ) 16 | ) 17 | 18 | @classmethod 19 | def __module_namespace(cls, handler_name: str) -> str: 20 | return '%s.%s' % ('.'.join(cls.handlers_base_path), handler_name) 21 | 22 | @classmethod 23 | def iterator(cls) -> Iterator[ModuleType]: 24 | for module in cls.__all_module_names(): 25 | import importlib 26 | handler = importlib.import_module(cls.__module_namespace(module[:-3])) 27 | yield handler 28 | 29 | @classmethod 30 | def modules(cls) -> map: 31 | return map( 32 | lambda module: cls.__module_namespace(module[:-3]), cls.__all_module_names() 33 | ) 34 | -------------------------------------------------------------------------------- /app/infrastructure/handlers/products.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from dependency_injector.wiring import inject, Provide 3 | from fastapi import APIRouter, Depends 4 | from app.domain.entities.product import ProductEntity, ProductEntityFactory 5 | from app.infrastructure.container import Container 6 | from app.application.services.product import ProductService 7 | from app.infrastructure.schemas.product import ProductOutput, ProductInput 8 | 9 | router = APIRouter( 10 | prefix='/products', 11 | tags=['products'] 12 | ) 13 | 14 | @router.get('/', response_model=List[ProductOutput]) 15 | @inject 16 | def get_catalog(product_services: ProductService = Depends(Provide[Container.product_services])) -> List[dict]: 17 | response: List[ProductEntity] = product_services.products_catalog() 18 | return [product_entity.__dict__ for product_entity in response] 19 | 20 | @router.get('/{id}', response_model=ProductOutput) 21 | @inject 22 | def get_description(id: str, product_services: ProductService = Depends(Provide[Container.product_services])) -> dict: 23 | response: ProductEntity = product_services.product_detail(id) 24 | return response.__dict__ 25 | 26 | @router.post('/', response_model=ProductOutput) 27 | @inject 28 | def register_product( 29 | product: ProductInput, 30 | product_factory: ProductEntityFactory = Depends(Provide[Container.product_factory]), 31 | product_services: ProductService = Depends(Provide[Container.product_services]) 32 | ) -> dict: 33 | name, description, price, stock, image = product.__dict__.values() 34 | product_entity: ProductEntity = product_factory.create(None, name, description, price, stock, image) 35 | response: ProductEntity = product_services.register_product(product_entity) 36 | return response.__dict__ 37 | 38 | @router.put('/{id}', response_model=ProductOutput) 39 | @inject 40 | def update_product( 41 | id: str, 42 | product: ProductInput, 43 | product_factory: ProductEntityFactory = Depends(Provide[Container.product_factory]), 44 | product_services: ProductService = Depends(Provide[Container.product_services]) 45 | ) -> dict: 46 | name, description, price, stock, image = product.__dict__.values() 47 | product_entity: ProductEntity = product_factory.create(id, name, description, price, stock, image) 48 | response: ProductEntity = product_services.update_product(product_entity) 49 | return response.__dict__ 50 | -------------------------------------------------------------------------------- /app/infrastructure/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/infrastructure/repositories/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/repositories/product.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from typing import List 3 | from app.domain.entities.product import ProductEntityFactory, ProductEntity 4 | from app.domain.repositories.product import ProductRepository 5 | 6 | 7 | class ProductInMemoryRepository(ProductRepository): 8 | 9 | products: List[dict] = [ 10 | {'id': '3f996431-e90e-4d12-b2be-5614959c0202', 'name': 'milk', 'description': 'skimmed cows milk', 'price': 10.50, 'stock': 1, 'image': 'milk.jpg'}, 11 | {'id': '3f996431-e90e-4d12-b2be-5614959c0201', 'name': 'meat', 'description': 'beef licence', 'price': 20.50, 'stock': 2, 'image': 'meat.jpg'} 12 | ] 13 | 14 | def get_all(self) -> List[ProductEntity]: 15 | return [ProductEntityFactory.create(**product) for product in self.products] 16 | 17 | def get_by_id(self, id: str) -> ProductEntity|None: 18 | try: 19 | product = next(filter(lambda p: p['id'] == id, self.products)) 20 | return ProductEntityFactory.create(**product) 21 | except StopIteration: 22 | return None 23 | 24 | def add(self, product: ProductEntity) -> ProductEntity: 25 | self.products.append(copy(product.__dict__)) 26 | return product 27 | 28 | def update(self, product: ProductEntity) -> ProductEntity: 29 | for key, value in enumerate(self.products): 30 | if value['id'] == product.id: 31 | self.products[key] = copy(product.__dict__) 32 | return product 33 | -------------------------------------------------------------------------------- /app/infrastructure/schemas/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/infrastructure/schemas/__init__.py -------------------------------------------------------------------------------- /app/infrastructure/schemas/product.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class ProductInput(BaseModel): 5 | name: str 6 | description: str 7 | price: float 8 | stock: int 9 | image: str 10 | 11 | class ProductOutput(BaseModel): 12 | id: str 13 | name: str 14 | description: str 15 | price: float 16 | stock: int 17 | image: str 18 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from app.infrastructure.fast_api import create_app 2 | 3 | application = create_app() 4 | -------------------------------------------------------------------------------- /app/test/__init__.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | from app.domain.entities.product import ProductEntityFactory 3 | from app.domain.repositories.product import ProductRepository 4 | from app.test.data.product import expected_products_catalog, expected_product_description 5 | 6 | 7 | product_data = {'id': 'MockID', 'name': 'MockProduct', 'description': 'MockDescription', 'price': 10, 'stock': 1, 'image': 'MockImage.jpg'} 8 | product_repository_get_all = [product_data for x in range(2)] 9 | product_repository_get_by_id = product_data 10 | 11 | @patch('app.infrastructure.repositories.product.ProductInMemoryRepository', spec=True) 12 | def create_mock_product_repository(mock_repository: Mock): 13 | mock_product_repo: ProductRepository = mock_repository.return_value 14 | mock_product_repo.get_all = Mock(return_value=[ProductEntityFactory.create(**product) for product in product_repository_get_all]) 15 | mock_product_repo.get_by_id = Mock(return_value=ProductEntityFactory.create(**product_repository_get_by_id)) 16 | mock_product_repo.add = Mock(return_value=ProductEntityFactory.create(**product_repository_get_by_id)) 17 | mock_product_repo.update = Mock(return_value=ProductEntityFactory.create(**product_repository_get_by_id)) 18 | return mock_product_repo 19 | -------------------------------------------------------------------------------- /app/test/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/test/data/__init__.py -------------------------------------------------------------------------------- /app/test/data/product.py: -------------------------------------------------------------------------------- 1 | product_data = {'id': 'MockID', 'name': 'MockProduct', 'description': 'MockDescription', 'price': 10, 'stock': 1, 'image': 'https://devlusaja.com/MockImage.jpg'} 2 | expected_products_catalog = [product_data for x in range(2)] 3 | expected_product_description = product_data 4 | -------------------------------------------------------------------------------- /app/test/fastapi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/test/fastapi/__init__.py -------------------------------------------------------------------------------- /app/test/fastapi/test_product_api.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from app.infrastructure.fast_api import create_app 3 | from fastapi.testclient import TestClient 4 | from app.test import create_mock_product_repository, expected_products_catalog, expected_product_description 5 | 6 | 7 | class TestUserApi: 8 | 9 | @pytest.fixture(autouse=True) 10 | def setup(self): 11 | self.app = create_app() 12 | self.client = TestClient(self.app) 13 | self.base_path = '/products' 14 | 15 | def test_get_product_catalog(self): 16 | with self.app.container.product_repository.override(create_mock_product_repository()): 17 | response = self.client.get(self.base_path + '/') 18 | assert expected_products_catalog == response.json() 19 | assert response.status_code == 200 20 | 21 | def test_get_product_detail(self): 22 | with self.app.container.product_repository.override(create_mock_product_repository()): 23 | response = self.client.get(self.base_path + '/mock_id') 24 | assert expected_product_description == response.json() 25 | assert response.status_code == 200 26 | 27 | def test_post_register_product(self): 28 | with self.app.container.product_repository.override(create_mock_product_repository()): 29 | response = self.client.post( 30 | self.base_path, 31 | json=expected_product_description 32 | ) 33 | assert expected_product_description == response.json() 34 | assert response.status_code == 200 35 | 36 | def test_put_update_product(self): 37 | with self.app.container.product_repository.override(create_mock_product_repository()): 38 | response = self.client.put( 39 | self.base_path + '/mock_id', 40 | json=expected_product_description 41 | ) 42 | assert expected_product_description == response.json() 43 | assert response.status_code == 200 -------------------------------------------------------------------------------- /app/test/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/app/test/services/__init__.py -------------------------------------------------------------------------------- /app/test/services/test_product.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from app.application.services.product import ProductService 3 | from app.domain.entities.product import ProductEntity, ProductEntityFactory 4 | from app.infrastructure.container import Container 5 | from app.test import create_mock_product_repository 6 | from app.test.data.product import expected_product_description 7 | 8 | 9 | class TestProductService: 10 | 11 | """ 12 | this fixture fulfills the function of dependency injection for all tests 13 | """ 14 | @pytest.fixture(autouse=True) 15 | def injector(self): 16 | container = Container() 17 | with container.product_repository.override(create_mock_product_repository()): 18 | self.product_service: ProductService = container.product_services() 19 | self.product_factory: ProductEntityFactory = container.product_factory() 20 | 21 | def test_that_all_products_in_the_catalog_are_an_entity(self): 22 | catalog = self.product_service.products_catalog() 23 | for product in catalog: 24 | assert type(product) is ProductEntity 25 | 26 | def test_that_product_detail_is_an_entity(self): 27 | product = self.product_service.product_detail('mock_id') 28 | assert type(product) is ProductEntity 29 | 30 | def test_product_name_is_correct(self): 31 | product = self.product_service.product_detail('mock_id') 32 | assert product.name == expected_product_description['name'] 33 | 34 | def test_register_product(self): 35 | uid, name, description, price, stock, image = expected_product_description.values() 36 | product_entity = self.product_factory.create(None, name, description, float(price), int(stock), image) 37 | product = self.product_service.register_product(product_entity) 38 | assert type(product) is ProductEntity 39 | assert product.id == uid 40 | 41 | def test_update_product(self): 42 | uid, name, description, price, stock, image = expected_product_description.values() 43 | product_entity = self.product_factory.create(uid, name, description, price, stock, image) 44 | product = self.product_service.update_product(product_entity) 45 | assert type(product) is ProductEntity 46 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dev-lusaja/fastapi-hexagonal/c5a5e330899c1c74308a38bba2987d715c693057/architecture.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.93.0 2 | uvicorn[standard]==0.20.0 3 | dependency-injector==4.41.0 4 | pytest==7.2.2 5 | httpx==0.23.3 6 | pydantic==1.10.6 --------------------------------------------------------------------------------