今回つくるもの
読み込み、時刻とイベントIDの生成、保存を外から渡し、診察開始イベントを1回保存します。
追加したい機能
誰がいつ診察を開始したかを業務イベントとし、予約状態と監査記録を一つの保存境界へ渡す。
素朴な実装の落とし穴
時刻と ID を処理中に直接作り、状態と監査記録を別々に保存するため、テスト結果が安定せず、片方だけが残り得る。
このセッションのゴール
Clock と EventIdGenerator を port として注入し、副作用を調整するユースケースで EventContext を一度だけ生成する。純粋な状態遷移にはその EventContext を渡し、状態と監査記録を表す ExaminationStarted を単一の Store へ渡す。予約が存在しない・予約の状態が不正・予約が競合しているケースは Result のエラーを返す。データベース起因のエラーやデータが破損しているエラーは業務エラーには変換せず、Promise の reject として外側の境界へ伝える。
このセッションで守ること
- 時刻とイベント ID は1回のユースケース実行で一度だけ生成し、同じ実行コンテキストから状態と監査記録を作る。
- 状態と監査記録は同時に残るか、どちらも残らない。
- 保存障害を、呼び出し側が選択できる業務上の失敗へ偽装しない。
今回の変更は examples/session-06/src/useCase 内の1モジュールに限定します。
コードを読み、失敗を再現する
直接時刻を参照したりIDを生成したりしている処理、状態の保存と監査記録の保存がアトミックになっていない実装を読んでみましょう。例えば監査記録の保存だけが失敗した場合、状態の保存のみが実行され、不整合な状況が発生してしまいます。また、用意された単一の store に業務競合が届かない箇所を探します。
編集する範囲は examples/session-06/src/useCase、変更するファイル数は最大
3ファイル・約55行です。
-
output event の時刻と ID をユースケース内で生成している
src/useCase/startExamination.ts:45-67 export const startExaminationWithEffects = (deps: EffectsDependencies) => async ( input: Omit<StartExaminationInput, "examinationStartedAt">, ): Promise<Result<void, StartExaminationWithEffectsError>> => { const occurredAt = new Date().toISOString(); const result = startExamination({ resolver: deps.resolver, store: { save: () => undefined }, })({ ...input, examinationStartedAt: occurredAt }); if (result.isErr()) { return err(result.error); } const event = { kind: "ExaminationStarted", eventId: EventId.parse(crypto.randomUUID()), occurredAt, appointmentId: result.value.appointmentId, aggregateState: result.value, } as const satisfies ExaminationStarted;Date と randomUUID を直接呼び、同じ入力でも output event が変わります。 期待値を固定できず、テストが実行時刻に依存します。
-
side effects が2つの保存処理に分かれている
src/useCase/startExamination.ts:69-70 await deps.stateStore.save(event.aggregateState); await deps.eventLog.append(event);side effects として stateStore.save と eventLog.append を順番に await しています。 片方だけ成功すると、監査記録を伴わない状態変更が残ります。
修正前の失敗を確認する
修正前は、次の4件に対応する演習テストが失敗します。
- 同じ条件で実行しても、時刻とイベント ID が毎回変わる。
- 状態と監査記録を別々に保存している。
- 保存後に、更新した予約の状態を受け取れない。
- 業務上の競合と、保存障害・破損データを区別して返せない。
失敗を確認する
pnpm exercise:06 期待結果: 4件の演習テストが失敗します。
ブラウザ内の変更はローカルへ反映されません。
output event と side effects の要求を開始 snapshot で確認します。
exercises/effects-and-events.test.tsimport { errAsync, okAsync, ResultAsync } from "neverthrow";import { describe, expect, it } from "vitest";import { z } from "zod";import type { ExaminationStarted } from "../src/domain/appointment/index.js";import type { Appointment, CheckedIn } from "../src/domain/appointment/index.js";import { EventId } from "../src/domain/aggregate/eventId.js";import { AppointmentId } from "../src/domain/appointment/index.js";import { OwnerId } from "../src/domain/owner/index.js";import { PetId } from "../src/domain/pet/index.js";import { VeterinarianId } from "../src/domain/appointment/index.js";import { startExaminationWithEffects as startExamination } from "../src/useCase/startExamination.js";import { clinicFixture } from "../../fixtures/clinic.js";const FIXED_EVENT_ID = EventId.parse("55555555-5555-4555-8555-555555555555");const FIXED_OCCURRED_AT = "2026-08-30T06:30:00.000Z";const appointmentId = AppointmentId.parse(clinicFixture.appointmentId);const veterinarianId = VeterinarianId.parse(clinicFixture.veterinarianId);const checkedIn = { kind: "CheckedIn", appointmentId, petId: PetId.parse(clinicFixture.petId), ownerId: OwnerId.parse(clinicFixture.ownerId), scheduledAt: clinicFixture.scheduledAt, reason: "skin check", checkedInAt: clinicFixture.checkedInAt,} as const satisfies CheckedIn;const input = { appointmentId, veterinarianId } as const;const diagnosticCause = { ownerName: "Owner Secret", email: "owner-secret@example.test", phone: "090-9999-9999", message: "S6 storage unavailable for Owner Secret", stack: "S4DiagnosticError: storage unavailable\n at ExaminationStartedStore.store", error: new Error("S6 storage unavailable for Owner Secret"),} as const;describe("Step 1: 同じ clock と ID generator なら同じイベントになる", () => { it("固定 context から同じ eventId と occurredAt を返す", async () => { const harness = createHarness(); await startExamination(harness.dependencies)(input); await startExamination(harness.dependencies)(input); expect(harness.recordedEvents.map(({ eventId, occurredAt }) => ({ eventId, occurredAt }))).toEqual([ { eventId: FIXED_EVENT_ID, occurredAt: FIXED_OCCURRED_AT }, { eventId: FIXED_EVENT_ID, occurredAt: FIXED_OCCURRED_AT }, ]); });});describe("Step 2: 状態と監査記録は1回の保存で残る", () => { it("store(event) を1回だけ呼ぶ", async () => { const harness = createHarness(); await startExamination(harness.dependencies)(input); expect(harness.storeCalls).toBe(1); expect(harness.stateWrites).toBe(0); expect(harness.eventWrites).toBe(0); });});describe("Step 3: 非同期保存後もイベントが pipeline に残る", () => { it("保存成功時は store の void ではなく aggregateState を返す", async () => { const harness = createHarness(); const result = await startExamination(harness.dependencies)(input); expect(result.isOk() ? result.value : undefined).toMatchObject({ kind: "InExamination", appointmentId, }); });});describe("Step 4: 業務失敗とインフラ例外を別の経路で返す", () => { it("業務競合は Result、保存障害と破損データは reject で返す", async () => { const conflict = createHarness({ kind: "conflict" }); const storeFailure = createHarness({ kind: "store-failure", cause: diagnosticCause, }); const corruptData = createHarness({ kind: "corrupt-data" }); const [conflictOutcome, storeFailureOutcome, corruptDataOutcome] = await Promise.allSettled([ startExamination(conflict.dependencies)(input), startExamination(storeFailure.dependencies)(input), startExamination(corruptData.dependencies)(input), ]); expect({ conflict: conflictOutcome.status === "fulfilled" && conflictOutcome.value.isErr() ? conflictOutcome.value.error : conflictOutcome.status, storeFailure: storeFailureOutcome.status === "rejected" ? storeFailureOutcome.reason : storeFailureOutcome.status, corruptData: corruptDataOutcome.status === "rejected" && corruptDataOutcome.reason instanceof z.ZodError, storedStates: [ ...conflict.storedStates, ...storeFailure.storedStates, ...corruptData.storedStates, ], recordedEvents: [ ...conflict.recordedEvents, ...storeFailure.recordedEvents, ...corruptData.recordedEvents, ], }).toEqual({ conflict: { kind: "AppointmentConflict", appointmentId }, storeFailure: diagnosticCause, corruptData: true, storedStates: [], recordedEvents: [], }); });});type HarnessOutcome = | Readonly<{ kind: "success" }> | Readonly<{ kind: "conflict" }> | Readonly<{ kind: "store-failure"; cause: unknown }> | Readonly<{ kind: "corrupt-data" }>;const createHarness = ( outcome: HarnessOutcome = { kind: "success" },) => { const storedStates: Array<Appointment> = []; const recordedEvents: Array<ExaminationStarted> = []; let storeCalls = 0; let stateWrites = 0; let eventWrites = 0; return { dependencies: { resolver: { resolveById: () => { if (outcome.kind === "corrupt-data") { z.literal("CheckedIn").parse("corrupt persisted appointment"); } return checkedIn; }, }, clock: { now: () => FIXED_OCCURRED_AT }, eventIdGenerator: { generate: () => FIXED_EVENT_ID }, store: { store: (event: ExaminationStarted) => { storeCalls += 1; if (outcome.kind === "conflict") { return errAsync({ kind: "AppointmentConflict", appointmentId, } as const); } if (outcome.kind === "store-failure") { return ResultAsync.fromSafePromise(Promise.reject(outcome.cause)); } storedStates.push(event.aggregateState); recordedEvents.push(event); return okAsync(undefined); }, }, stateStore: { save: async (appointment: Appointment) => { stateWrites += 1; storedStates.push(appointment); }, }, eventLog: { append: async (event: ExaminationStarted) => { eventWrites += 1; recordedEvents.push(event); }, }, }, storedStates, recordedEvents, get storeCalls() { return storeCalls; }, get stateWrites() { return stateWrites; }, get eventWrites() { return eventWrites; }, };};演習
進め方: 言語化 → Agentへの依頼 → 検証
- 言語化(2分): 一緒に記録する必要がある値を1文で書く。
- 依頼(10分): その1文と自分の判断をテンプレートへ入れ、Agent に依頼する。
- 検証(3分): 型、テスト、差分の範囲を確認する。
プロンプトのテンプレート
角括弧の中は、事故と配布コードを読んだ自分の判断で埋めます。技法名や完成形を先に指定する必要はありません。
次の変更を実装してください。
業務背景:
実行のたびに変わる値でテスト結果が安定せず、状態だけ保存され監査記録が残らない予約が生まれた。
依頼:
診察開始で作る状態と監査記録に、1回の実行で生成した値を使ってください。保存時に片方だけが残らない形へ改善してください。
一緒に記録する必要がある値: [言語化フェーズで書いた1文]
着手前に判断すること:
- 実行ごとに変わる値をいつ生成するか: [自分の判断を書く]
- どの状態と記録を同時に保存するか: [自分の判断を書く]
- 保存障害をどの境界まで伝えるか: [自分の判断を書く]
まず配布コードと失敗しているテストを読み、上の判断と変更方針を短く説明してください。説明のあとで実装に進んでください。設計手段は既存コードに合うものを選び、選んだ理由も示してください。
受け入れ条件:
- Clock と ID generator を使って EventContext を一度だけ生成する。
- 状態と監査記録を1回の保存で残す。
- 非同期で保存しても、イベントを結果として返す。
- 保存失敗を業務Resultへ変換せず、例外として外側の境界へ伝播する。
変更範囲:
- examples/session-06/src/useCase/ のみ
- 最大 3 ファイル・約 55 行
- 範囲外の変更が必要に見えた場合は、変更せず理由を報告する
- 型エラーを `as` によるキャストで回避しない
検証:
- pnpm exercise:06 を実行し、すべての assertion が成功すること
完了時の報告:
- 変更したファイルと判断理由
- 検証コマンドの結果
- 型だけでは守れず、テストまたはレビューに残した点 この演習で解決しないこと
in-memory の一括保存は契約の形を示す。本番の永続化層でアトミック性を守るには、transaction と結合テストが必要になる。
完成ファイルの解答例
演習の開始時点: 予期できる業務 Result と、保存処理が reject する技術的な例外経路を分けて読む。
完成例: 下のすべての target file には、後続 step を含む完成状態が表示される。
Clock と ID generator を使って EventContext を一度だけ生成する。
この完成例には後続 step を含みます。すべての target file を反映してから検証します。
examples/session-07/src/useCase/dependencies.ts
import type { ResultAsync } from "neverthrow";
import type { Clock } from "../domain/aggregate/clock.js";
import type { EventIdGenerator } from "../domain/aggregate/eventIdGenerator.js";
import type { Appointment, InExamination } from "../domain/appointment/index.js";
import type { ExaminationStarted } from "../domain/appointment/index.js";
import type { AppointmentId } from "../domain/appointment/index.js";
import type { AppointmentConflict } from "./errors.js";
export type AppointmentResolver = Readonly<{
resolveById: (appointmentId: AppointmentId) => Appointment | undefined;
}>;
export type InExaminationStore = Readonly<{
save: (appointment: InExamination) => void;
}>;
export type Dependencies = Readonly<{
resolver: AppointmentResolver;
store: InExaminationStore;
}>;
export type EventContextDependencies = Readonly<{
clock: Clock;
eventIdGenerator: EventIdGenerator;
}>;
export type ExaminationStartedStore = Readonly<{
store: (event: ExaminationStarted) => ResultAsync<void, AppointmentConflict>;
}>;
export type EffectsDependencies = Readonly<{
resolver: AppointmentResolver;
store: ExaminationStartedStore;
}> & EventContextDependencies; examples/session-07/src/useCase/startExamination.ts
import { ResultAsync, type Result } from "neverthrow";
import type { EventContext } from "../domain/aggregate/eventContext.js";
import type { Appointment as AppointmentState, InExamination } from "../domain/appointment/index.js";
import type { ExaminationStarted } from "../domain/appointment/index.js";
import { Appointment, startExamination as transitionToInExamination } from "../domain/appointment/index.js";
import type { AppointmentId } from "../domain/appointment/index.js";
import type { VeterinarianId } from "../domain/appointment/index.js";
import type {
Dependencies,
EffectsDependencies,
EventContextDependencies,
} from "./dependencies.js";
import {
ensureAppointmentFound,
ensureCheckedIn,
type StartExaminationError,
type StartExaminationWithEffectsError,
} from "./errors.js";
export type StartExaminationInput = Readonly<{
appointmentId: AppointmentId;
veterinarianId: VeterinarianId;
examinationStartedAt: string;
}>;
export type StartExaminationWithEffectsInput = Omit<
StartExaminationInput,
"examinationStartedAt"
>;
export const startExamination =
(deps: Dependencies) =>
(input: StartExaminationInput): Result<InExamination, StartExaminationError> =>
ensureAppointmentFound(
deps.resolver.resolveById(input.appointmentId),
input.appointmentId,
)
.andThen(ensureCheckedIn)
.map((appointment) =>
transitionToInExamination(
appointment,
input.veterinarianId,
input.examinationStartedAt,
),
)
.map((appointment) => {
deps.store.save(appointment);
return appointment;
});
export const createEventContext = (
deps: EventContextDependencies,
): EventContext => ({
eventId: deps.eventIdGenerator.generate(),
occurredAt: deps.clock.now(),
});
export const startExaminationWithEffects =
(deps: EffectsDependencies) =>
(
input: StartExaminationWithEffectsInput,
): ResultAsync<InExamination, StartExaminationWithEffectsError> =>
// ラボ結果到着は別の trigger から始まるため、この診察開始 workflow へ接続しません。
ResultAsync.fromSafePromise<AppointmentState | undefined>(
Promise.resolve().then(() =>
deps.resolver.resolveById(input.appointmentId),
),
)
.andThen((appointment) =>
ensureAppointmentFound(appointment, input.appointmentId),
)
.andThen(ensureCheckedIn)
.map((appointment) =>
Appointment.startExamination(createEventContext(deps))(
appointment,
input.veterinarianId,
),
)
.andThrough((event) => deps.store.store(event))
.map((event) => event.aggregateState); 状態と監査記録を1回の保存で残す。
この完成例には後続 step を含みます。すべての target file を反映してから検証します。
examples/session-07/src/useCase/dependencies.ts
import type { ResultAsync } from "neverthrow";
import type { Clock } from "../domain/aggregate/clock.js";
import type { EventIdGenerator } from "../domain/aggregate/eventIdGenerator.js";
import type { Appointment, InExamination } from "../domain/appointment/index.js";
import type { ExaminationStarted } from "../domain/appointment/index.js";
import type { AppointmentId } from "../domain/appointment/index.js";
import type { AppointmentConflict } from "./errors.js";
export type AppointmentResolver = Readonly<{
resolveById: (appointmentId: AppointmentId) => Appointment | undefined;
}>;
export type InExaminationStore = Readonly<{
save: (appointment: InExamination) => void;
}>;
export type Dependencies = Readonly<{
resolver: AppointmentResolver;
store: InExaminationStore;
}>;
export type EventContextDependencies = Readonly<{
clock: Clock;
eventIdGenerator: EventIdGenerator;
}>;
export type ExaminationStartedStore = Readonly<{
store: (event: ExaminationStarted) => ResultAsync<void, AppointmentConflict>;
}>;
export type EffectsDependencies = Readonly<{
resolver: AppointmentResolver;
store: ExaminationStartedStore;
}> & EventContextDependencies; 非同期で保存しても、イベントを結果として返す。
この完成例には後続 step を含みます。すべての target file を反映してから検証します。
examples/session-07/src/useCase/startExamination.ts
import { ResultAsync, type Result } from "neverthrow";
import type { EventContext } from "../domain/aggregate/eventContext.js";
import type { Appointment as AppointmentState, InExamination } from "../domain/appointment/index.js";
import type { ExaminationStarted } from "../domain/appointment/index.js";
import { Appointment, startExamination as transitionToInExamination } from "../domain/appointment/index.js";
import type { AppointmentId } from "../domain/appointment/index.js";
import type { VeterinarianId } from "../domain/appointment/index.js";
import type {
Dependencies,
EffectsDependencies,
EventContextDependencies,
} from "./dependencies.js";
import {
ensureAppointmentFound,
ensureCheckedIn,
type StartExaminationError,
type StartExaminationWithEffectsError,
} from "./errors.js";
export type StartExaminationInput = Readonly<{
appointmentId: AppointmentId;
veterinarianId: VeterinarianId;
examinationStartedAt: string;
}>;
export type StartExaminationWithEffectsInput = Omit<
StartExaminationInput,
"examinationStartedAt"
>;
export const startExamination =
(deps: Dependencies) =>
(input: StartExaminationInput): Result<InExamination, StartExaminationError> =>
ensureAppointmentFound(
deps.resolver.resolveById(input.appointmentId),
input.appointmentId,
)
.andThen(ensureCheckedIn)
.map((appointment) =>
transitionToInExamination(
appointment,
input.veterinarianId,
input.examinationStartedAt,
),
)
.map((appointment) => {
deps.store.save(appointment);
return appointment;
});
export const createEventContext = (
deps: EventContextDependencies,
): EventContext => ({
eventId: deps.eventIdGenerator.generate(),
occurredAt: deps.clock.now(),
});
export const startExaminationWithEffects =
(deps: EffectsDependencies) =>
(
input: StartExaminationWithEffectsInput,
): ResultAsync<InExamination, StartExaminationWithEffectsError> =>
// ラボ結果到着は別の trigger から始まるため、この診察開始 workflow へ接続しません。
ResultAsync.fromSafePromise<AppointmentState | undefined>(
Promise.resolve().then(() =>
deps.resolver.resolveById(input.appointmentId),
),
)
.andThen((appointment) =>
ensureAppointmentFound(appointment, input.appointmentId),
)
.andThen(ensureCheckedIn)
.map((appointment) =>
Appointment.startExamination(createEventContext(deps))(
appointment,
input.veterinarianId,
),
)
.andThrough((event) => deps.store.store(event))
.map((event) => event.aggregateState); 保存失敗を業務Resultへ変換せず、例外として外側の境界へ伝播する。
この完成例には後続 step を含みます。すべての target file を反映してから検証します。
examples/session-07/src/useCase/errors.ts
import { err, ok, type Result } from "neverthrow";
import type { Appointment, CheckedIn } from "../domain/appointment/index.js";
import type { AppointmentId } from "../domain/appointment/index.js";
export type AppointmentNotFound = Readonly<{
kind: "AppointmentNotFound";
appointmentId: AppointmentId;
}>;
export type InvalidAppointmentState = Readonly<{
kind: "InvalidAppointmentState";
actual: Appointment["kind"];
}>;
export type AppointmentConflict = Readonly<{
kind: "AppointmentConflict";
appointmentId: AppointmentId;
}>;
export type StartExaminationError = AppointmentNotFound | InvalidAppointmentState;
export type StartExaminationWithEffectsError =
| StartExaminationError
| AppointmentConflict;
export const ensureAppointmentFound = (
appointment: Appointment | undefined,
appointmentId: AppointmentId,
): Result<Appointment, AppointmentNotFound> =>
appointment === undefined
? err({ kind: "AppointmentNotFound", appointmentId })
: ok(appointment);
export const ensureCheckedIn = (
appointment: Appointment,
): Result<CheckedIn, InvalidAppointmentState> =>
appointment.kind === "CheckedIn"
? ok(appointment)
: err({ kind: "InvalidAppointmentState", actual: appointment.kind }); examples/session-07/src/useCase/startExamination.ts
import { ResultAsync, type Result } from "neverthrow";
import type { EventContext } from "../domain/aggregate/eventContext.js";
import type { Appointment as AppointmentState, InExamination } from "../domain/appointment/index.js";
import type { ExaminationStarted } from "../domain/appointment/index.js";
import { Appointment, startExamination as transitionToInExamination } from "../domain/appointment/index.js";
import type { AppointmentId } from "../domain/appointment/index.js";
import type { VeterinarianId } from "../domain/appointment/index.js";
import type {
Dependencies,
EffectsDependencies,
EventContextDependencies,
} from "./dependencies.js";
import {
ensureAppointmentFound,
ensureCheckedIn,
type StartExaminationError,
type StartExaminationWithEffectsError,
} from "./errors.js";
export type StartExaminationInput = Readonly<{
appointmentId: AppointmentId;
veterinarianId: VeterinarianId;
examinationStartedAt: string;
}>;
export type StartExaminationWithEffectsInput = Omit<
StartExaminationInput,
"examinationStartedAt"
>;
export const startExamination =
(deps: Dependencies) =>
(input: StartExaminationInput): Result<InExamination, StartExaminationError> =>
ensureAppointmentFound(
deps.resolver.resolveById(input.appointmentId),
input.appointmentId,
)
.andThen(ensureCheckedIn)
.map((appointment) =>
transitionToInExamination(
appointment,
input.veterinarianId,
input.examinationStartedAt,
),
)
.map((appointment) => {
deps.store.save(appointment);
return appointment;
});
export const createEventContext = (
deps: EventContextDependencies,
): EventContext => ({
eventId: deps.eventIdGenerator.generate(),
occurredAt: deps.clock.now(),
});
export const startExaminationWithEffects =
(deps: EffectsDependencies) =>
(
input: StartExaminationWithEffectsInput,
): ResultAsync<InExamination, StartExaminationWithEffectsError> =>
// ラボ結果到着は別の trigger から始まるため、この診察開始 workflow へ接続しません。
ResultAsync.fromSafePromise<AppointmentState | undefined>(
Promise.resolve().then(() =>
deps.resolver.resolveById(input.appointmentId),
),
)
.andThen((appointment) =>
ensureAppointmentFound(appointment, input.appointmentId),
)
.andThen(ensureCheckedIn)
.map((appointment) =>
Appointment.startExamination(createEventContext(deps))(
appointment,
input.veterinarianId,
),
)
.andThrough((event) => deps.store.store(event))
.map((event) => event.aggregateState); 効果を確認する
pnpm exercise:06 期待結果: 演習テストがすべて成功します。
演習を完了できないとき
各解答欄には、import と後続 step を含む完成ファイルを target ごとに示します。表示されたすべての target file を反映した後、同じ exercise の検査がパスすることを確認します。各 step を個別にパスすることは想定していません。講師のデモへ切り替えた場合でも、相互レビューは実施しましょう。
レビューと持ち帰り
個人で確認する
- `as` によるキャストが入っていないか全文検索して確認する。
- `git diff --stat -- examples/session-06` で今回の snapshot だけを確認する。`git status --short` で想定外の path がないか確認する。
- 型検査では確認できないことを、テストまたは実行時に確認して記録する。
前のセッションの未commit差分は残して構いません。reset、stash、commit は不要です。
残すもの: 一緒に記録する必要がある値、Agentへの依頼文、型検査では確認できず、テストまたは実行時に確認すること
業務へ持ち帰る
自分の業務コードで、今回と同種の問題が起きうる箇所はどこですか。
班内相互レビュー(8分・1〜2名)
- 時刻とイベント ID は実行ごとに一度だけ生成され、同じ `EventContext` に入りますか。
- 状態と監査記録は、1つのイベントとして同じ `store` に渡されますか。
- 業務上の競合だけを `Result` で返し、保存障害は reject のまま伝播しますか。