Metadata-Version: 2.5
Name: fastship-py
Version: 0.11.0
Summary: モジュラモノリス + DDD のカーネル。集約・ドメインイベント・UnitOfWork・トランザクショナル outbox/inbox を業務語彙ゼロで提供する
Project-URL: Homepage, https://github.com/theindiehacker/fastship-py
Project-URL: Repository, https://github.com/theindiehacker/fastship-py
Author-email: taiyo tamura <gtaiyou24@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: ddd,hexagonal-architecture,modular-monolith,outbox,unit-of-work
Requires-Python: >=3.14
Requires-Dist: fastapi<1.0.0,>=0.143.1
Requires-Dist: injector<1.0.0,>=0.22.0
Requires-Dist: psycopg[binary]<4.0.0,>=3.3.6
Requires-Dist: pydantic-settings<3.0.0,>=2.14.2
Requires-Dist: pydantic<3.0.0,>=2.13.0
Requires-Dist: pytz>=2026.2
Requires-Dist: sqlalchemy<3.0.0,>=2.1.0
Requires-Dist: starlette<2.0.0,>=1.8.0
Provides-Extra: fastmcp
Requires-Dist: fastmcp<5.0.0,>=4.1.3; extra == 'fastmcp'
Requires-Dist: mcp<3.0.0,>=2.2.0; extra == 'fastmcp'
Provides-Extra: gcp
Requires-Dist: cachecontrol<1.0.0,>=0.14.4; extra == 'gcp'
Requires-Dist: google-auth<3.0.0,>=2.62.0; extra == 'gcp'
Requires-Dist: google-cloud-pubsub<3.0.0,>=2.43.0; extra == 'gcp'
Requires-Dist: requests<3.0.0,>=2.34.2; extra == 'gcp'
Provides-Extra: linter
Requires-Dist: import-linter<3.0.0,>=2.14; extra == 'linter'
Description-Content-Type: text/markdown

# 🚀 fastship-py

```bash
# app.fastship.jp テンプレートから作ったリポジトリの backend/ (pyproject.toml のあるところ) で実行する
uv sync
```

DB は PostgreSQL、API は FastAPI を前提にしており、SQLAlchemy / psycopg / FastAPI も一緒に入る (extras の指定は要らない)。

モジュールの足し方は、テンプレートの `backend/README.md` にある。

## ⚙️ 環境変数

インフラ (Cloud Run など) が注入し、アプリ側では設定しない。使う部品の行だけを設定すればよい。
Secret Manager の値は、実行基盤でシークレットを同名の環境変数として公開する。

| 環境変数 | 使う部品 | 必須か | 内容 |
|:---|:---|:---|:---|
| `DATABASE_WRITER_URL` | SQLAlchemy | 必須 | プライマリの接続先 |
| `DATABASE_READER_URL` | SQLAlchemy | 任意 | リードレプリカ (Read Pool のエンドポイント)。未設定ならすべてプライマリへ送る |
| `NOTIFICATION_PUBLISH_TO` | outbox の中継 | 任意 | 通知を発行するトピック。既定は `notifications` |
| `GOOGLE_CLOUD_PROJECT` | Pub/Sub (`[gcp]`) | エミュレータでは必須 | トピックのあるプロジェクト。未設定なら ADC から引く (Cloud Run ではメタデータサーバー) |
| `PUBSUB_PUSH_AUDIENCE` | Pub/Sub の受信 (`[gcp]`) | push を受けるなら必須 | push サブスクリプションの audience (`--push-auth-token-audience`。省略したときは push エンドポイントの URL) |
| `PUBSUB_PUSH_SERVICE_ACCOUNT` | Pub/Sub の受信 (`[gcp]`) | push を受けるなら必須 | push サブスクリプションのサービスアカウントのメールアドレス (`--push-auth-service-account`) |
| `PUBSUB_EMULATOR_HOST` | Pub/Sub (`[gcp]`) | ローカルだけ | エミュレータの `host:port`。設定すると ADC (実際の GCP の資格情報) を引かず、push の OIDC トークンも検証しない。本番では設定しない |
| `DI_FOR_PY` | DI コンテナ | 任意 | 有効にするプロファイル (カンマ区切り)。テストで `InMem` にするなど |

