メインコンテンツまでスキップ

第86章:時間の設計:Clock注入⏰🧪

時間って、コードの中では めちゃ地雷 です💣😵‍💫 とくに「締切」「有効期限」「キャンセル可能時間」「〇分以内」みたいな仕様が入った瞬間、Date.now() 直呼びがじわじわ効いてきます…🥲

この章は、その地雷を踏まないための Clock注入(クロックちゅうにゅう) を、カフェ注文ドメインで手を動かしながら身につける回だよ☕🧡


1) まず結論:時間は「依存」だよ⏳➡️🔌

「今(now)」は値じゃなくて、外から来る依存だと思うとラクになるよ✨ だから…

  • Date.now() をドメインで直に呼ぶ
  • clock.now() を呼ぶ(Clockを注入する)

に変えるだけで、テストが急に平和になります🕊️🌸


2) なぜ Date.now() 直呼びがダメになりやすいの?😵‍💫

事故① テストが不安定(たまに落ちる)🎲

「1msズレただけ」で境界テストがコケたりするの、あるある🥺

事故② “境界”が鬼門(10分以内って、ちょうど10分はOK?)⏱️😈

仕様の日本語が曖昧だと、実装もテストもブレるよね💦 なので、境界をコードで固定していく必要があるよ✅

事故③ タイムゾーン・DST(夏時間)で爆発💥🌍

時間は「世界」を持ってるので、雑に扱うといつか爆発する… (この章はまず「今」を固定するのが主役!でも後半で注意点もまとめるよ😊)


3) 今日のゴール🎯✨

  • 「ドメインの中から Date.now() を追放」できる🏃‍♀️💨
  • 期限判定や“〇分以内”を テストで100%再現できる🧪✅
  • ついでに「時間系の設計の型」を覚える📦

4) Clock注入の“型”🧩🧠

✅ 基本形(おすすめ)

  • domainClock インターフェースを置く
  • infraSystemClock(本物の時計)を置く
  • testFixedClock(止まった時計)を置く

こうすると依存がきれいに守れるよ🏰➡️🧱


5) 例題:注文は「10分以内ならキャンセルOK」☕🧾⏰

仕様をこう決めちゃおう👇(曖昧さを潰すの大事!)

  • 「10分以内」= 作成時刻 + 10分 まではOK
  • それより後(>)はNG

つまり、now > createdAt + 10min なら失敗💥 (now === limit はOK✨)


6) 実装してみよう(Clock注入フルセット)🧑‍💻💖

6.1 domain:Clock と “時刻VO” を用意する⏰📦

ポイント:この章では UTCの瞬間(epoch ms) として扱うとラクだよ🙂 「表示」や「ローカル時刻」は外側でやる感じ!

// src/domain/time/Clock.ts
export interface Clock {
now(): UtcInstant;
}

// src/domain/time/UtcInstant.ts
export class UtcInstant {
private constructor(public readonly epochMs: number) {}

static fromEpochMs(epochMs: number): UtcInstant {
if (!Number.isFinite(epochMs)) throw new Error("epochMs must be a finite number");
return new UtcInstant(epochMs);
}

addMinutes(minutes: number): UtcInstant {
if (!Number.isFinite(minutes)) throw new Error("minutes must be finite");
return UtcInstant.fromEpochMs(this.epochMs + minutes * 60_000);
}

isAfter(other: UtcInstant): boolean {
return this.epochMs > other.epochMs;
}
}

6.2 domain:キャンセル失敗を表す軽い例外🧯(次章の布石)

次の第87章で「例外作法」をガッツリやるけど、ここでは最小でOK✨

// src/domain/errors/DomainRuleError.ts
export class DomainRuleError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(message);
this.name = "DomainRuleError";
}
}

6.3 domain:Order に「キャンセル」ルールを閉じ込める🛡️

// src/domain/order/Order.ts
import { Clock } from "../time/Clock";
import { UtcInstant } from "../time/UtcInstant";
import { DomainRuleError } from "../errors/DomainRuleError";

export type OrderStatus = "Draft" | "Confirmed" | "Paid" | "Canceled";

export class Order {
private status: OrderStatus;

private constructor(
public readonly id: string,
private readonly createdAt: UtcInstant,
status: OrderStatus,
) {
this.status = status;
}

static create(id: string, clock: Clock): Order {
return new Order(id, clock.now(), "Draft");
}

getStatus(): OrderStatus {
return this.status;
}

cancel(clock: Clock): void {
if (this.status === "Paid") {
throw new DomainRuleError("ORDER_CANNOT_CANCEL_PAID", "支払い済みの注文はキャンセルできません🥲");
}
if (this.status === "Canceled") {
throw new DomainRuleError("ORDER_ALREADY_CANCELED", "すでにキャンセル済みです🙇‍♀️");
}

const limit = this.createdAt.addMinutes(10);
const now = clock.now();

// 「10分以内」= limit まではOK、超えたらNG
if (now.isAfter(limit)) {
throw new DomainRuleError("ORDER_CANCEL_WINDOW_EXPIRED", "キャンセル期限(10分)を過ぎました⏰💦");
}

this.status = "Canceled";
}
}

ここがめちゃ重要👇✨ 「時間に依存するルール」でも Order が自分で守ってる🛡️ アプリ層が if で頑張らないのがDDDっぽい💖


6.4 infra:SystemClock(本物の時計)🕰️

// src/infra/time/SystemClock.ts
import { Clock } from "../../domain/time/Clock";
import { UtcInstant } from "../../domain/time/UtcInstant";

export class SystemClock implements Clock {
now(): UtcInstant {
return UtcInstant.fromEpochMs(Date.now());
}
}

