🦉

フクロウと学ぶアーキテクチャ #2 ─ ドメイン中心アーキテクチャ入門(Clean / Hexagonal / DDD)

に公開

※本記事は個人の知見と学習の記録であり、所属組織の見解を代表するものではありません。

🦉フクロウと学ぶアーキテクチャ #2

ドメイン中心アーキテクチャ入門 ─ Clean / Hexagonal / DDD を「タスク管理アプリ」で体験する

ソフトウェアアーキテクチャの旅、その 2ページ目
ドメイン中心アーキテクチャ(Domain-Centric Architecture) です。
前回に続き、今回も「迷子にならないための地図」を一緒に描いていきます。

前回は レイヤードアーキテクチャ(N-Tier) を題材に、

  • UI
  • サービス(ビジネスロジック)
  • データアクセス

という「技術ごとの棚」でコードを整理する設計を見てきました。

今回はその“次の一歩”として、
技術よりもドメイン(ビジネスの意味そのもの)を中心に据える設計──

  • Clean Architecture
  • Hexagonal Architecture(Ports & Adapters)
  • DDD(Domain-Driven Design)

共通エッセンス(内側を守る構造) を、
今回は タスク管理 CLI アプリを通して体験します。

※本サンプルは Clean/Hexagonal をベースにしており、DDD の戦術パターン(集約・仕様・ドメインサービスなど)は最小限に抑えた“DDD 入門向け”の軽量版です。

Layered vs Domain-Centric

なぜ「レイヤードの次」がドメイン中心なのか?

レイヤードは歴史が長く、今でも多くのフレームワークで
デフォルトの地図」として採用される構造です。
しかしモバイルアプリ、Web API、Webhook、エージェント、外部 SaaS など
“入口(UI)”が爆発的に増える現代では、レイヤード構造は次のような壁にぶつかります:

  • UI が複数になると Service 層が太りがち
  • DB が複数になると Repository が複雑化
  • 外部サービス(SaaS)が増えると境界が曖昧化

ドメイン中心アーキテクチャは、この「外側の増加と複雑化」から
内側(ドメインの“意味”)を守り抜くために登場した考え方です。
“変わりやすい外側”と“変わってほしくない中心”を切り離すための地図、と言ってもよいでしょう。

外側の増加と複雑化


今回めざすゴール

  • 「ドメイン中心アーキテクチャって、ざっくり何?」がつかめる
  • Clean / Hexagonal / DDD が 何を守ろうとしているのか が理解できる
  • GitHub のサンプルコードをいじりながら、
    ドメインを変えても外側は壊れない
    外側を変えてもドメインはそのまま
    を実感できる

Clean / Hexagonal / DDD をざっくり整理しておく

ここから先を読みやすくするために、3つの名前を先にざっくりそろえておきます。

  • Clean Architecture
    • 内側ほど「ビジネスのルール」、外側ほど「技術的な都合」という入れ子構造
    • 依存は必ず「外側 → 内側」に向かう というルールが中心
  • Hexagonal Architecture(Ports & Adapters)
    • ドメイン側から見た「お願い窓口(Port)」と、その実装である「Adapter」に分ける考え方
    • UI や DB、外部 SaaS など、外の世界を Port/Adapter 経由で差し替え可能にする
  • DDD(Domain-Driven Design)
    • ドメインの専門家と同じ言葉でモデルをつくり、
      コードと会話の言葉をそろえていくプラクティスの集合
    • 戦術パターン(Entity, ValueObject など)や戦略パターン(境界づけられたコンテキストなど)がある

今回のサンプルコードは、Clean / Hexagonal の構造をベースにしつつ、DDD の「ドメインの意味を中心に置く」という部分だけを軽量に取り入れたもの と捉えてもらえるとイメージしやすいと思います。


pythonで体験するdomain-centric

サンプルの全体像 ─ core が中心、adapters が外側

今回のサンプルはこちらです:

👉 samples/domain-centric/simple-cli
https://github.com/naokky-tech/sample-architecture-journey/tree/main/samples/domain-centric/simple-cli

構成は次のようになっています。

