Skip to content

refactor: Affiliationの型表現をDiscriminated Unionに変更#98

Closed
KinjiKawaguchi wants to merge 208 commits intodevelopfrom
refactor/domain-serialization-types
Closed

refactor: Affiliationの型表現をDiscriminated Unionに変更#98
KinjiKawaguchi wants to merge 208 commits intodevelopfrom
refactor/domain-serialization-types

Conversation

@KinjiKawaguchi
Copy link
Copy Markdown
Member

Why

AffiliationがValueObjectクラスだったため、JSONB永続化にSerializedAffiliation型とserialize/deserialize関数が必要だった。ドメイン型を直接シリアライズ可能にし、インフラ層の変換コードを削減する。

What

  • UndergraduateAffiliation等をクラスからplain typeのdiscriminated unionに変更
  • SerializedAffiliation型を削除(Affiliation型がそのままJSONBに格納可能に)
  • serializeAffiliation() / deserializeAffiliation()を削除
  • affiliationTypeNamesマップを追加(表示名のドメイン知識をAffiliation.tsに集約)
  • Member.tsの全instanceof分岐をtypeフィールド分岐に統一
  • DrizzleMemberRepositoryからAffiliation変換コードを削除

How

Affiliation = { type: "undergraduate"; value: UndergraduateAffiliationValue } | ... のdiscriminated unionにすることで、型自体がシリアライズ可能な形を持つ。validate()が全て空だったため、クラスを残す理由がなかった。

🤖 Generated with Claude Code

KinjiKawaguchi and others added 30 commits November 6, 2024 12:04
KinjiKawaguchi and others added 26 commits July 18, 2025 17:35
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
## Summary

- `domain/value-objects/` を廃止し、各値オブジェクトを使用元の集約ディレクトリに移動
  - `Email`, `UniversityEmail`, `Departments` → `aggregates/member/`
  - `LightningTalkDuration`, `Url` → `aggregates/event/`
- `ValueObject` 基底クラスを `domain/base/` に移動
- 型別ではなくドメイン概念別のディレクトリ構成に統一

## Motivation


既存の構造では値オブジェクトがビルディングブロックの型別(`value-objects/`)にトップレベルで切り出されており、どの集約に属する概念なのかが不明瞭だった。

各値オブジェクトは実際には特定の集約でのみ使用されているため、使用元の集約ディレクトリに配置することで、ドメイン概念の凝集度を高める。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
- vitest v4をdevDependenciesに追加し、`npm test` / `npm run test:watch`
スクリプトを追加
- `vitest.config.ts`でパスエイリアス(`#domain`, `#application`,
`#infrastructure`)を解決
- `ValueObject`基底クラスのユニットテストをサンプルとして追加(8テストケース)
- GitHub Actions `test.yml`ワークフローを追加(push/PR時に自動実行)

## Test plan
- [x] `npm test` で8件のテストが全てパスすること
- [x] `npx biome check` がパスすること
- [x] GitHub Actions test workflowが正常に実行されること

🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/su-its/core/pull/83"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
- 4つの個別CIワークフロー(build/lint/typecheck/test)を `ci.yml` に統合(Reusable
Workflow)
- `setup-node` Composite Action でセットアップ処理(setup-node + npm ci)を共通化
- `publish.yml` が `ci.yml` を前提条件として呼び出すオーケストレーションを追加
- 全ジョブに `permissions: contents: read` と `timeout-minutes: 10` を設定
- アクションを SHA pinning に更新(Node.js 20 deprecation 警告の解消)

## マージ後に必要な作業
- [ ] Branch Protection Rules の required status checks を `CI / lint`,
`CI / typecheck`, `CI / test`, `CI / build` に更新
- [ ] NPM_TOKEN シークレットの更新(Publish失敗の根本原因)
- [ ] GitHub Settings → Environments で `npm` 環境を作成
- [ ] 旧 `code_check.yml` ワークフローの無効化


🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/su-its/core/pull/88"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
- 学籍番号(StudentId)の値オブジェクトをSharedKernelとして追加
- 旧形式(8桁数字)と新形式(3桁数字+英字+4桁数字)の両方に対応
- `fromString` ファクトリメソッドで小文字→大文字正規化・空白トリムを実施
- private constructorにより直接インスタンス化を防止
- 12件のユニットテストを追加

## Test plan
- [x] 旧形式・新形式の正常系テスト
- [x] 大文字正規化・空白トリムのテスト
- [x] 桁数不正・英字のみ・英字位置不正・空文字等の異常系テスト
- [x] equalsメソッドの等価性テスト
- [x] lint, typecheck, test全てパス

## Related
- PR #81 から StudentId 部分を分割したPR
- マージ後、PR #81 をリベースしてAffiliation部分のみにする

🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/su-its/core/pull/87"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
- `Event.removeExhibitMemberId`のボディが空で、展示からのメンバー削除がno-opだった問題を修正
- 該当Exhibitからメンバーを削除し、他のExhibitにも所属していなければEvent.memberIdsからも削除する
- PR #81 のレビューで指摘された pre-existing issue への対応

🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/su-its/core/pull/85"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Summary
- 値オブジェクトを所属する集約ディレクトリに移動し、`domain/base` に `ValueObject` を配置
- Shared Kernel として `Affiliation`(学部・修士・博士・専門職)と `StudentId` 値オブジェクトを追加
- 静岡大学の全組織構造を型レベルの判別共用体で表現(学部ごとに異なる階層構造を正確にモデル化)
- Affiliation のバリデーションは型制約で保証し、ランタイム検証はシステム境界の責務とする設計


🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/su-its/core/pull/81"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## 概要

PC相談室のカルテ(相談記録)を表すKarte集約を追加する。

## 背景

PC相談室では相談対応の記録をカルテとして管理している。このPRではカルテのドメインモデルを定義し、ドメイン層に集約として追加する。

## 集約の構造

```
Karte(集約ルート / immutable)
├── Client          相談者(Student | Teacher | Staff | Other)
├── Consent         同意事項
├── Consultation    相談事
│   ├── categories      相談カテゴリ(複数選択)
│   ├── targetDevice    対象機器
│   └── troubleDetails  トラブル詳細
└── SupportRecord   対応記録
    ├── assignedMemberIds  担当メンバー
    ├── content            対応内容
    ├── resolution         解決ステータス(resolved | unresolved → FollowUp)
    └── workDuration       作業時間
```

## 設計の要点

### 1. immutableな「記録型」集約

カルテは一度書いたら変わらない「記録」としてimmutableに設計した。
- `create()` で新規作成、`correct()` で訂正(記録ミスの修正用)
- `correct()` は `recordedAt` を保持し `lastUpdatedAt` のみ更新する
- `reconstruct()` は永続化データからの復元用(バリデーションなし)

### 2. Recorded\<T\> — 過去データの欠損を型で表現

過去のカルテには一部フィールドが存在しない。null/undefinedに暗黙の意味を持たせず、`{ type: "recorded",
value: T } | { type: "notRecorded" }` の判別共用体で明示する。

- `create()` では全フィールドを生の値で受け取り、内部で `recorded()` に包む → 新規カルテは常に完全
- `reconstruct()` では `Recorded<T>` をそのまま受け取る → 過去データの欠損を許容

### 3. Branded型 + ファクトリ関数によるVO実装

`WorkDuration`, `KarteId` などの値オブジェクトは、既存の `ValueObject<T>`
基底クラスを使わずbranded型 + ファクトリ関数で実装した。

理由: `ValueObject<T>` 基底クラスは `equals()` が JSON.stringify
比較に依存しており実装として脆い。今後他の集約でも基底クラスから剥がしていく可能性があるため、新規コードでは採用しなかった。

### 4. KarteRepositoryは find/save のみ

ユースケースが明確なメソッドだけ定義し、CRUD網羅はしない(YAGNI)。検索や一覧のユースケースが出た時点で追加する。

### 5. Recorded\<T\> の値にはドメインルールを適用

