本章の方針
本章は、これまでの章で個別に解説した概念を1つのコードベースに統合する。対象は選考管理コンテキストの実装である。
扱うパターンは以下の通りだ。
- 値オブジェクト(Value Object)・エンティティ(Entity)(第6章)
- 集約(Aggregate)・集約ルート(第7章)
- ドメインイベント(Domain Event)(第8章)
- CQRS(第9章)
- ヘキサゴナルアーキテクチャ(Hexagonal Architecture)のポート・アダプター
各コードブロックの冒頭に // [パターン名] を付記する。どのパターンが実装されているかが一目でわかるようにするためだ。
ディレクトリ構造
src/
├── screening/ # 選考管理コンテキスト
│ ├── domain/
│ │ ├── ScreeningProcess.ts # 集約ルート
│ │ ├── ScreeningStage.ts # 値オブジェクト
│ │ ├── events/
│ │ │ ├── ScreeningAdvanced.ts
│ │ │ └── OfferExtended.ts
│ │ └── ports/
│ │ └── ScreeningProcessRepository.ts # ポート
│ ├── application/
│ │ ├── commands/
│ │ │ └── AdvanceScreeningHandler.ts
│ │ └── queries/
│ │ └── ScreeningDashboardQuery.ts
│ └── infrastructure/
│ └── PostgresScreeningProcessRepository.ts # アダプター
ドメイン層は外部への依存を持たない。ポート(インターフェース)を通じてインフラ層と接続する。アプリケーション層はドメイン層を呼び出し、ユースケースを実現する。
値オブジェクトの実装
ID型と Money
// [値オブジェクト] ScreeningProcessId
class ScreeningProcessId {
private constructor(readonly value: string) {}
static create(): ScreeningProcessId {
return new ScreeningProcessId(crypto.randomUUID())
}
static reconstruct(value: string): ScreeningProcessId {
if (!value) throw new Error('ScreeningProcessId cannot be empty')
return new ScreeningProcessId(value)
}
equals(other: ScreeningProcessId): boolean {
return this.value === other.value
}
toString(): string {
return this.value
}
}
// [値オブジェクト] CandidateId
class CandidateId {
private constructor(readonly value: string) {}
static reconstruct(value: string): CandidateId {
if (!value) throw new Error('CandidateId cannot be empty')
return new CandidateId(value)
}
equals(other: CandidateId): boolean {
return this.value === other.value
}
toString(): string {
return this.value
}
}
// [値オブジェクト] JobPostingId
class JobPostingId {
private constructor(readonly value: string) {}
static reconstruct(value: string): JobPostingId {
if (!value) throw new Error('JobPostingId cannot be empty')
return new JobPostingId(value)
}
equals(other: JobPostingId): boolean {
return this.value === other.value
}
toString(): string {
return this.value
}
}
// [値オブジェクト] Money(給与オファー用)
class Money {
private constructor(
readonly amount: number,
readonly currency: string,
) {}
static create(amount: number, currency: string): Money {
if (amount < 0) throw new Error('Amount must be non-negative')
if (!currency) throw new Error('Currency is required')
return new Money(amount, currency)
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency
}
format(): string {
return `${this.amount.toLocaleString()} ${this.currency}`
}
}
ScreeningStage
ScreeningStage は状態遷移のルールをカプセル化する値オブジェクトだ。遷移の可否判断を外部のサービスや集約に委ねず、値オブジェクト自身が持つ。
// [値オブジェクト] ScreeningStage(状態遷移ルールを内包)
type StageType =
| 'APPLICATION_RECEIVED' // 応募受付
| 'DOCUMENT_SCREENING' // 書類選考
| 'FIRST_INTERVIEW' // 一次面接
| 'SECOND_INTERVIEW' // 二次面接
| 'FINAL_INTERVIEW' // 最終面接
| 'OFFER_EXTENDED' // 内定
| 'REJECTED' // 不採用
| 'WITHDRAWN' // 辞退
const STAGE_ORDER: StageType[] = [
'APPLICATION_RECEIVED',
'DOCUMENT_SCREENING',
'FIRST_INTERVIEW',
'SECOND_INTERVIEW',
'FINAL_INTERVIEW',
'OFFER_EXTENDED',
]
class ScreeningStage {
private constructor(readonly type: StageType) {}
static of(type: StageType): ScreeningStage {
return new ScreeningStage(type)
}
static initial(): ScreeningStage {
return new ScreeningStage('APPLICATION_RECEIVED')
}
// 次のステージへ遷移できるか
canAdvance(): boolean {
return STAGE_ORDER.includes(this.type) &&
STAGE_ORDER.indexOf(this.type) < STAGE_ORDER.length - 1
}
// 次のステージを返す(遷移不可なら例外)
next(): ScreeningStage {
const index = STAGE_ORDER.indexOf(this.type)
if (index === -1 || index >= STAGE_ORDER.length - 1) {
throw new Error(`Cannot advance from stage: ${this.type}`)
}
return new ScreeningStage(STAGE_ORDER[index + 1])
}
isInterviewCompleted(): boolean {
return this.type === 'FINAL_INTERVIEW'
}
isFinal(): boolean {
return (
this.type === 'OFFER_EXTENDED' ||
this.type === 'REJECTED' ||
this.type === 'WITHDRAWN'
)
}
equals(other: ScreeningStage): boolean {
return this.type === other.type
}
}
集約の実装
AggregateRoot 基底クラス
// [集約ルート基底クラス] ドメインイベントの収集
abstract class DomainEvent {
readonly occurredAt: Date
constructor() {
this.occurredAt = new Date()
}
}
abstract class AggregateRoot<T> {
protected readonly _id: T
private _domainEvents: DomainEvent[] = []
constructor(id: T) {
this._id = id
}
get id(): T {
return this._id
}
protected addDomainEvent(event: DomainEvent): void {
this._domainEvents.push(event)
}
// リポジトリが保存後に呼び出してイベントを取り出す
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents]
this._domainEvents = []
return events
}
}
ScreeningAdvanced と OfferExtended
// [ドメインイベント] ScreeningAdvanced
// src/screening/domain/events/ScreeningAdvanced.ts
class ScreeningAdvanced extends DomainEvent {
constructor(
readonly screeningProcessId: ScreeningProcessId,
readonly candidateId: CandidateId,
readonly jobPostingId: JobPostingId,
readonly newStage: ScreeningStage,
) {
super()
}
}
// [ドメインイベント] OfferExtended
// src/screening/domain/events/OfferExtended.ts
class OfferExtended extends DomainEvent {
constructor(
readonly screeningProcessId: ScreeningProcessId,
readonly candidateId: CandidateId,
readonly offeredSalary: Money,
) {
super()
}
}
ScreeningProcess
不変条件のチェックは各メソッド内に集約し、ドメインロジックがコードから直接読み取れるようにする。
// [集約ルート] ScreeningProcess
// src/screening/domain/ScreeningProcess.ts
class ScreeningProcess extends AggregateRoot<ScreeningProcessId> {
private readonly _candidateId: CandidateId // IDで参照(オブジェクト参照なし)
private readonly _jobPostingId: JobPostingId // IDで参照(オブジェクト参照なし)
private _currentStage: ScreeningStage
private _rejectionReason: string | null
private constructor(
id: ScreeningProcessId,
candidateId: CandidateId,
jobPostingId: JobPostingId,
currentStage: ScreeningStage,
rejectionReason: string | null,
) {
super(id)
this._candidateId = candidateId
this._jobPostingId = jobPostingId
this._currentStage = currentStage
this._rejectionReason = rejectionReason
}
// ファクトリメソッド: 新規作成
static create(
id: ScreeningProcessId,
candidateId: CandidateId,
jobPostingId: JobPostingId,
): ScreeningProcess {
const process = new ScreeningProcess(
id,
candidateId,
jobPostingId,
ScreeningStage.initial(),
null,
)
process.addDomainEvent(
new ScreeningAdvanced(id, candidateId, jobPostingId, ScreeningStage.initial()),
)
return process
}
// ファクトリメソッド: DBからの復元
static reconstruct(
id: ScreeningProcessId,
candidateId: CandidateId,
jobPostingId: JobPostingId,
currentStage: ScreeningStage,
rejectionReason: string | null,
): ScreeningProcess {
return new ScreeningProcess(id, candidateId, jobPostingId, currentStage, rejectionReason)
}
get candidateId(): CandidateId { return this._candidateId }
get jobPostingId(): JobPostingId { return this._jobPostingId }
get currentStage(): ScreeningStage { return this._currentStage }
// 選考を次のステージへ進める
advanceStage(): void {
// 不変条件: 終了済みの選考は進められない
if (this._currentStage.isFinal()) {
throw new Error(`Cannot advance a finalized screening: ${this._currentStage.type}`)
}
// 不変条件: 次のステージが存在しない場合は進められない
if (!this._currentStage.canAdvance()) {
throw new Error(`No next stage available from: ${this._currentStage.type}`)
}
this._currentStage = this._currentStage.next()
this.addDomainEvent(
new ScreeningAdvanced(this._id, this._candidateId, this._jobPostingId, this._currentStage),
)
}
// 内定を出す
extendOffer(salary: Money): void {
// 不変条件: 最終面接完了後のみ内定を出せる
if (!this._currentStage.isInterviewCompleted()) {
throw new Error('Offer can only be extended after final interview')
}
this._currentStage = ScreeningStage.of('OFFER_EXTENDED')
this.addDomainEvent(
new OfferExtended(this._id, this._candidateId, salary),
)
}
// 不採用にする
reject(reason: string): void {
// 不変条件: すでに終了している選考は変更できない
if (this._currentStage.isFinal()) {
throw new Error(`Cannot reject a finalized screening: ${this._currentStage.type}`)
}
if (!reason || reason.trim().length === 0) {
throw new Error('Rejection reason is required')
}
this._currentStage = ScreeningStage.of('REJECTED')
this._rejectionReason = reason
}
}
リポジトリポートと CQRS の実装
ScreeningProcessRepository ポート
// [ポート] ScreeningProcessRepository
// src/screening/domain/ports/ScreeningProcessRepository.ts
interface ScreeningProcessRepository {
findById(id: ScreeningProcessId): Promise<ScreeningProcess | null>
save(process: ScreeningProcess): Promise<void>
}
インターフェースはドメイン層に置く。実装(アダプター)はインフラ層に置く。ドメイン層はインフラ層に依存しない。
AdvanceScreeningHandler(コマンド)
// [コマンドハンドラー] AdvanceScreeningHandler
// src/screening/application/commands/AdvanceScreeningHandler.ts
// コマンド(Command): データクラス、ロジックを持たない
class AdvanceScreeningCommand {
constructor(
readonly screeningProcessId: string,
readonly advancedBy: string,
) {}
}
// イベントパブリッシャーのポート
interface DomainEventPublisher {
publish(events: DomainEvent[]): Promise<void>
}
class AdvanceScreeningHandler {
constructor(
private readonly repo: ScreeningProcessRepository,
private readonly publisher: DomainEventPublisher,
) {}
async handle(cmd: AdvanceScreeningCommand): Promise<void> {
const id = ScreeningProcessId.reconstruct(cmd.screeningProcessId)
const process = await this.repo.findById(id)
if (process === null) {
throw new Error(`ScreeningProcess not found: ${cmd.screeningProcessId}`)
}
// ドメインロジックはすべて集約内に閉じている
process.advanceStage()
await this.repo.save(process)
// 保存完了後にイベントを発行する(永続化とイベント配送を分離)
const events = process.pullDomainEvents()
await this.publisher.publish(events)
}
}
ScreeningDashboardQuery(クエリ)
// [クエリ] ScreeningDashboardQuery
// src/screening/application/queries/ScreeningDashboardQuery.ts
// 読み取り専用の表示用データ構造(DTO)
interface ScreeningDashboardDTO {
screeningProcessId: string
candidateName: string
jobTitle: string
currentStage: string
lastUpdatedAt: Date
}
// 読み取り専用DBのポート
interface ReadOnlyDatabase {
query<T>(sql: string, params: unknown[]): Promise<T[]>
}
class ScreeningDashboardQuery {
constructor(private readonly readDb: ReadOnlyDatabase) {}
// クエリはドメインオブジェクトを経由しない
// 集約のロードコストが発生せず、表示に最適化したSQLを直接発行できる
async execute(recruiterId: string): Promise<ScreeningDashboardDTO[]> {
return this.readDb.query<ScreeningDashboardDTO>(
`SELECT
sp.id AS "screeningProcessId",
c.full_name AS "candidateName",
jp.title AS "jobTitle",
sp.current_stage AS "currentStage",
sp.updated_at AS "lastUpdatedAt"
FROM screening_processes sp
JOIN candidates c ON c.id = sp.candidate_id
JOIN job_postings jp ON jp.id = sp.job_posting_id
WHERE jp.recruiter_id = $1
ORDER BY sp.updated_at DESC`,
[recruiterId],
)
}
}
インフラ層: PostgresScreeningProcessRepository
// [アダプター] PostgresScreeningProcessRepository
// src/screening/infrastructure/PostgresScreeningProcessRepository.ts
interface DatabaseClient {
queryOne<T>(sql: string, params: unknown[]): Promise<T | null>
execute(sql: string, params: unknown[]): Promise<void>
}
class PostgresScreeningProcessRepository implements ScreeningProcessRepository {
constructor(private readonly db: DatabaseClient) {}
async findById(id: ScreeningProcessId): Promise<ScreeningProcess | null> {
const row = await this.db.queryOne<{
id: string
candidate_id: string
job_posting_id: string
current_stage: StageType
rejection_reason: string | null
}>(
'SELECT id, candidate_id, job_posting_id, current_stage, rejection_reason FROM screening_processes WHERE id = $1',
[id.toString()],
)
if (row === null) return null
// DBの生データをドメインオブジェクトへ復元
return ScreeningProcess.reconstruct(
ScreeningProcessId.reconstruct(row.id),
CandidateId.reconstruct(row.candidate_id),
JobPostingId.reconstruct(row.job_posting_id),
ScreeningStage.of(row.current_stage),
row.rejection_reason,
)
}
async save(process: ScreeningProcess): Promise<void> {
await this.db.execute(
`INSERT INTO screening_processes (id, candidate_id, job_posting_id, current_stage, rejection_reason, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (id) DO UPDATE
SET current_stage = EXCLUDED.current_stage,
rejection_reason = EXCLUDED.rejection_reason,
updated_at = NOW()`,
[
process.id.toString(),
process.candidateId.toString(),
process.jobPostingId.toString(),
process.currentStage.type,
null, // rejectionReasonはgetterを追加すれば取得可能
],
)
}
}
ドメインイベントの発行と購読
イベントバスの実装例
// [インフラ] インメモリのイベントバス(テストおよびシンプルな環境向け)
type EventHandler<T extends DomainEvent> = (event: T) => Promise<void>
class InMemoryEventBus implements DomainEventPublisher {
private handlers: Map<string, EventHandler<DomainEvent>[]> = new Map()
subscribe<T extends DomainEvent>(
eventName: string,
handler: EventHandler<T>,
): void {
const existing = this.handlers.get(eventName) ?? []
this.handlers.set(eventName, [...existing, handler as EventHandler<DomainEvent>])
}
async publish(events: DomainEvent[]): Promise<void> {
for (const event of events) {
const eventName = event.constructor.name
const handlers = this.handlers.get(eventName) ?? []
for (const handler of handlers) {
await handler(event)
}
}
}
}
通知コンテキストへのイベント通知
// [ドメインイベントハンドラー] 通知コンテキスト側のサブスクライバー
// 選考管理コンテキストはこのハンドラーの存在を知らない
interface EmailService {
sendOfferEmail(params: { to: string; processId: string; salary: string }): Promise<void>
}
interface CandidateQuery {
findEmailById(candidateId: string): Promise<string | null>
}
class OfferExtendedNotificationHandler {
constructor(
private readonly candidateQuery: CandidateQuery,
private readonly emailService: EmailService,
) {}
async handle(event: OfferExtended): Promise<void> {
const email = await this.candidateQuery.findEmailById(event.candidateId.toString())
if (email === null) {
throw new Error(`Candidate not found: ${event.candidateId}`)
}
await this.emailService.sendOfferEmail({
to: email,
processId: event.screeningProcessId.toString(),
salary: event.offeredSalary.format(),
})
}
}
// イベントバスへのサブスクリプション登録
// アプリケーション起動時に1度だけ実行する
function registerEventHandlers(
eventBus: InMemoryEventBus,
handler: OfferExtendedNotificationHandler,
): void {
eventBus.subscribe<OfferExtended>(
'OfferExtended',
(event) => handler.handle(event),
)
}
インフォグラフィック