src/
  main.py                # コンポジションルート(依存の組み立て)
  core/                  # アプリケーションの中心(ドメイン+ユースケース)
    entities.py          # ドメインモデル(Task など)
    ports.py             # ポート(インターフェース)=ドメイン視点の依存先
    use_cases.py         # ユースケース(アプリケーションサービス)
  adapters/              # 外側の世界(入出力・永続化など)
    in_memory_task_repository.py  # ポートを実装したインメモリリポジトリ
    cli/
      controller.py      # CLI からユースケースを呼び出すアダプタ

この構成でいちばん大事なのは 依存の向き です。

cleanアーキテクチャ

  • core(ドメイン・ユースケース)は 何にも依存しない
  • adapterscore に依存する
  • main.py依存を組み立てるだけ

つまり、

依存は常に “外側 → 内側” に向かう。
内側(ドメイン)は、外側の都合や技術変更から守られる。


core:アプリケーションの中心(ドメイン+ユースケース)

🔰 この記事での core の意味
前回の “domain 層 + service 層” の役割を、
「ビジネス(entities)+ユースケース(use_cases)」 に再構成したものです。

1. ドメインモデル(entities.py)

まずは、このアプリケーションが扱いたい「モノ」を定義します。
今回はシンプルに タスク管理 を扱うことにします。

from dataclasses import dataclass

@dataclass
class Task:
    id: int
    title: str
    is_completed: bool = False

    def complete(self) -> None:
        self.is_completed = True

ここに書かれているのは、

  • タスクとは何か(id, title, 状態)
  • タスクの振る舞い(complete)

だけであり、

❌ DB
❌ CLI
❌ ファイル保存

といった技術要素は一切入りません。


2. ポート(ports.py)─ ドメインから見た「お願い」

Port & Adapters

次に、「タスクを扱ううえで、外側に何をお願いしたいか」を定義します。
“お願いの窓口”が Port(ポート) です。

from abc import ABC, abstractmethod
from typing import List, Optional
from .entities import Task

class TaskRepository(ABC):

    @abstractmethod
    def next_identity(self) -> int:
        ...

    @abstractmethod
    def save(self, task: Task) -> None:
        ...

    @abstractmethod
    def list_all(self) -> List[Task]:
        ...

    @abstractmethod
    def find_by_id(self, task_id: int) -> Optional[Task]:
        ...

ここで定義しているのは 「やってほしいこと」だけ


3. ユースケース(use_cases.py)─ アプリの“用事”

from dataclasses import dataclass
from .entities import Task
from .ports import TaskRepository

@dataclass
class AddTaskUseCase:
    repository: TaskRepository

    def execute(self, title: str) -> Task:
        new_id = self.repository.next_identity()
        task = Task(id=new_id, title=title)
        self.repository.save(task)
        return task

他にも、

  • List(一覧)
  • Complete(完了)

が定義されています。

ここでのポイントは、

  • 具体的な実装(メモリか DB か)は知らない
  • TaskRepository という Port(インターフェース) に対して命令しているだけ

という点です。

ユースケースは“1つの用事を表すクラス”
として、ビジネスの流れを整理する役割を持ちます。

この「タスク追加」の用事が、CLI からどのように core と外側を行き来するかをシーケンス図で眺めてみます。

CLI はあくまで「タイトルを渡して実行するだけ」で、保存の詳細や ID 採番の方法は Port 経由で外側の Adapter に委ねられている、という構造が見えると思います。


adapters:外の世界(永続化・CLI)

adapters は 前回の “presentation + infrastructure” の外側部分を統合した領域です。
UI と永続化はどちらも「core の外」にあるため、ドメイン中心では同じ箱に入ります。

4. 永続化 Adapter(InMemoryTaskRepository)

from typing import Dict, List, Optional
from core.entities import Task
from core.ports import TaskRepository

class InMemoryTaskRepository(TaskRepository):

    def __init__(self) -> None:
        self._tasks: Dict[int, Task] = {}
        self._current_id: int = 0

    def next_identity(self) -> int:
        self._current_id += 1
        return self._current_id

    def save(self, task: Task) -> None:
        self._tasks[task.id] = task

    def list_all(self) -> List[Task]:
        return list(self._tasks.values())

    def find_by_id(self, task_id: int) -> Optional[Task]:
        return self._tasks.get(task_id)

