Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions docs/adr/0010-async-use-case.md
Original file line number Diff line number Diff line change
@@ -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 の混用を検出する
50 changes: 50 additions & 0 deletions src/example/note/async_use_case.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions src/nene2/use_case/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""UseCase contracts — synchronous and asynchronous Protocol definitions."""

from .protocols import AsyncUseCaseProtocol, UseCaseProtocol

__all__ = ["AsyncUseCaseProtocol", "UseCaseProtocol"]
24 changes: 24 additions & 0 deletions src/nene2/use_case/protocols.py
Original file line number Diff line number Diff line change
@@ -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: ...
43 changes: 43 additions & 0 deletions tests/example/note/test_async_note_use_case.py
Original file line number Diff line number Diff line change
@@ -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))
Empty file.
40 changes: 40 additions & 0 deletions tests/nene2/use_case/test_protocols.py
Original file line number Diff line number Diff line change
@@ -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)
Loading