Clean Architectureを3年本番運用して「教科書通り」が崩壊した話

「これで設計の悩みが全部解決する」と信じてClean Architectureを導入して3年。UseCaseが3000行になった地雷から、チームがようやく落ち着いた実装パターンまで正直に書きます。

3年前、うちのチームがClean Architectureを採用したとき、僕は「これで設計の悩みが全部解決する」と本気で思っていた。Uncle Bobの本を読んで、図を眺めて、「依存の方向が全部内側に向く」という概念に感動した記憶がある。

あれから3年。正直に言うと、Clean Architectureは「正しく理解するのが難しい設計思想」だと今は思っている。導入初期は崩壊し、中期は過剰設計との戦いで、後期でようやく「このプロジェクトにとっての正解」が見えてきた。今日はその3年間で踏んだ地雷と、2026年時点でチームが落ち着いている実装パターンを共有したい。

GoとRustでの実装パターンはClean Architecture実践ガイド2026|Go・Rustで学ぶレイヤー設計に詳しく書いてあるので、今回はPython(FastAPI)でのケースに絞って話す。

最初の6ヶ月で崩壊した「教科書通り」の構成

最初に作ったディレクトリ構成はこれだ。

src/
├── domain/
│   ├── entities/
│   ├── repositories/       # Interface
│   └── value_objects/
├── application/
│   └── use_cases/
├── infrastructure/
│   ├── repositories/       # Implementation
│   └── external/
└── presentation/
    └── api/

見た目はきれい。でも3ヶ月後にはこうなっていた。

use_cases/
├── user_use_case.py        # 2000行
├── order_use_case.py       # 1800行
└── payment_use_case.py     # 3000行(なぜ...)

UserUseCaseクラスにcreate_userupdate_usersend_verification_emailcalculate_loyalty_pointsも全部入っていた。「UseCase層」というラベルが付いた神クラスの誕生だ。「ユーザーに関することだから同じUseCaseクラス」というロジックで膨らんでいったんですよね。

これが最初の教訓で、UseCaseは「機能」ではなく「ユーザーの意図(インテント)」単位で切るべきだった。

# Bad: 機能でまとめた神クラス
class UserUseCase:
    def create_user(self, ...): ...
    def update_user(self, ...): ...
    def send_verification_email(self, ...): ...
    def calculate_loyalty_points(self, ...): ...

# Good: インテント単位のUseCase
class RegisterUserUseCase:
    """ユーザー登録という1つの意図だけを担う"""
    def __init__(
        self,
        user_repo: UserRepository,
        email_service: EmailServicePort,
        event_publisher: DomainEventPublisher,
    ):
        self._user_repo = user_repo
        self._email_service = email_service
        self._event_publisher = event_publisher

    async def execute(self, command: RegisterUserCommand) -> RegisterUserResult:
        # 重複チェック
        if await self._user_repo.exists_by_email(command.email):
            raise UserAlreadyExistsError(command.email)

        user = User.create(
            email=command.email,
            name=command.name,
        )
        await self._user_repo.save(user)
        await self._event_publisher.publish(UserRegistered(user_id=user.id))

        return RegisterUserResult(user_id=user.id)

この変更だけでpayment_use_case.pyが3000行から平均150行の複数ファイルに分解された。地味だけど、これがマジで一番効いた改善だった。

レイヤー境界が崩壊する3つのパターン

3年間で観察した「ここから設計が腐り始める」パターンをまとめる。経験ある人には「あるある」に見えるかもしれない。

flowchart TB
    subgraph pat1["崩壊パターン1: ショートカット依存"]
        P1A[Presentation Layer] -->|「ちょっとだけ」直接参照| P1C[Infrastructure Layer]
    end
    subgraph pat2["崩壊パターン2: Entityの知識漏洩"]
        P2A[Entity] -->|SQLAlchemy Modelを直接使用| P2B[DB Schema知識の混入]
    end
    subgraph pat3["崩壊パターン3: UseCase間の直接呼び出し"]
        P3A[OrderUseCase] -->|直接インスタンス化| P3B[PaymentUseCase]
    end