`Recorded<T>` が `recorded` であれば、その値はドメインルールに従うべき。
- `assignedMemberIds`: `Recorded<NonEmptyArray<string>>` — 記録されているなら1人以上
- `categories`: `Recorded<NonEmptyArray<ConsultationCategory>>` —
記録されているなら1つ以上

## 議論したいこと

- **カルテは本当に変更すべきでないか?**
現在の設計は「記録型(immutable)」を前提としている。correct()は記録ミスの訂正用として用意したが、そもそもカルテは一度記録したら変更不可とすべきか、それとも訂正を許容すべきか。
- **変更履歴を残すべきか?**
現在のcorrect()は最新の状態だけを保持し、変更前の内容は残らない。訂正を許容する場合、誰がいつ何を変更したかの履歴(監査ログ)が必要ではないか。
- **FollowUp「その他」選択時の自由記述フィールド** が必要か(ドメインエキスパートとの議論待ち)

## テスト

- `WorkDuration`: バリデーション境界値(0分OK、負数/小数/NaN → 例外)
- `Karte`:
create/correctのドメインロジック(タイムスタンプ、Recorded包装、訂正時のid/recordedAt保持)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
## Why

集約ルートのID(Member,
Event)および子エンティティのID(Exhibit)が`string`型であり、異なるID種別の取り違えがコンパイル時に検出できなかった。既存のKarteIdパターンに合わせてBranded
Typeを導入し、型安全性を確保する。

## What

- `MemberId`, `EventId`, `ExhibitId` のBranded Type定義とファクトリ関数を追加
- ドメイン層(集約ルート、エンティティ、リポジトリインターフェース、値オブジェクト)の全ID参照をBranded Typeに変更
- アプリケーション層(全ユースケースのInput型)をBranded Typeに変更
- インフラ層(DrizzleRepository)のDB↔ドメイン変換境界でファクトリ関数によるキャストを追加
- テストコードのassignedMemberIdsをMemberIdに更新

## BREAKING CHANGE

全集約ルートID(MemberId, EventId)およびExhibitIdの型が`string`からBranded Typeに変更。
呼び出し側は`memberId()`, `eventId()`, `exhibitId()`ファクトリ関数でIDを生成する必要がある。

🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/su-its/core/pull/92"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## Why

室員管理システムのドメインモデル設計書に基づき、Member集約を全レイヤーにわたって再設計する。

現行のMemberは単一クラスで全状態を扱い、Discord連携やDepartment(CS/BI/IA等)を集約内に持つ構造だが、仕様で求められる室員/未確認/元室員の状態遷移やAffiliationベースの所属管理に対応できていない。

BREAKING CHANGE: Member集約のAPI、DBスキーマ、ユースケースI/Oが全面的に変更される。

## What

### ドメイン層

#### Member集約の3状態モデル
- `ActiveMember`(室員): MemberId, email, 名前, 個人メール, 学籍番号, 所属
- `UnconfirmedMember`(未確認): MemberId, email, 名前, 個人メール
- `FormerMember`(元室員): MemberId, email, 名前, 個人メール
- `type Member = ActiveMember | UnconfirmedMember | FormerMember`

#### 設計上の特徴
- **イミュータブル集約**: 全操作が新インスタンスを返す
- **型レベルでの状態保証**: 元室員に学籍番号でアクセスしようとするとコンパイルエラー
- **二重識別子**: `MemberId`(UUID, Branded
Type)は技術的識別子、`UniversityEmail`はビジネス識別子
- **Affiliationによる区分表現**: 学部生/修士/博士の区分はAffiliationの型が内包
- **Recorded\<T\>**: personalEmailの記録有無をnullではなく型で明示(shared kernelに配置)
- **ドメインイベント収集**: 12種のイベントを集約内で蓄積し`pullDomainEvents()`で取り出し

#### DiscordAccount集約(独立した境界づけられたコンテキスト)
- 旧MemberからDiscordAccountを分離し、独自の集約ルートとして再設計
- `DiscordId`(Branded Type)で型安全に識別
- `nickName`をDiscord固有の属性として保持
- `MemberId`でMember集約を参照

