From 7c8c77a78190fb99b4f0ee73e9431ae389ded524 Mon Sep 17 00:00:00 2001 From: hideyukiMORI Date: Tue, 19 May 2026 21:10:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20AsyncUseCase=20=E3=83=91=E3=82=BF?= =?UTF-8?q?=E3=83=BC=E3=83=B3=E3=82=92=20nene2.use=5Fcase=20=E3=83=91?= =?UTF-8?q?=E3=83=83=E3=82=B1=E3=83=BC=E3=82=B8=E3=81=A8=E3=81=97=E3=81=A6?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=81=99=E3=82=8B=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docs/adr/0010-async-use-case.md | 63 +++++++++++++++++++ src/example/note/async_use_case.py | 50 +++++++++++++++ src/nene2/use_case/__init__.py | 5 ++ src/nene2/use_case/protocols.py | 24 +++++++ .../example/note/test_async_note_use_case.py | 43 +++++++++++++ tests/nene2/use_case/__init__.py | 0 tests/nene2/use_case/test_protocols.py | 40 ++++++++++++ 7 files changed, 225 insertions(+) create mode 100644 docs/adr/0010-async-use-case.md create mode 100644 src/example/note/async_use_case.py create mode 100644 src/nene2/use_case/__init__.py create mode 100644 src/nene2/use_case/protocols.py create mode 100644 tests/example/note/test_async_note_use_case.py create mode 100644 tests/nene2/use_case/__init__.py create mode 100644 tests/nene2/use_case/test_protocols.py diff --git a/docs/adr/0010-async-use-case.md b/docs/adr/0010-async-use-case.md new file mode 100644 index 0000000..8284d4e --- /dev/null +++ b/docs/adr/0010-async-use-case.md @@ -0,0 +1,63 @@ +# ADR-0010: AsyncUseCase パターン + +## ステータス + +承認済み (2026-05-19) + +## コンテキスト + +FastAPI は ASGI ベースで、ハンドラーは `async def` で定義できる。既存の UseCase はすべて同期(`def execute`)で実装されており、ブロッキング I/O(SQLAlchemy Core)を直接呼び出す。 + +問題: +- 外部 API 呼び出し・非同期 DB ドライバなど、真の非同期 I/O が必要なユースケースを表現できない +- 複数の I/O 操作を `asyncio.gather` で並列実行する手段がない +- 同期 UseCase と非同期 UseCase を型で区別できない + +## 決定 + +`nene2.use_case` パッケージに 2 つの `@runtime_checkable Protocol` を定義する: + +```python +@runtime_checkable +class UseCaseProtocol[I, O](Protocol): + def execute(self, input_: I) -> O: ... + +@runtime_checkable +class AsyncUseCaseProtocol[I, O](Protocol): + async def execute(self, input_: I) -> O: ... +``` + +### 非同期 UseCase の実装パターン + +既存の同期リポジトリを非同期コンテキストで安全に使うには `asyncio.to_thread` で I/O をスレッドプールに逃がす: + +```python +class AsyncListNotesUseCase: + async def execute(self, input_: ListNotesInput) -> ListNotesOutput: + items, total = await asyncio.gather( + asyncio.to_thread(self._repository.find_all, input_.limit, input_.offset), + asyncio.to_thread(self._repository.count), + ) + return ListNotesOutput(items=items, ...) +``` + +真の非同期 DB ドライバ(`sqlalchemy.ext.asyncio`、`aiosqlite` 等)を使う場合は `asyncio.to_thread` は不要で `await` を直接使う。 + +### Runtime 検査の限界 + +`isinstance(obj, AsyncUseCaseProtocol)` はメソッド名の存在のみを検査する。同期/非同期の区別は `mypy --strict` が静的に保証する。 + +## 代替案 + +| 案 | 却下理由 | +|---|---| +| ABC で `AsyncUseCase` 基底クラスを作る | 継承を強制する。Protocol(構造的サブタイピング)の方が柔軟 | +| すべての UseCase を async に統一する | 不要な複雑性。同期リポジトリとの互換性が失われる | +| フレームワーク側に組み込まない | Protocol 定義だけでもフレームワークが「認知」していることが重要 | + +## 結果 + +- 非同期 I/O が必要な UseCase を型で宣言できる +- 同期 UseCase は変更不要(後方互換を維持) +- `asyncio.gather` による並列 I/O が UseCase 層で表現できる +- mypy --strict がコンパイル時に sync/async の混用を検出する diff --git a/src/example/note/async_use_case.py b/src/example/note/async_use_case.py new file mode 100644 index 0000000..efd405a --- /dev/null +++ b/src/example/note/async_use_case.py @@ -0,0 +1,50 @@ +"""Async Note use-cases — demonstrates AsyncUseCaseProtocol with asyncio. + +Sync repositories are wrapped with asyncio.to_thread so the event loop +is never blocked. asyncio.gather enables concurrent repo calls. +""" + +import asyncio +from dataclasses import dataclass + +from .entity import Note +from .exceptions import NoteNotFoundException +from .repository import NoteRepositoryInterface +from .use_case import ListNotesInput, ListNotesOutput + + +class AsyncListNotesUseCase: + """Lists notes; runs find_all and count concurrently via asyncio.gather.""" + + def __init__(self, repository: NoteRepositoryInterface) -> None: + self._repository = repository + + async def execute(self, input_: ListNotesInput) -> ListNotesOutput: + items, total = await asyncio.gather( + asyncio.to_thread(self._repository.find_all, input_.limit, input_.offset), + asyncio.to_thread(self._repository.count), + ) + return ListNotesOutput( + items=items, + limit=input_.limit, + offset=input_.offset, + total=total, + ) + + +@dataclass(frozen=True, slots=True) +class AsyncGetNoteInput: + note_id: int + + +class AsyncGetNoteUseCase: + """Fetches a single note asynchronously.""" + + def __init__(self, repository: NoteRepositoryInterface) -> None: + self._repository = repository + + async def execute(self, input_: AsyncGetNoteInput) -> Note: + note = await asyncio.to_thread(self._repository.find_by_id, input_.note_id) + if note is None: + raise NoteNotFoundException(input_.note_id) + return note diff --git a/src/nene2/use_case/__init__.py b/src/nene2/use_case/__init__.py new file mode 100644 index 0000000..9cc54c6 --- /dev/null +++ b/src/nene2/use_case/__init__.py @@ -0,0 +1,5 @@ +"""UseCase contracts — synchronous and asynchronous Protocol definitions.""" + +from .protocols import AsyncUseCaseProtocol, UseCaseProtocol + +__all__ = ["AsyncUseCaseProtocol", "UseCaseProtocol"] diff --git a/src/nene2/use_case/protocols.py b/src/nene2/use_case/protocols.py new file mode 100644 index 0000000..83d58c4 --- /dev/null +++ b/src/nene2/use_case/protocols.py @@ -0,0 +1,24 @@ +"""Structural type contracts for UseCase and AsyncUseCase. + +UseCaseProtocol — synchronous execute(input_) -> output +AsyncUseCaseProtocol — async execute(input_) -> output (awaitable) + +Both use Python 3.12 generic syntax; any class with a matching +execute() signature satisfies them structurally (no inheritance needed). +""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class UseCaseProtocol[I, O](Protocol): + """Synchronous use-case contract.""" + + def execute(self, input_: I) -> O: ... + + +@runtime_checkable +class AsyncUseCaseProtocol[I, O](Protocol): + """Asynchronous use-case contract — execute must be a coroutine.""" + + async def execute(self, input_: I) -> O: ... diff --git a/tests/example/note/test_async_note_use_case.py b/tests/example/note/test_async_note_use_case.py new file mode 100644 index 0000000..c2514d0 --- /dev/null +++ b/tests/example/note/test_async_note_use_case.py @@ -0,0 +1,43 @@ +"""Async Note use-case tests — validates AsyncUseCaseProtocol integration.""" + +import pytest + +from example.note.async_use_case import ( + AsyncGetNoteInput, + AsyncGetNoteUseCase, + AsyncListNotesUseCase, +) +from example.note.exceptions import NoteNotFoundException +from example.note.repository import InMemoryNoteRepository +from example.note.use_case import CreateNoteInput, CreateNoteUseCase, ListNotesInput + + +@pytest.fixture() +def repo() -> InMemoryNoteRepository: + return InMemoryNoteRepository() + + +async def test_async_list_returns_empty_when_no_notes( + repo: InMemoryNoteRepository, +) -> None: + result = await AsyncListNotesUseCase(repo).execute(ListNotesInput(limit=10, offset=0)) + assert result.total == 0 + assert result.items == [] + + +async def test_async_list_returns_created_notes(repo: InMemoryNoteRepository) -> None: + CreateNoteUseCase(repo).execute(CreateNoteInput(title="t1", body="b1")) + CreateNoteUseCase(repo).execute(CreateNoteInput(title="t2", body="b2")) + result = await AsyncListNotesUseCase(repo).execute(ListNotesInput(limit=10, offset=0)) + assert result.total == 2 + + +async def test_async_get_returns_note(repo: InMemoryNoteRepository) -> None: + note = CreateNoteUseCase(repo).execute(CreateNoteInput(title="hello", body="world")) + fetched = await AsyncGetNoteUseCase(repo).execute(AsyncGetNoteInput(note_id=note.id)) + assert fetched == note + + +async def test_async_get_raises_when_not_found(repo: InMemoryNoteRepository) -> None: + with pytest.raises(NoteNotFoundException): + await AsyncGetNoteUseCase(repo).execute(AsyncGetNoteInput(note_id=9999)) diff --git a/tests/nene2/use_case/__init__.py b/tests/nene2/use_case/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nene2/use_case/test_protocols.py b/tests/nene2/use_case/test_protocols.py new file mode 100644 index 0000000..382b2ac --- /dev/null +++ b/tests/nene2/use_case/test_protocols.py @@ -0,0 +1,40 @@ +"""Protocol compliance tests for UseCaseProtocol and AsyncUseCaseProtocol.""" + +from nene2.use_case import AsyncUseCaseProtocol, UseCaseProtocol + + +class _SyncDouble: + def execute(self, input_: int) -> str: + return str(input_) + + +class _AsyncDouble: + async def execute(self, input_: int) -> str: + return str(input_) + + +class _BadDouble: + def run(self, input_: int) -> str: + return str(input_) + + +def test_sync_double_satisfies_use_case_protocol() -> None: + assert isinstance(_SyncDouble(), UseCaseProtocol) + + +def test_async_double_satisfies_async_use_case_protocol() -> None: + assert isinstance(_AsyncDouble(), AsyncUseCaseProtocol) + + +def test_bad_double_does_not_satisfy_use_case_protocol() -> None: + assert not isinstance(_BadDouble(), UseCaseProtocol) + + +def test_bad_double_does_not_satisfy_async_use_case_protocol() -> None: + assert not isinstance(_BadDouble(), AsyncUseCaseProtocol) + + +def test_runtime_isinstance_cannot_distinguish_sync_from_async() -> None: + # @runtime_checkable only checks attribute presence, not coroutine type. + # Sync/async distinction is enforced by mypy --strict, not at runtime. + assert isinstance(_SyncDouble(), AsyncUseCaseProtocol)