🗯️

実務でテンプレートメソッドパターンを運用してみた話

2023/11/23に公開

問題

最近の業務では、何種類のユーザー操作に対して履歴データを記録するニーズがありました。操作の種類によって違うデータが必要ものの、共通するデータフィールドもたくさん存在するという状況です。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