日付フォーマットの前に設計すべきこと:PlainDate / Instant / TimeZone を分離するカレンダーアーキテクチャ
カレンダー機能を実装するとき、多くのコードベースでは最初に次のような処理が登場します。
new Date().toLocaleDateString("ja-JP")
単に今日の日付を画面へ表示するだけなら、これで十分です。
しかし、システムが少しでも成長すると、問題は「日付をどう表示するか」ではなくなります。
例えば次のような要件です。
- ユーザーが選択した
2027-01-01は時刻を持つのか - その日付はどのタイムゾーンに属するのか
- UTC に変換する必要があるのか
- ISO Week Year の年境界をどう扱うか
- Gregorian / Julian calendar の変換をどこで行うか
- Locale と Calendar System を分離できているか
- DST の gap / overlap をどう解決するか
- Web と Print が同じ calendar calculation を共有しているか
- JSON / CSV / ICS の出力が domain logic を再実装していないか
- 「1日後」と「24時間後」を同じ操作として扱っていないか
この段階まで来ると、これは formatting の問題ではありません。
Temporal Domain Modeling と Software Architecture の問題です。
この記事では、カレンダーシステムを単なる UI コンポーネントとしてではなく、
Temporal Primitives
↓
Calendar Domain
↓
Application Model
↓
Rendering / Serialization
というレイヤー構造として設計します。
1. 最初に分離すべき3つの概念
カレンダーシステムの設計で最初に区別したいのは、次の3つです。
PlainDate
Instant
TimeZone
これらは似ていますが、意味はまったく異なります。
PlainDate
例えば、
2027-01-01
は「カレンダー上の日」です。
そこには時刻もタイムゾーンも存在しません。
type PlainDate = {
year: number
month: number
day: number
}
誕生日、祝日、請求日、締切日、月間カレンダー上の日セルなどは、多くの場合 PlainDate として考えた方が自然です。
Instant
一方、
2027-01-01T00:00:00Z
はタイムライン上の一点です。
type Instant = {
epochNanoseconds: bigint
}
Instant は、
世界中のどこから見ても同じ瞬間
を表します。
ログ、イベント発生時刻、トランザクションの記録、API のタイムスタンプなどはこちらです。
TimeZone
Time Zone は Instant でも PlainDate でもありません。
type TimeZoneId = string
例えば、
Asia/Tokyo
Europe/London
America/New_York
のような zone identifier と、歴史的な timezone rule の集合です。
2. 2027-01-01 を UTC midnight に変換してはいけない理由
よくある設計は、日付だけの値を内部的にこう保存する方法です。
2027-01-01
↓
2027-01-01T00:00:00Z
一見すると便利です。
しかし、もともと存在しなかった「UTC の午前0時」という意味を追加しています。
例えば誕生日が、
1995-08-12
だったとします。
これは通常、
1995-08-12T00:00:00Z
という瞬間を意味しません。
単に、
August 12, 1995
という calendar date です。
つまり、
PlainDate ≠ Instant
です。
この違いを型で表現できないシステムでは、後から timezone conversion が追加された瞬間にバグが発生しやすくなります。
3. Temporal Model を変換グラフとして考える
すべてを Date 型へ押し込むのではなく、時間に関する値の変換関係を明示すると設計が安定します。
┌──────────────┐
│ PlainDate │
└──────┬───────┘
│
│ + PlainTime
▼
┌───────────────────┐
│ PlainDateTime │
└────────┬──────────┘
│
│ + TimeZone
▼
┌───────────────────┐
│ ZonedDateTime │
└────────┬──────────┘
│
│ resolve
▼
┌─────────────┐
│ Instant │
└─────────────┘
ここで重要なのは、
すべての変換が完全に可逆とは限らない
という点です。
例えば、
Instant
+
TimeZone
→
ZonedDateTime
は基本的に決定できます。
しかし、
PlainDateTime
+
TimeZone
→
Instant
は必ずしも一意ではありません。
その理由が DST です。
4. Time Zone は UTC Offset ではない
次のようなモデルは一見シンプルです。
type TimeZone = {
offsetMinutes: number
}
しかし実際の timezone modeling としては不十分です。
例えば、
America/New_York
と、
UTC-05:00
は同じ意味ではありません。
固定 offset は単なる offset です。
一方、Time Zone は概念的には、
Instant
→
UTC Offset
を時刻によって変化させる rule set です。
DST や歴史的な timezone rule の変更によって、同じ地域でも offset は時間によって変化します。
したがって、
TimeZoneId
と、
UTC Offset
は別の型として扱うべきです。
5. DST Gap:存在しないローカル時刻
DST 開始時には、一部のローカル時刻が存在しなくなる場合があります。
概念的には、
01:59
↓
03:00
のように時計が進みます。
この場合、
02:30
という local time は存在しません。
つまり、
PlainDateTime
+
TimeZone
→
Instant
という変換は、失敗する可能性があります。
そのため API は resolution policy を持つ必要があります。
type GapResolution =
| "reject"
| "shift-forward"
| "shift-backward"
例えば、
resolveLocalDateTime(localDateTime, timeZone, {
gap: "reject"
})
のように、呼び出し側が明示的に選択できる方が安全です。
暗黙に1時間進める API は便利ですが、金融処理や予約システムでは危険です。
6. DST Overlap:2回存在するローカル時刻
DST 終了時には逆の問題があります。
例えば、
01:59
↓
01:00
のように時計が戻るとします。
この場合、
01:30
が2回存在する可能性があります。
つまり、
2027-11-07 01:30
だけでは、どちらの Instant なのか決まりません。
設計としては、
type OverlapResolution =
| "earlier"
| "later"
| "reject"
のような policy が必要です。
resolveLocalDateTime(localDateTime, timeZone, {
overlap: "earlier"
})
この違いは小さく見えますが、
- 予約
- 決済
- ログ
- スケジューリング
- ジョブ実行
- 航空・交通システム
では非常に重要です。
7. Calendar System と Time Zone は完全に別の軸
Calendar System と Time Zone はしばしば同じ「国際化」のカテゴリに入れられますが、役割は異なります。
Time Zone は、
Instant を local date/time へマッピングする規則
です。
Calendar System は、
日を year / month / day として表現する規則
です。
例えば、
type CalendarId =
| "iso8601"
| "gregory"
| "julian"
そして、
type CalendarDate = {
calendar: CalendarId
year: number
month: number
day: number
}
とします。
ここで、
Asia/Tokyo
は Time Zone ですが、
gregory
は Calendar System です。
この2つを同じ abstraction に押し込むべきではありません。
8. Locale と Calendar System も別物
さらに、
Locale
と
Calendar System
も別です。
例えば Locale が、
ja-JP
だからといって、内部の calendar arithmetic まで locale に依存させる必要はありません。
Locale は主に presentation concern です。
例えば同じ PlainDate が、
2027年1月1日
January 1, 2027
1 janvier 2027
のように表示されます。
内部モデルは同じです。
const date: PlainDate = {
year: 2027,
month: 1,
day: 1
}
そして外側で、
formatDate(date, "ja-JP")
formatDate(date, "en-US")
formatDate(date, "fr-FR")
のように変換します。
つまり、
Domain
↓
Localization
↓
String
です。
9. String を Domain Model にしない
次のようなデータがあります。
{
"date": "01/02/27"
}
これは非常に危険です。
Locale によって、
January 2, 2027
かもしれませんし、
February 1, 2027
かもしれません。
さらに、
2027-01-02
という形式であっても、
それが、
PlainDate
なのか、
UTC midnight Instant
なのかは別問題です。
API schema では意味まで定義する必要があります。
例えば、
{
"date": {
"calendar": "iso8601",
"year": 2027,
"month": 1,
"day": 2
}
}
のように構造化する方法もあります。
あるいは API contract が十分に明確なら、
{
"date": "2027-01-02"
}
でも問題ありません。
重要なのは、文字列形式ではなく semantic contract です。
10. Gregorian / Julian Conversion は formatting ではない
Gregorian / Julian calendar の変換は UI の文字列変換ではありません。
例えば同じ absolute day が、
Gregorian Date
と、
Julian Date
で異なる year/month/day を持つ場合があります。
この種の変換では、
CalendarDate
↓
Absolute Day
↓
CalendarDate
という architecture が扱いやすくなります。
例えば、
type AbsoluteDay = number
function toAbsoluteDay(
date: CalendarDate
): AbsoluteDay
function fromAbsoluteDay(
absoluteDay: AbsoluteDay,
targetCalendar: CalendarId
): CalendarDate
という interface です。
すると、
Gregorian
→
AbsoluteDay
→
Julian
という形で変換できます。
このロジックを、
formatDate()
の中に入れてはいけません。
Calendar conversion は Domain Layer の責務です。
11. Calendar Arithmetic と Timeline Arithmetic
次の2つは似ています。
明日の同じ時刻
と、
24時間後
しかし同じ意味ではありません。
例えば DST transition をまたぐ場合、
zonedDateTime.add({
days: 1
})
と、
instant.add({
hours: 24
})
の結果が異なる可能性があります。
つまり、
Calendar Arithmetic
と、
Duration Arithmetic
は分離すべきです。
例えば API も、
addCalendarDays(date, 1)
と、
addDuration(instant, {
hours: 24
})
を別にします。
一見すると冗長ですが、意味が明確になります。
12. 「Month + 1」は単純な演算ではない
さらに、
2027-01-31 + 1 month
のような処理も policy が必要です。
結果を、
2027-02-28
にするのか、
error にするのか、
翌月へ overflow させるのか。
これも domain policy です。
例えば、
type OverflowPolicy =
| "constrain"
| "reject"
として、
addMonths(date, 1, {
overflow: "constrain"
})
のように扱えます。
Calendar API では、
計算式より policy の方が重要になるケースが多い
という点が特徴です。
13. 「年」は1種類ではない
次の property があるとします。
date.year
何の year でしょうか。
通常は calendar year です。
しかし ISO Week Date では、
week-based year
があります。
例えば年末年始では、
Calendar Year
と、
ISO Week Year
が異なる場合があります。
そのため、
type IsoWeekDate = {
weekYear: number
week: number
weekday: number
}
のように、
year
ではなく、
weekYear
と明示した方が安全です。
命名は単なる readability の問題ではありません。
異なる domain concept をコード上で区別するための仕組みです。
14. ISO Week Number は単純な dayOfYear / 7 ではない
例えば、
Math.ceil(dayOfYear / 7)
で week number を求める実装があります。
しかし ISO week はそのような単純なルールではありません。
基本的には、
- 週は月曜日始まり
- Week 1 は1月4日を含む週
- または、その年最初の木曜日を含む週
として定義できます。
したがって年末年始には、
Calendar Year
と、
Week Year
の境界がずれます。
この部分は特に test case を充実させる必要があります。
15. Domain Model を Immutable にする
Temporal object は immutable にした方が扱いやすいです。
interface PlainDate {
readonly year: number
readonly month: number
readonly day: number
}
日付を変更するときは、
function addDays(
date: PlainDate,
amount: number
): PlainDate
のように新しい値を返します。
例えば、
const tomorrow = addDays(today, 1)
です。
一方、
date.setDate(date.getDate() + 1)
のような mutable operation は、大きなシステムでは追跡が難しくなります。
特に object が、
Domain
↓
Application
↓
Component
と渡される場合、
どこで値が mutate されたのか分からなくなることがあります。
16. Domain Invariant を UI Validation にしない
例えば、
2027-02-29
は無効です。
しかし、
2028-02-29
は有効です。
これは UI の validation rule ではありません。
Calendar Domain の invariant です。
function isValidDate(
calendar: CalendarId,
year: number,
month: number,
day: number
): boolean
Domain Layer が保証するべきです。
なぜなら入力は UI からだけ来るとは限らないからです。
例えば、
API
CSV
Database
Batch
CLI
Import
などからもデータが入ります。
Domain invariant が UI にしか存在しないと、別経路から invalid state が作られます。
17. Leap Year は Calendar Rule
Gregorian calendar における leap year は、
単純に、
year % 4 === 0
ではありません。
例えば、
2000 → leap year
1900 → not leap year
2004 → leap year
2100 → not leap year
です。
そのため test は、
expect(isLeapYear(2000)).toBe(true)
expect(isLeapYear(1900)).toBe(false)
expect(isLeapYear(2004)).toBe(true)
expect(isLeapYear(2100)).toBe(false)
のように boundary case を含めます。
Calendar logic は happy path よりも boundary case が重要です。
18. Month Length も Renderer が計算してはいけない
例えば React component の中で、
if (month === 2) {
// ...
}
のように month length を計算し始めると、architecture が崩れます。
Month length は Calendar Domain の responsibility です。
function daysInMonth(
year: number,
month: number,
calendar: CalendarId
): number
Renderer は結果を受け取るだけにします。
19. 7×6 Calendar Grid を Domain/Application Layer で作る
月間カレンダーでは、
7 columns × 6 rows
の42セル grid がよく使われます。
例えば、
type DayCell = {
date: PlainDate
dayNumber: number
weekday: number
isCurrentMonth: boolean
}
そして、
type MonthGrid = {
year: number
month: number
rows: readonly DayCell[][]
}
という model を作ります。
Calendar calculation は、
function buildMonthGrid(
year: number,
month: number,
weekStartsOn: number
): MonthGrid
に集約します。
すると、
Calendar Rules
↓
MonthGrid
↓
Web Renderer
だけでなく、
Calendar Rules
↓
MonthGrid
↓
Print Renderer
でも同じ結果を使えます。
これが重要です。
Web と PDF が別々に、
「この月は何曜日から始まるか」
を計算してはいけません。
20. Renderer-Independent View Model を作る
Domain Object を React component へ直接渡す方法でも動作します。
しかし大規模になると、renderer-independent な model を1層挟むと扱いやすくなります。
例えば、
type CalendarViewModel = {
title: string
weeks: readonly WeekRow[]
}
そして、
Calendar Domain
↓
View Model
├── React
├── Vue
├── HTML
├── PDF
└── Image
とします。
この構造にすると、presentation layer の違いによって calendar arithmetic が変わらなくなります。
21. Web Rendering と Print Rendering は別の problem space
Web と Print は要求が異なります。
Web では、
- responsive layout
- interaction
- hover
- keyboard focus
- dynamic resizing
- locale switching
- accessibility
が重要です。
一方 Print では、
- physical page size
- margin
- pagination
- page break
- DPI
- bleed
- font metrics
- deterministic layout
が重要です。
したがって、
Calendar Domain
↓
Presentation Model
├── Web Renderer
└── Print Renderer
という architecture が自然です。
同じ HTML/CSS を無理に両方へ適用するより、共通の calendar model を使いながら renderer を分ける方が拡張しやすくなります。
22. Data Export も Renderer と考えられる
JSON、CSV、ICS は UI ではありません。
しかし architecture 上は、
Domain
↓
Representation
という意味で renderer に近い存在です。
例えば、
Calendar Domain
│
├── Web Renderer
├── Print Renderer
├── JSON Serializer
├── CSV Serializer
└── ICS Serializer
という構造です。
ここで非常に重要なのは、
JSON Serializer
が leap year や ISO week を再計算しないことです。
Calendar Rules は一箇所に置きます。
23. Serialization Contract を型ごとに分ける
日付バグの多くは serialization boundary でも発生します。
例えば、
JSON.stringify(new Date())
を実行すると、通常 ISO string へ変換されます。
つまり application が local time を扱っていたとしても、serialization 時に UTC representation へ変化する可能性があります。
そのため、
serializePlainDate()
serializeInstant()
serializeZonedDateTime()
を分けます。
例えば、
function serializePlainDate(
date: PlainDate
): string {
return [
date.year.toString().padStart(4, "0"),
date.month.toString().padStart(2, "0"),
date.day.toString().padStart(2, "0")
].join("-")
}
PlainDate に Z を付けてはいけません。
Z は UTC の Instant semantics を暗示するからです。
24. Clock を Dependency Injection する
現在時刻を直接取得するコードは testability を下げます。
例えば、
function isToday(date: PlainDate) {
const now = new Date()
// ...
}
では test が実行時刻に依存します。
代わりに、
interface Clock {
now(): Instant
}
を定義します。
Production:
class SystemClock implements Clock {
now(): Instant {
return getCurrentInstant()
}
}
Test:
class FixedClock implements Clock {
constructor(
private readonly value: Instant
) {}
now(): Instant {
return this.value
}
}
すると、
midnight
month end
year end
leap day
DST transition
を deterministic に test できます。
25. Temporal Test は境界値が中心になる
Date/Time code では happy path より boundary が重要です。
最低でも次の boundary を意識します。
00:00
23:59:59
month end
year end
leap day
DST start
DST end
ISO week-year boundary
calendar conversion boundary
timezone offset transition
例えば、
describe("addDays", () => {
it("crosses month boundary", () => {
expect(
addDays(
{ year: 2027, month: 1, day: 31 },
1
)
).toEqual({
year: 2027,
month: 2,
day: 1
})
})
})
Calendar system の品質は、
通常日の test 数よりも、
boundary condition をどれだけ設計できているか
で大きく変わります。
26. Property-Based Testing と Calendar Logic
日付処理は入力空間が巨大です。
例えば Gregorian calendar だけでも、年月日すべての combination を手動で test することは現実的ではありません。
そこで property-based testing が有効です。
例えば calendar conversion なら、
Date
→
Absolute Day
→
Date
が元に戻ることを invariant として定義できます。
概念的には、
property(
arbitraryValidCalendarDate(),
date => {
const absoluteDay =
toAbsoluteDay(date)
const restored =
fromAbsoluteDay(
absoluteDay,
date.calendar
)
assertEqual(restored, date)
}
)
です。
この方法なら、人間が思いつかなかった boundary case を発見できる可能性があります。
27. Round-Trip Invariant を積極的に使う
Temporal domain では round-trip property が非常に強力です。
例えば ISO Week Date なら、
PlainDate
→
IsoWeekDate
→
PlainDate
です。
const iso = toIsoWeekDate(date)
const restored =
fromIsoWeekDate(iso)
expect(restored).toEqual(date)
Serialization でも、
PlainDate
→
String
→
PlainDate
が成立するか確認できます。
const encoded =
serializePlainDate(date)
const decoded =
parsePlainDate(encoded)
expect(decoded).toEqual(date)
このような invariant は、単発の example test より広い範囲をカバーできます。
28. Invalid State を作りにくい型にする
例えば次の型では、
type DateInput = {
year: number
month: number
day: number
}
2027-99-99 も作れてしまいます。
そのため construction boundary で validation を行います。
例えば、
class PlainDate {
private constructor(
readonly year: number,
readonly month: number,
readonly day: number
) {}
static create(
year: number,
month: number,
day: number
): PlainDate {
if (!isValidGregorianDate(
year,
month,
day
)) {
throw new Error(
"Invalid PlainDate"
)
}
return new PlainDate(
year,
month,
day
)
}
}
すると、
PlainDate instance exists
という事実そのものが、
valid date
を保証できます。
これは Domain-Driven Design の考え方とも相性が良いです。
29. Parsing と Formatting を対称だと思わない
Formatting は比較的簡単です。
Domain Value
→
String
しかし Parsing は違います。
String
→
Domain Value
では ambiguity が発生します。
例えば、
03/04/2027
は locale によって意味が変わります。
そのため free-form parser より、
Explicit Input Contract
を優先した方が安全です。
例えば API なら、
YYYY-MM-DD
に固定します。
UI なら locale-aware input component を使い、最終的には structured value へ変換します。
30. Temporal API の設計では意味を method name に含める
例えば、
add(1)
だけでは意味が不明です。
より安全なのは、
addCalendarDays(1)
addHours(24)
addMonths(1)
のような API です。
同様に、
convert()
ではなく、
convertCalendar()
toInstant()
toZonedDateTime()
など、semantic boundary を名前で表現します。
Temporal programming では、
短い API より意味の明確な API の方が重要
です。
31. TimeZone Database も Dependency として考える
TimeZone は単なる string ではありません。
America/New_York
を解釈するためには timezone database が必要です。
その rule は歴史的に変更される場合があります。
したがって architecture 上は、
Temporal Domain
↓
TimeZone Rules Provider
↓
TZ Database
のように考えることができます。
特に historical data を扱うシステムでは、
database version
が結果に影響する可能性があります。
これは reproducibility の観点から重要です。
32. Cache Key に TimeZone を含めるべきケース
例えば、
Instant
→
Local Date
の変換結果を cache する場合、
cache key が、
instant
だけでは不十分です。
最低でも、
instant
+
timeZone
が必要です。
場合によっては、
calendar
locale
timezone-data-version
まで影響します。
Temporal system は cache key の設計にも domain semantics が必要です。
33. Localization Layer に Calendar Logic を入れない
例えば、
formatMonth()
の中で、
month length
を計算したり、
leap year
を判定したりするのは避けます。
Localization layer は、
Domain Value
→
Localized Representation
だけを担当します。
依存方向は、
Calendar Domain
↓
Localization
です。
逆に、
Localization
↓
Calendar Rules
へ business logic が漏れ始めると、テストと再利用が難しくなります。
34. UI Component に Calendar Engine を作らない
例えば React component の中で、
const firstDay =
new Date(year, month, 1)
.getDay()
const days =
new Date(
year,
month + 1,
0
).getDate()
と書くこと自体が常に悪いわけではありません。
しかし calendar product 全体で同じロジックを使うなら、component から外すべきです。
function CalendarMonth({
model
}: {
model: MonthGrid
}) {
return (
<div>
{model.rows.map(renderWeek)}
</div>
)
}
React は rendering に集中します。
Calendar Engine は calendar calculation に集中します。
35. Domain Layer を Framework Independent にする
理想的には、
React
Vue
Next.js
PDF Engine
CLI
Node.js
を削除しても Calendar Domain が動く状態です。
例えば、
packages/
temporal-domain/
calendar-domain/
calendar-view-model/
web-renderer/
print-renderer/
exporters/
のように分離できます。
Core package は UI framework を import しません。
これは単なる clean architecture のためではありません。
同じ domain model を複数の出力形式へ再利用するためです。
36. Web / Print / Data を同じ Domain から生成する
例えば architecture を次のようにします。
┌───────────────┐
│ Temporal Core │
└───────┬───────┘
│
▼
┌───────────────┐
│ Calendar Core │
└───────┬───────┘
│
▼
┌───────────────┐
│ View Model │
└───────┬───────┘
│
┌─────────────────┼─────────────────┐
│ │ │
▼ ▼ ▼
Web Renderer Print Renderer Exporters
│
┌─────────┼─────────┐
▼ ▼ ▼
JSON CSV ICS
この architecture の利点は、
January 2027
の calendar structure を一度だけ計算し、
Web でも、
Print でも、
Data Export でも、
同じ結果を使えることです。
37. Calendar Architecture に必要な Dependency Direction
依存方向は非常に重要です。
理想的には、
Rendering
↓
Application Model
↓
Calendar Domain
↓
Temporal Primitives
です。
つまり Renderer が Domain に依存します。
Domain が Renderer に依存してはいけません。
避けたいのは、
Calendar Domain
↓
React
のような dependency です。
Core calendar engine が React を知る必要はありません。
同じことは PDF library や browser API にも言えます。
38. Temporal Error を型として表現する
日付処理で error は例外的ではありません。
例えば、
invalid date
nonexistent local time
ambiguous local time
unsupported calendar
invalid timezone
overflow
などがあります。
すべて throw new Error() だけで表現するより、
type TemporalError =
| {
type: "InvalidDate"
}
| {
type: "NonexistentLocalTime"
}
| {
type: "AmbiguousLocalTime"
}
| {
type: "UnsupportedCalendar"
}
のように domain error として扱う方法もあります。
例えば、
type Result<T, E> =
| {
ok: true
value: T
}
| {
ok: false
error: E
}
を使って、
function resolveDateTime(
input: PlainDateTime,
zone: TimeZoneId
): Result<Instant, TemporalError>
とできます。
39. Observability にも Temporal Semantics を残す
Temporal bug は production でしか発生しないことがあります。
特に DST や timezone boundary です。
そのため log では単に、
2027-11-07 01:30
だけを残すのではなく、
localDateTime
timeZone
offset
instant
resolutionPolicy
を記録すると debugging が容易になります。
例えば、
{
"localDateTime": "2027-11-07T01:30:00",
"timeZone": "America/New_York",
"offset": "-04:00",
"instant": "2027-11-07T05:30:00Z",
"overlapResolution": "earlier"
}
のような log です。
Temporal bug は「値」だけでなく、
変換コンテキスト
を記録する必要があります。
40. Calendar Architecture 全体像
最終的には次のような architecture に整理できます。
┌────────────────────────────────────────────┐
│ Temporal Primitives │
│ │
│ PlainDate │
│ PlainTime │
│ PlainDateTime │
│ Instant │
│ Duration │
│ TimeZoneId │
└────────────────────┬───────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ Calendar Domain │
│ │
│ Date Validation │
│ Leap Year │
│ Month Length │
│ Calendar Arithmetic │
│ ISO Week │
│ Gregorian / Julian Conversion │
│ Absolute Day Mapping │
└────────────────────┬───────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ Application Model │
│ │
│ MonthGrid │
│ WeekRow │
│ DayCell │
│ CalendarViewModel │
└────────────────────┬───────────────────────┘
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌──────────────┐
│ Web │ │ Print │ │ Data Export │
│ Rendering │ │ Rendering │ │ │
│ │ │ │ │ JSON / CSV │
│ React/Vue │ │ PDF/Print │ │ ICS │
└────────────┘ └────────────┘ └──────────────┘
ここで重要なのは、
Formatting
が一番外側にあることです。
41. 「表示」と「意味」を分離する
最終的に Calendar Architecture で最も重要なのは、
Meaning
と、
Representation
を分けることです。
例えば、
PlainDate(2027, 1, 1)
という domain value があるとします。
Web UI では、
Jan 1
になるかもしれません。
日本語 UI では、
2027年1月1日
になるかもしれません。
月間カレンダーでは、
1
だけ表示するかもしれません。
JSON では、
"2027-01-01"
になるかもしれません。
Print では、
01
になるかもしれません。
しかし Domain 上の意味は同じです。
PlainDate(2027, 1, 1)
Presentation が変化しても Domain が変わらない。
これが強い設計です。
42. 実装時のチェックリスト
Calendar / DateTime system を設計するとき、少なくとも次を確認すると良いと思います。
-
PlainDateとInstantを分離しているか -
TimeZoneとUTC Offsetを混同していないか -
Calendar SystemとLocaleを分離しているか -
Calendar YearとISO Week Yearを分離しているか - Calendar Arithmetic と Duration Arithmetic を分離しているか
- DST Gap の policy が明示されているか
- DST Overlap の policy が明示されているか
- Month overflow の policy が明示されているか
- Domain object が immutable か
- Invalid state を construction boundary で防げるか
- Calendar Rules が UI component に入っていないか
- Formatting が Domain Layer に入っていないか
- Web と Print が同じ calendar calculation を利用しているか
- JSON / CSV / ICS が Domain logic を再実装していないか
- Serialization contract が temporal type ごとに定義されているか
- Clock が dependency として test 可能になっているか
- Leap Year の boundary case を test しているか
- ISO Week Year の boundary case を test しているか
- DST transition を test しているか
- Round-trip invariant を test しているか
- Property-based testing を適用できる箇所がないか
- Log に timezone / offset / instant が残っているか
43. まとめ
Calendar UI だけを見ると、日付処理は非常に単純に見えます。
year
month
day
を取得して、画面に並べれば完成するように思えます。
しかし実際の Calendar System には、
PlainDate
Instant
TimeZone
UTC Offset
Calendar System
ISO Week Year
Duration
Localization
Serialization
Web Rendering
Print Rendering
Data Export
という複数の独立した概念があります。
そして Date/Time 系の難しい bug の多くは、
計算式そのものを間違えたことよりも、
本来異なる概念を同じ型、同じ abstraction、同じ layer で扱ったこと
から発生します。
そのため Calendar System を設計するとき、最初に考えるべき質問は、
「どう表示するか?」
ではありません。
まず考えるべきなのは、
「この値は何を意味しているのか?」
です。
そして、
PlainDate
Instant
TimeZone
Calendar
Locale
を明確に分離します。
Formatting はその後です。
Calendar Architecture は Date Formatting ではありません。
Date Formatting は、Calendar Architecture の一番外側に存在する Presentation の一工程にすぎません。
参考
カレンダー構造、月間・年間カレンダー、Web / Print での表現、日付モデルなどを実際のカレンダー視点から検証するときは、以下も参考になります。
実装を考えるときは、完成した UI だけを見るのではなく、
Domain
→
Calendar Model
→
Web
→
Print
→
Data
という変換の流れを見ると、Calendar Architecture の責務境界がより分かりやすくなります。