このドキュメントでは、Effect-TSを用いた関数型DDD、CQRS、イベントソーシングによる履修管理システムの実装方法を説明します。科目の追加・削除は一括操作として実装されています。
- CQRS (Command Query Responsibility Segregation): コマンド(書き込み)とクエリ(読み込み)を分離
- イベントソーシング: 状態変更をイベントとして記録(Update禁止、Insertのみ)
- 関数型DDD: Effect-TSによる型安全で副作用管理された実装
- 履修ライフサイクル管理: 明確な状態遷移とビジネスルールの実装
- 3層データベース構造: Write Model(履修集約 + イベント)とRead Model(投影)の分離
- 一括操作: 科目の追加・削除は常に一括で実行
- RegistrationSession(履修登録セッション): 学期ごとの履修登録を管理
- Enrollment(個別履修): 個々の科目履修を管理
graph TD
A[学生] -->|履修登録開始| B[Registration Session作成]
B -->|科目一括追加| C[Draft状態のSession]
C -->|科目一括削除| C
C -->|科目一括置換| C
C -->|一括提出| D[Submitted状態のSession]
D -->|アドバイザー確認| E{承認判定}
E -->|承認| F[Approved状態のSession]
E -->|却下| G[Rejected状態のSession]
F -->|個別履修開始| H[各Enrollmentが進行]
import { Schema, Effect, Context, Brand, Data, Option } from "effect";
import { StudentId, CourseId, Term, EnrollmentId } from "./enrollment";
// --- 履修登録セッションID ---
export type RegistrationSessionId = string & Brand.Brand<"RegistrationSessionId">;
const RegistrationSessionIdSchema = Schema.String.pipe(
Schema.pattern(/^RS\d{8}$/, {
message: () => "セッションIDは'RS'で始まる10文字である必要があります"
}),
Schema.brand("RegistrationSessionId")
);
export const RegistrationSessionId = Schema.decode(RegistrationSessionIdSchema);
// セッションIDの生成
export const createRegistrationSessionId = (
studentId: StudentId,
term: Term
): RegistrationSessionId => {
// 実際は連番やUUIDを使用
const timestamp = Date.now().toString().slice(-8);
return `RS${timestamp}` as RegistrationSessionId;
};
// --- 履修登録セッション状態 ---
export const RegistrationSessionStatus = Schema.Literal(
"Draft", // 下書き(科目の追加・削除可能)
"Submitted", // 提出済み(承認待ち)
"Approved", // 承認済み
"Rejected" // 却下
);
export type RegistrationSessionStatus = Schema.Schema.Type<typeof RegistrationSessionStatus>;
// --- 科目情報 ---
export interface CourseInfo {
readonly courseId: CourseId;
readonly units: number;
}
// --- 履修登録セッション集約 ---
export class RegistrationSession extends Data.Class<{
readonly id: RegistrationSessionId;
readonly studentId: StudentId;
readonly term: Term;
readonly enrollments: ReadonlyArray<{
enrollmentId: EnrollmentId;
courseId: CourseId;
units: number;
}>;
readonly status: RegistrationSessionStatus;
readonly totalUnits: number;
readonly version: number;
}> {}
// --- ドメインイベント(セッション関連) ---
export class RegistrationSessionCreated extends Data.TaggedClass("RegistrationSessionCreated")<{
readonly sessionId: RegistrationSessionId;
readonly studentId: StudentId;
readonly term: Term;
readonly createdAt: Date;
}> {}
export class CoursesAddedToSession extends Data.TaggedClass("CoursesAddedToSession")<{
readonly sessionId: RegistrationSessionId;
readonly courses: ReadonlyArray<{
enrollmentId: EnrollmentId;
courseId: CourseId;
units: number;
}>;
readonly totalUnitsAfter: number;
readonly addedAt: Date;
}> {}
export class CoursesRemovedFromSession extends Data.TaggedClass("CoursesRemovedFromSession")<{
readonly sessionId: RegistrationSessionId;
readonly enrollmentIds: ReadonlyArray<EnrollmentId>;
readonly totalUnitsAfter: number;
readonly removedAt: Date;
}> {}
export class SessionCoursesReplaced extends Data.TaggedClass("SessionCoursesReplaced")<{
readonly sessionId: RegistrationSessionId;
readonly newCourses: ReadonlyArray<{
enrollmentId: EnrollmentId;
courseId: CourseId;
units: number;
}>;
readonly totalUnits: number;
readonly replacedAt: Date;
}> {}
export class RegistrationSessionSubmitted extends Data.TaggedClass("RegistrationSessionSubmitted")<{
readonly sessionId: RegistrationSessionId;
readonly enrollmentIds: ReadonlyArray<EnrollmentId>;
readonly totalUnits: number;
readonly submittedAt: Date;
}> {}
export class RegistrationSessionApproved extends Data.TaggedClass("RegistrationSessionApproved")<{
readonly sessionId: RegistrationSessionId;
readonly approvedBy: string;
readonly approvedAt: Date;
}> {}
export class RegistrationSessionRejected extends Data.TaggedClass("RegistrationSessionRejected")<{
readonly sessionId: RegistrationSessionId;
readonly rejectedBy: string;
readonly reason: string;
readonly rejectedAt: Date;
}> {}
export type RegistrationSessionEvent =
| RegistrationSessionCreated
| CoursesAddedToSession
| CoursesRemovedFromSession
| SessionCoursesReplaced
| RegistrationSessionSubmitted
| RegistrationSessionApproved
| RegistrationSessionRejected;
// --- ドメインエラー(セッション関連) ---
export class InvalidSessionState extends Data.TaggedError("InvalidSessionState")<{
readonly sessionId: RegistrationSessionId;
readonly currentState: RegistrationSessionStatus;
readonly attemptedAction: string;
}> {}
export class MaxUnitsExceeded extends Data.TaggedError("MaxUnitsExceeded")<{
readonly currentUnits: number;
readonly requestedUnits: number;
readonly maxUnits: number;
}> {}
export class DuplicateCourseInSession extends Data.TaggedError("DuplicateCourseInSession")<{
readonly sessionId: RegistrationSessionId;
readonly courseIds: ReadonlyArray<CourseId>;
}> {}
export class SessionNotFound extends Data.TaggedError("SessionNotFound")<{
readonly sessionId: RegistrationSessionId;
}> {}
export class MinUnitsNotMet extends Data.TaggedError("MinUnitsNotMet")<{
readonly currentUnits: number;
readonly minUnits: number;
}> {}
// --- ビジネスルール定数 ---
export const MAX_UNITS_PER_TERM = 20;
export const MIN_UNITS_PER_TERM = 12;
// --- ドメインロジック(一括操作) ---
// 科目の一括追加
export const addCoursesToSession = (
session: RegistrationSession,
courses: ReadonlyArray<CourseInfo>
): Effect.Effect<readonly [RegistrationSession, ReadonlyArray<RegistrationSessionEvent>], InvalidSessionState | MaxUnitsExceeded | DuplicateCourseInSession> => {
if (session.status !== "Draft") {
return Effect.fail(new InvalidSessionState({
sessionId: session.id,
currentState: session.status,
attemptedAction: "addCourses"
}));
}
// 重複チェック
const existingCourseIds = new Set(session.enrollments.map(e => e.courseId));
const duplicates = courses.filter(c => existingCourseIds.has(c.courseId));
if (duplicates.length > 0) {
return Effect.fail(new DuplicateCourseInSession({
sessionId: session.id,
courseIds: duplicates.map(d => d.courseId)
}));
}
// 単位数チェック
const additionalUnits = courses.reduce((sum, c) => sum + c.units, 0);
const newTotalUnits = session.totalUnits + additionalUnits;
if (newTotalUnits > MAX_UNITS_PER_TERM) {
return Effect.fail(new MaxUnitsExceeded({
currentUnits: session.totalUnits,
requestedUnits: additionalUnits,
maxUnits: MAX_UNITS_PER_TERM
}));
}
// 新しい履修エントリの作成
const newEnrollments = courses.map(course => ({
enrollmentId: createEnrollmentId(session.studentId, course.courseId, session.term),
courseId: course.courseId,
units: course.units
}));
const updatedSession = new RegistrationSession({
...session,
enrollments: [...session.enrollments, ...newEnrollments],
totalUnits: newTotalUnits,
version: session.version + 1
});
const event = new CoursesAddedToSession({
sessionId: session.id,
courses: newEnrollments,
totalUnitsAfter: newTotalUnits,
addedAt: new Date()
});
return Effect.succeed([updatedSession, [event]]);
};
// 科目の一括削除
export const removeCoursesFromSession = (
session: RegistrationSession,
enrollmentIds: ReadonlyArray<EnrollmentId>
): Effect.Effect<readonly [RegistrationSession, ReadonlyArray<RegistrationSessionEvent>], InvalidSessionState> => {
if (session.status !== "Draft") {
return Effect.fail(new InvalidSessionState({
sessionId: session.id,
currentState: session.status,
attemptedAction: "removeCourses"
}));
}
const idsToRemove = new Set(enrollmentIds);
const remainingEnrollments = session.enrollments.filter(e => !idsToRemove.has(e.enrollmentId));
const removedEnrollments = session.enrollments.filter(e => idsToRemove.has(e.enrollmentId));
const removedUnits = removedEnrollments.reduce((sum, e) => sum + e.units, 0);
const newTotalUnits = session.totalUnits - removedUnits;
const updatedSession = new RegistrationSession({
...session,
enrollments: remainingEnrollments,
totalUnits: newTotalUnits,
version: session.version + 1
});
const event = new CoursesRemovedFromSession({
sessionId: session.id,
enrollmentIds,
totalUnitsAfter: newTotalUnits,
removedAt: new Date()
});
return Effect.succeed([updatedSession, [event]]);
};
// 科目の一括置換(全削除して新規追加)
export const replaceAllCoursesInSession = (
session: RegistrationSession,
newCourses: ReadonlyArray<CourseInfo>
): Effect.Effect<readonly [RegistrationSession, ReadonlyArray<RegistrationSessionEvent>], InvalidSessionState | MaxUnitsExceeded> => {
if (session.status !== "Draft") {
return Effect.fail(new InvalidSessionState({
sessionId: session.id,
currentState: session.status,
attemptedAction: "replaceAllCourses"
}));
}
// 単位数チェック
const newTotalUnits = newCourses.reduce((sum, c) => sum + c.units, 0);
if (newTotalUnits > MAX_UNITS_PER_TERM) {
return Effect.fail(new MaxUnitsExceeded({
currentUnits: 0,
requestedUnits: newTotalUnits,
maxUnits: MAX_UNITS_PER_TERM
}));
}
// 新しい履修エントリの作成
const newEnrollments = newCourses.map(course => ({
enrollmentId: createEnrollmentId(session.studentId, course.courseId, session.term),
courseId: course.courseId,
units: course.units
}));
const updatedSession = new RegistrationSession({
...session,
enrollments: newEnrollments,
totalUnits: newTotalUnits,
version: session.version + 1
});
const event = new SessionCoursesReplaced({
sessionId: session.id,
newCourses: newEnrollments,
totalUnits: newTotalUnits,
replacedAt: new Date()
});
return Effect.succeed([updatedSession, [event]]);
};
// セッションの提出
export const submitSession = (
session: RegistrationSession
): Effect.Effect<readonly [RegistrationSession, ReadonlyArray<RegistrationSessionEvent>], InvalidSessionState | MinUnitsNotMet> => {
if (session.status !== "Draft") {
return Effect.fail(new InvalidSessionState({
sessionId: session.id,
currentState: session.status,
attemptedAction: "submit"
}));
}
// 最小単位数チェック
if (session.totalUnits < MIN_UNITS_PER_TERM) {
return Effect.fail(new MinUnitsNotMet({
currentUnits: session.totalUnits,
minUnits: MIN_UNITS_PER_TERM
}));
}
const updatedSession = new RegistrationSession({
...session,
status: "Submitted",
version: session.version + 1
});
const event = new RegistrationSessionSubmitted({
sessionId: session.id,
enrollmentIds: session.enrollments.map(e => e.enrollmentId),
totalUnits: session.totalUnits,
submittedAt: new Date()
});
return Effect.succeed([updatedSession, [event]]);
};
// --- リポジトリインターフェース ---
export interface RegistrationSessionRepository {
readonly findById: (id: RegistrationSessionId) => Effect.Effect<Option.Option<RegistrationSession>, never>;
readonly findByStudentAndTerm: (studentId: StudentId, term: Term) => Effect.Effect<Option.Option<RegistrationSession>, never>;
}
export const RegistrationSessionRepository = Context.Tag<RegistrationSessionRepository>("@app/RegistrationSessionRepository");import { Schema, Effect, Context, Brand, Data, Option } from "effect";
// --- 値オブジェクト ---
export type StudentId = string & Brand.Brand<"StudentId">;
const StudentIdSchema = Schema.String.pipe(
Schema.pattern(/^S\d{8}$/, { message: () => "学生IDは'S'で始まる9文字である必要があります" }),
Schema.brand("StudentId")
);
export const StudentId = Schema.decode(StudentIdSchema);
export type CourseId = string & Brand.Brand<"CourseId">;
const CourseIdSchema = Schema.String.pipe(
Schema.pattern(/^C\d{6}$/, { message: () => "コースIDは'C'で始まる7文字である必要があります" }),
Schema.brand("CourseId")
);
export const CourseId = Schema.decode(CourseIdSchema);
export type Term = string & Brand.Brand<"Term">;
const TermSchema = Schema.String.pipe(
Schema.pattern(/^\d{4}-(Spring|Fall|Summer)$/, { message: () => "学期は'YYYY-Spring/Fall/Summer'形式である必要があります" }),
Schema.brand("Term")
);
export const Term = Schema.decode(TermSchema);
// 複合IDとしてのEnrollmentId(StudentId:CourseId:Term)
export type EnrollmentId = string & Brand.Brand<"EnrollmentId">;
export const createEnrollmentId = (
studentId: StudentId,
courseId: CourseId,
term: Term
): EnrollmentId => {
return `${studentId}:${courseId}:${term}` as EnrollmentId;
};
export const parseEnrollmentId = (
enrollmentId: EnrollmentId
): { studentId: StudentId; courseId: CourseId; term: Term } => {
const [studentId, courseId, term] = enrollmentId.split(':');
return {
studentId: studentId as StudentId,
courseId: courseId as CourseId,
term: term as Term
};
};
// 成績の定義
export const Grade = Schema.Literal("A", "B", "C", "D", "F", "W", "I", "P");
export type Grade = Schema.Schema.Type<typeof Grade>;
// --- 履修状態(簡略化) ---
export const EnrollmentStatus = Schema.Literal(
"Requested", // リクエスト済み(セッション内)
"Approved", // 承認済み
"InProgress", // 履修中
"Completed", // 完了
"Cancelled", // キャンセル
"Withdrawn" // 離脱
);
export type EnrollmentStatus = Schema.Schema.Type<typeof EnrollmentStatus>;
// --- 履修エンティティ ---
export class Enrollment extends Data.Class<{
readonly id: EnrollmentId;
readonly sessionId: RegistrationSessionId; // 所属するセッション
readonly studentId: StudentId;
readonly courseId: CourseId;
readonly term: Term;
readonly status: EnrollmentStatus;
readonly grade: Option.Option<Grade>;
readonly version: number;
}> {}
// --- ドメインイベント(履修関連) ---
export class EnrollmentsRequestedBatch extends Data.TaggedClass("EnrollmentsRequestedBatch")<{
readonly sessionId: RegistrationSessionId;
readonly enrollments: ReadonlyArray<{
enrollmentId: EnrollmentId;
studentId: StudentId;
courseId: CourseId;
term: Term;
}>;
readonly requestedAt: Date;
}> {}
export class EnrollmentsCancelledBatch extends Data.TaggedClass("EnrollmentsCancelledBatch")<{
readonly sessionId: RegistrationSessionId;
readonly enrollmentIds: ReadonlyArray<EnrollmentId>;
readonly reason: string;
readonly cancelledAt: Date;
}> {}
export class EnrollmentsApprovedBatch extends Data.TaggedClass("EnrollmentsApprovedBatch")<{
readonly sessionId: RegistrationSessionId;
readonly enrollmentIds: ReadonlyArray<EnrollmentId>;
readonly approvedAt: Date;
}> {}
// 個別の履修進行イベント(これらは個別に発生)
export class EnrollmentStarted extends Data.TaggedClass("EnrollmentStarted")<{
readonly enrollmentId: EnrollmentId;
readonly startedAt: Date;
}> {}
export class EnrollmentCompleted extends Data.TaggedClass("EnrollmentCompleted")<{
readonly enrollmentId: EnrollmentId;
readonly grade: Grade;
readonly completedAt: Date;
}> {}
export class EnrollmentWithdrawn extends Data.TaggedClass("EnrollmentWithdrawn")<{
readonly enrollmentId: EnrollmentId;
readonly withdrawnAt: Date;
}> {}
export type EnrollmentEvent =
| EnrollmentsRequestedBatch
| EnrollmentsCancelledBatch
| EnrollmentsApprovedBatch
| EnrollmentStarted
| EnrollmentCompleted
| EnrollmentWithdrawn;
// すべてのドメインイベント
export type DomainEvent = RegistrationSessionEvent | EnrollmentEvent;
// --- ドメインエラー ---
export class InvalidEnrollmentTransition extends Data.TaggedError("InvalidEnrollmentTransition")<{
readonly enrollmentId: EnrollmentId;
readonly currentState: EnrollmentStatus;
readonly attemptedTransition: string;
}> {}
export class EnrollmentNotFound extends Data.TaggedError("EnrollmentNotFound")<{
readonly enrollmentId: EnrollmentId;
}> {}
export type DomainError =
| InvalidSessionState
| MaxUnitsExceeded
| MinUnitsNotMet
| DuplicateCourseInSession
| SessionNotFound
| InvalidEnrollmentTransition
| EnrollmentNotFound;
// --- リポジトリインターフェース ---
export interface EnrollmentRepository {
readonly findById: (id: EnrollmentId) => Effect.Effect<Option.Option<Enrollment>, never>;
readonly findBySessionId: (sessionId: RegistrationSessionId) => Effect.Effect<ReadonlyArray<Enrollment>, never>;
readonly findByStudentAndTerm: (studentId: StudentId, term: Term) => Effect.Effect<ReadonlyArray<Enrollment>, never>;
}
export const EnrollmentRepository = Context.Tag<EnrollmentRepository>("@app/EnrollmentRepository");
// --- イベントストア ---
export class EventStoreError extends Data.TaggedError("EventStoreError")<{
readonly cause: unknown;
}> {}
export interface EventStore {
// セッションの作成
readonly createSession: (
sessionId: RegistrationSessionId,
studentId: StudentId,
term: Term,
events: ReadonlyArray<DomainEvent>
) => Effect.Effect<void, EventStoreError>;
// 履修の一括作成(セッションに紐付け)
readonly createEnrollmentsBatch: (
sessionId: RegistrationSessionId,
enrollments: ReadonlyArray<{
enrollmentId: EnrollmentId;
studentId: StudentId;
courseId: CourseId;
term: Term;
}>,
events: ReadonlyArray<DomainEvent>
) => Effect.Effect<void, EventStoreError>;
// 既存エンティティへのイベント追加
readonly appendEvents: (
events: ReadonlyArray<DomainEvent>
) => Effect.Effect<void, EventStoreError>;
// イベントの取得
readonly getSessionEvents: (sessionId: RegistrationSessionId) => Effect.Effect<ReadonlyArray<DomainEvent>, EventStoreError>;
readonly getEnrollmentEvents: (enrollmentId: EnrollmentId) => Effect.Effect<ReadonlyArray<DomainEvent>, EventStoreError>;
}
export const EventStore = Context.Tag<EventStore>("@app/EventStore");
// --- イベント投影 ---
export interface EventProjector {
readonly project: (events: ReadonlyArray<DomainEvent>) => Effect.Effect<void, never>;
}
export const EventProjector = Context.Tag<EventProjector>("@app/EventProjector");
// --- イベントバス ---
export interface EventBus {
readonly publish: (events: ReadonlyArray<DomainEvent>) => Effect.Effect<void, never>;
readonly subscribe: (handler: (event: DomainEvent) => Effect.Effect<void, never>) => Effect.Effect<void, never>;
}
export const EventBus = Context.Tag<EventBus>("@app/EventBus");import { Effect, Option } from "effect";
import * as Domain from "../domain";
type CommandError = Domain.DomainError | Domain.EventStoreError;
// --- 履修登録セッションコマンド ---
// セッションの作成
export const createRegistrationSessionCommand = (
studentId: Domain.StudentId,
term: Domain.Term
): Effect.Effect<Domain.RegistrationSessionId, CommandError, Domain.EventStore | Domain.RegistrationSessionRepository> =>
Effect.gen(function* () {
const eventStore = yield* Domain.EventStore;
const repo = yield* Domain.RegistrationSessionRepository;
// 既存セッションのチェック
const existingSession = yield* repo.findByStudentAndTerm(studentId, term);
if (Option.isSome(existingSession)) {
return existingSession.value.id; // 既存のセッションを返す
}
const sessionId = Domain.createRegistrationSessionId(studentId, term);
const event = new Domain.RegistrationSessionCreated({
sessionId,
studentId,
term,
createdAt: new Date()
});
yield* eventStore.createSession(sessionId, studentId, term, [event]);
return sessionId;
});
// セッションに科目を一括追加
export const addCoursesToSessionCommand = (
sessionId: Domain.RegistrationSessionId,
courses: ReadonlyArray<Domain.CourseInfo>
): Effect.Effect<ReadonlyArray<Domain.EnrollmentId>, CommandError, Domain.EventStore | Domain.RegistrationSessionRepository> =>
Effect.gen(function* () {
const eventStore = yield* Domain.EventStore;
const repo = yield* Domain.RegistrationSessionRepository;
// セッションの取得
const sessionOption = yield* repo.findById(sessionId);
const session = yield* Effect.fromOption(sessionOption, () =>
new Domain.SessionNotFound({ sessionId })
);
// ドメインロジックの実行
const [updatedSession, sessionEvents] = yield* Domain.addCoursesToSession(session, courses);
// 履修エンティティの作成情報
const enrollmentData = courses.map(course => ({
enrollmentId: Domain.createEnrollmentId(session.studentId, course.courseId, session.term),
studentId: session.studentId,
courseId: course.courseId,
term: session.term
}));
// 履修作成イベント
const enrollmentEvent = new Domain.EnrollmentsRequestedBatch({
sessionId,
enrollments: enrollmentData,
requestedAt: new Date()
});
// 履修エンティティとイベントの保存
yield* eventStore.createEnrollmentsBatch(
sessionId,
enrollmentData,
[...sessionEvents, enrollmentEvent]
);
return enrollmentData.map(e => e.enrollmentId);
});
// セッションから科目を一括削除
export const removeCoursesFromSessionCommand = (
sessionId: Domain.RegistrationSessionId,
enrollmentIds: ReadonlyArray<Domain.EnrollmentId>
): Effect.Effect<void, CommandError, Domain.EventStore | Domain.RegistrationSessionRepository> =>
Effect.gen(function* () {
const eventStore = yield* Domain.EventStore;
const repo = yield* Domain.RegistrationSessionRepository;
const sessionOption = yield* repo.findById(sessionId);
const session = yield* Effect.fromOption(sessionOption, () =>
new Domain.SessionNotFound({ sessionId })
);
const [updatedSession, sessionEvents] = yield* Domain.removeCoursesFromSession(session, enrollmentIds);
const cancelledEvent = new Domain.EnrollmentsCancelledBatch({
sessionId,
enrollmentIds,
reason: "Removed from registration session",
cancelledAt: new Date()
});
yield* eventStore.appendEvents([...sessionEvents, cancelledEvent]);
});
// セッションの科目を全置換
export const replaceAllCoursesInSessionCommand = (
sessionId: Domain.RegistrationSessionId,
newCourses: ReadonlyArray<Domain.CourseInfo>
): Effect.Effect<ReadonlyArray<Domain.EnrollmentId>, CommandError, Domain.EventStore | Domain.RegistrationSessionRepository | Domain.EnrollmentRepository> =>
Effect.gen(function* () {
const eventStore = yield* Domain.EventStore;
const sessionRepo = yield* Domain.RegistrationSessionRepository;
const enrollmentRepo = yield* Domain.EnrollmentRepository;
const sessionOption = yield* sessionRepo.findById(sessionId);
const session = yield* Effect.fromOption(sessionOption, () =>
new Domain.SessionNotFound({ sessionId })
);
// 既存の履修を取得
const existingEnrollments = yield* enrollmentRepo.findBySessionId(sessionId);
const existingEnrollmentIds = existingEnrollments.map(e => e.id);
// ドメインロジックの実行
const [updatedSession, sessionEvents] = yield* Domain.replaceAllCoursesInSession(session, newCourses);
// 新しい履修エンティティの作成情報
const enrollmentData = newCourses.map(course => ({
enrollmentId: Domain.createEnrollmentId(session.studentId, course.courseId, session.term),
studentId: session.studentId,
courseId: course.courseId,
term: session.term
}));
// 既存履修のキャンセルイベント(もしあれば)
const events: Domain.DomainEvent[] = [...sessionEvents];
if (existingEnrollmentIds.length > 0) {
events.push(new Domain.EnrollmentsCancelledBatch({
sessionId,
enrollmentIds: existingEnrollmentIds,
reason: "Replaced by new course selection",
cancelledAt: new Date()
}));
}
// 新規履修の作成イベント
events.push(new Domain.EnrollmentsRequestedBatch({
sessionId,
enrollments: enrollmentData,
requestedAt: new Date()
}));
// 履修エンティティとイベントの保存
yield* eventStore.createEnrollmentsBatch(
sessionId,
enrollmentData,
events
);
return enrollmentData.map(e => e.enrollmentId);
});
// セッションの一括提出
export const submitRegistrationSessionCommand = (
sessionId: Domain.RegistrationSessionId
): Effect.Effect<void, CommandError, Domain.EventStore | Domain.RegistrationSessionRepository> =>
Effect.gen(function* () {
const eventStore = yield* Domain.EventStore;
const repo = yield* Domain.RegistrationSessionRepository;
const sessionOption = yield* repo.findById(sessionId);
const session = yield* Effect.fromOption(sessionOption, () =>
new Domain.SessionNotFound({ sessionId })
);
const [updatedSession, events] = yield* Domain.submitSession(session);
yield* eventStore.appendEvents(events);
});
// セッションの承認(アドバイザー)
export const approveRegistrationSessionCommand = (
sessionId: Domain.RegistrationSessionId,
approvedBy: string
): Effect.Effect<void, CommandError, Domain.EventStore | Domain.RegistrationSessionRepository | Domain.EnrollmentRepository> =>
Effect.gen(function* () {
const eventStore = yield* Domain.EventStore;
const sessionRepo = yield* Domain.RegistrationSessionRepository;
const enrollmentRepo = yield* Domain.EnrollmentRepository;
const sessionOption = yield* sessionRepo.findById(sessionId);
const session = yield* Effect.fromOption(sessionOption, () =>
new Domain.SessionNotFound({ sessionId })
);
if (session.status !== "Submitted") {
return yield* Effect.fail(new Domain.InvalidSessionState({
sessionId: session.id,
currentState: session.status,
attemptedAction: "approve"
}));
}
// セッション承認イベント
const sessionApprovedEvent = new Domain.RegistrationSessionApproved({
sessionId,
approvedBy,
approvedAt: new Date()
});
// 履修の一括承認イベント
const enrollments = yield* enrollmentRepo.findBySessionId(sessionId);
const enrollmentApprovedEvent = new Domain.EnrollmentsApprovedBatch({
sessionId,
enrollmentIds: enrollments.map(e => e.id),
approvedAt: new Date()
});
yield* eventStore.appendEvents([sessionApprovedEvent, enrollmentApprovedEvent]);
});
// セッションの却下(アドバイザー)
export const rejectRegistrationSessionCommand = (
sessionId: Domain.RegistrationSessionId,
rejectedBy: string,
reason: string
): Effect.Effect<void, CommandError, Domain.EventStore | Domain.RegistrationSessionRepository | Domain.EnrollmentRepository> =>
Effect.gen(function* () {
const eventStore = yield* Domain.EventStore;
const sessionRepo = yield* Domain.RegistrationSessionRepository;
const enrollmentRepo = yield* Domain.EnrollmentRepository;
const sessionOption = yield* sessionRepo.findById(sessionId);
const session = yield* Effect.fromOption(sessionOption, () =>
new Domain.SessionNotFound({ sessionId })
);
if (session.status !== "Submitted") {
return yield* Effect.fail(new Domain.InvalidSessionState({
sessionId: session.id,
currentState: session.status,
attemptedAction: "reject"
}));
}
// セッション却下イベント
const sessionRejectedEvent = new Domain.RegistrationSessionRejected({
sessionId,
rejectedBy,
reason,
rejectedAt: new Date()
});
// 履修の一括キャンセルイベント
const enrollments = yield* enrollmentRepo.findBySessionId(sessionId);
const enrollmentCancelledEvent = new Domain.EnrollmentsCancelledBatch({
sessionId,
enrollmentIds: enrollments.map(e => e.id),
reason: `Session rejected: ${reason}`,
cancelledAt: new Date()
});
yield* eventStore.appendEvents([sessionRejectedEvent, enrollmentCancelledEvent]);
});
// --- 個別履修コマンド(セッション承認後の操作) ---
// 履修開始(学期開始時)
export const startEnrollmentCommand = (
enrollmentId: Domain.EnrollmentId
): Effect.Effect<void, CommandError, Domain.EventStore | Domain.EnrollmentRepository> =>
Effect.gen(function* () {
const eventStore = yield* Domain.EventStore;
const repo = yield* Domain.EnrollmentRepository;
const enrollmentOption = yield* repo.findById(enrollmentId);
const enrollment = yield* Effect.fromOption(enrollmentOption, () =>
new Domain.EnrollmentNotFound({ enrollmentId })
);
if (enrollment.status !== "Approved") {
return yield* Effect.fail(new Domain.InvalidEnrollmentTransition({
enrollmentId,
currentState: enrollment.status,
attemptedTransition: "start"
}));
}
const event = new Domain.EnrollmentStarted({
enrollmentId,
startedAt: new Date()
});
yield* eventStore.appendEvents([event]);
});
// 履修完了(成績付与)
export const completeEnrollmentCommand = (
enrollmentId: Domain.EnrollmentId,
grade: Domain.Grade
): Effect.Effect<void, CommandError, Domain.EventStore | Domain.EnrollmentRepository> =>
Effect.gen(function* () {
const eventStore = yield* Domain.EventStore;
const repo = yield* Domain.EnrollmentRepository;
const enrollmentOption = yield* repo.findById(enrollmentId);
const enrollment = yield* Effect.fromOption(enrollmentOption, () =>
new Domain.EnrollmentNotFound({ enrollmentId })
);
if (enrollment.status !== "InProgress") {
return yield* Effect.fail(new Domain.InvalidEnrollmentTransition({
enrollmentId,
currentState: enrollment.status,
attemptedTransition: "complete"
}));
}
const event = new Domain.EnrollmentCompleted({
enrollmentId,
grade,
completedAt: new Date()
});
yield* eventStore.appendEvents([event]);
});
// 履修離脱
export const withdrawFromEnrollmentCommand = (
enrollmentId: Domain.EnrollmentId
): Effect.Effect<void, CommandError, Domain.EventStore | Domain.EnrollmentRepository> =>
Effect.gen(function* () {
const eventStore = yield* Domain.EventStore;
const repo = yield* Domain.EnrollmentRepository;
const enrollmentOption = yield* repo.findById(enrollmentId);
const enrollment = yield* Effect.fromOption(enrollmentOption, () =>
new Domain.EnrollmentNotFound({ enrollmentId })
);
if (enrollment.status !== "InProgress") {
return yield* Effect.fail(new Domain.InvalidEnrollmentTransition({
enrollmentId,
currentState: enrollment.status,
attemptedTransition: "withdraw"
}));
}
const event = new Domain.EnrollmentWithdrawn({
enrollmentId,
withdrawnAt: new Date()
});
yield* eventStore.appendEvents([event]);
});import { Effect, Layer, Exit } from "effect";
import * as Domain from "../domain";
// PostgreSQL実装
export const PostgreSQLEventStore = Layer.effect(
Domain.EventStore,
Effect.gen(function* () {
const eventBus = yield* Domain.EventBus;
// バージョン管理用の内部関数
const getNextVersion = (tx: any, aggregateId: string, aggregateType: string) =>
Effect.gen(function* () {
const result = yield* Effect.promise(() =>
tx.query(`
SELECT COALESCE(MAX(version), 0) as current_version
FROM domain_events
WHERE aggregate_id = $1 AND aggregate_type = $2
`, [aggregateId, aggregateType])
);
return result.rows[0].current_version + 1;
});
return {
createSession: (sessionId, studentId, term, events) =>
Effect.gen(function* () {
yield* Effect.acquireUseRelease(
Effect.promise(() => db.begin()),
(tx) => Effect.gen(function* () {
// 1. セッション集約を作成
yield* Effect.promise(() =>
tx.query(`
INSERT INTO registration_sessions (session_id, student_id, term)
VALUES ($1, $2, $3)
`, [sessionId, studentId, term])
);
// 2. イベントを保存
for (const event of events) {
const version = yield* getNextVersion(tx, sessionId, 'RegistrationSession');
yield* Effect.promise(() =>
tx.query(`
INSERT INTO domain_events
(aggregate_id, aggregate_type, version, event_type, event_data, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, [sessionId, 'RegistrationSession', version, event._tag, event, new Date()])
);
}
// 3. イベントバスに通知
yield* eventBus.publish(events);
}),
(tx, exit) => Exit.isSuccess(exit)
? Effect.promise(() => tx.commit())
: Effect.promise(() => tx.rollback())
);
}).pipe(
Effect.catchAll(error =>
Effect.fail(new Domain.EventStoreError({ cause: error }))
)
),
createEnrollmentsBatch: (sessionId, enrollments, events) =>
Effect.gen(function* () {
yield* Effect.acquireUseRelease(
Effect.promise(() => db.begin()),
(tx) => Effect.gen(function* () {
// 1. 履修集約を一括作成
for (const enrollment of enrollments) {
yield* Effect.promise(() =>
tx.query(`
INSERT INTO enrollments
(enrollment_id, session_id, student_id, course_id, term)
VALUES ($1, $2, $3, $4, $5)
`, [enrollment.enrollmentId, sessionId, enrollment.studentId,
enrollment.courseId, enrollment.term])
);
}
// 2. イベントを保存
for (const event of events) {
let aggregateId: string;
let aggregateType: string;
// イベントタイプから集約を判定
if ('sessionId' in event && !('enrollmentId' in event)) {
aggregateId = event.sessionId;
aggregateType = 'RegistrationSession';
} else if ('enrollmentId' in event) {
aggregateId = event.enrollmentId;
aggregateType = 'Enrollment';
} else if ('enrollmentIds' in event) {
// バッチイベントはセッションレベルで記録
aggregateId = event.sessionId;
aggregateType = 'RegistrationSession';
} else {
throw new Error(`Unknown event type: ${event._tag}`);
}
const version = yield* getNextVersion(tx, aggregateId, aggregateType);
yield* Effect.promise(() =>
tx.query(`
INSERT INTO domain_events
(aggregate_id, aggregate_type, version, event_type, event_data, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, [aggregateId, aggregateType, version, event._tag, event, new Date()])
);
}
// 3. イベントバスに通知
yield* eventBus.publish(events);
}),
(tx, exit) => Exit.isSuccess(exit)
? Effect.promise(() => tx.commit())
: Effect.promise(() => tx.rollback())
);
}).pipe(
Effect.catchAll(error =>
Effect.fail(new Domain.EventStoreError({ cause: error }))
)
),
appendEvents: (events) =>
Effect.gen(function* () {
yield* Effect.acquireUseRelease(
Effect.promise(() => db.begin()),
(tx) => Effect.gen(function* () {
for (const event of events) {
// イベントタイプから集約を判定
let aggregateId: string;
let aggregateType: string;
if ('sessionId' in event && !('enrollmentId' in event) && !('enrollmentIds' in event)) {
aggregateId = event.sessionId;
aggregateType = 'RegistrationSession';
} else if ('enrollmentId' in event) {
aggregateId = event.enrollmentId;
aggregateType = 'Enrollment';
} else if ('enrollmentIds' in event) {
// バッチイベントはセッションレベルで記録
aggregateId = event.sessionId;
aggregateType = 'RegistrationSession';
} else {
throw new Error(`Unknown event type: ${event._tag}`);
}
const version = yield* getNextVersion(tx, aggregateId, aggregateType);
yield* Effect.promise(() =>
tx.query(`
INSERT INTO domain_events
(aggregate_id, aggregate_type, version, event_type, event_data, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, [aggregateId, aggregateType, version, event._tag, event, new Date()])
);
}
yield* eventBus.publish(events);
}),
(tx, exit) => Exit.isSuccess(exit)
? Effect.promise(() => tx.commit())
: Effect.promise(() => tx.rollback())
);
}).pipe(
Effect.catchAll(error =>
Effect.fail(new Domain.EventStoreError({ cause: error }))
)
),
getSessionEvents: (sessionId) =>
Effect.gen(function* () {
const result = yield* Effect.promise(() =>
db.query(`
SELECT event_data
FROM domain_events
WHERE aggregate_id = $1 AND aggregate_type = 'RegistrationSession'
ORDER BY version
`, [sessionId])
);
return result.rows.map(row => row.event_data as Domain.DomainEvent);
}).pipe(
Effect.catchAll(error =>
Effect.fail(new Domain.EventStoreError({ cause: error }))
)
),
getEnrollmentEvents: (enrollmentId) =>
Effect.gen(function* () {
const result = yield* Effect.promise(() =>
db.query(`
SELECT event_data
FROM domain_events
WHERE aggregate_id = $1 AND aggregate_type = 'Enrollment'
ORDER BY version
`, [enrollmentId])
);
return result.rows.map(row => row.event_data as Domain.DomainEvent);
}).pipe(
Effect.catchAll(error =>
Effect.fail(new Domain.EventStoreError({ cause: error }))
)
)
};
})
);import { Effect, Layer, Option } from "effect";
import * as Domain from "../domain";
// PostgreSQL投影実装
export const PostgreSQLEventProjector = Layer.effect(
Domain.EventProjector,
Effect.gen(function* () {
const eventBus = yield* Domain.EventBus;
// イベントバスからのイベントを購読
yield* eventBus.subscribe((event) => projectEvent(event));
const projectEvent = (event: Domain.DomainEvent): Effect.Effect<void> =>
Effect.gen(function* () {
switch (event._tag) {
// --- セッション関連イベント ---
case "RegistrationSessionCreated": {
yield* Effect.promise(() =>
db.query(`
INSERT INTO registration_session_projections
(session_id, student_id, term, status, total_units, enrollment_count,
created_at, last_event_version, projected_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, [
event.sessionId, event.studentId, event.term,
'Draft', 0, 0,
event.createdAt, 1, new Date()
])
);
break;
}
case "CoursesAddedToSession": {
yield* Effect.promise(() =>
db.query(`
UPDATE registration_session_projections
SET total_units = $1,
enrollment_count = enrollment_count + $2,
last_event_version = last_event_version + 1,
projected_at = $3
WHERE session_id = $4
`, [event.totalUnitsAfter, event.courses.length, new Date(), event.sessionId])
);
break;
}
case "CoursesRemovedFromSession": {
yield* Effect.promise(() =>
db.query(`
UPDATE registration_session_projections
SET total_units = $1,
enrollment_count = enrollment_count - $2,
last_event_version = last_event_version + 1,
projected_at = $3
WHERE session_id = $4
`, [event.totalUnitsAfter, event.enrollmentIds.length, new Date(), event.sessionId])
);
break;
}
case "SessionCoursesReplaced": {
yield* Effect.promise(() =>
db.query(`
UPDATE registration_session_projections
SET total_units = $1,
enrollment_count = $2,
last_event_version = last_event_version + 1,
projected_at = $3
WHERE session_id = $4
`, [event.totalUnits, event.newCourses.length, new Date(), event.sessionId])
);
break;
}
case "RegistrationSessionSubmitted": {
yield* Effect.promise(() =>
db.query(`
UPDATE registration_session_projections
SET status = 'Submitted',
submitted_at = $1,
last_event_version = last_event_version + 1,
projected_at = $2
WHERE session_id = $3
`, [event.submittedAt, new Date(), event.sessionId])
);
break;
}
case "RegistrationSessionApproved": {
yield* Effect.promise(() =>
db.query(`
UPDATE registration_session_projections
SET status = 'Approved',
approved_at = $1,
approved_by = $2,
last_event_version = last_event_version + 1,
projected_at = $3
WHERE session_id = $4
`, [event.approvedAt, event.approvedBy, new Date(), event.sessionId])
);
break;
}
case "RegistrationSessionRejected": {
yield* Effect.promise(() =>
db.query(`
UPDATE registration_session_projections
SET status = 'Rejected',
rejected_at = $1,
rejected_by = $2,
rejection_reason = $3,
last_event_version = last_event_version + 1,
projected_at = $4
WHERE session_id = $5
`, [event.rejectedAt, event.rejectedBy, event.reason, new Date(), event.sessionId])
);
break;
}
// --- 履修関連バッチイベント ---
case "EnrollmentsRequestedBatch": {
for (const enrollment of event.enrollments) {
yield* Effect.promise(() =>
db.query(`
INSERT INTO enrollment_projections
(enrollment_id, session_id, student_id, course_id, term,
status, requested_at, last_event_version, projected_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`, [
enrollment.enrollmentId, event.sessionId, enrollment.studentId,
enrollment.courseId, enrollment.term,
'Requested', event.requestedAt, 1, new Date()
])
);
}
break;
}
case "EnrollmentsCancelledBatch": {
yield* Effect.promise(() =>
db.query(`
UPDATE enrollment_projections
SET status = 'Cancelled',
cancellation_reason = $1,
cancelled_at = $2,
last_event_version = last_event_version + 1,
projected_at = $3
WHERE enrollment_id = ANY($4)
`, [event.reason, event.cancelledAt, new Date(), event.enrollmentIds])
);
break;
}
case "EnrollmentsApprovedBatch": {
yield* Effect.promise(() =>
db.query(`
UPDATE enrollment_projections
SET status = 'Approved',
approved_at = $1,
last_event_version = last_event_version + 1,
projected_at = $2
WHERE enrollment_id = ANY($3)
`, [event.approvedAt, new Date(), event.enrollmentIds])
);
break;
}
// --- 個別履修イベント ---
case "EnrollmentStarted": {
yield* Effect.promise(() =>
db.query(`
UPDATE enrollment_projections
SET status = 'InProgress',
started_at = $1,
last_event_version = last_event_version + 1,
projected_at = $2
WHERE enrollment_id = $3
`, [event.startedAt, new Date(), event.enrollmentId])
);
break;
}
case "EnrollmentCompleted": {
yield* Effect.promise(() =>
db.query(`
UPDATE enrollment_projections
SET status = 'Completed',
grade = $1,
completed_at = $2,
last_event_version = last_event_version + 1,
projected_at = $3
WHERE enrollment_id = $4
`, [event.grade, event.completedAt, new Date(), event.enrollmentId])
);
break;
}
case "EnrollmentWithdrawn": {
yield* Effect.promise(() =>
db.query(`
UPDATE enrollment_projections
SET status = 'Withdrawn',
grade = 'W',
withdrawn_at = $1,
last_event_version = last_event_version + 1,
projected_at = $2
WHERE enrollment_id = $3
`, [event.withdrawnAt, new Date(), event.enrollmentId])
);
break;
}
default:
const _exhaustiveCheck: never = event;
return Effect.die(_exhaustiveCheck);
}
});
return {
project: (events) =>
Effect.all(events.map(projectEvent), { concurrency: "unbounded" })
.pipe(Effect.asVoid)
};
})
);
// Read Model用リポジトリ実装
export const PostgreSQLRegistrationSessionRepository = Layer.succeed(
Domain.RegistrationSessionRepository,
{
findById: (id) =>
Effect.gen(function* () {
const result = yield* Effect.promise(() =>
db.query(`
SELECT s.*,
array_agg(
json_build_object(
'enrollmentId', e.enrollment_id,
'courseId', e.course_id,
'units', c.units
)
) FILTER (WHERE e.enrollment_id IS NOT NULL) as enrollments
FROM registration_session_projections s
LEFT JOIN enrollment_projections e ON s.session_id = e.session_id
LEFT JOIN courses c ON e.course_id = c.course_id
WHERE s.session_id = $1
GROUP BY s.session_id
`, [id])
);
if (result.rows.length === 0) {
return Option.none();
}
const row = result.rows[0];
const session = reconstructSessionFromProjection(row);
return Option.some(session);
}),
findByStudentAndTerm: (studentId, term) =>
Effect.gen(function* () {
const result = yield* Effect.promise(() =>
db.query(`
SELECT s.*,
array_agg(
json_build_object(
'enrollmentId', e.enrollment_id,
'courseId', e.course_id,
'units', c.units
)
) FILTER (WHERE e.enrollment_id IS NOT NULL) as enrollments
FROM registration_session_projections s
LEFT JOIN enrollment_projections e ON s.session_id = e.session_id
LEFT JOIN courses c ON e.course_id = c.course_id
WHERE s.student_id = $1 AND s.term = $2
GROUP BY s.session_id
`, [studentId, term])
);
if (result.rows.length === 0) {
return Option.none();
}
const row = result.rows[0];
const session = reconstructSessionFromProjection(row);
return Option.some(session);
})
}
);
// セッション再構築ヘルパー
function reconstructSessionFromProjection(row: any): Domain.RegistrationSession {
return new Domain.RegistrationSession({
id: row.session_id,
studentId: row.student_id,
term: row.term,
enrollments: row.enrollments || [],
status: row.status,
totalUnits: row.total_units,
version: row.last_event_version
});
}import { Effect, Layer, Console } from "effect";
import * as Commands from "./application/commands";
import * as Queries from "./application/queries";
import { PostgreSQLEventStore } from "./infrastructure/event-store";
import {
PostgreSQLEventProjector,
PostgreSQLRegistrationSessionRepository,
PostgreSQLEnrollmentRepository
} from "./infrastructure/projector";
import { InMemoryEventBus } from "./infrastructure/event-bus";
import * as Domain from "./domain";
// アプリケーション層の構築
const AppLayer = Layer.mergeAll(
PostgreSQLEventStore,
PostgreSQLEventProjector,
PostgreSQLRegistrationSessionRepository,
PostgreSQLEnrollmentRepository,
InMemoryEventBus
);
// 履修登録フローのサンプル
const registrationFlow = Effect.gen(function* () {
// 学生情報
const studentId = yield* Domain.StudentId("S20240001");
const term = yield* Domain.Term("2024-Spring");
// 1. 履修登録セッションの作成
yield* Console.log("1. 履修登録セッションを作成");
const sessionId = yield* Commands.createRegistrationSessionCommand(studentId, term);
yield* Console.log(` セッションID: ${sessionId}`);
// 2. 科目を一括追加
yield* Console.log("2. 科目を一括追加");
const courses = [
{ courseId: yield* Domain.CourseId("C000101"), units: 3 },
{ courseId: yield* Domain.CourseId("C000201"), units: 3 },
{ courseId: yield* Domain.CourseId("C000301"), units: 4 }
];
const enrollmentIds = yield* Commands.addCoursesToSessionCommand(sessionId, courses);
yield* Console.log(` 追加された履修数: ${enrollmentIds.length}`);
enrollmentIds.forEach((id, index) => {
yield* Console.log(` 履修${index + 1}: ${id}`);
});
// 少し待機(投影の非同期処理を考慮)
yield* Effect.sleep("100 millis");
// 3. セッション詳細を確認
yield* Console.log("3. セッション詳細を確認");
const sessionDetails = yield* Queries.getSessionDetailsQuery(sessionId);
yield* Console.log(` 合計単位数: ${sessionDetails.pipe(
Option.map(d => d.totalUnits),
Option.getOrElse(() => 0)
)}`);
// 4. 一部科目を削除
yield* Console.log("4. 一部科目を削除");
const toRemove = [enrollmentIds[2]]; // 3つ目の科目を削除
yield* Commands.removeCoursesFromSessionCommand(sessionId, toRemove);
// 5. 新しい科目を追加
yield* Console.log("5. 新しい科目を追加");
const additionalCourses = [
{ courseId: yield* Domain.CourseId("C000401"), units: 2 },
{ courseId: yield* Domain.CourseId("C000501"), units: 3 }
];
yield* Commands.addCoursesToSessionCommand(sessionId, additionalCourses);
// 少し待機
yield* Effect.sleep("100 millis");
// 6. セッションを提出
yield* Console.log("6. 履修登録を提出");
yield* Commands.submitRegistrationSessionCommand(sessionId);
// 少し待機
yield* Effect.sleep("100 millis");
// 7. アドバイザーが承認
yield* Console.log("7. アドバイザーが承認");
yield* Commands.approveRegistrationSessionCommand(sessionId, "[email protected]");
// 少し待機
yield* Effect.sleep("100 millis");
// 8. 承認後の状態を確認
const finalSession = yield* Queries.getRegistrationSessionQuery(sessionId);
yield* Console.log("8. 最終的なセッション状態:", finalSession);
// 9. 学期開始 - 各履修を開始
yield* Console.log("9. 学期開始 - 履修を開始");
const currentEnrollments = yield* Queries.getStudentEnrollmentsQuery(studentId, term);
const activeEnrollmentIds = currentEnrollments
.filter(e => e.status === "Approved")
.map(e => e.id);
for (const enrollmentId of activeEnrollmentIds) {
yield* Commands.startEnrollmentCommand(enrollmentId);
}
// 10. 学期終了 - 成績付与
yield* Console.log("10. 学期終了 - 成績付与");
yield* Commands.completeEnrollmentCommand(activeEnrollmentIds[0], "A" as Domain.Grade);
yield* Commands.completeEnrollmentCommand(activeEnrollmentIds[1], "B" as Domain.Grade);
// 一つは離脱
if (activeEnrollmentIds[2]) {
yield* Commands.withdrawFromEnrollmentCommand(activeEnrollmentIds[2]);
}
// 少し待機
yield* Effect.sleep("100 millis");
// 11. 最終的な履修状況を確認
yield* Console.log("11. 最終的な履修状況");
const finalEnrollments = yield* Queries.getStudentEnrollmentsQuery(studentId, term);
yield* Console.log(` 履修数: ${finalEnrollments.length}`);
finalEnrollments.forEach(e => {
yield* Console.log(` ${e.courseId}: ${e.status} ${Option.getOrElse(e.grade, () => "")}`);
});
});
// 科目の全置換フローのサンプル
const replaceAllCoursesFlow = Effect.gen(function* () {
const studentId = yield* Domain.StudentId("S20240002");
const term = yield* Domain.Term("2024-Spring");
// 1. セッション作成と初期科目登録
yield* Console.log("\n=== 科目全置換フロー ===");
yield* Console.log("1. セッション作成と初期科目登録");
const sessionId = yield* Commands.createRegistrationSessionCommand(studentId, term);
const initialCourses = [
{ courseId: yield* Domain.CourseId("C000101"), units: 3 },
{ courseId: yield* Domain.CourseId("C000201"), units: 3 }
];
yield* Commands.addCoursesToSessionCommand(sessionId, initialCourses);
// 2. 全科目を置換
yield* Console.log("2. 全科目を新しい科目セットに置換");
const newCourses = [
{ courseId: yield* Domain.CourseId("C000301"), units: 4 },
{ courseId: yield* Domain.CourseId("C000401"), units: 3 },
{ courseId: yield* Domain.CourseId("C000501"), units: 3 }
];
const newEnrollmentIds = yield* Commands.replaceAllCoursesInSessionCommand(sessionId, newCourses);
yield* Console.log(` 新しい履修数: ${newEnrollmentIds.length}`);
// 3. 提出と承認
yield* Commands.submitRegistrationSessionCommand(sessionId);
yield* Commands.approveRegistrationSessionCommand(sessionId, "[email protected]");
});
// プログラムの実行
const program = Effect.gen(function* () {
yield* registrationFlow;
yield* replaceAllCoursesFlow;
});
Effect.runPromise(program.pipe(Effect.provide(AppLayer)))
.then(() => console.log("✅ 正常終了"))
.catch(console.error);import { describe, it, expect } from "vitest";
import { Effect, Either } from "effect";
import * as Domain from "../../src/domain";
describe("履修登録セッションのドメインロジック(一括操作)", () => {
it("Draft状態のセッションに科目を一括追加できる", () =>
Effect.gen(function* () {
// Arrange
const sessionId = "RS00000001" as Domain.RegistrationSessionId;
const studentId = "S00000001" as Domain.StudentId;
const term = "2024-Spring" as Domain.Term;
const session = new Domain.RegistrationSession({
id: sessionId,
studentId,
term,
enrollments: [],
status: "Draft",
totalUnits: 0,
version: 1
});
const courses: Domain.CourseInfo[] = [
{ courseId: "C000001" as Domain.CourseId, units: 3 },
{ courseId: "C000002" as Domain.CourseId, units: 4 }
];
// Act
const [updated, events] = yield* Domain.addCoursesToSession(session, courses);
// Assert
expect(updated.enrollments).toHaveLength(2);
expect(updated.totalUnits).toBe(7);
expect(updated.version).toBe(2);
expect(events).toHaveLength(1);
expect(events[0]._tag).toBe("CoursesAddedToSession");
if (events[0]._tag === "CoursesAddedToSession") {
expect(events[0].courses).toHaveLength(2);
expect(events[0].totalUnitsAfter).toBe(7);
}
}).pipe(Effect.runPromise)
);
it("最大単位数を超える科目追加は失敗する", () =>
Effect.gen(function* () {
// Arrange
const sessionId = "RS00000001" as Domain.RegistrationSessionId;
const studentId = "S00000001" as Domain.StudentId;
const term = "2024-Spring" as Domain.Term;
const session = new Domain.RegistrationSession({
id: sessionId,
studentId,
term,
enrollments: [],
status: "Draft",
totalUnits: 18, // すでに18単位
version: 1
});
const courses: Domain.CourseInfo[] = [
{ courseId: "C000001" as Domain.CourseId, units: 3 },
{ courseId: "C000002" as Domain.CourseId, units: 2 } // 合計5単位で23単位に
];
// Act & Assert
const result = yield* Effect.either(
Domain.addCoursesToSession(session, courses)
);
expect(Either.isLeft(result)).toBe(true);
if (Either.isLeft(result)) {
expect(result.left).toBeInstanceOf(Domain.MaxUnitsExceeded);
}
}).pipe(Effect.runPromise)
);
it("重複する科目の追加は失敗する", () =>
Effect.gen(function* () {
// Arrange
const sessionId = "RS00000001" as Domain.RegistrationSessionId;
const studentId = "S00000001" as Domain.StudentId;
const courseId = "C000001" as Domain.CourseId;
const term = "2024-Spring" as Domain.Term;
const session = new Domain.RegistrationSession({
id: sessionId,
studentId,
term,
enrollments: [{
enrollmentId: Domain.createEnrollmentId(studentId, courseId, term),
courseId,
units: 3
}],
status: "Draft",
totalUnits: 3,
version: 2
});
const courses: Domain.CourseInfo[] = [
{ courseId, units: 3 } // 既存と同じ科目
];
// Act & Assert
const result = yield* Effect.either(
Domain.addCoursesToSession(session, courses)
);
expect(Either.isLeft(result)).toBe(true);
if (Either.isLeft(result)) {
expect(result.left).toBeInstanceOf(Domain.DuplicateCourseInSession);
}
}).pipe(Effect.runPromise)
);
it("科目を一括削除できる", () =>
Effect.gen(function* () {
// Arrange
const sessionId = "RS00000001" as Domain.RegistrationSessionId;
const studentId = "S00000001" as Domain.StudentId;
const term = "2024-Spring" as Domain.Term;
const enrollments = [
{
enrollmentId: Domain.createEnrollmentId(studentId, "C000001" as Domain.CourseId, term),
courseId: "C000001" as Domain.CourseId,
units: 3
},
{
enrollmentId: Domain.createEnrollmentId(studentId, "C000002" as Domain.CourseId, term),
courseId: "C000002" as Domain.CourseId,
units: 4
}
];
const session = new Domain.RegistrationSession({
id: sessionId,
studentId,
term,
enrollments,
status: "Draft",
totalUnits: 7,
version: 2
});
// Act
const [updated, events] = yield* Domain.removeCoursesFromSession(
session,
[enrollments[0].enrollmentId]
);
// Assert
expect(updated.enrollments).toHaveLength(1);
expect(updated.enrollments[0].courseId).toBe("C000002");
expect(updated.totalUnits).toBe(4);
expect(events).toHaveLength(1);
expect(events[0]._tag).toBe("CoursesRemovedFromSession");
}).pipe(Effect.runPromise)
);
it("全科目を置換できる", () =>
Effect.gen(function* () {
// Arrange
const sessionId = "RS00000001" as Domain.RegistrationSessionId;
const studentId = "S00000001" as Domain.StudentId;
const term = "2024-Spring" as Domain.Term;
const session = new Domain.RegistrationSession({
id: sessionId,
studentId,
term,
enrollments: [
{
enrollmentId: Domain.createEnrollmentId(studentId, "C000001" as Domain.CourseId, term),
courseId: "C000001" as Domain.CourseId,
units: 3
}
],
status: "Draft",
totalUnits: 3,
version: 2
});
const newCourses: Domain.CourseInfo[] = [
{ courseId: "C000002" as Domain.CourseId, units: 4 },
{ courseId: "C000003" as Domain.CourseId, units: 3 }
];
// Act
const [updated, events] = yield* Domain.replaceAllCoursesInSession(session, newCourses);
// Assert
expect(updated.enrollments).toHaveLength(2);
expect(updated.totalUnits).toBe(7);
expect(events).toHaveLength(1);
expect(events[0]._tag).toBe("SessionCoursesReplaced");
}).pipe(Effect.runPromise)
);
it("最小単位数を満たさないセッションは提出できない", () =>
Effect.gen(function* () {
// Arrange
const sessionId = "RS00000001" as Domain.RegistrationSessionId;
const studentId = "S00000001" as Domain.StudentId;
const term = "2024-Spring" as Domain.Term;
const session = new Domain.RegistrationSession({
id: sessionId,
studentId,
term,
enrollments: [{
enrollmentId: Domain.createEnrollmentId(studentId, "C000001" as Domain.CourseId, term),
courseId: "C000001" as Domain.CourseId,
units: 3
}],
status: "Draft",
totalUnits: 3, // 最小12単位未満
version: 2
});
// Act & Assert
const result = yield* Effect.either(
Domain.submitSession(session)
);
expect(Either.isLeft(result)).toBe(true);
if (Either.isLeft(result)) {
expect(result.left).toBeInstanceOf(Domain.MinUnitsNotMet);
}
}).pipe(Effect.runPromise)
);
});import { describe, it, expect, beforeEach } from "vitest";
import { Effect, Layer, Ref, Option, Either } from "effect";
import * as Commands from "../../src/application/commands";
import * as Domain from "../../src/domain";
describe("履修登録コマンド(一括操作)", () => {
// テスト用のインメモリ実装
const createTestLayer = () => {
const events = Ref.unsafeMake<Domain.DomainEvent[]>([]);
const sessions = Ref.unsafeMake<Map<string, Domain.RegistrationSession>>(new Map());
const enrollments = Ref.unsafeMake<Map<string, Domain.Enrollment>>(new Map());
const TestEventStore = Layer.succeed(
Domain.EventStore,
{
createSession: (sessionId, studentId, term, evts) =>
Effect.gen(function* () {
yield* Ref.update(events, arr => [...arr, ...evts]);
const session = new Domain.RegistrationSession({
id: sessionId,
studentId,
term,
enrollments: [],
status: "Draft",
totalUnits: 0,
version: 1
});
yield* Ref.update(sessions, map => new Map(map).set(sessionId, session));
}),
createEnrollmentsBatch: (sessionId, enrollmentData, evts) =>
Effect.gen(function* () {
yield* Ref.update(events, arr => [...arr, ...evts]);
// セッションの更新
const sessionMap = yield* Ref.get(sessions);
const session = sessionMap.get(sessionId);
if (session) {
// イベントから新しい状態を構築
let updatedSession = session;
for (const event of evts) {
if (event._tag === "CoursesAddedToSession") {
updatedSession = new Domain.RegistrationSession({
...updatedSession,
enrollments: [...updatedSession.enrollments, ...event.courses],
totalUnits: event.totalUnitsAfter,
version: updatedSession.version + 1
});
}
}
yield* Ref.update(sessions, map => new Map(map).set(sessionId, updatedSession));
}
// 履修の作成
for (const enrollment of enrollmentData) {
const e = new Domain.Enrollment({
id: enrollment.enrollmentId,
sessionId,
studentId: enrollment.studentId,
courseId: enrollment.courseId,
term: enrollment.term,
status: "Requested",
grade: Option.none(),
version: 1
});
yield* Ref.update(enrollments, map => new Map(map).set(enrollment.enrollmentId, e));
}
}),
appendEvents: (evts) =>
Effect.gen(function* () {
yield* Ref.update(events, arr => [...arr, ...evts]);
}),
getSessionEvents: () => Effect.succeed([]),
getEnrollmentEvents: () => Effect.succeed([])
}
);
const TestSessionRepository = Layer.succeed(
Domain.RegistrationSessionRepository,
{
findById: (id) =>
Effect.gen(function* () {
const map = yield* Ref.get(sessions);
return Option.fromNullable(map.get(id));
}),
findByStudentAndTerm: (studentId, term) =>
Effect.gen(function* () {
const map = yield* Ref.get(sessions);
const found = Array.from(map.values()).find(
s => s.studentId === studentId && s.term === term
);
return Option.fromNullable(found);
})
}
);
const TestEnrollmentRepository = Layer.succeed(
Domain.EnrollmentRepository,
{
findById: (id) =>
Effect.gen(function* () {
const map = yield* Ref.get(enrollments);
return Option.fromNullable(map.get(id));
}),
findBySessionId: (sessionId) =>
Effect.gen(function* () {
const map = yield* Ref.get(enrollments);
return Array.from(map.values()).filter(e => e.sessionId === sessionId);
}),
findByStudentAndTerm: (studentId, term) =>
Effect.gen(function* () {
const map = yield* Ref.get(enrollments);
return Array.from(map.values()).filter(
e => e.studentId === studentId && e.term === term
);
})
}
);
return Layer.mergeAll(TestEventStore, TestSessionRepository, TestEnrollmentRepository);
};
it("セッションに科目を一括追加できる", () =>
Effect.gen(function* () {
// Arrange
const studentId = "S00000001" as Domain.StudentId;
const term = "2024-Spring" as Domain.Term;
const testLayer = createTestLayer();
const courses: Domain.CourseInfo[] = [
{ courseId: "C000001" as Domain.CourseId, units: 3 },
{ courseId: "C000002" as Domain.CourseId, units: 4 },
{ courseId: "C000003" as Domain.CourseId, units: 3 }
];
// Act
const program = Effect.gen(function* () {
const sessionId = yield* Commands.createRegistrationSessionCommand(studentId, term);
const enrollmentIds = yield* Commands.addCoursesToSessionCommand(sessionId, courses);
return { sessionId, enrollmentIds };
});
const result = yield* program.pipe(Effect.provide(testLayer));
// Assert
expect(result.enrollmentIds).toHaveLength(3);
expect(result.enrollmentIds[0]).toBe(`${studentId}:C000001:${term}`);
expect(result.enrollmentIds[1]).toBe(`${studentId}:C000002:${term}`);
expect(result.enrollmentIds[2]).toBe(`${studentId}:C000003:${term}`);
}).pipe(Effect.runPromise)
);
it("科目の全置換ができる", () =>
Effect.gen(function* () {
// Arrange
const studentId = "S00000001" as Domain.StudentId;
const term = "2024-Spring" as Domain.Term;
const testLayer = createTestLayer();
const initialCourses: Domain.CourseInfo[] = [
{ courseId: "C000001" as Domain.CourseId, units: 3 },
{ courseId: "C000002" as Domain.CourseId, units: 4 }
];
const newCourses: Domain.CourseInfo[] = [
{ courseId: "C000003" as Domain.CourseId, units: 3 },
{ courseId: "C000004" as Domain.CourseId, units: 3 },
{ courseId: "C000005" as Domain.CourseId, units: 4 }
];
// Act
const program = Effect.gen(function* () {
const sessionId = yield* Commands.createRegistrationSessionCommand(studentId, term);
// 初期科目を追加
yield* Commands.addCoursesToSessionCommand(sessionId, initialCourses);
// 全置換
const newEnrollmentIds = yield* Commands.replaceAllCoursesInSessionCommand(sessionId, newCourses);
return { sessionId, newEnrollmentIds };
});
const result = yield* program.pipe(Effect.provide(testLayer));
// Assert
expect(result.newEnrollmentIds).toHaveLength(3);
expect(result.newEnrollmentIds[0]).toBe(`${studentId}:C000003:${term}`);
expect(result.newEnrollmentIds[1]).toBe(`${studentId}:C000004:${term}`);
expect(result.newEnrollmentIds[2]).toBe(`${studentId}:C000005:${term}`);
}).pipe(Effect.runPromise)
);
it("セッションの一括承認が履修にも反映される", () =>
Effect.gen(function* () {
// Arrange
const studentId = "S00000001" as Domain.StudentId;
const term = "2024-Spring" as Domain.Term;
const testLayer = createTestLayer();
const courses: Domain.CourseInfo[] = [
{ courseId: "C000001" as Domain.CourseId, units: 4 },
{ courseId: "C000002" as Domain.CourseId, units: 4 },
{ courseId: "C000003" as Domain.CourseId, units: 4 }
];
// Act
const program = Effect.gen(function* () {
const sessionId = yield* Commands.createRegistrationSessionCommand(studentId, term);
const enrollmentIds = yield* Commands.addCoursesToSessionCommand(sessionId, courses);
yield* Commands.submitRegistrationSessionCommand(sessionId);
yield* Commands.approveRegistrationSessionCommand(sessionId, "[email protected]");
return { sessionId, enrollmentIds };
});
const result = yield* program.pipe(Effect.provide(testLayer));
// Assert
expect(result.sessionId).toMatch(/^RS\d{8}$/);
expect(result.enrollmentIds).toHaveLength(3);
}).pipe(Effect.runPromise)
);
});この一括操作版の実装により実現される改善点:
- Before: 科目追加・削除ごとにイベント発生
- After: 一括操作単位でイベント発生
// Before: 3科目追加 = 3イベント
CourseAddedToSession
CourseAddedToSession
CourseAddedToSession
// After: 3科目追加 = 1イベント
CoursesAddedToSession (courses: 3)- 実際のユーザー操作(「これらの科目を登録」「選択した科目を削除」)に対応
- CSVインポートなどの一括操作に適合
- トランザクション的な操作の表現が自然
- CoursesAddedToSession: 複数科目の一括追加
- CoursesRemovedFromSession: 複数科目の一括削除
- SessionCoursesReplaced: 全科目の置換
- EnrollmentsRequestedBatch: 履修の一括作成
- EnrollmentsCancelledBatch: 履修の一括キャンセル
- EnrollmentsApprovedBatch: 履修の一括承認
- イベント数の削減によるストレージ効率の向上
- 投影処理の効率化(バッチ更新)
- ネットワーク通信の削減
- 個別操作の繰り返しではなく、一括操作として実装
- エラーハンドリングがシンプル(全体の成功/失敗)
- トランザクション境界が明確
この設計により、実用的な履修管理システムとして、より効率的で保守性の高い実装となっています。