後で DB 版に差し替えても、core は一切変更しなくてOK


5. CLI Adapter(TaskCLIController)

class TaskCLIController:

    def run(self) -> None:
        while True:
            self._print_menu()
            choice = input("番号を選んでください: ").strip()
            ...

UI 側の Adapter が TaskCLIController です。
ここは“人が触る部分”を専門に担当します。
責務は UI のみに限定されています。


main.py:依存を“組み立てるだけ”の場所

from adapters.in_memory_task_repository import InMemoryTaskRepository
from adapters.cli.controller import TaskCLIController
from core.use_cases import AddTaskUseCase, ListTasksUseCase, CompleteTaskUseCase

def main() -> None:
    repo = InMemoryTaskRepository()

    add = AddTaskUseCase(repo)
    list_ = ListTasksUseCase(repo)
    complete = CompleteTaskUseCase(repo)

    controller = TaskCLIController(add, list_, complete)
    controller.run()

ここが Clean Architecture でいうコンポジションルートです。
“アプリの最後の 1 手”をまとめる場所、と覚えると理解しやすくなります。

依存注入の流れをシーケンス図で見ると、main.py が「どの順番でオブジェクトを組み立てているか」もイメージしやすくなります。

main.py 自体はビジネスロジックを一切持たず、「依存を外側から内側へとつないで、最後に CLI を走らせるだけ」という役割に絞られていることが伝わるはずです。


DeepDive

レイヤード版との違い(おさらい)

レイヤード:技術ごとに棚を分ける構造

UI
サービス
リポジトリ
DB

ドメイン中心:ドメイン(core) → ユースケース(core) → Adapter(外側)

core
adapters
main

最大の違いはとてもシンプルで、この一文に集約できます。

ドメインは外側を知らない。
(外側がドメインに合わせる)

これが Clean / Hexagonal / DDD に共通する大事な思想です。

ドメイン中心アーキテクチャのメリット / デメリット

前回と同じ形式で、初心者向けに整理すると次のとおりです。

項目 メリット デメリット
UI / Adapter 追加 CLI → Web → Agent など容易 Adapter 実装が増える
永続化変更 InMemory → DB へ差し替え容易 抽象化(Port)が必要
テスト容易性 core を単体テストしやすい 設計に慣れが必要
責務の分離 core が汚れない 小規模アプリはやりすぎ感

遊んでみよう(学びを深める小ネタ)

  • タスクに「締切日」や「優先度」を追加してみる
  • タイトルが空文字ならエラーにしてみる
  • リポジトリをファイル保存版に差し替えてみる
  • ユースケースだけテストして動くか確認する

「core を変えても外側は壊れないか?」
「外側を変えても core はそのまま動くか?」
を意識すると理解が深まります。


まとめ

  • core(ドメイン+ユースケース)は技術変更に影響されない
  • adapters はいくつでも増やせる(CLI → Web → Agent)
  • Clean / Hexagonal / DDD は “ドメイン(アプリの意味)を外側の変化から守って長生きさせる” という共通目的を持つ
  • AI エージェント時代のように外の技術が激しく変わる時代ほど
    ドメイン中心アーキテクチャが効いてくる

このシンプル CLI を触りながら、
レイヤードとは少し違う「設計の地図」を体で感じてもらえたら嬉しいです 🦉

domain-centricアーキテクチャ

次回

この連載では、最初に レイヤードドメイン中心 という
「1つのアプリケーションの中の構造」を見てきました。

次はそこから一歩外に出て、

「サービスをどう分けるか?」──マイクロサービス / SOA の世界

に足を踏み入れていく予定です。

  • どこまでを 1 システムとみなすのか
  • どこからを別サービスとして切り出すのか
  • 分けすぎたとき、分けなさすぎたとき、何が起こるのか

といったテーマを、またフクロウと一緒にゆるく旅していきます。

Discussion