実務でテンプレートメソッドパターンを運用してみた話
問題
最近の業務では、何種類のユーザー操作に対して履歴データを記録するニーズがありました。操作の種類によって違うデータが必要ものの、共通するデータフィールドもたくさん存在するという状況です。DBレベルで見ると、このようなテーブル設計で、要は1->Nの親テーブル+子テーブルの形となります。
このDBの設計をコード上反映すると、自然に「継承」という言葉が出るかもしれません。実際にほぼ全ての操作で、親テーブルとのジョインが必要なので、強依存にはなるものの、継承で問題ないと考えていました。
それで、DBアクセスのレイヤーとして、レポジトリーを導入していました。共通する部分の操作があるので、繰り返しの定義をさけるためにも、親クラスのところでCRUDのAPIなどを実装してみようと思いました。しかし、やってみるといくつか問題とありました。
素朴な継承
まず、全てのrepositoryに継承させるための、ベースがあります。これは操作履歴に限らず、全てのrepositoryに使えます。
// use Knex for example
export default class BaseRepository {
protected trxProvider: Knex.TransactionProvider;
constructor(
protected db: Knex,
protected logger: Logger = getDefaultLogger(),
) {
this.trxProvider = this.db.transactionProvider();
}
}
次に、履歴の親クラスを実装。
class OperationHistoryRepository extends BaseRepository {
constructor(db: Knex) {
super(db);
}
async create(data: Record<string, unknown>): Promise<void> {...}
async update(data: Partial<Record<string, unknown>>): Promise<void> {...}
async deleteById(historyId: number): Promise<void> {...}
async getById(historyId: number): Promise<Record<string, unknown>> {...}
}
これを子クラスに継承させると、
class OperationHistoryA extends OperationHistoryRepository {
private tableName = tables.operationHistoryA.name;
private operationType = "operation_a";
}
ここで2つの問題があります。
- 親に共通操作を持たせているが、パラメーターもリターン値もタイプがわからない
- 子のテーブルと親のテーブルの操作が同時に必要な時に、子テーブルの情報を持っていない
ジェネリックタイプ
まず一個目の問題を対処するために、子クラスのタイプを、ジェネリックタイプとして親クラスに引き渡します。
type OperationHistory = {...}
type OperationHistoryA = {...}
type OperationHistoryB = {...}
type OperationHistoryC = {...}
type OperationHistoryChild =
| OperationHistoryA
| OperationHistoryB
| OperationHistoryC;
class OperationHistoryRepository<T extends OperationHistoryChild> extends BaseRepository {
async create(data: Omit<T, 'history_id'>): Promise<void> {...}
async update(data: Partial<Omit<T, 'history_id'>>): Promise<void> {...}
async deleteById(historyId: number): Promise<void> {...}
async getById(historyId: number): Promise<T & OperationHistory> {...}
}
class OperationHistoryA extends OperationHistoryRepository<OperationHistoryA> {...}
テーブルジョイン後のタイプを正しく伝えるために、T & OperationHistory
で得られます。
これで、親クラスに共通メソッドを持たせてもOKになります。タイプがわかると大きな一歩でした。
アブストラクトクラス
ここで実はもう一つ問題があります。というのは、親クラスはあくまでも共通情報を持つために存在し、実際に単独で親クラスを使うことがありません。なぜなら、ユーザー操作にはA,B,Cといった種類しかなく、Parentというような種類がないからです。この場合、親クラスを抽象化して、アブストラクトクラス に定義すると良いでしょう。
abstract class OperationHistoryRepository<
T extends OperationHistoryChild,
> extends BaseRepository {
constructor(db: Knex) {
super(db);
if (this.constructor === OperationHistoryRepository) {
throw new Error("Cannot instantiate an abstract class.");
}
}
}
なお、JSにはアブストラクトクラスがないため、コンパイル後は普通のクラスとしてインスタンス化可能です。それも防止したいので、コンストラクター関数ではチェックを入れています。
テンプレートメソッドパターン
これまでにタイプの問題が解決できたが、もう一つの問題として、子クラステーブル名といった情報がわかりません。なぜ必要かというと、
- createとの操作の時に、トランザクションで親テーブルと子テーブルへ同時にレコードを挿入したい
- get操作の時に、親子のテーブルのジョインが必要で、子テーブル名が必要
- 取得データに対して、repositoryの責務としてスキーマチェックを入れているが、そのスキーマ情報がわからないとガードができなくなる
このような、親で共通操作を定義している中で、一部の情報が子の方に存在し、それを取らないと行かない、という場面だと、テンプレートメソッドパターンの登場です。
ポイントとして、子クラスから情報を取るメソッドは、アブストラクトメソッドとして定義することです。なぜなら、
- シグネチャーだけを残し、実装は子クラスに任せられる
- アブストラクトメソッドは、継承時に必ず実装をしなければならない制約でもある
abstract class OperationHistoryRepository<
T extends OperationHistoryChild,
> extends BaseRepository {
constructor(db: Knex) {
super(db);
if (this.constructor === OperationHistoryRepository) {
throw new Error("Cannot instantiate an abstract class.");
}
}
protected abstract getChildOperationType(): OperationHistoryType;
protected abstract getChildOperationTableName(): OperationHistoryTableName;
public async create({
userId,
data,
}: {
userId: string;
data: ChildInsertData<T>;
}) {
// 二つのテーブルに挿入が必要なのでトランザクション必要
const trx = await this.trxProvider();
try {
const historyId = await this.insertParentTable(trx, userId);
await this.insertChildTable(trx, historyId, data);
await trx.commit();
return historyId;
} catch (error: unknown) {
await trx.rollback();
throw error;
}
}
private async insertParentTable(trx: Knex.Transaction, userId: string) {
// ここで子クラスから操作の種類を取得
const type = this.getChildOperationType();
const [inserted] = await trx.table(this.parentTableName).insert(
{
user_id: userId,
created_at: trx.fn.now(),
operation_type: type,
state: "pending",
},
"*",
);
if (inserted == null) throw new Error("Failed");
return inserted.history_id;
}
private insertChildTable(
trx: Knex.Transaction,
historyId: number,
childData: ChildInsertData<T>,
) {
// ここで子クラスからテーブル名を取得
const childTable = this.getChildOperationTableName();
return trx
.table(childTable)
.insert({ ...childData, [this.parentTableFields.id]: historyId });
}
public async getById(
historyId: number,
): Promise<(T & OperationHistory) | null | undefined> {
// ここで子クラスからテーブル名を取得
const childTable = this.getChildOperationTableName();
const row = await this.db
.table(childTable)
.select(["*"])
.leftJoin(
this.parentTableName,
`${childTable}.${this.primaryKey}`,
`${this.parentTableName}.${this.primaryKey}`,
)
.where({
[`${childTable}.${this.primaryKey}`]: historyId,
})
.first();
return row;
}
}
子クラスでは、定義されているアブストラクトメソッドを実装すれば完成。
class OperationHistoryA extends OperationHistoryRepository {
private tableName = "operation_a_table";
private operationType = "operation_a";
protected getChildOperationType() {
return this.operationType;
}
protected getChildOperationTableName() {
return this.tableName;
}
}
スキーマバリデーション
前節で触れていましたが、テーブル名など以外にも、スキーマ定義を取得したいのです。
スキーマの定義とバリデーションは、zodを例にします。
const zOperationHistory = z.object({
historyId: z.number(),
createdAt: z.string().datetime(),
finishedAt: z.Astring().datetime().nullish(),
state: z.enum([...]),
})
type OperationHistory = z.infer<typeof zOperationHistory>
const zOperationHistoryA = zOperationHistory.extend({...})
type OperationHistoryA = z.infer<typeof zOperationHistoryA>
これでスキーマを取得するために一個メソッドを追加します。
protected abstract getChildOperationSchema(): z.ZodSchema<T>;
すると、getとかの時に取得されたデータに対してバリデーションをかけられます。
private async getSchema() {
const childSchema = this.getChildOperationSchema();
return z.intersection(zOperationHistory, childSchema);
}
public async getById(
historyId: number,
): Promise<(T & OperationHistory) | null | undefined> {
const childTable = this.getChildOperationTableName();
const zRow = this.getSchema();
const row = zRow.nullish().parse(
await this.db
.table(childTable)
.select(["*"])
.leftJoin(
...
)
.where({
...
})
.first(),
);
return row;
}
// nullishの値を除外したい場合はLaravelに因んでorFail系も考えられる
public async getOrFail(historyId: number): Promise<T & OperationHistory> {
const row = await this.getById(historyId);
if (row == null) {
throw new Error(`History ${historyId} does not exist`);
}
return row;
}
public async list(userId: string): Promise<Array<T & OperationHistory>> {
const zRow = this.getSchema();
const rows = zRow.array().parse(...);
return rows;
}
考え
テンプレートメソッドを使わなければならないのか、と言われると、そうでもないのです。設計パターンを全部取り除くと、大体はif/else
の分岐に集約できます。例えば、スキーマの取得だと次になります。
private getChildOperationSchema(): z.ZodSchema<T> {
const operationType = this.getChildOperationType();
return match(operationType)
.with("operation_a", () => {
return zOperationHistoryA as unknown as z.ZodSchema<T>;
})
.with("operation_b", () => {
return zOperationHistoryB as unknown as z.ZodSchema<T>;
})
.with("operation_c", () => {
return zOperationHistoryC as unknown as z.ZodSchema<T>;
})
.exhaustive();
}
このやり方が悪いのか?そうでもない気もします。enum系の値に対して、きちんとexhaustiveまで実装していけば、一般的なelse
問題(ケースを追加する際に分岐が追加されていなく、結局elseに落ちる問題)を完全に避けられます。
ならなぜテンプレートメソッドが良いのか。SOLIDだからだと考えています。
- 単一責任原則(SRP) aのものはaで、bのものはbで集約する形になります。
-
オープンクローズ原則(OCP) 拡張には開放的でありながら、変更には閉鎖的、とのことです。分岐で実装すると、今度操作タイプDが出たら、
getChildOperationSchema
の実装に対して、変更を行わなければなりません。exhaustiveにしているならまだしも、else
問題に落ちいる可能性もあります。テンプレートメソッドでやると、アブストラクトメソッドは必ず実装しないといけないので、絶対にこの状況は避けられるとも言えます。 - リスコフ置き換え原則(LSP) 子クラスは、親クラスに入れ替えられる。インターフェース向けのプログラミングですね。
- インターフェース分離原則(ISP) テンプレートメソッドでは、アブストラクトメソッドの定義を勧めるので、割とシンプルなインターフェース(メソッド)になりやすいです。他の原則とも絡んでいるのですが、これは実際地味にありがたいですね。例えば、今回の例では操作のタイプ、テーブル名、スキーマに対してそれぞれ極シンプルなAPIができています。設計する時に余計に複雑な中身を考えることが、抑えられいるのではないかと思いますね。
- 依存反転原則(DIP) 感動が止まらない原則。親は子クラスの実装に依存せず、その抽象だけを依存する。子クラスも、その抽象化されたメソッドのシグネチャーに沿って実装すればOK。親子はお互いへの依存はなく、抽象定義に依存することに。
ということで、今回は実践中の設計パターン、テンプレートメソッドについて書いてみました。非常に強力でエレガントで美しいパターンなので、今後も末長く付き合っていきたいと思います(笑)。
Discussion