1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DDDのValue Object / Entity / RepositoryをTypeScriptで書く — 売上レポート自動化スクリプトを題材に

1
Posted at

はじめに

当社(合同会社ジョインクラス)は、Zenn書籍・Kindle書籍の売上取得、SNS投稿キューの生成、朝のダイジェスト作成まで、業務の約98%をClaude Codeとlaunchdのスクリプトで回しています。

その中で一番最初に書いたのが「Zennの売上ダッシュボードをPlaywrightで開いて、金額を正規表現で抜いてJSONで吐く」スクリプトでした。動いているので放置していたのですが、Kindle側の売上スクリプトを足し、朝ダイジェストで両方を合算しようとしたときに、こんなコードが生まれました。

// 実際にあったコード(抜粋)
const report = {
  date: new Date().toISOString().slice(0, 10),
  receivable: data.receivable ? `¥${data.receivable}` : "¥0",
  currentMonth: data.currentMonth ? `¥${data.currentMonth}` : "不明",
};

金額が "¥14,756" という表示用文字列のまま保存され、合算するときに parseInt(s.replace(/[¥,]/g, "")) を3箇所で書き、"不明" が混ざって NaN になり、ダイジェストに「今月の収益: ¥NaN」と出ました。

この記事では、このスクリプトを題材に、DDDの3つの基本パターン(Value Object / Entity / Repository)をTypeScriptで実装します。理論の説明は最小限にして、今日このまま自分のスクリプトに移植できる粒度で書きます。

前提環境

node -v   # v22.x
npm init -y
npm i -D typescript tsx vitest @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --strict

package.json に "type": "module" を追加してください(トップレベル await と .js 拡張子付きimportのため)。

tsconfig.json で strict: true にしておくのは必須です。後述の readonly と判別可能ユニオンは strict なしだとほぼ意味がありません。

ディレクトリは次の形にします。

src/
  domain/
    money.ts            # Value Object
    sales-report.ts     # Entity
    sales-report-repository.ts  # Repositoryインターフェース
  infra/
    json-sales-report-repository.ts  # ファイル実装
  main.ts

1. Value Object: Money

Value Objectの要件は3つだけ覚えればいいです。

  1. 不変(作ったら変えられない)
  2. 同値性は値で判定(参照ではなく中身で equals)
  3. 不正な値は生成時に弾く(存在するなら必ず正しい)
// src/domain/money.ts
export class Money {
  private constructor(
    private readonly amountInYen: number,
    public readonly currency: "JPY" = "JPY",
  ) {}

  static yen(amount: number): Money {
    if (!Number.isInteger(amount)) {
      throw new Error(`Money must be an integer in yen: ${amount}`);
    }
    if (amount < 0) {
      throw new Error(`Money must not be negative: ${amount}`);
    }
    return new Money(amount);
  }

  /** "¥14,756" や "14756" のような表示文字列をパースする */
  static parse(text: string): Money {
    const digits = text.replace(/[¥,\s]/g, "");
    if (!/^\d+$/.test(digits)) {
      throw new Error(`Cannot parse money: "${text}"`);
    }
    return Money.yen(Number(digits));
  }

  static zero(): Money {
    return Money.yen(0);
  }

  add(other: Money): Money {
    return Money.yen(this.amountInYen + other.amountInYen);
  }

  equals(other: Money): boolean {
    return this.amountInYen === other.amountInYen && this.currency === other.currency;
  }

  toNumber(): number {
    return this.amountInYen;
  }

  /** 表示は最後に1回だけ */
  format(): string {
    return `¥${this.amountInYen.toLocaleString("ja-JP")}`;
  }
}

ポイントは private constructor です。new Money(-100) を書けなくして、入口を yen() と parse() に限定します。これで「Money 型の値が手元にある時点で、それは整数かつ0以上」と信じてよくなります。

元のスクリプトで "不明" が混ざった問題は、parse が例外を投げるのでダッシュボードのHTML構造が変わった瞬間に落ちるようになります。黙って NaN を出し続けるより、朝ダイジェストが失敗してSlackに通知が飛ぶほうが圧倒的に安全です。