パターン1: 「急ぎだから」ショートカット

締め切りが近いとやりがちな「ちょっとだけInfraを直接呼ぶ」。これが積み重なると境界が完全に溶ける。git blameで確認すると必ず金曜日の深夜にコミットされてた(笑)。

パターン2: ORMオブジェクトのDomain層への漏洩

SQLAlchemyのModelをDomainEntityとして使い回すやつ。うちのチームも最初やってた。user.relationshipが突然DetachedInstanceErrorを投げて本番で死ぬパターン、心当たりある人も多いんじゃないかと思う。

# Bad: SQLAlchemy ModelがDomain知識を持ってしまっている
class UserModel(Base):  # Infrastructure層のクラス
    __tablename__ = 'users'
    id = Column(UUID)
    email = Column(String)
    
    def is_premium(self) -> bool:  # ← Domain知識がInfraに漏れてる
        return self.subscription_tier == 'premium'

# Good: Domain EntityとInfra Modelを明確に分離
# domain/entities/user.py
@dataclass
class User:
    id: UserId
    email: Email
    subscription_tier: SubscriptionTier

    def is_premium(self) -> bool:
        return self.subscription_tier == SubscriptionTier.PREMIUM

# infrastructure/models/user_model.py
class UserModel(Base):
    __tablename__ = 'users'
    id = Column(UUID, primary_key=True)
    email = Column(String, unique=True)
    subscription_tier = Column(String)

パターン3: UseCase間の直接依存

「OrderUseCaseからPaymentUseCaseを直接呼ぶ」はよくある地雷だ。UseCaseは横に並ぶ存在で、依存関係を持つべきではない。代わりにDomainEventを使う。この設計とイベント駆動アーキテクチャ実装ガイドを組み合わせると、疎結合が一段と強くなる。

2026年のAI統合でClean Architectureがさらに試される

最近チームで直面しているのが「AIの組み込み」問題だ。OpenAI APIやAnthropic Claudeの呼び出しをどのレイヤーに置くか、正直まだ議論中だけど、うちが今採用している方針を共有する。

AIをただの「外部APIの一種」として扱うという発想の転換が鍵になった。要するにPortとして抽象化すれば、Domain層はAI実装を一切知らなくて済む。

# domain/ports/ai_suggestion_port.py
# AI呼び出しもPortとして抽象化する
from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class ProductDescriptionSuggestion:
    content: str
    confidence_score: float
    model_used: str

class AIProductSuggestionPort(ABC):
    """Domain層はAI実装を知らない。Portだけ知る。"""
    
    @abstractmethod
    async def suggest_description(
        self,
        product_name: str,
        category: str,
    ) -> ProductDescriptionSuggestion:
        ...
# infrastructure/ai/anthropic_suggestion_adapter.py
import anthropic
from domain.ports.ai_suggestion_port import (
    AIProductSuggestionPort,
    ProductDescriptionSuggestion,
)

class AnthropicSuggestionAdapter(AIProductSuggestionPort):
    """Infrastructure層でClaude APIを実装"""
    
    def __init__(self, client: anthropic.AsyncAnthropic):
        self._client = client
    
    async def suggest_description(
        self,
        product_name: str,
        category: str,
    ) -> ProductDescriptionSuggestion:
        message = await self._client.messages.create(
            model="claude-opus-4-5",
            max_tokens=500,
            messages=[{
                "role": "user",
                "content": f"商品名: {product_name}\nカテゴリ: {category}\n\n商品説明文を200字以内で生成してください。"
            }]
        )
        return ProductDescriptionSuggestion(
            content=message.content[0].text,
            confidence_score=0.85,  # TODO: 精度スコアの実装
            model_used=message.model,
        )

こうするとDomain層はAIの実装を完全に知らない状態になる。テスト時はMockを差し込めるし、将来GPT-5に乗り換えるときもAdapterを差し替えるだけだ。ここは好み分かれるかもしれないけど、個人的には今これに一番納得している。

アーキテクチャ全体像を図で示すとこうなる。依存の矢印が全部Domain層に向かっているのがポイントで、これが守れているかどうかが設計の健全性のバロメーターになる。

flowchart TB
    subgraph Presentation
        A[FastAPI Router]
        B[Request/Response Schema]
    end
    
    subgraph Application
        C[RegisterUserUseCase]
        D[CreateOrderUseCase]
        E[SuggestProductUseCase]
    end
    
    subgraph Domain
        F[User Entity]
        G[Order Entity]
        H[UserRepository Port]
        I[OrderRepository Port]
        J[AIProductSuggestion Port]
        K[EmailService Port]
        L[DomainEventPublisher Port]
    end
    
    subgraph Infrastructure
        M[PostgreSQL UserRepo Impl]
        N[PostgreSQL OrderRepo Impl]
        O[Anthropic Adapter]
        P[SendGrid Adapter]
        Q[EventBridge Publisher]
    end

    A --> C
    A --> D
    A --> E
    C --> H
    C --> K
    C --> L
    D --> I
    D --> L
    E --> J
    H -.->|実装| M
    I -.->|実装| N
    J -.->|実装| O
    K -.->|実装| P
    L -.->|実装| Q

テストで「本当に境界が守れているか」を検証する

Clean Architectureの境界が守れているかどうかはコードレビューだけでは限界がある。うちのチームではimport-linterを使って境界違反を自動検出している。設定自体は10分もあれば終わるので、やってない人はとりあえず今日入れてほしい。

# .importlinter
[importlinter]
root_package = src

[importlinter:contract:domain-is-independent]
name = Domain層は他のレイヤーに依存しない
type = forbidden
source_modules =
    src.domain
forbidden_modules =
    src.application
    src.infrastructure
    src.presentation

[importlinter:contract:application-no-infra]
name = Application層はInfrastructure層に依存しない
type = forbidden
source_modules =
    src.application
forbidden_modules =
    src.infrastructure
    src.presentation

[importlinter:contract:presentation-only-application]
name = Presentation層はDomainに直接触れない
type = forbidden
source_modules =
    src.presentation
forbidden_modules =
    src.domain.entities
    src.infrastructure

CIに組み込むことで「金曜深夜のショートカット」を自動で検知できるようになった。これは地味に便利で、導入してから境界違反のコードがほぼゼロになった。人間のレビューで防ぐより、仕組みで防ぐほうが絶対に強い。

各UseCaseのテストはこんな感じで書いている。Infrastructure層をまるごとMockに差し替えられるのがClean Architectureの本当の恩恵だと思う。

# tests/application/test_register_user_use_case.py
import pytest
from unittest.mock import AsyncMock, MagicMock
from application.use_cases.register_user import (
    RegisterUserUseCase,
    RegisterUserCommand,
)
from domain.errors import UserAlreadyExistsError

@pytest.fixture
def mock_user_repo():
    repo = AsyncMock()
    repo.exists_by_email.return_value = False
    repo.save.return_value = None
    return repo

@pytest.fixture
def mock_email_service():
    return AsyncMock()

@pytest.fixture
def mock_event_publisher():
    return AsyncMock()

@pytest.fixture
def use_case(mock_user_repo, mock_email_service, mock_event_publisher):
    return RegisterUserUseCase(
        user_repo=mock_user_repo,
        email_service=mock_email_service,
        event_publisher=mock_event_publisher,
    )

async def test_register_user_success(use_case, mock_user_repo, mock_event_publisher):
    command = RegisterUserCommand(
        email="test@example.com",
        name="テストユーザー",
    )
    
    result = await use_case.execute(command)
    
    assert result.user_id is not None
    mock_user_repo.save.assert_called_once()
    mock_event_publisher.publish.assert_called_once()  # イベント発行を検証

async def test_register_duplicate_user_raises_error(
    use_case, mock_user_repo
):
    mock_user_repo.exists_by_email.return_value = True  # 重複ユーザーの状態
    
    command = RegisterUserCommand(
        email="existing@example.com",
        name="既存ユーザー",
    )
    
    with pytest.raises(UserAlreadyExistsError):
        await use_case.execute(command)

DBなしでUseCaseロジックを完全にテストできる。これが実現できていると開発速度が体感で違う。Python 3.13 FastAPI非同期処理実装ガイドと組み合わせると、TaskGroupを使ったより複雑な非同期UseCaseも書けるようになる。

3年運用して見えたコスト・ベネフィットの正直な評価

Clean Architectureはタダじゃない。正直なコスト感を共有しておく。

観点メリットデメリット現実的な評価
コード量同じ機能が通常の1.5〜2倍の行数になる小規模プロジェクトには過剰
テスト容易性Mockが差し込みやすいテスト自体の設計も学習コスト◎ 長期で絶大な効果
変更容易性影響範囲が明確リファクタリング時の移動作業が多い○ 中長期で回収
学習コスト新メンバーの理解に2〜4週間かかるチームサイズに依存
AI統合Port設計で差し替え可能AIの非決定性との相性は要検討△ まだ模索中
採用活動設計力のアピールになる◎ 地味に採用に効く
xychart-beta
    title "プロジェクト規模別 Clean Architecture 導入効果スコア(自チーム主観評価)"
    x-axis ["小規模(1-3人)", "中規模(4-10人)", "大規模(11-30人)", "エンタープライズ(30人+)"]
    y-axis "効果スコア(10点満点)" 0 --> 10
    bar [3, 6, 9, 8]

正直、3人以下のチームで短命なプロジェクトには勧めない。オーバーエンジニアリングになる可能性が高い。一方で4人以上、1年以上運用する予定があって、チームに設計議論ができる人がいるなら、導入コストを回収できると思っている。

うちは10人チームで3年運用した結果、「新機能追加時の影響範囲の特定」が劇的に楽になった。UserRepositoryのインターフェースを見れば何ができるかわかる、という状態になれたのが一番の成果だ。

セキュリティ面では、Presigned URLやAPIキーの管理が適切に行えているかも定期的に確認が必要で、OWASP Top 10 2024対策と組み合わせることで脆弱性への対応もアーキテクチャレベルで考えられるようになった。

まとめ

3年間でClean Architectureについて学んだことを5つに絞る。

  1. UseCaseは「機能」ではなく「ユーザーの意図」単位で切る。1UseCaseクラス = 1インテント。これだけで神クラス問題の8割は解決する。

  2. ORMモデルをDomain Entityとして使い回すのは初期だけで必ず後悔する。最初から分離するか、プロジェクト初期に意識的に分離する判断をする。

  3. AIやExternalサービスはPortとして抽象化する。2026年現在、AIの呼び出しはどのサービスに変わるかわからない。差し替え可能にしておくのはインフラと同じロジックで重要だ。

  4. import-linterでCI/CDに境界違反の自動検出を組み込む。コードレビューだけでは締め切り前夜のショートカットを止められない。

  5. 3人以下・短命なプロジェクトには過剰設計になりうる。「設計の美しさ」より「チームとプロジェクトのフィット」が大事。


次のアクション

  • 既存プロジェクトに導入するなら、まずUseCaseの「インテント単位への分解」から始めてみてほしい。コード量は増えるが、見通しが劇的に改善する。
  • import-linterを今日のCIに追加するだけでも、境界崩壊の抑止力になる。設定10分でできる。
  • AI統合の設計については正直まだ検証中なので、皆さんのチームでどう対処しているか教えてもらえたら嬉しい。

皆さんのチームではClean Architectureどう運用してます?思ったより難しかった、思ったより楽だった、どちらの経験でもコメントで聞かせてもらえると参考になります。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事