Metadata-Version: 2.5
Name: fastship-py
Version: 0.5.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: injector<1.0.0,>=0.22.0
Requires-Dist: pydantic-settings<3.0.0,>=2.14.2
Requires-Dist: pydantic<3.0.0,>=2.13.0
Requires-Dist: pytz>=2026.2
Provides-Extra: linter
Requires-Dist: import-linter<3.0.0,>=2.14; extra == 'linter'
Requires-Dist: jinja2<4.0.0,>=3.1.6; extra == 'linter'
Requires-Dist: tomlkit<1.0.0,>=0.15.0; extra == 'linter'
Requires-Dist: typer<1.0.0,>=0.27.3; extra == 'linter'
Provides-Extra: sqlalchemy
Requires-Dist: sqlalchemy<3.0.0,>=2.0.51; extra == 'sqlalchemy'
Description-Content-Type: text/markdown

# 🚀 fastship-py

```bash
# app.fastship.jp テンプレートから作ったリポジトリの backend/ (pyproject.toml のあるところ) で実行する
uv sync
uv run fastship sync                   # 規約を導入する。fastship-py を更新したら再実行する (何度実行してもよい)
uv run fastship add module billing     # モジュールの雛形を足す
```

```
backend/
├── pyproject.toml       # ruff / mypy / deptry のプロジェクト固有の設定もここに書く
├── fastship.ruff.toml   # 🔄 ruff の規約 (pyproject.toml の [tool.ruff] が extend する)
├── .importlinter        # 🔄 アーキテクチャ規約 (import-linter)
└── src/
    ├── core.py          #    全モジュールを束ねる Backend (テンプレートにある)
    └── billing/         # 🌱 fastship add module が作るモジュール
        ├── core.py      #    DI 登録・購読者・テーブル定義の宣言点 (AppModule)
        ├── application/
        ├── domain/model/
        ├── exception/
        └── port/adapter/{persistence,resource,service}/
```

- 🔄 規約: fastship が管理する。`fastship sync` のたびに最新へ上書きする (手で書き換えても戻る)
- 🌱 雛形: `fastship add module` が作る。以後はプロジェクトのもので、`fastship sync` も触らない

`src` 直下のモジュールは `uv sync` で editable インストールされる (テンプレートの pyproject.toml の `[build-system]`)。
そのため ruff / mypy / deptry / lint-imports / pytest / alembic のどれも、`PYTHONPATH` なしでプロジェクトルートから実行できる。

## 📚 How To
### 💾 Persistence

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

`〇〇.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>

接続先はインフラが環境変数で注入する (Secret Manager の値は実行基盤で環境変数として公開する)。アプリ側では設定しない。

| 環境変数 | 内容 |
|:---|:---|
| `DATABASE_WRITER_URL` | プライマリ。必須 |
| `DATABASE_READER_URL` | リードレプリカ (Read Pool のエンドポイント)。未設定ならすべてプライマリへ送る |

```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>


### 🛠️ 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>

### ✏️ Style Check

CI でも検査の前に `fastship sync` を実行する。コミット済みの設定ではなく、ロックした fastship-py の規約で検査するため
```bash
uv run fastship sync
uv run ruff format --check . && uv run ruff check . && uv run mypy && uv run deptry src && uv run lint-imports
```
`fastship sync` で差分が出たら (fastship-py を更新した / モジュールを足した) そのままコミットする。

<details><summary><b>🪄 lint 規約 (ruff / mypy)</b></summary>

`fastship sync` が管理するのは次の 2 つ
- `fastship.ruff.toml` (テンプレートの `pyproject.toml` が `[tool.ruff]` の `extend` で読み込む)
- `[tool.mypy]` のうち規約が持つキー (`disallow_untyped_defs` / `strict_optional` など)

deptry の設定は fastship が持たない。
- fastship-py 経由で入る依存 (`injector` / `pydantic` / `pydantic-settings` / `sqlalchemy`) も、import するならプロジェクトの `dependencies` に直接書く
- deptry は `uv run deptry src` で実行する (`.` で実行すると `src` 直下のモジュールを依存とみなしてしまう)

プロジェクト固有の設定は `pyproject.toml` に書く
```toml
[tool.ruff.lint]
extend-per-file-ignores = { "src/foo/**/*.py" = ["S101"] }  # per-file-ignores と書くと規約の分が消える

[tool.mypy]
plugins = ["pydantic.mypy"]

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

`ruff.toml` / `mypy.ini` があると `pyproject.toml` の設定が読まれないため、`fastship sync` は失敗する。

</details>

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

`fastship sync` が規約を `.importlinter` に書き出し、`root_packages` を `src` 直下のモジュールに合わせる。
`.importlinter` は丸ごと fastship のもので、プロジェクト独自の contract は足せない (sync のたびに上書きされる)。
import-linter は `root_packages` が空だと動かないため、最初のモジュールを足すまでは書き出さない。

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

<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>
