|
| 1 | +# Field Trial 5 — wallet: transactional() DX 検証 |
| 2 | + |
| 3 | +## Date |
| 4 | + |
| 5 | +2026-05-19 |
| 6 | + |
| 7 | +## Baseline |
| 8 | + |
| 9 | +- nene2-python v0.1.0 (`uv add git+https://github.com/hideyukiMORI/nene2-python.git`) |
| 10 | +- Python 3.14.5(uv managed) |
| 11 | +- プロジェクト: **wallet** — ウォレット送金 JSON API |
| 12 | +- エンティティ: `Account`(id, name, balance_cents)、`Transfer`(id, from/to, amount_cents) |
| 13 | +- 5 エンドポイント(GET/POST accounts, POST transfer, GET transfers) |
| 14 | +- **`SqlAlchemyTransactionManager.transactional()`** ← FT1〜FT4 で未検証のコア機能 |
| 15 | + |
| 16 | +## Goal |
| 17 | + |
| 18 | +1. `transactional()` コールバックパターンの実用 DX を確認する |
| 19 | +2. `transactional()` と Repository パターンの組み合わせ方を検証する |
| 20 | +3. 原子性(失敗時ロールバック)が実際に機能することを確認する |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Steps Taken |
| 25 | + |
| 26 | +### 1. プロジェクト初期化・インストール |
| 27 | + |
| 28 | +問題なし。FT1〜FT4 で確立されたパターン通り。 |
| 29 | + |
| 30 | +### 2. `_in_tx` パターンの設計 |
| 31 | + |
| 32 | +`transactional()` コールバック内でリポジトリ操作を行うため、executor を受け取る専用メソッドを定義した(**F-2**): |
| 33 | + |
| 34 | +```python |
| 35 | +class AccountRepositoryInterface(ABC): |
| 36 | + def find_by_id(self, account_id: int) -> Account | None: ... |
| 37 | + |
| 38 | + # トランザクション内専用 — transactional() コールバック内から呼ぶ |
| 39 | + def find_by_id_in_tx( |
| 40 | + self, executor: DatabaseQueryExecutorInterface, account_id: int |
| 41 | + ) -> Account | None: ... |
| 42 | + def update_balance_in_tx( |
| 43 | + self, executor: DatabaseQueryExecutorInterface, account_id: int, delta_cents: int |
| 44 | + ) -> None: ... |
| 45 | +``` |
| 46 | + |
| 47 | +UseCase での使い方: |
| 48 | + |
| 49 | +```python |
| 50 | +class TransferUseCase: |
| 51 | + def execute(self, input_: TransferInput) -> Transfer: |
| 52 | + def _run(executor: DatabaseQueryExecutorInterface) -> Transfer: |
| 53 | + source = self._accounts.find_by_id_in_tx(executor, input_.from_account_id) |
| 54 | + if source is None: |
| 55 | + raise AccountNotFoundException(input_.from_account_id) |
| 56 | + target = self._accounts.find_by_id_in_tx(executor, input_.to_account_id) |
| 57 | + if target is None: |
| 58 | + raise AccountNotFoundException(input_.to_account_id) |
| 59 | + if source.balance_cents < input_.amount_cents: |
| 60 | + raise InsufficientBalanceException(...) |
| 61 | + |
| 62 | + self._accounts.update_balance_in_tx(executor, input_.from_account_id, -input_.amount_cents) |
| 63 | + self._accounts.update_balance_in_tx(executor, input_.to_account_id, input_.amount_cents) |
| 64 | + return self._transfers.create_in_tx(executor, ...) |
| 65 | + |
| 66 | + return self._tx.transactional(_run) |
| 67 | +``` |
| 68 | + |
| 69 | +### 3. transactional() 動作確認 |
| 70 | + |
| 71 | +``` |
| 72 | +POST /accounts {"name": "Alice", "initial_balance_cents": 10000} → 201 |
| 73 | +POST /accounts {"name": "Bob", "initial_balance_cents": 5000} → 201 |
| 74 | +POST /accounts/1/transfer {"to_account_id": 2, "amount_cents": 3000} → 201 |
| 75 | +GET /accounts/1 → balance_cents: 7000 ✓ (10000 - 3000) |
| 76 | +GET /accounts/2 → balance_cents: 8000 ✓ (5000 + 3000) |
| 77 | +``` |
| 78 | + |
| 79 | +### 4. 原子性の確認 |
| 80 | + |
| 81 | +存在しない転送先を指定 → Alice の残高が変わらないことを確認: |
| 82 | + |
| 83 | +``` |
| 84 | +POST /accounts/1/transfer {"to_account_id": 9999, "amount_cents": 1000} → 404 |
| 85 | +GET /accounts/1 → balance_cents: 7000 ✓ (変化なし = ロールバック成功) |
| 86 | +``` |
| 87 | + |
| 88 | +`engine.begin()` コンテキストマネージャーが例外で自動ロールバックすることを実証。 |
| 89 | + |
| 90 | +### 5. エラーレスポンス確認 |
| 91 | + |
| 92 | +``` |
| 93 | +POST /accounts/1/transfer {"amount_cents": 9999} (残高 7000 に対して) |
| 94 | +→ 422 {"type": ".../insufficient-balance", "title": "Insufficient Balance", ...} ✓ |
| 95 | +``` |
| 96 | + |
| 97 | +### 6. mypy 実行(F-1 発見) |
| 98 | + |
| 99 | +``` |
| 100 | +Skipping analyzing "nene2.http": module is installed, but missing library stubs or py.typed marker |
| 101 | +``` |
| 102 | + |
| 103 | +`ignore_missing_imports = true` + `warn_return_any = false` で回避。詳細は F-1 参照。 |
| 104 | + |
| 105 | +--- |
| 106 | + |
| 107 | +## Friction Points |
| 108 | + |
| 109 | +### F-1 nene2-python に py.typed マーカーがなく型情報が失われる |
| 110 | + |
| 111 | +**severity**: 中 |
| 112 | +**type**: パッケージ設定不備 |
| 113 | + |
| 114 | +`uv run mypy src/` を実行すると nene2 モジュールの型情報が読み込まれない。 |
| 115 | +PEP 561 の `py.typed` マーカーファイルが `src/nene2/` に存在しないため。 |
| 116 | + |
| 117 | +回避策として以下を `[tool.mypy]` に追加した: |
| 118 | +```toml |
| 119 | +ignore_missing_imports = true |
| 120 | +warn_return_any = false |
| 121 | +``` |
| 122 | + |
| 123 | +本来これらは不要なはず。nene2 には型注釈が完備されており、`py.typed` を追加するだけで解決する。 |
| 124 | + |
| 125 | +**Follow-up**: `src/nene2/py.typed` を追加して PEP 561 対応する。 |
| 126 | + |
| 127 | +### F-2 transactional() とリポジトリを組み合わせるパターンがドキュメントなし |
| 128 | + |
| 129 | +**severity**: 中 |
| 130 | +**type**: ドキュメント不足 |
| 131 | + |
| 132 | +`transactional(callback)` でコールバック内からリポジトリを呼ぶ方法が非自明。 |
| 133 | +実装してみると「`_in_tx` サフィックス付きメソッドで executor を受け取る」パターンが自然だと分かったが、ガイドがない。 |
| 134 | + |
| 135 | +**Follow-up**: `docs/reference/framework-modules.md` と `docs/how-to/sqlalchemy-repository.md` に `transactional()` 実践パターンを追記する。 |
| 136 | + |
| 137 | +--- |
| 138 | + |
| 139 | +## Summary |
| 140 | + |
| 141 | +| ID | 摩擦 | 深刻度 | 種別 | Follow-up Issue | |
| 142 | +|-----|--------------------------------------------------------------|--------|------------------|-----------------| |
| 143 | +| F-1 | `py.typed` マーカーなしで mypy 型情報が失われる | 中 | パッケージ設定 | #94 | |
| 144 | +| F-2 | `transactional()` + リポジトリの `_in_tx` パターンが非文書化 | 中 | ドキュメント不足 | #95 | |
| 145 | + |
| 146 | +`transactional()` 自体は設計通り動作し、原子性も確認。 |
| 147 | +`_in_tx` パターンは一度設計すると明快で、InMemory 実装でも再現しやすい(テスト容易性◎)。 |
| 148 | + |
| 149 | +次回 FT6 は以下のいずれかを推奨: |
| 150 | +- F-1/F-2 修正後に **PyPI 公開フロー**の検証 |
| 151 | +- WebSocket サポートの検討(FastAPI の WebSocket エンドポイントをフレームワーク観点で評価) |
0 commit comments