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_userもupdate_userもsend_verification_emailもcalculate_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つに絞る。
-
UseCaseは「機能」ではなく「ユーザーの意図」単位で切る。1UseCaseクラス = 1インテント。これだけで神クラス問題の8割は解決する。
-
ORMモデルをDomain Entityとして使い回すのは初期だけで必ず後悔する。最初から分離するか、プロジェクト初期に意識的に分離する判断をする。
-
AIやExternalサービスはPortとして抽象化する。2026年現在、AIの呼び出しはどのサービスに変わるかわからない。差し替え可能にしておくのはインフラと同じロジックで重要だ。
-
import-linterでCI/CDに境界違反の自動検出を組み込む。コードレビューだけでは締め切り前夜のショートカットを止められない。 -
3人以下・短命なプロジェクトには過剰設計になりうる。「設計の美しさ」より「チームとプロジェクトのフィット」が大事。
次のアクション
- 既存プロジェクトに導入するなら、まずUseCaseの「インテント単位への分解」から始めてみてほしい。コード量は増えるが、見通しが劇的に改善する。
import-linterを今日のCIに追加するだけでも、境界崩壊の抑止力になる。設定10分でできる。- AI統合の設計については正直まだ検証中なので、皆さんのチームでどう対処しているか教えてもらえたら嬉しい。
皆さんのチームではClean Architectureどう運用してます?思ったより難しかった、思ったより楽だった、どちらの経験でもコメントで聞かせてもらえると参考になります。