## 📚 How To
### 🧩 既定のモジュール (Common)

`Common` を `CompositeModule` の先頭に並べると、カーネルの部品が DI コンテナに登録され、outbox / inbox のテーブルも Alembic に載る

```python
from fastship.common.core import Common
from fastship.common.module import CompositeModule


class Backend(CompositeModule):
    def __init__(self) -> None:
        super().__init__([Common(), 〇〇()])
```

| interface | 本番・ローカル | テスト (`DI_FOR_PY=InMem`) |
|:---|:---|:---|
| `UnitOfWork` | `SQLAlchemyUnitOfWork` (`DATABASE_*` の接続先) | `InMemUnitOfWork` |
| `EventContextProvider` | `NullEventContextProvider` | 同左 |
| `EventStore` (outbox) | `PostgreSQLEventStore` | `InMemEventStore` |
| `ConsumedNotificationStore` (inbox) | `PostgreSQLConsumedNotificationStore` | `InMemConsumedNotificationStore` |
| `NotificationPublisher` (中継) | `NotificationPublisherImpl` | 同左 |
| `MessagePublisher` | `CloudPubSubPublisher` | `MessagePublisherStub` (発行した通知を記録する) |
| `MessageSubscriber` | `CloudPubSubSubscriber` | `MessageSubscriberStub` (Notification の JSON を listener に振り分ける) |

- ローカルは本番と同じ実装を使い、環境変数 (`DATABASE_WRITER_URL` / `PUBSUB_EMULATOR_HOST` など) をローカルの DB とエミュレータに向ける
- 実装は最初に使うときに作られる。InMem では DB や Pub/Sub の設定が要らない
- Pub/Sub の実装を使うには extras の `[gcp]` を入れる (「📤 Pub/Sub への発行」)。入れていなくても、`Common` の import と InMem での起動はできる

束縛を差し替えるときは、差し替えるモジュールを `Common` の後ろに並べる (後から登録した束縛が勝つ)

```python
class 〇〇(AppModule):
    @override
    async def startup(self) -> None:
        DIContainer.instance().register(
            DI.of(EventContextProvider, {}, TenantEventContextProvider),  # マルチテナントの場合
        )
```

### 🌐 API

<details><summary><b>🌐 モジュールの API を載せる</b></summary>

モジュールの `routers` に APIRouter を並べる。`CompositeModule` に並べた全モジュールの `routers` が 1 つにまとまる。
`routers` を書かないモジュール (購読だけのものなど) は何も載せない。

```python
from fastapi import APIRouter


class 〇〇(AppModule):
    @property
    @override
    def routers(self) -> list[APIRouter]:
        return [UserResource().router]
```

`api.py`:
```python
from fastapi import FastAPI
from fastship.common.port.adapter.resource import add_exception_handlers

from core import Backend  # 全モジュールを束ねた CompositeModule

backend = Backend()
app = FastAPI(lifespan=lifespan)  # lifespan で backend.startup() / backend.shutdown() を呼ぶ
add_exception_handlers(app)  # エラー応答を RFC 9457 にそろえる (「⚠️ エラー応答」)

for router in backend.routers:
    app.include_router(router)
```

- lifespan の中ではなく、`app` を作った直後に載せる。lifespan の外で呼ぶ `app.openapi()` にも載り、テストごとに lifespan を回してもルートが重複しない
- `routers` は起動 (DI 登録) の前に呼ばれる。Resource のコンストラクタでは DI を解決せず、リクエストを処理するときに解決する

</details>

### 🤖 MCP

<details><summary><b>🤖 モジュールのツールを MCP サーバーに載せる</b></summary>

