はじめに
例外処理は「大規模アプリの設計品質」を決定づける核心部分。
Clean Architecture と組み合わせると、
“どの層で何が起きたか一瞬でわかり、修復しやすいアプリ” が作れる。
今回は、FastAPI を例にしながら、
Domain / UseCase / Repository / Infra / Presentation すべてが破綻しない例外設計
をまとめる。
結論
層ごとに例外を分離する
DomainError(業務)
UseCaseError(ユースケース)
RepositoryError(データ層)
InfraError(外部要因)
下位層 → 上位層へ例外を「翻訳」する(translating exceptions)
DBError → RepositoryError → DomainError → PresentationError
-
プレゼンテーション層(FastAPI)で最終的に HTTP 400 / 404 / 500 に変換
-
エラーログはすべて「infra 層」か「entrypoint」で記録
業務ロジックに print/log は絶対に置かない。
1. なぜ Clean Architecture に例外設計が必要?
Clean Arch の本質は:
「依存方向を内側へ統一して、変化に強い構造を作る」
だが、例外処理を正しく設計しないと…
- どこでエラーが起きたのか判別できない
- UseCase から DB の例外が飛んでくる
- FastAPI が意図しない 500 を返す
- ログがバラバラな場所に散る
- エラー分類が曖昧で UI 層が扱えない
つまり、例外が体系化されていないだけで Clean Architecture が崩壊する。
2. 理想的な例外階層(Error Hierarchy)
ここが最重要。
AppError
├── DomainError (業務ロジック)
│ ├── ValidationError
│ ├── PermissionError
│ └── NotFoundError
│
├── UseCaseError (ユースケースの失敗)
│
├── RepositoryError (リポジトリ層)
│ ├── DBError
│ ├── DuplicateError
│ └── RecordNotFound
│
└── InfraError (外部環境)
├── NetworkError
├── FileSystemError
└── ExternalAPIError
ポイント
-
Infra と Repository の違い
- Repository は「抽象化されたデータ操作の責務」
- Infra は「OS/ネットワーク/外部APIなど外部環境」
- DomainError は決して DB の例外を含まない(分離徹底)
3. データ層(Repository)での例外処理
「元の例外を包んで throw」するのが正解
NG(生の例外を外に漏らす)
def find_user(id):
return db.fetch(id) # DBError が上位に飛び出し CleanArch 崩壊
正しい書き方
from app.errors import RepositoryError, RecordNotFound
def find_user(id):
try:
row = db.fetch(id)
if not row:
raise RecordNotFound(f"user {id} not found")
return row
except DatabaseError as e:
raise RepositoryError("DB failure") from e
Why?
上位層の UseCase は「DB が壊れたかどうか」を気にしない。
あくまで「Repository が失敗した」という抽象化された情報で十分。
4. UseCase 層の例外処理
RepositoryError → UseCaseError に翻訳する
from app.errors import UseCaseError, RepositoryError
class CreateUserUseCase:
def execute(self, data):
try:
self.repo.insert(data)
except RepositoryError as e:
raise UseCaseError("ユーザー作成に失敗") from e
ポイント
- UseCase は業務フローの責務を持つ
- 詳細は知らず、抽象化されたエラーのみ扱う
- ログはここで書かない(責務外)
5. ドメイン層(Entity/Domain Service)の例外扱い
ドメイン層では外部要因は扱わない。
DomainError だけ投げる
from app.errors import ValidationError
def create_user(name: str):
if len(name) < 3:
raise ValidationError("名前は3文字以上が必要です")
❌ NG:DatabaseError をこの層に入れる
絶対に層が汚染される。
6. Presentation 層(FastAPI)での例外処理
最後に “HTTP エラー” に変換する
例外ハンドラを作る:
from fastapi import FastAPI, HTTPException
from app.errors import DomainError, UseCaseError, RepositoryError
app = FastAPI()
@app.exception_handler(DomainError)
async def domain_error_handler(_, exc):
return JSONResponse(
status_code=400,
content={"error": str(exc)}
)
@app.exception_handler(UseCaseError)
async def usecase_error_handler(_, exc):
return JSONResponse(
status_code=422,
content={"error": str(exc)}
)
@app.exception_handler(RepositoryError)
async def repo_error_handler(_, exc):
return JSONResponse(
status_code=500,
content={"error": "internal repository error"}
)
Presentation 層ではじめて log を記録
logger.exception(exc)
ここなら責務的にも正しい。
7. 全体フロー図(Exception Flow)
もう少し詳細版:
8. 実例:User 作成の完全コード
errors.py(例外階層)
class AppError(Exception):
pass
class DomainError(AppError):
pass
class ValidationError(DomainError):
pass
class UseCaseError(AppError):
pass
class RepositoryError(AppError):
pass
class RecordNotFound(RepositoryError):
pass
class InfraError(AppError):
pass
repository.py(データ層)
from app.errors import RepositoryError, RecordNotFound
class UserRepository:
def find(self, user_id):
try:
row = db.fetch(user_id)
if not row:
raise RecordNotFound(f"user {user_id} not found")
return row
except DBError as e:
raise RepositoryError("database failure") from e
usecase.py(ユースケース層)
from app.errors import UseCaseError, RepositoryError
class CreateUserUseCase:
def __init__(self, repo):
self.repo = repo
def execute(self, data):
try:
return self.repo.insert(data)
except RepositoryError as e:
raise UseCaseError("ユーザー作成に失敗") from e
domain.py(ドメイン層)
from app.errors import ValidationError
def validate_user(name):
if len(name) < 3:
raise ValidationError("名前は3文字以上が必要です")
fastapi_entrypoint.py(プレゼンテーション層)
@app.post("/users")
def create_user(data: UserInput):
usecase = CreateUserUseCase(repo)
try:
return usecase.execute(data)
except AppError as exc:
raise HTTPException(status_code=400, detail=str(exc))
まとめ
Clean Architecture × 例外処理の黄金原則(保存版)
1. 例外を下位層 → 上位層へ“翻訳”して渡す
2. 生のライブラリ例外を外に漏らさない
3.Domain には外部要因の例外を絶対に入れない
4. ログは Presentation 層(もしくは EntryPoint)で書く
5.例外階層は最初に設計し、チームで統一する
6. HTTP のエラーは最終的に Presentation 層で決定する
これができていると、
アプリ全体の「例外流」が非常に美しく、壊れにくくなる。