📝

前職での学び #1 ─Django でクリーンアーキテクチャ─

2022/05/12に公開

はじめに

前職での経験を踏まえて,Django でクリーンアーキテクチャのサンプルを試しに実装してみたので,今回はそれについて紹介したいと思います.初めての技術記事投稿なので,至らないところがあればご指摘いただければ幸いです.

クリーンアーキテクチャとは

さて,クリーンアーキテクチャとは何でしょうか? よく引用されるのは次図です.

クリーンアーキテクチャ
 "The Clean Architecture" (https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) より引用

説明については次のサイトが分かりやすいと思います:https://nrslib.com/clean-architecture/

要点としては,あるモジュールで別のモジュールを呼び出しているとき,呼び出している側から呼び出されている側に 依存の矢印を向かわせると,一方向のみに向かっている というルール (Dependency Rule) を守っているものです.

こうすると,矢印が向かっていくアーキテクチャの中心部に位置する ビジネスロジック (Use Case) は,周縁部にある些末な詳細 (どこに出力するかなど) のことを知らずに,その変更から保護される特権的位置を獲得 します.Use Case はフレームワークや詳細に依存しない純粋な入出力を持つものとして想定しています.それに加えて周縁部の詳細を挿げ替える (向き先を DB から In-memory にするなど) ことで,テスト容易性 (testability) も高まります.また,責務が分離されているため,どの処理がどのファイルにあるかわかりやすいというのも利点です.

Django でやる

さて,バックエンドの責務とはなんでしょうか?

  1. HTTP リクエストを受けて,
  2. 適切なユーザーであるか認証し,
  3. リクエストボディのバリデーションをし,
  4. 永続化されているデータを用いて何かしらの処理をして,
  5. レスポンスボディとしてデータを返す

すなわち,データの永続化とそのデータの状態を取得・変更するものだと思っています.

上の 4. が Use Case に相当します.また,その Use Case は永続化されているデータに対応する Entity に依存するので, Entity がもっとも中核的な部分 ということになります.

また,Use Case の純粋な入出力を HTTP リクエスト → レスポンス という外部の入出力関係に変換する周辺部分の詳細 (4. 以外) が Controller ということになります.

これを Django で実現する方法を見ていきましょう.試しにドラえもんひみつ道具を保存する API を作ってみます.

作成するにあたって,クリーンアーキテクチャーのサンプルとしてもう少し詳しい図があります:

クリーンアーキテクチャ サンプル
 "esakik/clean-architecture-python" (https://github.com/esakik/clean-architecture-python) より引用

色は上の同心円状の図と対応しており,Dependency Rule が成り立っていることがわかります.

以下,これに則って説明します.なお,全体的にファイル名は [model name]_[model | controller | use_case etc.].py 等としています.

Controller

今回は API を作りますので,HTTP Request を受けて Response を返します.従って,Presenter / View Model / View は不要です.やることとしては,リクエストボディを受け取り,リクエストオブジェクトに詰めて,ユースケースに渡すことでレスポンスオブジェクトを得ます.

mgadget_controller.py
def create_mgadget(request: HttpRequest) -> Optional[HttpResponse]:
    if request.method == 'POST':
        data = json.loads(request.body)
        
        req = CreateMGadgetRequest(**data)
        
        repo = MGadgetRepository(MGadgetModel)
        use_case = CreateMGadgetUseCase(repo)
        
        resp = use_case.handle(req)
        
        return handle_success(resp)

handle_success によって,レスポンスオブジェクトを HTTP レスポンス (を表すオブジェクト) に変換します.

Input Data

リクエストオブジェクトです.dataclass を利用すると便利だと思います.ここでバリデーションも行います.

create_mgadget_request.py
@dataclass
class CreateMGadgetRequest(BaseRequest):
    name: str
    desc: str
    ruby: str
    
    def __post_init__(self) -> None:
        if not self.name:
            raise ValidationError('gadget name is required.')
        
        if not self.desc:
            raise ValidationError('gadget desc is required.')
        
        if not self.ruby:
            raise ValidationError('gadget ruby is required.')

BaseRequest クラスを継承します.

Use Case Input Port

ユースケースのインターフェースです.handle メソッドの存在を明示します.

base_use_case.py
class BaseUseCase(ABC):
    @abstractmethod
    def __init__(self, repo: BaseRepository) -> None:
        raise NotImplementedError()
    
    @abstractmethod
    def handle(self, request: BaseRequest) -> BaseResponse:
        raise NotImplementedError()

リクエストオブジェクトを受け取り,レスポンスオブジェクトを返します.

Output Data

レスポンスオブジェクトです.BaseResponse クラスを継承します.

create_mgadget_response.py
@dataclass
class CreateMGadgetResponse(BaseResponse):
    mgadget_id: str
    
    def to_json(self) -> Dict[str, Any]:
        return asdict(self)

Data Access Interface

データベースへの永続化を抽象化して,できる操作を定義します.

base_repository.py
class BaseRepository(ABC):
    @abstractmethod
    def __init__(self, model_class: BaseModel) -> None:
        raise NotImplementedError()
    
    @abstractmethod
    def get(self, id: str) -> BaseModel:
        raise NotImplementedError()
    
    @abstractmethod
    def all(self) -> List[BaseModel]:
        raise NotImplementedError()

    @abstractmethod
    def create(self, data: Dict[str, Any]) -> str:
        raise NotImplementedError()
    
    @abstractmethod
    def update(self, data: Dict[str, Any]) -> None:
        raise NotImplementedError()
    
    @abstractmethod
    def delete(self, id: str) -> None:
        raise NotImplementedError()

Data Access

具体的なデータベースへの永続化を担います.

mgadget_repository.py
class MGadgetRepository(BaseRepository):
    def __init__(self, model_class: BaseModel) -> None:
        self.model_class = model_class
        
    def get(self, id: str) -> BaseModel:
        return self.model_class.objects.get(id=id)
    
    def all(self) -> List[BaseModel]:
        return self.model_class.objects.all()

    def create(self, data: Dict[str, Any]) -> str:
        model = self.model_class(**data)
        
        model.save()
        
        return str(model.id)

    def update(self, data: Dict[str, Any]) -> None:
        model = self.model_class(**data)
        data.pop('id')
        self.model_class.objects.bulk_update([model], data.keys())
        
    def delete(self, id: str) -> None:
        model = self.model_class.objects.get(id=id)
        
        model.delete()

Entities

エンティティ,ドメインにおける事物・対象を抽象化したものを定義します (ここではドラえもんのひみつ道具).

mgadget_model.py
class MGadgetModel(BaseModel):
    '''
    ドラえもんひみつ道具
    '''
    
    name = CharField(max_length=8192)
    ruby = CharField(max_length=8192)
    desc = CharField(max_length=8192)

おわりに

Django によるクリーンアーキテクチャの実践例は資料が少なく,また自分自身非常に学びの多いコードだったため,その概要を記事に残しておきたいと思った次第です.ご意見等いただければうれしく思います.

GitHubで編集を提案

Discussion