システム開発は「ライブラリを作る」を基準に考える——帳票出力を例に

業務システムのコードを書くとき、大きく2つの姿勢があります。1つは、目の前の業務要件をそのまま実装し、似た処理が重複してきたら都度共通関数に切り出す姿勢。もう1つは、まだ呼び出し元が1つしかなくても、そのコードを外部に公開する汎用ライブラリだと仮定して、呼び出し元の事情から切り離したAPIとして最初から設計する姿勢です。どちらが優れているという話ではなく、システムのどの部分にどちらの基準を持ち込むかを見極めることが実務上の判断になります。帳票出力を例に、この2つの姿勢の違いを見ていきます。

業務ロジックをそのまま実装するアプローチ

売上レポートと在庫レポートのPDF出力を、それぞれ個別に実装するケースを考えます。

// reports/salesReport.ts
export async function generateSalesReportPdf(data: SalesReportData) {
  const doc = new PDFDocument();
  doc.fontSize(18).text('売上レポート', { align: 'center' });
  doc.moveDown();

  let y = 100;
  for (const row of data.rows) {
    if (y > 700) {
      doc.addPage();
      y = 50;
    }
    doc.fontSize(10).text(`${row.date}  ${row.customer}  ${row.amount}`, 50, y);
    y += 20;
  }
  return doc;
}
// reports/inventoryReport.ts
export async function generateInventoryReportPdf(data: InventoryReportData) {
  const doc = new PDFDocument();
  doc.fontSize(18).text('在庫レポート', { align: 'center' });
  doc.moveDown();

  let y = 100;
  for (const row of data.rows) {
    if (y > 700) {
      doc.addPage();
      y = 50;
    }
    doc.fontSize(10).text(`${row.sku}  ${row.name}  ${row.qty}`, 50, y);
    y += 20;
  }
  return doc;
}

y > 700になったら改ページする、というページング処理がレポートごとに丸ごとコピーされています。動作としては正しく、開発スピードも速いのですが、ページング処理を1箇所直したくなったとき、レポートの数だけ同じ修正を繰り返す羽目になります。プロトタイプや、今後増える見込みのない一度きりの帳票であれば、この速度優先のアプローチは十分に合理的です。

「ライブラリを作る」基準で考える

同じ機能を、社内向けの汎用ライブラリを設計するつもりで書き直すと、ページングや描画位置の管理をレポート内容から切り離したAPIとして抽出できます。

// lib/reportBuilder.ts
export class ReportBuilder {
  private doc = new PDFDocument();
  private y = 100;

  constructor(
    title: string,
    private pageBottom = 700
  ) {
    this.doc.fontSize(18).text(title, { align: 'center' });
    this.doc.moveDown();
  }

  addRow(cells: string[], columnWidths: number[]) {
    if (this.y > this.pageBottom) {
      this.doc.addPage();
      this.y = 50;
    }
    let x = 50;
    cells.forEach((cell, i) => {
      this.doc.fontSize(10).text(cell, x, this.y);
      x += columnWidths[i];
    });
    this.y += 20;
  }

  build(): PDFDocument {
    return this.doc;
  }
}
// reports/salesReport.ts
export async function generateSalesReportPdf(data: SalesReportData) {
  const report = new ReportBuilder('売上レポート');
  for (const row of data.rows) {
    report.addRow([row.date, row.customer, String(row.amount)], [80, 150, 80]);
  }
  return report.build();
}

ReportBuilderは「タイトルと行データを渡せばページングつきのPDFを組み立てる」という、売上・在庫どちらのドメイン知識も持たないAPIとして設計されています。ページングのバグを直すべき場所はReportBuilderの1箇所だけになり、3つ目・4つ目のレポートを追加するコストも、行データと列幅を用意するだけまで下がります。呼び出し元は今のところ社内の2つのレポートだけですが、「もし将来、別のレポートや別のプロジェクトから呼ばれても困らないか」を基準にAPIの形(引数・戻り値・責務の境界)を決めている点が、直接実装との決定的な違いです。

どこにこの基準を持ち込むべきか

CRUD中心の業務システムの大部分——1画面1機能のフォーム処理や、単純な一覧・検索クエリ——では、この投資は割に合わないことがほとんどです。呼び出し元が実質1つしかなく、将来増える見込みも薄いコードにまで汎用API設計のコストをかけると、開発速度が落ちるだけで得るものがありません。

この基準が明確に効くのは、「見た目や中身は違うが、構造は同じものが繰り返し登場する」領域です。帳票出力はその典型で、レポートごとに中身のデータは違っても、ページング・ヘッダー・フッターといった構造は共通しています。UIライブラリの設計を扱った記事で見たノードエディタの部品設計も、実質的には同じ基準を個々のUI部品に適用した例です。ボタンやテーブルのような部品は、画面ごとの使われ方は違っても部品自体の責務は共通しており、呼び出し元(利用する画面)が今後も増え続けることが最初から分かっています。

判断基準

実務で迷ったときに使える問いは2つあります。「このコードの呼び出し元は、今後増える見込みがあるか」、そして「中身は違うが構造が同じパターンが、すでに2箇所以上で繰り返されているか」です。どちらの答えもNoなら、直接実装のまま重複を許容した方が速く、無駄がありません。どちらかがYesに変わった瞬間——3つ目の帳票を追加することになった、3つ目の画面で同じUIパーツが必要になった——が、直接実装からライブラリ的な設計に切り替えるべきタイミングのサインです。

まとめ

「ライブラリを作る」という基準は、実際にnpmパッケージとして公開することを意味するわけではありません。社内の1機能を実装するときに、それを外部の誰かが使う汎用ライブラリだと仮定して、呼び出し元の事情に依存しないAPIとして設計するという思考実験です。呼び出し元が本当に1つしかない現時点でこの投資をするかどうかは、将来同じ構造の呼び出し元が増える見込みがあるかどうかで判断するのが、最も実用的な線引きです。