Metadata-Version: 2.5
Name: fastship-py
Version: 0.4.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'
Provides-Extra: sqlalchemy
Requires-Dist: sqlalchemy<3.0.0,>=2.0.51; extra == 'sqlalchemy'
Description-Content-Type: text/markdown

# 🚀 fastship-py

```bash
uv add fastship-py
uv add "fastship-py[sqlalchemy]"   # SQLAlchemy アダプタも使う場合
```

## 📚 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.migration import load_metadata
from core import Backend  # 全モジュールを束ねた CompositeModule

target_metadata = load_metadata(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>