全体の依存関係図
graph TD
subgraph Domain["ドメイン層"]
VO1[ScreeningProcessId]
VO2[CandidateId]
VO3[JobPostingId]
VO4[Money]
VO5[ScreeningStage]
AR[ScreeningProcess<br/>集約ルート]
DE1[ScreeningAdvanced<br/>ドメインイベント]
DE2[OfferExtended<br/>ドメインイベント]
PORT[ScreeningProcessRepository<br/>ポート]
BASE[AggregateRoot]
end
subgraph Application["アプリケーション層"]
CMD[AdvanceScreeningCommand]
CMH[AdvanceScreeningHandler<br/>コマンドハンドラー]
QRY[ScreeningDashboardQuery<br/>クエリ]
PUB[DomainEventPublisher<br/>ポート]
end
subgraph Infrastructure["インフラ層"]
REPO[PostgresScreeningProcessRepository<br/>アダプター]
BUS[InMemoryEventBus<br/>アダプター]
NOTIF[OfferExtendedNotificationHandler]
RDB[(PostgreSQL<br/>書き込みDB)]
RO[(PostgreSQL<br/>読み取りビュー)]
end
BASE --> AR
AR --> VO1
AR --> VO2
AR --> VO3
AR --> VO4
AR --> VO5
AR --> DE1
AR --> DE2
CMH --> PORT
CMH --> PUB
CMH --> CMD
QRY --> RO
REPO -->|implements| PORT
BUS -->|implements| PUB
REPO --> RDB
BUS --> NOTIF
NOTIF --> DE2
style Domain fill:#dbeafe,stroke:#3b82f6
style Application fill:#dcfce7,stroke:#22c55e
style Infrastructure fill:#fef9c3,stroke:#eab308
依存の方向は常にインフラ層からドメイン層へ向かう。ドメイン層はインフラ層・アプリケーション層のどちらにも依存しない。ポート(インターフェース)がドメイン層に配置されることで、この依存方向が強制される。
コマンドとクエリは異なるデータソースに接続する。コマンドは ScreeningProcessRepository を通じてドメインオブジェクトを経由して書き込み、クエリは読み取り専用ビューへ直接SQLを発行する。ドメインイベントはアプリケーション層の AdvanceScreeningHandler が保存後に発行し、InMemoryEventBus を通じて通知コンテキストのハンドラーへ届く。