6.5 test:FixedClock(止まった時計)🧊⏰

// src/test/time/FixedClock.ts
import { Clock } from "../../domain/time/Clock";
import { UtcInstant } from "../../domain/time/UtcInstant";

export class FixedClock implements Clock {
constructor(private current: UtcInstant) {}

now(): UtcInstant {
return this.current;
}

setNow(next: UtcInstant): void {
this.current = next;
}
}

7) テスト(Vitest)で境界までバッチリ🧪💯

Vitest は Fake Timers とか setSystemTime があるけど、そもそも今回みたいに Clock注入してると、グローバルをいじらずに済むのが強みだよ😊 (Vitest側のFake Timers APIは vi.useFakeTimers()vi.setSystemTime(...) が用意されてるよ📌)(vitest.dev)

// src/domain/order/Order.test.ts
import { describe, it, expect } from "vitest";
import { Order } from "./Order";
import { FixedClock } from "../../test/time/FixedClock";
import { UtcInstant } from "../time/UtcInstant";
import { DomainRuleError } from "../errors/DomainRuleError";

describe("Order.cancel", () => {
it("作成から10分以内ならキャンセルできる✅", () => {
const t0 = UtcInstant.fromEpochMs(1_700_000_000_000); // 適当な固定時刻
const clock = new FixedClock(t0);

const order = Order.create("order-1", clock);

// ちょうど10分後(OK)
clock.setNow(t0.addMinutes(10));
order.cancel(clock);

expect(order.getStatus()).toBe("Canceled");
});

it("10分を1msでも超えるとキャンセルできない⛔", () => {
const t0 = UtcInstant.fromEpochMs(1_700_000_000_000);
const clock = new FixedClock(t0);

const order = Order.create("order-1", clock);

// 10分 + 1ms(NG)
clock.setNow(UtcInstant.fromEpochMs(t0.epochMs + 10 * 60_000 + 1));

expect(() => order.cancel(clock)).toThrowError(DomainRuleError);
});

it("支払い済みはキャンセル不可💳⛔", () => {
const t0 = UtcInstant.fromEpochMs(1_700_000_000_000);
const clock = new FixedClock(t0);

// ここは簡略化:テスト用に「Paid状態のOrder」を作る方法が必要だけど、
// 章の主題じゃないので、実プロジェクトなら factory / helper を用意しよう😊
});
});

※最後のテストみたいに「状態を作る」話が出てくるので、後々 Factoryテスト用ビルダー が効いてくるよ🏭🧪✨


8) 「Fake Timers」より Clock注入が気持ちいい理由💖

VitestのFake Timersは便利で、Date.nownew Date() を丸ごと偽装できるよ🪄(vitest.dev) でも、ドメイン設計としてはこうなりがち👇

  • Fake Timers:テストの都合でグローバルを書き換える(便利だけど依存が隠れる😶‍🌫️)
  • Clock注入:設計として「時間は依存」って表現できる(読みやすい&堅い🛡️)

もちろん、UI側で setTimeout をテストしたい等はFake Timersが向いてるよ🙆‍♀️ でも 期限判定・締切・状態遷移は、Clock注入が相性最高✨


9) 時刻APIの最新トピック:Temporal ってどうなの?🗓️✨

最近のJavaScriptでは Date の置き換えとして Temporal が進んでるよ📈 Temporalは「日時の型を分けて、バグを減らす」設計で、TC39側でも思想がはっきりしてる✨(tc39.es)

ただし、MDNでは互換性の都合で “Limited availability” になっていて、環境によってはまだ注意が必要だよ⚠️(MDN ウェブドキュメント) (なのでこの教材では、まず Clock注入で“今”を制御できるようにしておくのが超現実的😊)


10) よくある落とし穴まとめ(ここだけで事故が減る)🧯✨

  • ドメインは「UTCの瞬間(epoch)」で持つ(表示は外で)
  • new Date("2026-02-07") みたいな 文字列パースをドメインで多用しない(環境差が出やすい)
  • ✅ 「10分以内」みたいな仕様は 境界(ちょうどは?)を先に確定
  • ✅ “今”が必要なら clock.now() に寄せる

11) ミニ演習🎓✨(手を動かすと定着するよ🧡)

演習A:支払い期限(30分)を入れてみよう💳⏰

  • ルール:「作成から30分過ぎた注文は支払い不可」
  • Order.pay(clock) にガードを追加
  • テストは「29:59はOK」「30:00ちょうどOK/NG(決める)」「30:00+1msはNG」まで作る✅

演習B:仕様の言葉を揃える🗣️✨

  • 「期限」「締切」「猶予」「有効」 どれも似てるから、用語辞書に1行定義を追加してね📘💕

12) AIに頼むときのコツ(そのままコピペOK)🤖🧡

🧩 Clock導入のリファクタ依頼

DDDのdomain層から Date.now() / new Date() の直呼びを無くしたいです。
Clockインターフェースをdomainに置き、infraにSystemClock、testにFixedClockを置く構成で、
「期限判定」を clock.now() で書き直してください。
ただし、domainはinfraに依存しないようにしてください。

🧪 境界テストを作らせる

「作成から10分以内ならキャンセルOK、超えたらNG」という仕様です。
境界(ちょうど10分はOK)を含めたVitestのテストケースを3〜5個提案してください。

まとめ🎉✨

Clock注入は、地味だけどめちゃ強いよ💪🥰 特に 期限・締切・時間窓・状態遷移があるドメインでは、最初から入れる価値が高い🧡

次の第87章は、今日ちょっと出てきた「ドメイン例外」を もっと綺麗に整える作法に入っていくよ🧯📌✨