MCP サーバーは [FastMCP](https://gofastmcp.com) で立てる (fastmcp 4.1.3 で確かめた)。FastMCP は extras に分けている。
`fastmcp` を付けて入れ、`fastmcp` 自体も直接の依存として宣言する (宣言しないと deptry が推移的な依存 (DEP003) として検出する)。
```bash
uv add 'fastship-py[fastmcp]' fastmcp
```

モジュールの `tools` に FastMCP の Tool を並べる。`CompositeModule` に並べた全モジュールの `tools` が 1 つにまとまる。
`tools` を書かないモジュールは何も載せない。

`CompositeModule.tools` は全ツールの関数を包み (`fastship.common.port.adapter.tool.translate_errors`)、例外を API (`add_exception_handlers`) と同じ方針で MCP のエラーに変える。
モジュールは何も付けなくてよい。

```python
from fastmcp.tools import Tool


class UserTool:
    def register_user(self, email_address: str) -> UserOutput: ...


class 〇〇(AppModule):
    @property
    @override
    def tools(self) -> list[Tool]:
        tool = UserTool()
        return [Tool.from_function(tool.register_user, name="register_user", description="ユーザーを登録する")]
```

| ツールの中の例外 | ログ | モデルに返すもの |
|:--|:--|:--|
| FastMCP が扱う例外 (`ToolError` などの FastMCPError、MCPError、上流の 429・タイムアウト) | FastMCP が | FastMCP が決める (`ToolError` ならそのメッセージ) |
| `SystemException` | `logging()` (エラーコードの level) | エラーコードのメッセージ (detail は返さない) |
| `ValueError` / `AssertionError` (pydantic の検証エラーを含む) | COMMON_2000 (型と送出した場所だけ。pydantic はモデル名も) | 「不正なリクエストです」 |
| それ以外 | COMMON_1000 とトレースバック | 「想定外の原因エラーが発生しました」 |

例外の中身はクライアントに返さず、FastMCP の `mask_error_details` の設定にも頼らない。元の例外も切り離し、OTel のスパンなどにメッセージを残さない。

引数の検証 (不足・余分・型の誤り) は、本体の前に FastMCP が行い、どの引数が誤りかをモデルに返す (WARNING で記録する)。

`mcp_server.py`:
```python
import os
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastmcp import FastMCP
from fastmcp.server.auth import RemoteAuthProvider
from fastmcp.server.auth.providers.jwt import JWTVerifier
from pydantic import AnyHttpUrl

from core import Backend  # 全モジュールを束ねた CompositeModule

backend = Backend()

host = os.environ["MCP_HOST"]  # この MCP サーバーを公開するホスト名 (例: mcp.example.com)
issuer = os.environ["MCP_AUTH_ISSUER"]  # IdP の issuer (例: https://example.us.auth0.com/)
# IdP が発行したアクセストークン (JWT) を検証する。トークンが無い・正しくないリクエストは 401 で断る。
# 認可サーバー (IdP) の場所を /.well-known/oauth-protected-resource で知らせ、MCP のクライアントにトークンを取らせる
auth = RemoteAuthProvider(
    token_verifier=JWTVerifier(
        jwks_uri=os.environ["MCP_AUTH_JWKS_URI"],
        issuer=issuer,
        audience=os.environ["MCP_AUTH_AUDIENCE"],
    ),
    authorization_servers=[AnyHttpUrl(issuer)],
    base_url=f"https://{host}",
)
# ToolError 以外の例外の中身 (SQL・入力値など) を、クライアントに返さない
mcp = FastMCP("app", tools=backend.tools, auth=auth, mask_error_details=True)
# 公開するホスト名と localhost 以外の Host、信頼しない Origin を断る (DNS rebinding を防ぐ)
mcp_app = mcp.http_app(stateless_http=True, json_response=True, host_origin_protection=True, allowed_hosts=[host])


@asynccontextmanager
async def lifespan(_: FastAPI):
    await backend.startup()  # DI 登録。呼ばないと、DI を解決するツールがすべて失敗する
    async with mcp_app.lifespan(mcp_app):  # mount したアプリの lifespan は動かないため、ここで動かす
        yield
    await backend.shutdown()


app = FastAPI(lifespan=lifespan)
app.mount("/", mcp_app)  # エンドポイントは /mcp
```

- 認証は、IdP が発行した JWT を `JWTVerifier` で検証し、`RemoteAuthProvider` で認可サーバーの場所を知らせる。この形は、IdP が動的クライアント登録 (DCR) に対応している前提。
  対応していない IdP は、FastMCP の専用のプロバイダーを `auth=` に渡す (Cognito は `AWSCognitoProvider`、Identity Platform は `GoogleProvider`、Auth0 の Auth for MCP は `Auth0MCPProvider`)
- ツールの中では、検証済みのトークンを `fastmcp.server.dependencies.get_access_token()` で読む。IdP のユーザー ID は `claims["sub"]`
- `auth=` を外すのは、localhost だけで動かすときに限る。外したまま公開すると、書き込みのツールも誰でも呼べる
- ツール名は全モジュールで 1 つの名前空間になる。重なると `CompositeModule.tools` が ValueError を送出する (FastMCP は警告だけで後勝ちに置き換えるため)
- ツールは `Tool.from_function` で、関数かメソッドから作る。`Tool.from_tool` やプロキシのツール、`functools.partial` や呼び出し可能なインスタンスから作ったツールは、例外を変換できないため `CompositeModule.tools` が断る
- ツール名は `name=` に ASCII (`[A-Za-z0-9_.-]`) で渡す。既定は関数名で、日本語のメソッド名のままだと、名前の形式を強制するクライアントに拒まれる
- ツールの引数・戻り値の型は実行時に import する。`Tool.from_function` は登録の時点で型ヒントを解決するため、`TYPE_CHECKING` に移すと NameError になる。ruff の TC001〜TC003 は、ツールのファイルでは外す (`per-file-ignores`)
- ツールは値を返す同期関数 (`def`) で書く。`async def` とジェネレータは `CompositeModule.tools` が断る。UnitOfWork はスレッドごとに DB のセッションを持ち、async のツールはイベントループの 1 スレッドで動くため、await の間に別の呼び出しのトランザクションが混ざる。ジェネレータは本体が変換の外で動く
- そのため、FastMCP のバックグラウンドタスク (`task=True`、async の関数を求める) は使えない
- ツールの説明 (`description`) はモデルが読み、ツールを選ぶのに使う。docstring を使うと実装のメモまで渡るため、モデル向けの説明は `description=` で渡す
- `tools` は起動 (DI 登録) の前に呼ばれる。Tool の元のクラスのコンストラクタでは DI を解決せず、ツールが呼ばれたときに解決する
- 同期 (`def`) のツールは、FastMCP がスレッドで動かす。API の `def` のルートと同じく、`@transactional` のアプリケーションサービスをそのまま呼べる
- ツールの中で起きたドメインイベントは、HTTP で受けるなら `PublishNotificationsMiddleware` で発行する (「📮 API の応答で通知を発行する」)

</details>

### 💾 Persistence

<details><summary><b>💾 テーブルクラス ( SQLAlchemy ) の実装</b></summary>

テーブルクラスは `sqlalchemy` を直接 import するため、`uv add sqlalchemy` で直接の依存として宣言する
(fastship-py 経由で入っていても、宣言しないと deptry が推移的な依存 (DEP003) として検出する)。

`〇〇.port.adapter.persistence.repository.sqlalchemy.user.driver.users_table_row.py`
```python
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column

# NOTE: テーブルクラスは `DataBase` を継承して作ること
from fastship.common.port.adapter.persistence.sqlalchemy import DataBase


class UsersTableRow(DataBase):
    __tablename__ = "users"

    id: Mapped[str] = mapped_column(String(36), primary_key=True)
    name: Mapped[str] = mapped_column(String(50), index=True)  # index 名は ix_users_name
```

`〇〇.port.adapter.persistence.repository.sqlalchemy.user.driver.__init__.py`
```python
from .users_table_row import UsersTableRow
# 他のテーブルクラス
```

テーブルクラスを足したら、そのモジュールの `tables` にドライバーモジュールのパスを足す

```python
class 〇〇(AppModule):
    @property
    @override
    def tables(self) -> list[str]:
        return ["〇〇.port.adapter.persistence.repository.sqlalchemy.user.driver"]
```

</details>

<details><summary><b>🚚 マイグレーション (Alembic)</b></summary>

事前にマイグレーション用のパッケージを生成
```bash
uv add alembic
uv run alembic init migration
```

`migration/env.py`:
```python
# alembic init の生成物から、target_metadata = None をこの 3 行に差し替える

from fastship.common.port.adapter.persistence.sqlalchemy import DataBase
from core import Backend  # 全モジュールを束ねた CompositeModule

target_metadata = DataBase.metadata_of(Backend())
```

</details>

<details><summary><b>🔀 リードレプリカへの読み書き振り分け (SQLAlchemy)</b></summary>

接続先は `DATABASE_WRITER_URL` / `DATABASE_READER_URL` で注入する (冒頭の「⚙️ 環境変数」を参照)。

```python
from fastship.common.port.adapter.persistence.sqlalchemy import SQLAlchemyUnitOfWork

unit_of_work = SQLAlchemyUnitOfWork.from_settings(pool_pre_ping=True)
```

トランザクション (`unit_of_work.session()`) は、読み取りも含めてすべてプライマリで実行する。
レプリカの遅延した値を元に更新すると、プライマリの新しい値を上書きしてしまう (lost update) ため。
更新系の生 SQL (`text()`) もここで実行する。

トランザクション外の読み取り (`unit_of_work.query()`) は、以下の規則でリーダーとライターに振り分ける。

| クエリ | 送り先 |
|:---|:---|
| 読み取り (`select()` / `session.get()` / `text()`) | リーダー |
| 書き込み (flush / `insert()` / `update()` / `delete()`) | ライター |
| `SELECT ... FOR UPDATE` (`with_for_update()`) | ライター |
| 一度でもライターへ送ったセッションの以降のクエリ | ライター (書き込み直後に古いデータを読まない) |
| `session.use_writer()` を呼んだ後のクエリ | ライター (レプリカ遅延を許容できない読み取り) |

```python
with unit_of_work.query() as session:
    session.use_writer()
    session.get(...)
```

認可の判定 (無効化された API キー、停止されたユーザー、取り消された権限など) は、レプリカ遅延の間に
古い状態で許可しないよう、`session.use_writer()` を呼んでプライマリから読むこと。

Read Pool のノード数を増減しても、エンドポイントは変わらないためアプリ側の変更は要らない。
`SessionPreparer` (RLS の `SET LOCAL` など) は、振り分け先のコネクションそれぞれに適用される。

</details>


<details><summary><b>📬 outbox / inbox (PostgreSQL)</b></summary>

ドメインイベントは、集約の更新と同じトランザクションで `stored_events` に保存する (outbox)。中継が未発行のイベントを MQ に発行する。
受信側は、listener の処理と同じトランザクションで `consumed_notifications` に記録し、同じ通知が 2 回届いても 1 回だけ処理する (inbox)。

テーブル (`stored_events` / `consumed_notifications`) と、ストア・中継の DI 登録は `Common` が行う (「🧩 既定のモジュール」)。
ストアは `UnitOfWork` に登録した `SQLAlchemyUnitOfWork` のセッションに書く

中継 (`NotificationPublisherImpl`)
- 未発行 (`published_at` が空) の行を `FOR UPDATE SKIP LOCKED` で 100 件まで取り出し、`NOTIFICATION_PUBLISH_TO` に発行して `published_at` を付ける
- 発行済みを行ごとに記録するため、id の小さいイベントが後から commit されても取りこぼさない
- 中継が同時に走っても、ほかの中継がロックしている行は飛ばして分担する
- 発行に途中で失敗したら、発行できた行だけを発行済みにする。残りは次の中継で発行する
- 発行に失敗した行は後回しにする (失敗した回数の少ない順に取り出す)。発行できないイベントがあっても、ほかのイベントは止まらない
- 同じイベントを 2 回発行するのは、発行した後 commit する前に中継が落ちたときだけ。受信側の inbox が重複を捨てる

処理済みの記録は、Cloud Scheduler などから `ConsumedNotificationApplicationService.prune_consumed_notifications()` を定期的に呼んで消す (既定は 14 日を過ぎたもの)。
保持期間は、MQ が再配送しうる期間 (Pub/Sub は最大 7 日) より長く取る。短いと、消した後に届いた再配送を二重に処理する。

</details>

### 📨 Messaging

<details><summary><b>📤 Pub/Sub への発行</b></summary>

GCP の SDK は extras に分けている。`gcp` を付けて入れる
```bash
uv add 'fastship-py[gcp]'
```

接続先は `GOOGLE_CLOUD_PROJECT` / `PUBSUB_EMULATOR_HOST` で注入する (冒頭の「⚙️ 環境変数」を参照)。
`MessagePublisher` / `MessageSubscriber` の DI 登録は `Common` が行う (「🧩 既定のモジュール」)。Pub/Sub 以外の MQ を使うときは、後ろのモジュールで差し替える。

- 発行は Pub/Sub が受け取るまで同期で待つ (最大 300 秒)。async の処理から呼ぶときは、`asyncio.to_thread` などでスレッドに逃がす
- 発行に失敗すると例外になる (黙って捨てない)
- 本文 (Notification の JSON) に加えて、`event_type` と `partition_key` を属性にも載せる。サブスクリプションのフィルタで、購読するイベントを絞れる

```bash
gcloud pubsub subscriptions create billing --topic=notifications \
  --message-filter='attributes.partition_key = "tenant-1"'
```

</details>

<details><summary><b>📥 push サブスクリプションの受信</b></summary>

```mermaid
flowchart LR
    publisher["CloudPubSubPublisher"] --> topic[["Pub/Sub トピック"]]
    topic --> push["push サブスクリプション<br>(サービスアカウントの OIDC トークンを付ける)"]
    push -->|"Cloud Run の IAM が<br>run.invoker を確かめる"| subscriber["subscriber の Cloud Run<br>(CloudPubSubSubscriber が OIDC トークンを検証する)"]
    subscriber --> listener["ExchangeListener"]
```

CloudPubSubSubscriber は、push サブスクリプションが付けた OIDC トークン (JWT) の署名・有効期限・発行者・audience を検証し、
設定したサービスアカウントからのリクエストだけを受け付ける。
設定 (`PUBSUB_PUSH_AUDIENCE` / `PUBSUB_PUSH_SERVICE_ACCOUNT`。冒頭の「⚙️ 環境変数」を参照) が無いと、
`CloudPubSubSubscriber.from_settings()` の時点で例外になる。検証しないのは `PUBSUB_EMULATOR_HOST` があるときだけ。

受け口のルートはアプリに書く

```python
from fastapi import FastAPI, Request
from fastship.common.port.adapter.messaging.pubsub import CloudPubSubSubscriber

from core import Backend  # 全モジュールを束ねた CompositeModule

app = FastAPI()
subscriber = CloudPubSubSubscriber.from_settings()
subscriber.add(Backend().listeners)


@app.post("/pubsub/push", status_code=204)
async def push(request: Request) -> None:
    # 2xx 以外を返すと Pub/Sub が再配送する。同じメッセージが 2 回届いても、inbox が重複を捨てる
    await subscriber.receive(await request.json(), request.headers.get("Authorization"))
```

- 送信元を認証できないと `SystemException` (`COMMON_2002`) になり、`add_exception_handlers` の例外ハンドラが 401 を返す
- 読めないメッセージも再配送され続けるため、push サブスクリプションにはデッドレタートピックを設定する

トークンの検証に加えて、Cloud Run の IAM でも入口を閉じておく (設定を誤ったときの二重の守り)。
- subscriber の Cloud Run は、未認証の呼び出しを拒否する (`--no-allow-unauthenticated`)
- `roles/run.invoker` は、push サブスクリプションのサービスアカウントにだけ付ける

</details>

<details><summary><b>📮 API の応答で通知を発行する</b></summary>

`PublishNotificationsMiddleware` は、API が 2xx を返すリクエストで `NotificationApplicationService.publish_notifications()` を呼び、
outbox に溜まった未発行の通知を MQ に発行する。発行と、発行済みの記録は 1 つのトランザクションで行う (中継の動きは「📬 outbox / inbox」)。
`NotificationPublisher` の実装 (`NotificationPublisherImpl`) は `Common` が DI コンテナに登録する。API と、push を受ける worker の両方に入れる。

```python
from fastship.common.middleware import PublishNotificationsMiddleware

app.add_middleware(PublishNotificationsMiddleware, excluded_paths={"/health/check"})
```

- 応答の最後の本文を送る直前に発行し、応答は発行を待つ。Cloud Run の `cpu_idle` (CPU をリクエストの処理中だけ割り当てる) のままで動く
- 応答を返した後に発行を残さない (FastAPI の `BackgroundTasks` を使わない)。リクエストの外では CPU が絞られ、発行が止まるため
- 発行はミドルウェアが持つタスク 1 本で行い、同期の発行はスレッドに逃がす。イベントループを塞がず、他のリクエストの処理は止まらない
- 同じプロセスでは発行を並行させない。発行中に来たリクエストは、今の発行の後の 1 回にまとめて待つ
- 発行に失敗しても応答は 2xx のまま返り、失敗はログに残る。未発行の通知は outbox に残り、次の発行で送られる
- 発行は、リクエストの文脈 (ContextVar に置いたテナントなど) を引き継がない
- 複数のインスタンスからは並行に発行しうる。未発行の通知は中継どうしで分担し、同じ通知を二重に発行しない

応答の遅れが気になるとき
- Pub/Sub への発行は受け取られるまで最大 300 秒待つため、Pub/Sub の障害中は応答もその分遅れる
- 発行が重くなったら、ミドルウェアを外し、Cloud Tasks のキュー (または Cloud Scheduler) から `NotificationApplicationService.publish_notifications()` を呼ぶエンドポイントを叩く。API の処理と発行が切り離され、再試行もキューに任せられる

</details>


### 🛠️ Other

<details><summary><b>📦 DI コンテナ</b></summary>

登録方法:
```python
from sqlalchemy import create_engine

from fastship.common.application import UnitOfWork
from fastship.common.port.adapter.persistence.inmem import InMemUnitOfWork
from fastship.common.port.adapter.persistence.sqlalchemy import SQLAlchemyUnitOfWork
from fastship.di import DI, DIContainer

engine = create_engine("postgresql+psycopg://...")

DIContainer.instance().register(
    DI.of(UnitOfWork, {"InMem": InMemUnitOfWork()}, SQLAlchemyUnitOfWork(engine)),
)
```

利用方法:
```python
from fastship.di import DI, DIContainer

# `DI_FOR_PY=InMem` なら InMemUnitOfWork のインタンスを取得
# `DI_FOR_PY=` なら SQLAlchemyUnitOfWork のインタンスを取得
unit_of_work = DIContainer.instance().resolve(UnitOfWork)
```

```python
from injector import inject


class 〇〇ApplicationService:
    @inject
    def __init__(self, 〇〇_repository: 〇〇Repository) -> None: ...
```

```python
from fastship.common.domain.model import DomainRegistry

〇〇_repository = DomainRegistry.resolve(〇〇Repository)
```

</details>

<details><summary><b>⚠️ エラー応答 (RFC 9457)</b></summary>

`ErrorJson` は [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) の Problem Details を表す。`ErrorCode` から組み立てる。
- `type` は `/errors/{コード名}` になる。クライアントは `type` の完全一致で分岐する
- `SystemException.detail` はログ用で内部の情報を含みうるため、`detail` には渡さない

`add_exception_handlers(app)` は、例外を `ErrorJson` (`application/problem+json`) の応答に変える例外ハンドラを登録する。

| 例外 | ステータス | `type` |
|:---|:---|:---|
| `SystemException` | `ErrorCode` の `http_status` | `/errors/{コード名}` |
| `ValueError` / `AssertionError` (ドメインの自己検証) | 400 | `/errors/COMMON_2000` |
| `RequestValidationError` (リクエストのスキーマ検証) | 422 | `/errors/COMMON_2001` |
| `HTTPException` (未定義ルートの 404・405、`fastapi.security` の 401/403 など) | 例外のステータス | `about:blank` |
| その他の例外 | 500 | `/errors/COMMON_1000` |

- `ValueError` / `AssertionError` のログには、例外の型と送出した場所だけを出す (メッセージには入力値が入りうる)
- 応答を変えたい例外は、`add_exception_handlers(app)` の後に `app.add_exception_handler` で登録する (同じ例外なら後から登録したものが勝つ)。応答は `ErrorJsonResponse.of(エラーコード, request)` で返す

```json
{"type": "/errors/USER_NOT_FOUND", "title": "ユーザーが見つかりません", "status": 404, "instance": "https://api.example.com/users/1"}
```

</details>

### ✏️ Style Check

```bash
uv run ruff format --check . && uv run ruff check . && uv run pyright && uv run deptry . && uv run lint-imports
```

<details><summary><b>🪄 lint の設定 (ruff / Pyright / deptry)</b></summary>

設定は fastship が持たない。テンプレートの `pyproject.toml` にある設定を、プロジェクトで自由に変える。
- fastship-py 経由で入る依存 (`fastapi` / `injector` / `pydantic` / `pydantic-settings` / `sqlalchemy`) も、import するならプロジェクトの `dependencies` に直接書く
- `src` 直下のモジュールは `[tool.deptry]` の `known_first_party` に並べる (並べないと自分のモジュールを未宣言の依存とみなす)

```toml
[tool.deptry.per_rule_ignores]
DEP002 = ["uvicorn"]  # import せずに使う依存
```

</details>

<details><summary><b>🏛 アーキテクチャ規約 (import-linter)</b></summary>

テンプレートの `pyproject.toml` の `[tool.importlinter]` が規約を有効にしている。
- 規約の中身 (層の順序や許可する import) は fastship のコード (`fastship.linter.architecture`) にあり、fastship-py を更新すると変わる
- `root_packages` には `src` 直下のモジュールをすべて並べる。足し忘れは lint-imports が検出する
- import-linter は `root_packages` が空だと動かない。テンプレートが見本の `example` モジュールを持つのはそのため

プロジェクト独自の設定や contract も `[tool.importlinter]` に足す
```toml
[tool.importlinter]
root_packages = ["billing"]
include_external_packages = true  # sqlalchemy などの外部パッケージを forbidden_modules に書くときに要る

[[tool.importlinter.contracts]]
id = "no-sqlalchemy-in-domain"
name = "ドメイン層は永続化の技術を知らない"
type = "forbidden"
source_modules = ["billing.domain"]
forbidden_modules = ["sqlalchemy"]
```

**ヘキサゴナルアーキテクチャ** (モジュール内部の依存)

<img src="docs/images/hexagonal.svg" alt="core → middleware → port → application → domain → exception の向きにだけ import できる。domain を import できるのは core / application / port.adapter.persistence / port.adapter.service だけ">

**モジュラモノリス** (モジュール間の依存)

<img src="docs/images/modular-monolith.svg" alt="モジュール間の import は ACL アダプター (port.adapter.service.〇〇.adapter) から相手の公開 IF (port.adapter.resource) へだけ">

</details>