落とし穴: readonly だけでは不変にならない

class Bad {
  constructor(public readonly items: string[]) {}
}
const b = new Bad(["a"]);
b.items.push("b"); // コンパイルも実行も通る

readonly は再代入を禁止するだけで、配列やオブジェクトの中身は守りません。配列を持つValue Objectは ReadonlyArray<T> を使い、コンストラクタで [...items] とコピーしてください。

2. Entity: SalesReport

Entityは「IDで同一性を判定するもの」です。当社の売上レポートは「2026-10-04のZennのレポート」のように日付とチャネルで一意になるので、これを合成IDにします。

// src/domain/sales-report.ts
import { Money } from "./money.js";

export type SalesChannel = "zenn" | "kindle";

export type SalesReportId = `${SalesChannel}:${string}`; // "zenn:2026-10-04"

export interface PurchaseEvent {
  readonly bookTitle: string;
  readonly price: Money;
}

export class SalesReport {
  private constructor(
    public readonly id: SalesReportId,
    public readonly channel: SalesChannel,
    public readonly date: string, // YYYY-MM-DD
    private _monthToDate: Money,
    private readonly _events: PurchaseEvent[],
  ) {}

  static create(input: {
    channel: SalesChannel;
    date: string;
    monthToDate: Money;
    events?: PurchaseEvent[];
  }): SalesReport {
    if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date)) {
      throw new Error(`Invalid date: ${input.date}`);
    }
    // 型注釈を付けることで文字列リテラル型として推論される(注釈なしだと string になり代入エラー)
    const id: SalesReportId = `${input.channel}:${input.date}`;
    return new SalesReport(
      id,
      input.channel,
      input.date,
      input.monthToDate,
      [...(input.events ?? [])],
    );
  }

  get monthToDate(): Money {
    return this._monthToDate;
  }

  get events(): ReadonlyArray<PurchaseEvent> {
    return this._events;
  }

  /** 再取得で売上が増えていたら更新する。減ることはないので減少は拒否 */
  refresh(monthToDate: Money, events: PurchaseEvent[]): void {
    if (monthToDate.toNumber() < this._monthToDate.toNumber()) {
      throw new Error(
        `monthToDate decreased: ${this._monthToDate.format()} -> ${monthToDate.format()}`,
      );
    }
    this._monthToDate = monthToDate;
    this._events.splice(0, this._events.length, ...events);
  }

  equals(other: SalesReport): boolean {
    return this.id === other.id;
  }
}

Value Objectとの違いを表で整理します。

Value Object (Money) Entity (SalesReport)
同一性 値が同じなら同じ IDが同じなら同じ
可変性 不変。変更は新インスタンス 可変。ただしメソッド経由のみ
不変条件の置き場 ファクトリ (yen, parse) ファクトリ + 状態変更メソッド (refresh)

refresh に「売上は減らない」というルールを埋めたのは実体験からです。Zennのダッシュボードはスクロール前に取得すると前月の値が拾われることがあり、一度「今月の収益が減った」レポートが保存されてダイジェストが混乱しました。この種の業務ルールをEntityのメソッドに書いておくと、スクレイピングの不安定さがドメイン層で止まります。

落とし穴: equals を === で代用しない

JSON.parse でファイルから復元したEntityと、今スクレイプしたEntityは別インスタンスです。=== は常に false になります。Entityは必ず id で比較するメソッドを用意してください。

3. Repository: インターフェースと実装を分ける

Repositoryは「Entityをコレクションのように扱える窓口」です。ドメイン層にはインターフェースだけ置き、ファイルかDBかはインフラ層が決めます。

// src/domain/sales-report-repository.ts
import type { SalesReport, SalesReportId, SalesChannel } from "./sales-report.js";

export interface SalesReportRepository {
  findById(id: SalesReportId): Promise<SalesReport | null>;
  findByChannel(channel: SalesChannel): Promise<SalesReport[]>;
  save(report: SalesReport): Promise<void>;
}

当社はDBを持たず、.company/ 配下のJSONとMarkdownがすべてなので、実装はファイルです。

// src/infra/json-sales-report-repository.ts
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { Money } from "../domain/money.js";
import { SalesReport } from "../domain/sales-report.js";
import type { SalesReportId, SalesChannel } from "../domain/sales-report.js";
import type { SalesReportRepository } from "../domain/sales-report-repository.js";

/** ファイルに書く形。ドメインオブジェクトとは別物として定義する */
interface SalesReportRecord {
  id: SalesReportId;
  channel: SalesChannel;
  date: string;
  monthToDate: number;
  events: { bookTitle: string; price: number }[];
}

export class JsonSalesReportRepository implements SalesReportRepository {
  constructor(private readonly filePath: string) {}

  async findById(id: SalesReportId): Promise<SalesReport | null> {
    const records = await this.readAll();
    const record = records.find((r) => r.id === id);
    return record ? toDomain(record) : null;
  }

  async findByChannel(channel: SalesChannel): Promise<SalesReport[]> {
    const records = await this.readAll();
    return records.filter((r) => r.channel === channel).map(toDomain);
  }

  async save(report: SalesReport): Promise<void> {
    const records = await this.readAll();
    const next = records.filter((r) => r.id !== report.id);
    next.push(toRecord(report));
    await mkdir(dirname(this.filePath), { recursive: true });
    await writeFile(this.filePath, JSON.stringify(next, null, 2));
  }

  private async readAll(): Promise<SalesReportRecord[]> {
    try {
      return JSON.parse(await readFile(this.filePath, "utf8"));
    } catch (e) {
      if ((e as NodeJS.ErrnoException).code === "ENOENT") return [];
      throw e;
    }
  }
}

function toDomain(r: SalesReportRecord): SalesReport {
  return SalesReport.create({
    channel: r.channel,
    date: r.date,
    monthToDate: Money.yen(r.monthToDate),
    events: r.events.map((e) => ({ bookTitle: e.bookTitle, price: Money.yen(e.price) })),
  });
}

function toRecord(report: SalesReport): SalesReportRecord {
  return {
    id: report.id,
    channel: report.channel,
    date: report.date,
    monthToDate: report.monthToDate.toNumber(),
    events: report.events.map((e) => ({ bookTitle: e.bookTitle, price: e.price.toNumber() })),
  };
}

重要なのは SalesReportRecord をドメインの型と別に定義している点です。Money クラスをそのまま JSON.stringify すると {"amountInYen":14756,"currency":"JPY"} になり、復元時に Money のメソッドが消えます。永続化の形はインフラ層の都合なので、変換関数 toDomain / toRecord を挟んで隔離します。

4. 組み立てる

スクレイピング部分は元のスクリプトのままで、取得した生データを境界で Money.parse に通すだけです。

// src/main.ts
import { Money } from "./domain/money.js";
import { SalesReport } from "./domain/sales-report.js";
import { JsonSalesReportRepository } from "./infra/json-sales-report-repository.js";

// scrapeZenn() はPlaywrightで { currentMonth: "14,756", events: [...] } を返す既存関数
const raw = await scrapeZenn();

const today = new Date().toISOString().slice(0, 10);
const repo = new JsonSalesReportRepository(`${process.env.HOME}/.config/ai-ceo/sales-reports.json`);

const monthToDate = Money.parse(raw.currentMonth);
const events = raw.events.map((e) => ({ bookTitle: e.title, price: Money.parse(e.price) }));

const existing = await repo.findById(`zenn:${today}`);
if (existing) {
  existing.refresh(monthToDate, events);
  await repo.save(existing);
} else {
  await repo.save(SalesReport.create({ channel: "zenn", date: today, monthToDate, events }));
}

