├── .gitignore ├── Makefile ├── README.md ├── app.py ├── requirements └── requirements.txt ├── src ├── domain │ ├── article.py │ └── collection.py ├── driver │ └── article_driver.py ├── interactor │ └── article_interactor.py ├── interface │ ├── driver │ │ └── article_driver.py │ ├── repository │ │ └── article_repository.py │ └── usecase │ │ └── article_usecase.py ├── repository │ └── article_repository.py └── rest │ └── article_resource.py └── tests ├── __init__.py ├── repository ├── __init__.py └── test_article_repository.py └── usecase ├── __init__.py └── test_article_interactor.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | tmp 3 | venv 4 | __pycache__ -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | python -m unittest 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Clean Architecture Example 2 | 3 | This project is a sample Python web application adapting Clean Architecture. By using typing module, development experience similar to static type language is achieved. 4 | 5 | Dependency injection and Dependency inversion principle, which are the representative features in Clean Architecture, can be implemented without difficulties. 6 | 7 | In order to experience the separation of concerning and improvement of testability by Clean Architecture along with the high productivity by Python language, I've published this sample project. 8 | 9 | Also in `tests` directory, there are some test samples with partial mock. If you are interested, please take a look it. 10 | 11 | # How to run 12 | 13 | ```bash 14 | $ pip install -r requirements/requirements 15 | $ python -m flask run 16 | ``` 17 | 18 | Then open `http://localhost:5000` in your browser. 19 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | from src.driver.article_driver import ArticleDriverImpl 4 | from src.interactor.article_interactor import ArticleInteractor 5 | from src.repository.article_repository import ArticleRepositoryImpl 6 | from src.rest.article_resource import ArticleResource 7 | 8 | app = Flask(__name__) 9 | 10 | article_resource = ArticleResource( 11 | article_usecase=ArticleInteractor( 12 | article_repository=ArticleRepositoryImpl( 13 | article_driver=ArticleDriverImpl() 14 | ) 15 | ) 16 | ) 17 | 18 | app.add_url_rule('/', view_func=article_resource.index) 19 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | aiohttp 3 | -------------------------------------------------------------------------------- /src/domain/article.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from src.domain.collection import Collection 4 | 5 | 6 | @dataclass(frozen=True) 7 | class Article: 8 | id: str 9 | body: str 10 | 11 | 12 | @dataclass(frozen=True) 13 | class Articles(Collection[Article]): 14 | values: [Article] 15 | -------------------------------------------------------------------------------- /src/domain/collection.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar 2 | 3 | from attr import dataclass 4 | 5 | T = TypeVar('T') 6 | 7 | 8 | @dataclass(frozen=True) 9 | class Collection(Generic[T]): 10 | values: [T] 11 | 12 | def map(self, func) -> map: 13 | return map(func, self.values) 14 | -------------------------------------------------------------------------------- /src/driver/article_driver.py: -------------------------------------------------------------------------------- 1 | import json 2 | import aiohttp 3 | from src.interface.driver.article_driver import ArticleDriver 4 | 5 | 6 | class ArticleDriverImpl(ArticleDriver): 7 | async def get_articles(self, page: int) -> dict: 8 | response = await aiohttp.request('GET', 'https://qiita.com/api/v2/items?page=1&per_page=20').coro 9 | articles = json.loads(await response.text()) 10 | return { 11 | "articles": [{ 12 | "id": a["id"], 13 | "body": a["body"] 14 | } for a in articles] 15 | } 16 | -------------------------------------------------------------------------------- /src/interactor/article_interactor.py: -------------------------------------------------------------------------------- 1 | from src.domain.article import Articles 2 | from src.interface.repository.article_repository import ArticleRepository 3 | from src.interface.usecase.article_usecase import ArticleUsecase 4 | 5 | 6 | class ArticleInteractor(ArticleUsecase): 7 | article_repository: ArticleRepository 8 | 9 | def __init__(self, article_repository: ArticleRepository): 10 | self.article_repository = article_repository 11 | 12 | async def get_list(self, page: int) -> Articles: 13 | return await self.article_repository.get_list(page) 14 | -------------------------------------------------------------------------------- /src/interface/driver/article_driver.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class ArticleDriver(metaclass=ABCMeta): 5 | @abstractmethod 6 | async def get_articles(self, page: int) -> dict: 7 | raise NotImplementedError 8 | -------------------------------------------------------------------------------- /src/interface/repository/article_repository.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | from src.domain.article import Articles 4 | 5 | 6 | class ArticleRepository(metaclass=ABCMeta): 7 | @abstractmethod 8 | async def get_list(self, page: int) -> Articles: 9 | raise NotImplementedError 10 | -------------------------------------------------------------------------------- /src/interface/usecase/article_usecase.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | from src.domain.article import Articles 4 | 5 | 6 | class ArticleUsecase(metaclass=ABCMeta): 7 | @abstractmethod 8 | async def get_list(self, page: int) -> Articles: 9 | raise NotImplementedError 10 | -------------------------------------------------------------------------------- /src/repository/article_repository.py: -------------------------------------------------------------------------------- 1 | from src.domain.article import Article, Articles 2 | from src.interface.driver.article_driver import ArticleDriver 3 | from src.interface.repository.article_repository import ArticleRepository 4 | 5 | 6 | class ArticleRepositoryImpl(ArticleRepository): 7 | article_driver: ArticleDriver 8 | 9 | def __init__(self, article_driver: ArticleDriver): 10 | self.article_driver = article_driver 11 | 12 | async def get_list(self, page: int) -> Articles: 13 | res = await self.article_driver.get_articles(page) 14 | return Articles( 15 | values=[ 16 | Article(id=a["id"], body=a["body"]) 17 | for a in res["articles"] 18 | ] 19 | ) 20 | -------------------------------------------------------------------------------- /src/rest/article_resource.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from flask import jsonify 4 | 5 | from src.domain.article import Articles 6 | from src.interface.usecase.article_usecase import ArticleUsecase 7 | 8 | 9 | class ArticleResource: 10 | article_usecase: ArticleUsecase 11 | 12 | def __init__(self, article_usecase: ArticleUsecase): 13 | self.article_usecase = article_usecase 14 | 15 | def index(self): 16 | main_group: Articles = asyncio.run(self.article_usecase.get_list(0)) 17 | return jsonify(articles_response(main_group)) 18 | 19 | 20 | def articles_response(articles: Articles) -> dict: 21 | return { 22 | "items": [{ 23 | "id": article.id, 24 | "body": article.body 25 | } for article in articles.values] 26 | } 27 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | def async_test(coro): 5 | def wrapper(*args, **kwargs): 6 | loop = asyncio.new_event_loop() 7 | return loop.run_until_complete(coro(*args, **kwargs)) 8 | return wrapper 9 | -------------------------------------------------------------------------------- /tests/repository/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t-tiger/Python-CleanArchitecture-Example/1c8c05bf29ce85d5d28f832f6a85940b34120ce2/tests/repository/__init__.py -------------------------------------------------------------------------------- /tests/repository/test_article_repository.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from asyncmock import AsyncMock 4 | 5 | from src.domain.article import Article, Articles 6 | from src.interface.driver.article_driver import ArticleDriver 7 | from src.repository.article_repository import ArticleRepositoryImpl 8 | from tests import async_test 9 | 10 | 11 | class DriverMock(ArticleDriver): 12 | async def get_articles(self, page: int) -> dict: 13 | raise NotImplementedError 14 | 15 | 16 | class TestArticleRepository(TestCase): 17 | @async_test 18 | async def test_get(self): 19 | get_main_group_mock = AsyncMock(return_value={"articles": [ 20 | {"id": "1", "body": "test"} 21 | ]}) 22 | 23 | driver = DriverMock() 24 | driver.get_articles = get_main_group_mock 25 | repository = ArticleRepositoryImpl(article_driver=driver) 26 | 27 | self.assertEqual( 28 | await repository.get_list(1), 29 | Articles(values=[Article(id="1", body="test")]) 30 | ) 31 | get_main_group_mock.assert_called_with(1) 32 | -------------------------------------------------------------------------------- /tests/usecase/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t-tiger/Python-CleanArchitecture-Example/1c8c05bf29ce85d5d28f832f6a85940b34120ce2/tests/usecase/__init__.py -------------------------------------------------------------------------------- /tests/usecase/test_article_interactor.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from asyncmock import AsyncMock 4 | 5 | from src.domain.article import Article, Articles 6 | from src.interactor.article_interactor import ArticleInteractor 7 | from src.interface.repository.article_repository import ArticleRepository 8 | from tests import async_test 9 | 10 | 11 | class RepositoryMock(ArticleRepository): 12 | async def get_list(self, page: str) -> Articles: 13 | raise NotImplementedError 14 | 15 | 16 | class TestArticleInteractor(TestCase): 17 | @async_test 18 | async def test_get(self): 19 | get_mock = AsyncMock(return_value=Article(id="1", body='test')) 20 | 21 | repo = RepositoryMock() 22 | repo.get_list = get_mock 23 | usecase = ArticleInteractor(article_repository=repo) 24 | 25 | self.assertEqual(await usecase.get_list(1), Article(id="1", body="test")) 26 | get_mock.assert_called_with(1) 27 | --------------------------------------------------------------------------------