はじめに
当社(合同会社ジョインクラス)は、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つだけ覚えればいいです。
- 不変(作ったら変えられない)
-
同値性は値で判定(参照ではなく中身で
equals) - 不正な値は生成時に弾く(存在するなら必ず正しい)
// 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. 移植するときの順番
既存スクリプトをいきなり全部書き直すと壊れます。当社でやった順番はこうです。
-
Moneyだけ導入する。 文字列の金額が触れられている箇所をMoney.parseに置き換え、format()で出力。これだけでNaN問題は消える -
Repositoryをインターフェースで切る。 既存のファイル読み書きを
JsonSalesReportRepositoryに移動。呼び出し側はまだ生オブジェクト -
Entityを作る。 業務ルール(減少拒否など)を
refreshに移す - テストを書く。 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ステップずつ検証して進める」やり方の全体像はこちらです。