// 朝ダイジェスト用の合算。もう parseInt は書かない
const [zenn, kindle] = await Promise.all([
  repo.findByChannel("zenn"),
  repo.findByChannel("kindle"),
]);
const total = [...zenn, ...kindle]
  .filter((r) => r.date === today)
  .reduce((sum, r) => sum.add(r.monthToDate), Money.zero());

console.log(`今月の収益合計: ${total.format()}`);
npx tsx src/main.ts
# 今月の収益合計: ¥21,403

5. 検証: テストはドメイン層だけに集中させる

Repositoryをインターフェースにした最大の恩恵は、Playwrightもファイルも起動せずにドメインのテストが書けることです。

// src/domain/money.test.ts
import { describe, it, expect } from "vitest";
import { Money } from "./money.js";

describe("Money", () => {
  it("表示文字列をパースできる", () => {
    expect(Money.parse("¥14,756").toNumber()).toBe(14756);
  });
  it("不明な文字列は例外", () => {
    expect(() => Money.parse("不明")).toThrow(/Cannot parse/);
  });
  it("値で等価判定する", () => {
    expect(Money.yen(100).equals(Money.yen(100))).toBe(true);
  });
  it("加算は新インスタンスを返す", () => {
    const a = Money.yen(100);
    const b = a.add(Money.yen(50));
    expect(a.toNumber()).toBe(100);
    expect(b.toNumber()).toBe(150);
  });
});
// src/domain/sales-report.test.ts
import { describe, it, expect } from "vitest";
import { Money } from "./money.js";
import { SalesReport } from "./sales-report.js";

describe("SalesReport", () => {
  it("売上の減少を拒否する", () => {
    const r = SalesReport.create({ channel: "zenn", date: "2026-10-04", monthToDate: Money.yen(1000) });
    expect(() => r.refresh(Money.yen(900), [])).toThrow(/decreased/);
  });
  it("IDが同じなら同一", () => {
    const a = SalesReport.create({ channel: "zenn", date: "2026-10-04", monthToDate: Money.yen(1) });
    const b = SalesReport.create({ channel: "zenn", date: "2026-10-04", monthToDate: Money.yen(2) });
    expect(a.equals(b)).toBe(true);
  });
});
npx vitest run
# ✓ src/domain/money.test.ts (4)
# ✓ src/domain/sales-report.test.ts (2)

Repositoryのテストが必要なら、一時ディレクトリに書き込む統合テストを1本だけ書けば十分です。

6. 移植するときの順番

既存スクリプトをいきなり全部書き直すと壊れます。当社でやった順番はこうです。

  1. Money だけ導入する。 文字列の金額が触れられている箇所を Money.parse に置き換え、format() で出力。これだけで NaN 問題は消える
  2. Repositoryをインターフェースで切る。 既存のファイル読み書きを JsonSalesReportRepository に移動。呼び出し側はまだ生オブジェクト
  3. Entityを作る。 業務ルール(減少拒否など)を refresh に移す
  4. テストを書く。 1〜3の順で書くと、テストしやすい順にもなっている

Claude Codeに「このスクリプトをDDDで書き直して」と丸投げすると、一気に4までやろうとして動かないコードが出てきます。1ステップずつ指示し、各ステップで npx tsx と npx vitest run を通してから次に進むのが確実です。

おわりに

自動化スクリプトは「動いたら放置」になりがちですが、2本目・3本目が生まれた瞬間に型のない値が横断し始めます。Value Objectで値の正しさを、Entityで業務ルールを、Repositoryで保存先の都合を、それぞれ1箇所に閉じ込めておくと、AIに追加開発を頼むときも「Money を使って」の一言で済むようになります。

このあたりの「AIに書かせるコードの設計をどう保つか」「どの単位で検証を挟むか」は、当社の実践を書籍にまとめています。

📘 『AI駆動開発ライフサイクル(AI-DLC)の教科書』
AIと一緒にコードを書くときの、設計→実装→検証のサイクルの回し方を解説しています。本記事の「1ステップずつ検証して進める」やり方の全体像はこちらです。

書籍一覧: https://zenn.dev/joinclass?tab=books

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?