#### 削除・整理
- 旧`DiscordAccount`(Member集約内のエンティティ)→ 独立集約に移行
- `Departments`(CS/BI/IA等のenum)→ Affiliation(shared kernel)に置き換え
- Discord関連例外、InvalidDepartmentException を削除

### インフラ層

#### DBスキーマ変更
- `member_status` enum追加(active/unconfirmed/former)
- `status`列追加、`affiliation`列追加(JSONB, SerializedAffiliation型)
- `department`列削除
- `student_id`: text NOT NULL → varchar(8) nullable
- `timestamp`: precision(3)削除

#### リポジトリ
- `DrizzleMemberRepository`:
新Member型(ActiveMember/UnconfirmedMember/FormerMember)に対応。SerializedAffiliationでAffiliationを型安全にシリアライズ。
- `DrizzleDiscordAccountRepository`: 新規実装

### アプリケーション層

#### ユースケースI/O更新
- 全InputをBranded Type/ドメイン型に統一(MemberId, DiscordId, UniversityEmail,
StudentId, Recorded\<Email\>等)
- RegisterMember: Department→Affiliationベース
- UpdateMember:
イミュータブル集約のchangeName/changeStudentId/changePersonalEmail使用
- Discord系: DiscordAccountRepositoryを使う形に書き直し

#### 例外設計改善
- `InvalidAffiliationOperationException`: operation,
currentAffiliationType, reasonフィールドで原因を明示
- `DiscordAccountNotFoundException`, `MemberNotActiveException`を新規追加
- 汎用Errorの使用を排除

## Test plan
- [x] ActiveMember: 登録、復元、除籍、未確認移行、名前変更、学籍番号変更
- [x] ActiveMember: 内部進学(学部→修士、修士→博士、不正遷移のエラー)
- [x] ActiveMember: 転学部、転学科(同一学部制約)、転専攻(同一研究科制約)
- [x] UnconfirmedMember: 確認復帰、除籍、名前変更
- [x] FormerMember: 再登録、名前変更
- [x] ライフサイクル全体のイベント蓄積(登録→除籍→再登録、登録→未確認→確認)
- [x] ドメイン層の型エラー: 0件
- [x] 全64テスト合格

🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/su-its/core/pull/94"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ValueObjectクラスをやめてplain typeのdiscriminated unionにすることで、
SerializedAffiliationが不要になり、ドメイン型がそのままJSONBに
格納可能になった。instanceof分岐もtype分岐に統一。

- Affiliation型を { type, value } のdiscriminated unionに変更
- SerializedAffiliation型、serializeAffiliation/deserializeAffiliationを削除
- affiliationTypeNamesマップを追加(型と同じファイルに配置)
- Member.tsのinstanceof分岐をtype分岐に置き換え
- DrizzleMemberRepositoryからAffiliation変換コードを削除

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
KinjiKawaguchi added a commit that referenced this pull request Mar 23, 2026
> Reopened from #98 (closed by history rewrite)

## Why


AffiliationがValueObjectクラスだったため、JSONB永続化にSerializedAffiliation型とserialize/deserialize関数が必要だった。ドメイン型を直接シリアライズ可能にし、インフラ層の変換コードを削減する。

## What

- UndergraduateAffiliation等をクラスからplain typeのdiscriminated unionに変更
- SerializedAffiliation型を削除(Affiliation型がそのままJSONBに格納可能に)
- serializeAffiliation() / deserializeAffiliation()を削除
- affiliationTypeNamesマップを追加(表示名のドメイン知識をAffiliation.tsに集約)
- Member.tsの全instanceof分岐をtypeフィールド分岐に統一
- DrizzleMemberRepositoryからAffiliation変換コードを削除

## How

Affiliation = { type: "undergraduate"; value:
UndergraduateAffiliationValue } | ... のdiscriminated
unionにすることで、型自体がシリアライズ可能な形を持つ。validate()が全て空だったため、クラスを残す理由がなかった。

🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/su-its/core/pull/102"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants