Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/application/UnitOfWork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* 一連の操作をまとめて扱うためのインターフェース
* 具体的な実装(DBトランザクション等)はInfrastructure層が担当する
*/
export interface UnitOfWork {
run<T>(fn: () => Promise<T>): Promise<T>;
}
1 change: 1 addition & 0 deletions src/application/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./dto";
export * from "./UnitOfWork";
export * from "./usecase/member";
export * from "./usecase/event";
export * from "./usecase/eventParticipation";
Expand Down
24 changes: 24 additions & 0 deletions src/application/usecase/base.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
import type { UnitOfWork } from "../UnitOfWork";

/**
* ユースケースの基底抽象クラス
* TInputはユースケースへの入力型
* TOutputはユースケースからの出力型
*/
export abstract class IUseCase<Input, Output> {
/**
* ユースケースのビジネスロジックを実行する
* トランザクションなしで実行されるため、読み取り専用のUseCaseはこちらを使う
* 複数のDB書き込みを安全にまとめたい場合は run() を使うこと
*/
abstract execute(input: Input): Promise<Output>;

/**
* UnitOfWorkで囲んでexecuteを実行する
* トランザクション内で全てのDB操作がまとめて成功/失敗する
* 複数の集約をまたぐ書き込み操作がある場合に使用する
*
* 注意:
* - execute()内で外部API呼び出し(Discord API等)を行うと、
* その応答待ちの間トランザクションが開きっぱなしになる
* 外部連携はトランザクション完了後に行うこと
* - トランザクションがロールバックされてもメモリ上のドメインオブジェクトは
* 元に戻らない。execute()内で取得・変更したドメインオブジェクトを
* ロールバック後に再利用しないこと
*/
async run(input: Input, unitOfWork: UnitOfWork): Promise<Output> {
return unitOfWork.run(() => this.execute(input));
}
}
Comment on lines 8 to 32
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この設計では、execute() と run() のどちらを使うかを呼び出し側が判断します。もし複数のテーブルに書き込むようなUseCaseで execute() を使ってしまうと、途中で失敗したときに一部の書き込みだけが残り、データの整合性が壊れます。しかしこのミスはエラーにならず、正常に動いているように見えるため、問題が起きるまで気づけません。

その代わりに、execute() 自体がトランザクションを開始するようにしてはどうでしょうか。
全てのUseCaseは execute() を呼ぶだけでトランザクション内で実行されるようにするということです。

Copy link
Copy Markdown
Author

@Mel-906 Mel-906 Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KinjiKawaguchi

execute()にトランザクション開始を組み込む案だと、各UseCaseが実装しているメソッドの書き換え(35個)が必要になるのではと思います。
run()のみを入口にすれば、execute()の誤用も解消でき、トランザクションでの保護も行えます。

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Mel-906 ご返信ありがとうございます。

execute() 自体をトランザクション境界にする案を、もう少し具体的に説明させてください。

基底クラスをこう変えてみるのはどうでしょうか?

export abstract class IUseCase<Input, Output> {
    async execute(input: Input): Promise<Output> {
        return getUnitOfWork().run(() => this._execute(input));
    }
    protected abstract _execute(input: Input): Promise<Output>;
}

こうすると、各UseCaseでは executeprotected _execute にリネームするだけで済みます。中身の変更はありません。

例えば、

// Before
async execute(input: RegisterMemberInput): Promise<RegisterMemberOutput> {
// After
protected async _execute(input: RegisterMemberInput): Promise<RegisterMemberOutput> {

ファサード層は execute() をそのまま呼び続けるので変更不要です。

run() を唯一の入口にする案でも、execute() を外から呼べなくするために結局35個のUseCaseに protected を付ける変更は発生します。さらにファサード層でも全箇所 execute()run(input, uow) への書き換えとUoWの生成が必要になるので、変更量はむしろ多くなります。

ちなみに protected はそのクラスとサブクラスからだけアクセスできる修飾子で、外部から _execute() を直接呼ぶとコンパイルエラーになります。詳しくはこちら:
https://typescriptbook.jp/reference/object-oriented/class/access-modifiers#protected

12 changes: 6 additions & 6 deletions src/infrastructure/drizzle/DrizzleDiscordAccountRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
discordId,
memberId,
} from "#domain";
import { getDb } from "./client";
import { getClient } from "./client";
import { discordAccountDomainEvents, discordAccounts } from "./schema";
import { serializeDiscordAccountEventPayload } from "./serializeDiscordAccountEvent";

Expand All @@ -20,7 +20,7 @@ function toDomain(row: DiscordAccountRow): DiscordAccount {

export class DrizzleDiscordAccountRepository implements DiscordAccountRepository {
async findByDiscordId(id: DiscordId): Promise<DiscordAccount | null> {
const db = getDb();
const db = getClient();
const row = await db.query.discordAccounts.findFirst({
where: eq(discordAccounts.discordId, id as string),
});
Expand All @@ -29,21 +29,21 @@ export class DrizzleDiscordAccountRepository implements DiscordAccountRepository
}

async findByMemberId(id: MemberId): Promise<DiscordAccount[]> {
const db = getDb();
const db = getClient();
const rows = await db.query.discordAccounts.findMany({
where: eq(discordAccounts.memberId, id as string),
});
return rows.map(toDomain);
}

async findAll(): Promise<DiscordAccount[]> {
const db = getDb();
const db = getClient();
const rows = await db.query.discordAccounts.findMany();
return rows.map(toDomain);
}

async save(account: DiscordAccount): Promise<void> {
const db = getDb();
const db = getClient();
const now = new Date().toISOString();
const events = account.getDomainEvents();

Expand Down Expand Up @@ -80,7 +80,7 @@ export class DrizzleDiscordAccountRepository implements DiscordAccountRepository
}

async delete(id: DiscordId): Promise<void> {
const db = getDb();
const db = getClient();
await db.delete(discordAccounts).where(eq(discordAccounts.discordId, id as string));
}
}
22 changes: 11 additions & 11 deletions src/infrastructure/drizzle/DrizzleEventRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
exhibitId,
memberId,
} from "#domain";
import { type DrizzleDb, getDb } from "./client";
import { type DrizzleClient, getClient } from "./client";
import { events, exhibits, lightningTalks, memberEvents, memberExhibits } from "./schema";

// ============================================================================
Expand Down Expand Up @@ -101,7 +101,7 @@ export class DrizzleEventRepository implements EventRepository {
// ==========================================================================

private async persistEvent(event: Event): Promise<void> {
const db = getDb();
const db = getClient();
const snapshot = event.toSnapshot();
const now = new Date().toISOString();
const dateStr = snapshot.date instanceof Date ? snapshot.date.toISOString() : snapshot.date;
Expand Down Expand Up @@ -143,7 +143,7 @@ export class DrizzleEventRepository implements EventRepository {
}

private async deleteObsoleteExhibits(
db: DrizzleDb,
db: DrizzleClient,
eventId: EventId,
keptExhibitIds: ExhibitId[],
): Promise<void> {
Expand All @@ -164,7 +164,7 @@ export class DrizzleEventRepository implements EventRepository {
}

private async upsertExhibit(
db: DrizzleDb,
db: DrizzleClient,
eventId: EventId,
ex: ReturnType<Event["toSnapshot"]>["exhibits"][number],
): Promise<void> {
Expand Down Expand Up @@ -220,7 +220,7 @@ export class DrizzleEventRepository implements EventRepository {
}

private async syncMemberEvents(
db: DrizzleDb,
db: DrizzleClient,
eventId: EventId,
memberIds: MemberId[],
): Promise<void> {
Expand All @@ -241,7 +241,7 @@ export class DrizzleEventRepository implements EventRepository {
}

private async syncMemberExhibits(
db: DrizzleDb,
db: DrizzleClient,
exhibitId: ExhibitId,
memberIds: MemberId[],
): Promise<void> {
Expand All @@ -266,7 +266,7 @@ export class DrizzleEventRepository implements EventRepository {
// ==========================================================================

async findById(id: EventId): Promise<Event | null> {
const db = getDb();
const db = getClient();
const record = await db.query.events.findFirst({
where: eq(events.id, id),
with: {
Expand All @@ -285,7 +285,7 @@ export class DrizzleEventRepository implements EventRepository {
}

async findByParticipantMemberId(memberId: MemberId): Promise<Event[]> {
const db = getDb();
const db = getClient();

const participations = await db
.select({ eventId: memberEvents.eventId })
Expand All @@ -312,7 +312,7 @@ export class DrizzleEventRepository implements EventRepository {
}

async findByExhibitId(exhibitId: ExhibitId): Promise<Event | null> {
const db = getDb();
const db = getClient();

const exhibit = await db
.select({ eventId: exhibits.eventId })
Expand All @@ -325,7 +325,7 @@ export class DrizzleEventRepository implements EventRepository {
}

async findAll(): Promise<Event[]> {
const db = getDb();
const db = getClient();
const records = await db.query.events.findMany({
with: {
memberEvents: true,
Expand All @@ -346,7 +346,7 @@ export class DrizzleEventRepository implements EventRepository {
}

async delete(eventId: EventId): Promise<void> {
const db = getDb();
const db = getClient();

const exhibitRecords = await db
.select({ id: exhibits.id })
Expand Down
8 changes: 4 additions & 4 deletions src/infrastructure/drizzle/DrizzleKarteRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
type Resolution,
type SupportRecord,
} from "#domain";
import { getDb } from "./client";
import { getClient } from "./client";
import { karteAssignees, kartes } from "./schema";

// ============================================================================
Expand Down Expand Up @@ -250,7 +250,7 @@ function consultedAtToPrecision(r: Recorded<ConsultedAt>): ConsultedAt["precisio

export class DrizzleKarteRepository implements KarteRepository {
async findById(id: KarteId): Promise<Karte | null> {
const db = getDb();
const db = getClient();
const row = await db.query.kartes.findFirst({
where: eq(kartes.id, id as string),
with: {
Expand All @@ -263,7 +263,7 @@ export class DrizzleKarteRepository implements KarteRepository {
}

async findAll(): Promise<Karte[]> {
const db = getDb();
const db = getClient();
const rows = await db.query.kartes.findMany({
with: {
karteAssignees: true,
Expand All @@ -274,7 +274,7 @@ export class DrizzleKarteRepository implements KarteRepository {
}

async save(karte: Karte): Promise<void> {
const db = getDb();
const db = getClient();

const clientCols = clientToColumns(karte.client);
const resCols = resolutionToColumns(karte.supportRecord.resolution);
Expand Down
10 changes: 5 additions & 5 deletions src/infrastructure/drizzle/DrizzleMemberRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
type MemberRepository,
type Recorded,
} from "#domain";
import { getDb } from "./client";
import { getClient } from "./client";
import { memberDomainEvents, members } from "./schema";
import { serializeMemberEventPayload } from "./serializeMemberEvent";

Expand Down Expand Up @@ -100,7 +100,7 @@ function toInsertValues(member: Member): MemberInsert {

export class DrizzleMemberRepository implements MemberRepository {
async findById(id: MemberId): Promise<Member | null> {
const db = getDb();
const db = getClient();
const row = await db.query.members.findFirst({
where: eq(members.id, id as string),
});
Expand All @@ -109,7 +109,7 @@ export class DrizzleMemberRepository implements MemberRepository {
}

async findByEmail(email: UniversityEmail): Promise<Member | null> {
const db = getDb();
const db = getClient();
const row = await db.query.members.findFirst({
where: eq(members.email, email.getValue()),
});
Expand All @@ -118,13 +118,13 @@ export class DrizzleMemberRepository implements MemberRepository {
}

async findAll(): Promise<Member[]> {
const db = getDb();
const db = getClient();
const rows = await db.query.members.findMany();
return rows.map(toDomain);
}

async save(member: Member): Promise<void> {
const db = getDb();
const db = getClient();
const values = toInsertValues(member);
const events = member.getDomainEvents();

Expand Down
12 changes: 12 additions & 0 deletions src/infrastructure/drizzle/DrizzleUnitOfWork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { UnitOfWork } from "#application/UnitOfWork";
import { runInTransaction } from "./client";

/**
* UnitOfWorkのDrizzle実装
* AsyncLocalStorageを利用して、トランザクションを処理の流れ全体で共有する
*/
export class DrizzleUnitOfWork implements UnitOfWork {
async run<T>(fn: () => Promise<T>): Promise<T> {
return runInTransaction(fn);
}
}
46 changes: 38 additions & 8 deletions src/infrastructure/drizzle/client.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,54 @@
/// <reference types="node" />
import { AsyncLocalStorage } from "node:async_hooks";
import type { PgDatabase } from "drizzle-orm/pg-core";
import { drizzle } from "drizzle-orm/postgres-js";
import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js/session";
import postgres, { type Sql } from "postgres";
import * as schema from "./schema";

let client: Sql | null = null;
export type DrizzleClient = PgDatabase<PostgresJsQueryResultHKT, typeof schema>;

function getClient(): Sql {
if (!client) {
let sqlClient: Sql | null = null;

function getSqlClient(): Sql {
if (!sqlClient) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL environment variable is not set");
}
// Supabase の Transaction pool mode は prepared statement をサポートしないため無効化
client = postgres(connectionString, { prepare: false });
sqlClient = postgres(connectionString, { prepare: false });
}
return client;
return sqlClient;
}

function createClient(): DrizzleClient {
return drizzle(getSqlClient(), { schema });
}

export function getDb() {
return drizzle(getClient(), { schema });
const transactionContext = new AsyncLocalStorage<DrizzleClient>();

/**
* Drizzleクライアントを取得する
* トランザクション中であればそのトランザクションを返し、
* そうでなければ新しいクライアントを返す
*/
export function getClient(): DrizzleClient {
const tx = transactionContext.getStore();
if (tx) return tx;
return createClient();
}

export type DrizzleDb = ReturnType<typeof getDb>;
/**
* トランザクション内で処理を実行する
* すでにトランザクション中であればそのまま実行する(ネストしない)
*/
export function runInTransaction<T>(fn: () => Promise<T>): Promise<T> {
if (transactionContext.getStore()) {
return fn();
}
const db = createClient();
return db.transaction(async (tx) => {
return transactionContext.run(tx, fn);
});
}
1 change: 1 addition & 0 deletions src/infrastructure/drizzle/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { DrizzleDiscordAccountRepository } from "./DrizzleDiscordAccountReposito
export { DrizzleEventRepository } from "./DrizzleEventRepository";
export { DrizzleKarteRepository } from "./DrizzleKarteRepository";
export { DrizzleMemberRepository } from "./DrizzleMemberRepository";
export { DrizzleUnitOfWork } from "./DrizzleUnitOfWork";