Metadata-Version: 2.5
Name: fastship-py
Version: 0.6.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: 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
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 を前提にしており、SQLAlchemy と psycopg も一緒に入る (extras の指定は要らない)。

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

## 📚 How To
### 💾 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>

接続先はインフラが環境変数で注入する (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

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

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

設定は fastship が持たない。テンプレートの `pyproject.toml` にある設定を、プロジェクトで自由に変える。
- fastship-py 経由で入る依存 (`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>
