前書き
令和対応の皆様、対応及びテストお疲れさまでした。私は対応するのをすっかり忘れていました。なんせ五年前に「いつ元号が変わってもDBを操作するだけでOK」なように作っていたので流石に忘れてますわ。西暦ベースだったので簡単でしたが元号の年を直接記録するレガシー過ぎるシステムではさぞ辛かったでしょうね…。
ただ、対応が終わったといっても自分なりの設計ってそれがどれだけ正しかったとか分からないんですよね。
自分でもかなり古臭い設計だと思うので、晒して皆様の評価をいただければと思います。
クソ設計を真似する奴がいたら害悪だから記事引っ込めろなんていうコメントでも歓迎です!
要求
・元号が変わってもソースコードを修正せずに対応できるようにしたい
・商談の履歴を取りたい
あなたならどう抽象化しますか?いや抽象化するには早すぎる?何の商売をしているのかくらい説明したほうがいいですか?
そんなことはありません。この二つの問題はどちらも「時間的存在を管理する」問題です。
『人間は根源的に時間的存在である』~ハイデガー
有名なこの言葉は人によって受け取り方が全然違いますが、
時間の支配をもくろむ狂気のマッドサイエンティストたる我々はこう解釈します。
「人間の認識する全ての存在は時間的存在である、つまり時間を考慮したオブジェクトとして抽象化し一様に扱うことができる」
今回はこれを軸にして設計をします。
データ設計においてコードとか呼ばれるデータを時間的存在とする
DB上ではよく特定の数字(まれに文字列)に単語を結び付けて定数のように使い、テーブルをJoinする際にキーにしたり選択肢の一覧として提示したりします。これを単語を数値にコード化してることからコードと呼びます。
例えばユーザの誕生日を西暦ではなく年号+年で保存するとします。年のほうは数値をそのまま入れればいいとして、年号は次のように数字を振って数字のほうを保存し、Selectする際には年号の表示用文字列が入ったテーブルとJoinします。直接「令和」として保存しても良いのですが表示する文字列を複数持てたり(短縮形や英字「H・R」など)、誤字や異なる文字コードが混入することを防ぐことができます。
テーブル:ユーザの和暦誕生日
ユーザID | 年号 | 年月日 |
---|---|---|
1 | 1 | 31/4/30 |
2 | 2 | 1/5/1 |
テーブル:年号
コード | 名前 | 英字 |
---|---|---|
1 | 平成 | H |
2 | 令和 | R |
使い道は他にも特定の年号のユーザに対して処理をする際にif分で判定するのに使うとか、ユーザに誕生日を入力してもらう際に年号テーブルから年号のリストを作ってそれから選択してもらうとかあり結構便利です。
手始めとして「西暦か和暦かを選択して誕生年を入力する」ケースを考えてみましょう。
和暦はもちろん歳によって違いますし、西暦だって紀元前では表記が変わってきます。
普段使うにはちょっと使い道が思い浮かびませんが歴史上の人物の誕生年を入力するのとかには使えるかもしれません。
ただし、上記のように年号+日付で管理すると日付の変換がややこしいことになります。そこで全ては西暦というかDate/Timestampで扱い、表記するときだけ切り替えることとします。
開始日・終了日カラムを持つコード
コードは基本的に「数字」「文字列」のペアとして扱われます。
テーブル:紀年法
コード | 名前 |
---|---|
1 | 西暦 |
2 | 和暦 |
これにシンプルに開始日・終了日カラムを持たせます。
テーブルに直接持たせてもいいですし、元の開始日・終了日を持たないテーブルにJoinする形で別にテーブルを作っても良いです。別にすることで元のテーブルの数字をキーにすることができて、他のテーブルから外部キーとして参照できるようになるのでこちらの設計を好む人も多いかと思います。
テーブル:時間的紀年法
コード | 名前 | 開始日 | 終了日 |
---|---|---|---|
1 | 西暦 | AD.1/1/1 | AD.9999/12/31 |
1 | 紀元前 | BC.4712/1/1 | BC.1/12/31 |
2 | 平成 | BC.4712/1/1 | AD.2019/4/30 |
2 | 令和 | AD.2019/5/1 | AD.9999/12/31 |
これをSelectする際には今の時刻とか適当な日時が含まれる行を指定することになります。SQLのBetweenをstart <= now < end みたいな境界条件にできれば開始日と終了日に同じ時刻を入れられるのに…
select * from 紀年法 as k join 時間的紀年法 as tk on k.コード = tk.コード and now() between tk.開始日 and tk.終了日 order by tk.開始日 desc limit 1
Seinオブジェクト/Enumを作る
コードはSQLでJoinしてもいいのですがJoinが結構面倒なのでDBから読み出してキャッシュしておくコードを表現するオブジェクトを作ります。Codeというクラス名にすると後で後悔しそうなので存在-ザイン-Sein とでもしましょうか。
例えばJavaで歴史的人物のデータベースを画面に表示する際にはget/setを通して読み出しますので
コードを持っておいて画面がそれにアクセスするときに文字列として取り出せれば十分です。
class Sein {
val key : Int;
val label : String;
fun at(日付 : Date) : Sein {}
}
enum CodeEnum {
紀年法
;
fun sein(key Int) : Sein {
//対応するコードをキャッシュから取り出して返す
}
}
class 歴史的人物 {
val 紀年法コード : Int
val 誕生年 : Date
val 誕生年号 get() : String = CodeEnum.紀年法.sein(紀年法コード).at(誕生年).value
}
Seinを時間的存在にする
Seinは時間によって表記が変わりますが、それが「なんであるか」は変わりません。年号は日付で呼び方が変わりますが日付であること自体は変わらないのです。
そこで「時間的でない(現在の)」Seinをファサードにして、その内側に「時間的である(いつかの)」Seinと対象とする時間を閉じ込めます。
具体的には年号テーブルをSelectすると年号とその有効期間が得られるので、始点(ヘッダ)を現在のSeinとし新しい(または優先して表示したい)順からLinkedListを作ります。
ファサードとなるSeinヘッダを提供するEnumを作る
Enumから取得した現在のSein(ヘッダ)に対し、時間を指定したときはその時間のSeinを返します。いつかのSeinに対して時間を指定したときも同様にその時間のSeinを返します。現在を指定したときは現在のSeinを返します。これによりSein
の表記を好きなタイミングで指定できるようになります。
次のように日付を指定したりしなかったりして当日の日付の文字列が取り出せます。
CodeEnum.紀年法(2).sein.toTimeString() // 今の日付、例えば(令和元年5月1日)
CodeEnum.紀年法(2).sein.at("2019/4/30").toTimeString() // 指定した日付、例えば(平成31年4月30日)
これはequals()やhash()はコードだけを参照するようにすることで、時間的表記を求められた時以外は常に同じSeinであると振舞います。五年前は気が付きませんでしたがこれモナドみたいなもんですね。
コードは定数としてプログラムから直接指定する( if ( code == 西暦 ) {} )ことも多いので、
enum class 紀年法 {
西暦(1),
和暦(2)
;
Companion {
fun item(key : Int) {/* keyに応じて西暦 / 和暦を返す */ }
fun sein(key : Int) {/* keyに応じて西暦 / 和暦のSeinを返す */ }
}
}
のようにkeyごとEnumにしておくと便利です。むしろ時間的存在にしなくていいときはEnumのまま扱ったほうが楽なくらいです。
一見作るのが面倒なように見えますが、key も value もDBに入っているのでSelectした結果を
println("enum class 紀年法{")
seinList.forEach{println("$it.value ($it.key),")}
println("}")
みたいに出力するだけで簡単に生成できます。
最終的には次のように扱えるようにします。EnumとSeinは相互に変換ができるためギリギリまでEnumとしても扱えるようにしておくと時間が関係ないときに便利です。
紀年法.和暦.sein.enum // Enum 紀年法.和暦に戻る
紀年法.和暦.sein.toTimeString() // 現在の日付、例えば(令和元年5月1日)
紀年法.和暦.sein.at("2019/4/30").toTimeString() // 指定した日付、例えば(平成31年4月30日)
紀年法.item(2).sein.at(日付).toTimeString() // コードを指定してEnumを取り出して日付指定
紀年法.item(2).at(日付).toTimeString() // コードを指定してSeinを取り出して日付指定
コードリストを作る
コードは選択肢として使うことが多いのでリストとして扱えると広く利用できます。
SpringMVCではlabelとvalueのそれぞれのフィールド名を指定してオブジェクトをOptionsに展開します。
試しにこちらも同じように作りましょうーただし、こちらは対象の時間になったら表記を変えられるように作ります。なんせ時間的存在なのですから。
シンプルに別テーブルを作成し、コードごとに値/表記ペアを作成します
コード | 名前 | 開始日 | 終了日 | 表示 |
---|---|---|---|---|
1 | 平成 | BC.4712/1/1 | AD.2019/4/30 | 可 |
2 | 令和(改元前) | BC.4712/1/1 | AD.2019/4/30 | 不可 |
2 | 令和 | AD.2019/5/1 | AD.9999/12/31 | 可 |
CodeEnumにコードリストを出力する機能を追加する
同時にSeinにvisibleを追加し、表示可能なSeinだけを扱えるようにします。
和暦.items.filter{it.sein.at(日付).visible}
みたいに今または特定の日付のコードのリストとして取り出してフィルタなりなんなりをすることでその時間で適切なリストになります。
例えば誕生日の入力に「令和」を使えるようにするのは令和になってからで十分です。
別に平成のうちから表示しても害はないでしょうが、選ぶ必要のない選択肢はないほうがマシというケース例えば商品選択とかだと割と便利に使えます。
実務では親子関係を作れるようにしておくと捗る
表示するデータが階層構造になることは珍しくないですし、単に一部のデータだけリストにしたいときにすぐ使えます。
親(カテゴリ)カラムを用意して、カテゴリごと切り替えられるようにします。
選択肢に親子関係があるときはもちろん、単に表記ルールを切り替えたりするのにも使えます…例えば未来の日付を扱うときに改元の日が来るまで新元号を使わないようにするとか。
他に
- 表示順:リストでの順番
- フラグ:お客さんから「条件によってこの項目は隠したい」みたいな要望があったときに使えるように準備しておく
- 追加データ:他のプログラマから時期によって変わるデータがあるといわれたときに備え準備しておく。例えば10月に備え消費税率を格納しておくのに使える。
みたいな項目を用意しておく/すぐに作れるように気に留めておくと仕様変更に柔軟に対応できます。最初から全部の機能を提供すると使わない機能が出てくるので準備だけしておいて「こんなこともあろうかと」するのが良いかと思います。
親 | コード | 名前 | 開始日 | 終了日 | 表示 | 順序 | フラグ | 追加日付 | 追加データ |
---|---|---|---|---|---|---|---|---|---|
0 | 1 | 紀年法 | |||||||
0 | 2 | 和暦(選択肢) | |||||||
1 | 3 | 西暦(表示切替用) | |||||||
1 | 3 | 紀元前(表示切替用) | |||||||
1 | 4 | 平成(表示切替用) | |||||||
1 | 4 | 令和(表示切替用) | |||||||
2 | 5 | 平成(選択肢/切替前) | |||||||
2 | 5 | 平成(選択肢/切替後) | |||||||
2 | 6 | 令和(選択肢/切替前) | |||||||
2 | 6 | 令和(選択肢/切替後) |
階層化したときのソースコードの構造はこんな感じになります。必要に応じてSeinにデータスロットを追加します。
↑で書いた追加データの他、Enumへの参照とか
data class Sein<T>( // T が若干鬱陶しいが他の型と区別する役に立つ
val enum : T? = null,// Sein から enum を復元するときに使う。enum に戻す予定がないなら不要。
val category: Int = 0,
val key: Int = 0,
val label: String = "",
val start: Date = Date(), //InstantはAPIレベルが足りなかった…
val end: Date = Date(),
val time: Date? = null,//特定時刻. 時刻が指定されたときのみ値が入る
val header: Sein<T>? = null,//ファサードになる現在のコードを指す. header の時のみ null
val prev: Sein<T>? = null//時間的に前のコードを優先度順に並べたもの。最後はnull
) {
/**
* 時刻を特定しない存在(≒現在の存在)を返す。
*/
fun now() = header ?: this
/**
* 特定時刻の存在
*/
fun at(date: Date? = null): Sein<T> =
if (date == null) header ?: this // date が指定されなかったりnullだったりすると現在のCodeを出すようにすると使いやすい
else header?.dig(date) ?: dig(date) //rootから掘り進めて対象の時間のオブジェクトを探す
/**
* 対象の時間を含む存在を掘る.有ったら時間指定の上でコピー。最後まで存在しなかったら例外
*/
private fun dig(date: Date): Sein<T> = if (date.after(start) && date.before(end)) copy(time = date) else prev?.dig(date)
?: throw RuntimeException()
/**
* 現在時刻に即した表記を出力する.年号だけでいいときは label をそのまま出力するので十分
*/
fun toTimeString(): String = if (time == null) label else "2019年の時は元年にするとか必要に応じて編集"
/**
* 同じカテゴリ同じキーなら別の時間でも同じコード。hash()とかも同様に同じように振舞わせる
*/
override fun equals(other: Any?): Boolean = other is Sein<*> && other.category == this.category && other.key == this.key
override fun hashCode(): Int =category.hashCode() + key.hashCode()
}
//Enumがたくさんできるので適当にグループ化する
class 時間的Enum群 {
enum class 紀年法(override val category: Int,override val key: Int) : CodeEnum<紀年法> {
西暦(1, 1),
和暦(1, 2)
;
companion object :CodeCategory<紀年法>(1){
fun item(key: Int) = values().find { it.key == key }
}
}
enum class 和暦(override val category: Int,override val key: Int) : CodeEnum<和暦> {
平成(2, 1),
令和(2, 2)
;
companion object :CodeCategory<和暦>(2){
fun item(key: Int) = values().find { it.key == key }
}
}
}
interface CodeEnum<T> {
val category get() = 0
val key get() = 0
//対応するコードをキャッシュから取り出して返す
val sein get()= Sein<T>()
}
open class CodeCategory<T>(val category: Int) {
fun sein(key: Int): Sein<T> {
//対応するコードをキャッシュから取り出して返す
return Sein()
}
val list get(): List<Sein<T>> {
//対応するコードをキャッシュから取り出して返す
return listOf(Sein(key = 5), Sein(key = 6))
}
}
class 歴史的人物 {
var 紀年法コード: Int = 1
var 誕生年: Date = Date()
fun 誕生年(): Sein<時間的Enum群.紀年法> {
return 時間的Enum群.紀年法.sein(紀年法コード).at(誕生年)
}
fun 西暦誕生年(): Sein<時間的Enum群.紀年法> {
return 時間的Enum群.紀年法.西暦.sein.at(誕生年)
}
}
消費税なんかも時間的存在になる
親 | コード | 名前 | 開始日 | 終了日 | 追加データ |
---|---|---|---|---|---|
0 | 7 | 消費税 | 5 | ||
0 | 7 | 消費税 | 8 | ||
0 | 7 | 消費税 | 10 |
紀年法はいつの年でも紀年法であるように、消費税はいつの年でも消費税です。ただ税率は変わりますが。なので消費税も開始日ー終了日をもつ時間的存在です。
この場合はコードのEnum自体に消費税計算ロジックを書けるというメリットもあります。(税抜き価格に書くほうがいいかな?)
enum class 消費税Enum {
消費税(0,7);
fun 課税(price : 税抜き価格) = 税込み価格( price * (100 + sein.additionalData) / 100 )
}
軽減税率が導入されても安心です。商品・品目ごとに対応する消費税コードをもっておいてその日に備えます。まあ軽減税率自体はデメリットしかないのでやめて欲しいところですが。
親 | コード | 名前 | 開始日 | 終了日 | 追加データ |
---|---|---|---|---|---|
7 | 9 | 消費税(増税前は同じ) | 1900/1/1 | 2019/9/30 | 8 |
7 | 9 | 消費税(店内で食べる) | 2019/10/1 | 2999/12/31 | 10 |
7 | 10 | 消費税(増税前は同じ) | 1900/1/1 | 2019/9/30 | 8 |
7 | 10 | 消費税(テイクアウト) | 2019/10/1 | 2999/12/31 | 8 |
enum class 品目別消費税Enum {
外食(7,9);
食品(7,10);
fun 課税(price : 税抜き価格) = 税込み価格( price * (100 + sein.additionalData) / 100 )
}
マスタも時間的存在となる
DBでは「注文」のようにそれが発生するごとにDBに登録されるトランザクションとか呼ばれるデータと、「商品」のようにトランザクションから参照されるマスタがあります。
例えば商品マスタはこんな感じになってて、バッチで更新したりします。
ラーメン屋商品マスタ
型番 | 商品名 | 品目 | 価格 |
---|---|---|---|
01 | ラーメン | 麺類 | 500 |
02 | つけ麺 | 麺類 | 600 |
例えば夏になって冷やし中華を取り扱うようになったら、当日の営業前にバッチを走らせて商品を追加したり、取り扱いが終わったら消します。
価格改定の際には価格をUpdateすることになるでしょう。
つまり商品とかのマスタはちょくちょく増えたり減ったり変更されたりする時間的存在です。
時間的存在なのでこれらも開始日終了日を持つとするのが妥当です。
時間的ラーメン屋商品マスタ
型番 | 商品名 | 品目 | 開始日 | 終了日 | 価格 | 選択 |
---|---|---|---|---|---|---|
01 | ラーメン | 麺類 | 1900/1/1 | 2999/12/31 | 500 | 可 |
02 | つけ麺 | 麺類 | 1900/1/1 | 2999/12/31 | 600 | 可 |
03 | 冷やし中華 | 麺類 | 1900/1/1 | 2019/6/30 | 700 | 不可 |
03 | 冷やし中華 | 麺類 | 2019/7/1 | 2019/9/30 | 700 | 可 |
これは先ほどのコードと全く同じ構造です。
よってこれもまた、「型番」で検索するとメニューが取り出せるし、メニューに対してat(日付)することでその日付での表記や価格、選択の可・不可が取得できていいという事です。Enumにするかはif(ラーメンがEnumか)みたいに参照したくなるか・Enumにして破綻しない程度の種類に収まるか考えて決めましょう。
val つけ麺 = repository.getラーメン("02")
つけ麺.price //->700 今はすでに令和なので
つけ麺.at("2019/5/1").price //->700
つけ麺.at("2019/4/30").price //->600
repository.getラーメン("03").at("2019/5/1").visible //->false
また、マスタはコードを参照することがあります。例えば選択の可/不可は一般的には文字列で扱わず、
0/1 や「取り扱い出来ない理由」コードが使われます
時間的ラーメン屋商品マスタ
型番 | 商品名 | 品目 | 開始日 | 終了日 | 価格 | 取り扱い停止 |
---|---|---|---|---|---|---|
01 | ラーメン | 麺類 | 1900/1/1 | 2999/12/31 | 500 | 0 |
02 | つけ麺 | 麺類 | 1900/1/1 | 2999/12/31 | 600 | 0 |
03 | 冷やし中華 | 麺類 | 1900/1/1 | 2019/6/30 | 700 | 1 |
03 | 冷やし中華 | 麺類 | 2019/7/1 | 2019/9/30 | 700 | 0 |
取り扱い停止コード
コード | 値 |
---|---|
0 | 販売中 |
1 | 期間外 |
2 | 終了 |
先にやったように取り扱い停止コードもまた時間的存在です。
ある日付のメニューを参照した場合、このコードもまたその日付での表記でないと困ることになります(取り扱い停止の表記が変わるというのはちょっと考えにくいですが)
ですがコードはすでに日付が指定できるようになっているので何も心配はないですね。
class ラーメン {
val 商品名 : String
val 価格 : Int
val 日付 : Date
val 取り扱い停止 : Int // DBから取得して 0 | 1 | 2
val 取り扱い停止コード get() = 取り扱い停止Enum.item(取り扱い停止)
val 取り扱い停止表記 get() = 取り扱い停止コード.at(日付).toTimeString()
val ひとつ前のラーメン : ラーメン
fun at(日付) : ラーメン {/* その日付のラーメンまでさかのぼる */}
}
もちろん日付を指定してJoinすることで文字表現を取得してもいいのですが、むしろコードのまま取得して出力時に時間を指定するほうが簡単になります。
特に過去のメニューを参照して現在と比較する際に有効です。
マスタを取得する際には取得対象となる時間を指定してSQLを発行してもいいですしコードと同じようにリストで取得してLinkedListを作ってから対象の時間にat()してもいいでしょう。
マスタ同士がJoinしていても特に問題なく時間は指定できます。
例えばセットメニューがあったとします。半チャンラーメンとか。
この時にSQLを書かないマッパを使う場合はマスタがオブジェクトに格納されるかと思います。
class ラーメンセット {
val ラーメン : ラーメン
val 炒飯 : 炒飯
val 日付 : Date
}
ある存在があったとき、特に理由のない限り全体の時間と部分の時間は同じです。例えば今日の私の体に昨日の私の腕がくっついてるなんてことは観測されたことがありません。
なので、部分の要素は全体の時間をそのまま適用することができます。
class ラーメンセット {
val ラーメン : ラーメン
val 炒飯 : 炒飯
val 日付 : Date
fun その日のラーメン = ラーメン.at(日付)
fun その日の炒飯 = 炒飯.at(日付)
}
SQLで直接書いたときはどうなるでしょう?
例えば、ある日付の冷やし中華・炒飯のセットが取り扱われていたか?本来の価格はいくらか?をSQLで取得するとします。
時間的ラーメン屋商品マスタ
型番 | 商品名 | 品目 | 開始日 | 終了日 | 価格 | 取り扱い停止 |
---|---|---|---|---|---|---|
01 | ラーメン | 麺類 | 1900/1/1 | 2999/12/31 | 500 | 0 |
02 | つけ麺 | 麺類 | 1900/1/1 | 2999/12/31 | 600 | 0 |
03 | 冷やし中華 | 麺類 | 1900/1/1 | 2019/6/30 | 700 | 1 |
03 | 冷やし中華 | 麺類 | 2019/7/1 | 2019/9/30 | 700 | 0 |
時間的ラーメン屋炒飯マスタ
型番 | 商品名 | 品目 | 開始日 | 終了日 | 価格 | 取り扱い停止 |
---|---|---|---|---|---|---|
01 | 炒飯 | 炒飯 | 1900/1/1 | 2019/4/30 | 500 | 0 |
01 | 炒飯(価格改定) | 炒飯 | 2019/5/1 | 2999/12/31 | 600 | 0 |
02 | 五目炒飯 | 炒飯 | 1900/1/1 | 2999/12/31 | 700 | 0 |
03 | 海老炒飯 | 炒飯 | 1900/1/1 | 2999/12/31 | 700 | 0 |
join & 商品の型番で where した場合、日付を考慮しなければ4件、被ってる日付だけを考慮したときは3件となります。そしてどちらにしろ、特定の日付では一意になるので普通に where で指定するなりリストで取得してから at(日付) で選ぶなりすればいいだけとなります。
型番 | ラーメン | 炒飯 | 開始日 | 終了日 | 価格 | 取り扱い停止 |
---|---|---|---|---|---|---|
03/01 | 冷やし中華 | 炒飯 | 1900/1/1 | 2019/4/30 | 500 | 1 |
03/01 | 冷やし中華 | 炒飯(価格改定) | 2019/5/1 | 2019/6/30 | 600 | 1 |
03/01 | 冷やし中華 | 炒飯(価格改定) | 2019/7/1 | 2019/9/30 | 700 | 0 |
エンティティを時間的存在にすると履歴という概念がなくなる
DDDにはエンティティという概念があります。ユーザ情報のように個人に紐づけられている情報や、注文のように同一内容でも別口の注文として処理する・途中で変更されても同じ注文として処理する存在です。
エンティティはそもそも最初から時間的存在です。よくエンティティは「別の状態でも同一性を持つオブジェクトである」と言いますが、同一のオブジェクトが同一の時間に複数の状態であることはあり得ないため、別の状態のエンティティは必ず別の時間のエンティティであるからです。
エンティティはしばしばマスタを参照することがあります。例えば注文は商品マスタを参照します。
エンティティが時間的であるためには参照するマスタも時間的である必要があります。例えば私が商品を注文しようとして、去年のカタログを参照しても意味がありません。「今」のカタログが必要となります。逆に、去年買った商品の履歴を確認するときは「今」ではなく「去年」のカタログが必要となります。つまり注文エンティティは必ずその時の商品マスタを参照します。
通常の設計では履歴はかなり面倒な問題となります。なぜならカタログとなるマスタは変更されるため、当時の商品名や当時の価格を保存する必要があるからです。
class 注文履歴 {
val 日付 : Date
val 商品ID : Int
val 当時の商品名 : String
val 当時の商品price : Int
}
ですが我々はすでに注文を時間的存在にしているため当時の存在を取り出すだけです。
class 時間的注文 {
val 日付 : Date
val 商品 : 商品
fun 当時の商品名 = 商品.at(日付).toTimeString
fun 当時の商品price = 商品.at(日付).price
}
これはトランザクション処理において特に威力を発揮します。
例えば、商品を追加している最中に商品の値段が変更されることなどです。
ショッピングカートには次々と商品を追加しますが、全体の注文トランザクションは最終的に清算するまで終わりません。入れた商品が注文確定前に値上げされたとしても、注文した価格は変わらないはずです。
エリック・エヴァンスのDDD本では、こうあります。
「商品」で考えよう。アマンダが注文を追加している間に、誰かがトロンボーンの価格を変更したら、それも不変条件に違反することになるのではないだろうか?
DDD本では他の問題も含めてこれは集合(Aggregation)の問題として、テーブルにロックをかけたり注文エンティティに商品追加時の価格をコピーしたりします。
ですが、現在扱っている設計ではトランザクションの問題そのものが発生しません。商品が時間的であるため、「追加時の商品」を扱うだけでいいのです。
class 時間的注文 {
val 商品 : 商品
fun 商品追加(商品 : 商品) { this.商品 = 商品.at(画面表示時の日付)}
}
今まで単に商品と呼んでいたこれ、at(日付)により「ある日付の商品」になったこれは「商品のスナップショット」です。
時間には「今」と「いつか」の二つしかなく、「いつか」の存在は常にスナップショットです。
なぜなら、現在のオブジェクトは常に現在であるのと対象に、ある特定の時刻は常に過ぎ去り過去のものとなると同時に「ある時」のオブジェクトは「その時」のオブジェクトとなり不変となるからです。
例えば「今」のエンティティは常に変化をしますが、「今の時刻の」エンティティは一瞬のちには「その時刻の」エンティティとなります。そして「今」のエンティティがどうなろうと「その時刻の」エンティティが変わることはありません。
ショッピングカートに入れた商品は単なる「商品」ではなく、「ショッピングカートに入れたイベントで指し示される商品」
ここで、エンティティとそれに含まれる商品の日付が違うという問題が発生しました。これはそのまま集約にするとうっかり商品の日付を注文時の日付に更新してしまうかもしれません。つまり時間を区別する必要があります。この時間を区別する存在が「イベント」です。イベントは時間的存在ですが、時間に指し示される存在ではなく時間を指し示す存在です。
例えば「ショッピングカートに入れたイベント」はその一瞬以外には存在しません。イベントの一秒後の商品は存在するでしょうし、たまたまイベントの一秒後にその商品に起きた別イベントもひょっとしたら存在するかもしれません。ですが「ショッピングカートに入れた一秒後かつ同じ時刻に同じ商品を同じショッピングカートに入れたイベント」は存在しないのです。
//ある日付の商品を格納する。イベントは色々あるのだがそれはドメイン次第
data class Event<商品> (
val 発生日付:Date,
val 当時の商品:商品,// これは「日付」を含んだ商品。SQLで日付指定して取得するならこちらのが簡単
}
// 今の商品を格納する場合。例えば同じ商品をもう一度注文したいときとかはこの形のほうが使いやすいが商品.at()がキャッシュに入らない場合は重い。
//型落ちした商品名を「取り扱い終了(同等品は〇〇)」とかにしておく
data class Event<商品> (
val 発生日付:Date,
val 対象商品:商品,// これは「今」の商品。Enumでも機能する。
){
val 当時の商品 get() = 商品.at(発生日付)
}
class 時間的注文 {
val 追加された商品 : Event<商品>
fun 商品追加(商品 : 商品) { 追加された商品 = Event(商品,表示時)}
}
イベントはすでに起こったことなので不変ですし、at()で後から特定の日付を指定することもできません。イベントを介した商品もやはり不変となりました。これで安心ですね。
このイベントという考え方は例えば二人同時に商品を追加したときなんかにも有効です。単に両方のイベントを有効にすればいいだけになるからです。(おそらく想定より商品が増えますが現実で子供が勝手に追加した商品とかだって単に商品が増えるだけですよね?)
余談ですがこのイベントが時間を指し示すものとはどういう事でしょうか?
実は時刻だの絶対時間などというものは存在しません。ひょっとしたら昔はあったかもしれないのですがアインシュタインが相対性理論を成立させた時になくなってしまいました。
そこで残ったのが「或る事象が起きたのと同時」です。なので、時間でなく例えば Version カラムをマスタに作成し、イベントからは時間でなくVersionでJoinするなんてこともできます。
滅多に語られることはありませんが、「商品」はエンティティではないことがありますが「ショッピングカートに入れた商品」は常にエンティティです。集合がトランザクション単位になるというのはトランザクションはイベントとエンティティの集合であるということでもあります。・・・ですがエンティティの話は長くなるので一度〆ましょうか。
抽象化と共通化の違い
これでDDDにおいて設計で想定する存在(エンティティ)も実装で想定する存在(コード)も時間的存在にすることができました。
ところで俗に抽象化=共通化と言われますが、この設計で共通しているコードはどこでしょうか?
実のところ共通化しているコードはわずかです。
この設計は「全部の存在についてat(日付)で時刻を指定できるようにしよう」としか言っておらず、例えばDBアクセスも画面表示もそれぞれのフレームワーク頼みですし、個々のオブジェクトのat()の呼ばれる側は数行です。
またコード・マスタ・エンティティではat()の意味は同じですが実装を共通化することはないでしょう。エンティティはEnumになりませんし、マスタ/エンティティをSeinにする意味はあまりありません。
データソースへのアクセスにしろ、それぞれ規模感が違うためコードはキャッシュできますがエンティティをキャッシュに入れるのは現実的でないといった実装上の違いもあります。
ですがこの設計はたったこれだけの行数ですべての存在を同じように時間的存在にしています。
抽象化というのは「同じように扱えるようにする」こと、ということですね。
早すぎる抽象化
先日、早すぎる抽象化の記事がありました。そりゃまあ対象ドメインをろくに理解していないまま正しく共通化はできないでしょうねと言ったところですが、一方こちらの抽象化はどのくらい理解していたら正しく適用できるでしょうか?
この設計をプロジェクトに使ったのは2回だけなのですが、冒頭の要求ほぼそのままで適用を決めました。ドメインの話というか商品が何かも聞いてねぇ!お前はいつもDDDの話をしてたができてないじゃないかというレベルの話聞いて無さです。早い早すぎる。
ですがそれでも進めたのは、履歴を設計するのが面倒だったからです。履歴を作ると「何を履歴の対象にするのか」「項目はどうするのか」みたいな話がどうしても出てきます。新しいビジネスオブジェクトが発見されたら「それも履歴対象にするのか?」みたいな話にもなります。そんなのやってられません。履歴を管理するよりも履歴を不要にするほうが手っ取り早く単純です。
抽象化にも色々あって、例えばビジネスオブジェクトの抽象化となるときちんと商品と向き合う必要があるというかむしろ時間のようにドメイン関係なく適用できるもののほうがレアケースなのですが上手く抽象化すればむしろ話は単純になります。
それで設計が破綻しなかったのか?実装は爆発しましたが設計は破綻しませんでした。コード/EnumをDBから自動生成できるように作ったため商品(プランなのでそんなに多くない)やオプションなどもEnumとして生成されたり、画面上の選択肢が全部テーブルに放り込まれるようになったりして当初50テーブルくらいの見込みだったのが300だったか500テーブルだったかまで膨れ上がることになりました。
ですが管理しきれないみたいな話は聞きませんでした。特に問題はなかったようです。
先にもありましたが共通化の失敗というのは違うものを共通化しようとする失敗です。簡単に自動生成ができるようにすると別だなって思ったらすぐに別に生成しなおせばいいだけの話なので、私にクレームを飛ばすより生成しなおすほうが早くなり特に失敗の声は届いてきませんでしたね。
実装チームは「設計がややこしくなったら一度エンティティを整理して別ドメインに切り出す」というやり方をしてたようです。ドメインドリブンを導入していないのでなんとなく自然とそうなっただけなのでしょうが。例えば店頭相談時・清算後の社内手続きで話を分けていたようです。同じデータ構造でテーブルを二つ作って、店頭相談が終了したら確定したやつを社内手続きテーブルに転記してたのかな?その分テーブル数の増加を加速していたようですが。
派遣先で作ったものなので今どうなってるかというか実際に令和に対応できたのかは知らないのですが呼び出されもしなければ苦情も来なかったので特に問題はなかったのでしょう多分。