📌

Reladomoを活用して適用期間と履歴データの偶有的複雑性を隠蔽する

2024/05/02に公開

みなさんこんにちは。ログラスでVPoEをしているいとひろと申します。

本記事では、ReladomoというJavaのORMライブラリを活用すると、「適用期間」や「履歴データ」といった概念を扱う際に生じる偶有的複雑性を吸収することができるよ、というお話を書こうと思います。

最近、杉本啓(@sugimoto_kei) 氏が執筆した「データモデリングでドメインを駆動する──分散/疎結合な基幹系システムに向けて」という本(以下「データモデリングでドメインを駆動する」と表記)を非常に興味深く読ませていただいています。杉本氏と非常に近しいドメイン領域で開発に携わっている一技術者として、これだけ良質な知見を惜しみなくまとめていただいて、考えの枠組みを提供してくださっていることに純粋に尊敬の念を持つとともに、一章一章を噛み締めながら読ませていただいています。

杉本さん自身が以下のようにツイートしていたこともあり、「データモデリングでドメインを駆動する」の一トピックを扱うことで話題の一つとして貢献できればと思います。

https://twitter.com/sugimoto_kei/status/1755836670874992732

さて、「データモデリングでドメインを駆動する」の第12章では「偶有的複雑性」という概念が登場します。この概念はフレデリック・P・ブルックス,Jr.氏による「人月の神話」に出てくる「本質的困難」「偶有的困難」という概念からの引用だと理解しています。

「適用期間」「履歴」という概念を扱う際に生じる偶有的複雑性についてはぜひ「データモデリングでドメインを駆動する」の12章をご参照ください。特に、この章では範囲指定方式の問題を「閉世界仮説からの逸脱」にからめて論じている箇所は非常に興味深く読ませていただきました。

今回紹介するReladomoは、適用期間や履歴という概念を「範囲指定方式のまま閉世界仮説から逸脱せずに扱う」ことができるので、非常に素直なモデリングが可能です。Reladomoという技術を端的に表現すると、「データモデリングでドメインを駆動する」のコラム内にも記載されている「時制データベース」という概念を、RDB側ではなくORM側で吸収することができるフレームワークであると言えます。

Reladomoとは

Reladomoは、ゴールドマン・サックス社が開発しOSS化したエンタープライズグレードのJava製ORMフレームワークで、以下のような特徴を持ちます。

  • 型付けられたクエリ言語の活用
  • バイテンポラルデータモデルを透過的に扱うCRUDメソッドやRelationshipをサポート
  • 同一スキーマのシャーディングを透過的に扱えるデータソース設定をサポート

Java製なので、ScalaやKotlinからもそのまま扱うことができます。

テンポラルデータモデルやReladomoについては以下の公式ドキュメントやプレゼン資料をご参照ください。

サンプルコード

本記事のサンプルコードはこちらのGitHubリポジトリに格納してあります。Reladomoの設定ファイルやビルド方法等については本記事では大幅に省略しているので、上記ドキュメントと併せて適宜ご参照ください。

https://github.com/itohiro73/kotlin-reladomo-sample/

適用期間をテンポラルデータモデルとして範囲指定方式のまま扱う

さて、「データモデリングでドメインを駆動する」で登場している偶有的複雑性の一例である「適用期間」に関するデータモデルをReladomoで表現してみましょう。

ここでは、書籍で登場する得意先と担当営業部署の関係性について、以下のように変遷するシナリオを考えてみます。(書籍のシナリオを参考に少し改変を加えています)

  • 1月のA得意先の担当営業部署/部署種別 => X部署/営業部署
  • 2月のA得意先の担当営業部署/部署種別 => Y部署/営業支援部署
  • 3月のA得意先の担当営業部署/部署種別 => Y部署/営業部署
  • 7月のA得意先の担当営業部署/部署種別 => Y部署/営業部署 (変化なし。境界条件として表示)
  • 8月のA得意先の担当営業部署/部署種別 => Y部署/営業支援部署

ポイントとしては、1月から2月は担当営業部署自体が変遷している(X部署 => Y部署)こと、3月と8月のタイミングでは担当営業部署(Y部署)の部署種別が変遷している(営業支援部署 => 営業部署 => 営業支援部署)という点に着目いただければと思います。

上記のシナリオを表現するにあたって、得意先テーブルと部署テーブルそれぞれに適用期間を表現した模擬的なテーブルを以下に示します。

得意先(適用期間付き)

得意先コード 適用開始月 適用終了月 得意先名称 担当営業部署コード
A 2024年1月 2024年1月 A得意先 X
A 2024年2月 9999年12月 A得意先 Y

部署(適用期間付き)

部署コード 適用開始月 適用終了月 部署名称 部署種別
X 2024年1月 9999年12月 X部署 営業部署
Y 2024年1月 2024年2月 Y部署 営業支援部署
Y 2024年3月 2024年7月 Y部署 営業部署
Y 2024年8月 9999年12月 Y部署 営業支援部署

特定の時点におけるA得意先の担当営業部署とその部署種別を知りたいとなったときに、この二つのテーブルを以下のようにジョインしたくなるわけなのですが、適用期間付きデータ同士のジョインを素直に扱うのは難しい、というのがここの課題領域に存在する偶有的複雑性になります。

得意先コード 適用開始月 適用終了月 得意先名称 担当営業部署コード 部署名称 部署種別
A 2024年1月 2024年1月 A得意先 X X部署 営業部署
A 2024年2月 2024年2月 A得意先 Y Y部署 営業支援部署
A 2024年3月 2024年7月 A得意先 Y Y部署 営業部署
A 2024年8月 9999年12月 A得意先 Y Y部署 営業支援部署

補足として、Y部署の部署種別が「2024年3月から2024年の7月までの範囲で営業部署」と期間限定にしているのが元のシナリオから改変している点です。2024年8月からは「営業支援部署」と元に戻っているのがデータから見て取れます。あえてここを変えたのは、適用期間において特定の期間だけ変更してそのあとは元に戻す、という処理自体も偶有的複雑性をはらんでおり、このような処理もReladomoでは簡単に扱えることを示したいからです。

元の書籍では、「適用期間」を「適用期間」として扱うことの難しさと、「適用期間」のような連続的な概念の代わりに「最小期間(月毎)」のようなデジタルな情報を用いてジョインする方法について述べられていました。一方、Reladomoでは時制データ(テンポラルデータ)をそのまま扱えるAPIが備わっているため、適用期間を連続的な情報として保存したまま扱うことができます。

それでは、早速まずは上記のようなデータを作成するところから、実際のReladomoを用いたコードをみてみましょう。(コードはKotlinで記述しています。ドメインオブジェクトに相当する箇所は日本語で記述しようとしたのですがReladomoが日本語コードを扱えず、中途半端に日本語と英語のmixになってしまい読みづらくてすみません...)

val X部署 = Department(適用年月("202401"))
X部署.departmentName = "X部署"
X部署.departmentType = "営業部署"
// このinsert()が呼び出された時点でデータベースに保存され、X部署は2024年1月から永年で「営業部署」の部署タイプ情報が保存されます
X部署.insert()

// ReladomoのY部署オブジェクトを2024年1月付で作成
val Y部署 = Department(適用年月("202401"))
Y部署.departmentName = "Y部署"
Y部署.departmentType = "営業支援部署"
// このinsert()が呼び出された時点でデータベースに保存され、Y部署は2024年1月から永年で「営業支援部署」の部署タイプ情報が保存されます
Y部署.insert()

上記のアプリケーションコードにより、以下のようなsqlが発行され部署データが作成されます。

insert into DEPARTMENT (DEPARTMENT_CODE,DEPARTMENT_NAME,DEPARTMENT_TYPE,VALID_TIME_FROM,VALID_TIME_TO)
values (1,'X部署','営業部署','2024-01-01 00:00:00.000','9999-12-01 23:59:00.000')

insert into DEPARTMENT (DEPARTMENT_CODE,DEPARTMENT_NAME,DEPARTMENT_TYPE,VALID_TIME_FROM,VALID_TIME_TO)
values (2,'Y部署','営業支援部署','2024-01-01 00:00:00.000','9999-12-01 23:59:00.000')
department_code department_name department_type valid_time_from valid_time_to
1 X部署 営業部署 2024-01-01 00:00:00.000000 9999-12-01 23:59:00.000000
2 Y部署 営業支援部署 2024-01-01 00:00:00.000000 9999-12-01 23:59:00.000000

では、アプリケーションコードから「Y部署の部署種別を2024年3月から2024年の7月にかけて営業部署とする」変更を加えてみましょう。

以下のようなコードになります。

// Y部署の2024年3月以降の情報を変更するため、2024年3月時点でのY部署の情報を取得します
val Y部署3月以降 = DepartmentFinder.findOne(
    DepartmentFinder.departmentName().eq("Y部署")
        .and(DepartmentFinder.validTime().eq(適用年月("202403")))
)

// 2024年3月から2024年7月までの間、Y部署の部署タイプを「営業部署」に変更します
// 2024年8月からは元々の「営業支援部署」タイプが保持されます
Y部署3月以降.setDepartmentTypeUntil("営業部署", 適用終了年月("202407"))

上記のように、setDepartmentTypeUntil()メソッドをつかうことで、テンポラルデータモデルに対しては以下のようなsqlが発行され、上記の適用期間の更新を行うために 1)既存の「営業支援部署」を2月いっぱいまで有効に更新、2)「営業支援部署」を2024年8月以降に有効な行として新規挿入、3)「営業部署」を2024年3月から2024年7月いっぱいまで有効な行として新規挿入、という3つの処理を行っています。

今回のモデルではvalid_time_fromカラムがinclusive条件(その日時を含める)、valid_time_toカラムはexclusive条件(その日時を含めない)として扱われるので、valid_time_from:2024-03-01, valid_time_to:2024-08-01となっている行は「2024年3月から2024年7月いっぱいまで有効」ということになります。

update DEPARTMENT set VALID_TIME_TO = '2024-03-01 00:00:00.000'
where DEPARTMENT_CODE = 2
AND VALID_TIME_TO = '9999-12-01 23:59:00.000'

insert into DEPARTMENT (DEPARTMENT_CODE,DEPARTMENT_NAME,DEPARTMENT_TYPE,VALID_TIME_FROM,VALID_TIME_TO)
values (2,'Y部署','営業支援部署','2024-08-01 00:00:00.000','9999-12-01 23:59:00.000')

insert into DEPARTMENT (DEPARTMENT_CODE,DEPARTMENT_NAME,DEPARTMENT_TYPE,VALID_TIME_FROM,VALID_TIME_TO)
values (2,'Y部署','営業部署','2024-03-01 00:00:00.000','2024-08-01 00:00:00.000')
department_code department_name department_type valid_time_from valid_time_to
1 X部署 営業部署 2024-01-01 00:00:00.000000 9999-12-01 23:59:00.000000
2 Y部署 営業支援部署 2024-01-01 00:00:00.000000 2024-03-01 00:00:00.000000
2 Y部署 営業支援部署 2024-08-01 00:00:00.000000 9999-12-01 23:59:00.000000
2 Y部署 営業部署 2024-03-01 00:00:00.000000 2024-08-01 00:00:00.000000

このように、裏側で扱っているデータの処理は複雑ですが、アプリケーションコードとして扱う上ではその複雑さが隠蔽されていることがわかります。

さて、ここからは、得意先情報を作成していきましょう。

2024年1月はA得意先の営業担当部署はX部署となるので、DBから取得した1月のX部署を、新しく作成したA得意先の担当営業部署として登録し、作成します。補足として、Reladomoではモデルに対してrelationshipを登録することができ、今回のCustomerモデルでは salesDepartment というフィールドを通じてDepartomentモデルとのrelationshipを作成しています。

val X部署1月以降 = DepartmentFinder.findOne(
    DepartmentFinder.departmentName().eq("X部署")
        .and(DepartmentFinder.validTime().eq(適用年月("202401")))
)

val A得意先1月以降 = Customer(適用年月("202401"))
A得意先1月以降.customerName = "A得意先"
A得意先1月以降.salesDepartment = X部署1月以降

A得意先1月以降.insert()

この時点での得意先テーブルは以下のようになります。

customer_code customer_name sales_department_code valid_time_from valid_time_to
1 A得意先 1 2024-01-01 00:00:00.000000 9999-12-01 23:59:00.000000

次に、A得意先の2月以降の営業担当部署をY部署に変更します。

val A得意先2月以降 = CustomerFinder.findOne(
    CustomerFinder.customerName().eq("A得意先")
        .and(CustomerFinder.validTime().eq(適用年月("202402")))
)
val Y部署2月以降 = DepartmentFinder.findOne(
    DepartmentFinder.departmentName().eq("Y部署")
        .and(DepartmentFinder.validTime().eq(適用年月("202402")))
)

A得意先2月以降.salesDepartment = Y部署2月以降

この時点での得意先テーブルは以下のようになります。

customer_code customer_name sales_department_code valid_time_from valid_time_to
1 A得意先 1 2024-01-01 00:00:00.000000 2024-02-01 00:00:00.000000
1 A得意先 2 2024-02-01 00:00:00.000000 9999-12-01 23:59:00.000000

A得意先の営業担当部署がX部署(sales_department_code=1)の行が1月(from: 2024-01-01 to: 2024-02-01)の間だけ有効になっていて、2月以降(from: 2024-02-01 to: 9999-12-01)はY部署(sales_department_code=2)となっているのがわかります。

さて、ここからがReladomoのすごいところです。先ほども述べた通り今回のモデルではReladomoの設定ファイル上でCustomerとDepartomentの関係性を以下のようなRelationshipとして設定しており、Customerのオブジェクトからは salesDepartment というフィールドとしてアクセスすることができます。この関係性をもとに、 ReladomoがCustomerが現在指定しているvalidTimeをもとに同一時点をポイントしている適切なDepartmentオブジェクトを自動的にクエリして返してくれる という優れものなのです。

    <Relationship name="salesDepartment" relatedObject="Department" cardinality="one-to-one">
        this.salesDepartmentCode = Department.departmentCode
    </Relationship>

何を言っているのかわからないと思うので、実際のコードを見てみましょう。下記のコードでは、「A得意先」のCustomerオブジェクトを、「2024年1月付」、「2024年2月付」、「2024年3月付」、「2024年7月付」、「2024年8月付」のそれぞれの時点で取得しています。

val A得意先1= CustomerFinder.findOne(
    CustomerFinder.customerName().eq("A得意先")
        .and(CustomerFinder.validTime().eq(適用年月("202401")))
)
val A得意先2= CustomerFinder.findOne(
    CustomerFinder.customerName().eq("A得意先")
        .and(CustomerFinder.validTime().eq(適用年月("202402")))
)
val A得意先3= CustomerFinder.findOne(
    CustomerFinder.customerName().eq("A得意先")
        .and(CustomerFinder.validTime().eq(適用年月("202403")))
)
val A得意先7= CustomerFinder.findOne(
    CustomerFinder.customerName().eq("A得意先")
        .and(CustomerFinder.validTime().eq(適用年月("202407")))
)
val A得意先8= CustomerFinder.findOne(
    CustomerFinder.customerName().eq("A得意先")
        .and(CustomerFinder.validTime().eq(適用年月("202408")))
)

これらのCustomerオブジェクトから、関連する salesDepartment オブジェクトを取得すると、なんと、魔法のようにそれぞれの時点での担当営業部署情報、そしてY部署の部署種別も2月、3月から7月、8月以降の境界においてそれぞれ「営業支援部署」 => 「営業部署」 => 「営業支援部署」と正しい適用期間の情報を取得することができています。

assertEquals("X部署", A得意先1.salesDepartment.departmentName)
assertEquals("営業部署", A得意先1.salesDepartment.departmentType)
assertEquals("Y部署", A得意先2.salesDepartment.departmentName)
assertEquals("営業支援部署", A得意先2.salesDepartment.departmentType)
assertEquals("Y部署", A得意先3.salesDepartment.departmentName)
assertEquals("営業部署", A得意先3.salesDepartment.departmentType)
assertEquals("Y部署", A得意先7.salesDepartment.departmentName)
assertEquals("営業部署", A得意先7.salesDepartment.departmentType)
assertEquals("Y部署", A得意先8.salesDepartment.departmentName)
assertEquals("営業支援部署", A得意先8.salesDepartment.departmentType)

この裏側で発行されているsqlは以下のようになっています。Departmentが2024年8月付しかクエリされていないように見えるのですが、これ以前にinsertやselectを発行した際にJVMのメモリ上にキャッシュされている情報をReladomoが取得しており、ここでもDBに情報を取りに行くのかメモリ上から取得するのか等の偶有的複雑性はReladomoが吸収しています。

select t0.CUSTOMER_CODE,t0.CUSTOMER_NAME,t0.SALES_DEPARTMENT_CODE,t0.VALID_TIME_FROM,t0.VALID_TIME_TO
from CUSTOMER t0
where  t0.CUSTOMER_NAME = 'A得意先'
and t0.VALID_TIME_FROM <= '2024-01-01 00:00:00.000'
and t0.VALID_TIME_TO > '2024-01-01 00:00:00.000'

select t0.CUSTOMER_CODE,t0.CUSTOMER_NAME,t0.SALES_DEPARTMENT_CODE,t0.VALID_TIME_FROM,t0.VALID_TIME_TO
from CUSTOMER t0
where  t0.CUSTOMER_NAME = 'A得意先'
and t0.VALID_TIME_FROM <= '2024-02-01 00:00:00.000'
and t0.VALID_TIME_TO > '2024-02-01 00:00:00.000'

select t0.CUSTOMER_CODE,t0.CUSTOMER_NAME,t0.SALES_DEPARTMENT_CODE,t0.VALID_TIME_FROM,t0.VALID_TIME_TO
from CUSTOMER t0
where  t0.CUSTOMER_NAME = 'A得意先'
and t0.VALID_TIME_FROM <= '2024-03-01 00:00:00.000'
and t0.VALID_TIME_TO > '2024-03-01 00:00:00.000'

select t0.CUSTOMER_CODE,t0.CUSTOMER_NAME,t0.SALES_DEPARTMENT_CODE,t0.VALID_TIME_FROM,t0.VALID_TIME_TO
from CUSTOMER t0
where  t0.CUSTOMER_NAME = 'A得意先'
and t0.VALID_TIME_FROM <= '2024-07-01 00:00:00.000'
and t0.VALID_TIME_TO > '2024-07-01 00:00:00.000'

select t0.CUSTOMER_CODE,t0.CUSTOMER_NAME,t0.SALES_DEPARTMENT_CODE,t0.VALID_TIME_FROM,t0.VALID_TIME_TO
from CUSTOMER t0
where  t0.CUSTOMER_NAME = 'A得意先'
and t0.VALID_TIME_FROM <= '2024-08-01 00:00:00.000'
and t0.VALID_TIME_TO > '2024-08-01 00:00:00.000'

select t0.DEPARTMENT_CODE,t0.DEPARTMENT_NAME,t0.DEPARTMENT_TYPE,t0.VALID_TIME_FROM,t0.VALID_TIME_TO
from DEPARTMENT t0
where  t0.DEPARTMENT_CODE = 2
and t0.VALID_TIME_FROM <= '2024-08-01 00:00:00.000' and t0.VALID_TIME_TO > '2024-08-01 00:00:00.000'

本記事では深くは入り込みませんが、同一JVM上からのみReladomoを用いている場合はDBに取りに行くかメモリ上のキャッシュに取りに行くかはReladomoが最適な形で対応してくれます。さらに別のJVMインスタンスでReladomoを用いて同じオブジェクトに対するDBの更新・読み取り等を行なう必要がある場合はNotificationを通じてキャッシュの無効化や更新等を自動で扱ってくれる機能もあります。

履歴データをReladomoでバイテンポラルデータとして扱う

さて、今度は2種類の履歴データを同時に扱う際の偶有的複雑性についてReladomoを活用してみましょう。

「データモデリングでドメインを駆動する」の12章6節では以下のような、「記録対象の変化に関する履歴」と「記録自体の変化に関する履歴」を扱うケースを論じています。(テーブル内容は書籍のシナリオから少し表記に改変を加えています)

このシナリオでは、2024年1月 => 2024年2月 => 2024年3月 という形で記録対象(A商品の単価)の変化に関する履歴と、記録自体(2024年2月のA商品の単価)の変化に関する履歴の両方が表現される必要があります。

直前バージョン

商品コード 商品名 適用年月 単価
A A商品 2024年1月 100
A A商品 2024年2月 110
A A商品 2024年3月 120

最新バージョン

商品コード 商品名 適用年月 単価
A A商品 2024年1月 100
A A商品 2024年2月 115
A A商品 2024年3月 120

最初に示した「適応期間」のシナリオでは一つの時制情報(Valid Time)のみを扱っておりましたが、今回のシナリオでは二つの時制情報(Valid TimeとTransaction Time)をあつかえるバイテンポラルデータモデルと呼ばれる形式でデータを扱っていきます。

では、早速Reladomoのコードを見ていきましょう。裏側のデータモデルがバイテンポラルデータモデルであっても、コード上の扱いはほとんど変わりはありません。特に、Transaction Timeに関しては、Reladomoのモデルに一旦指定するとあとは勝手に裏側で必要なデータの処理をしてくれるので、意識をするのは最新データ以外の過去のデータをクエリする時くらいです。

// ReladomoのA商品オブジェクトを2024年1月付で作成
val A商品 = Product(適用年月("202401"))
A商品.productName = "A商品"
A商品.price = 100
// この時点でデータベースに保存され、A商品は2024年1月から永年で100円の価格情報が保存されます
A商品.insert()

// A商品の2024年2月以降の情報を変更するため、2024年2月時点でのA商品の情報を取得します
val A商品2月以降 = ProductFinder.findOne(
    ProductFinder.productName().eq("A商品")
        .and(ProductFinder.validTime().eq(適用年月("202402"))))
// 2024年2月以降のA商品の価格を110円に変更します。この時点では、2月以降の全ての価格情報が110円に変更されます
A商品2月以降.price = 110

// A商品の2024年3月以降の情報を変更するため、2024年3月時点でのA商品の情報を取得します
val A商品3月以降 = ProductFinder.findOne(
    ProductFinder.productName().eq("A商品")
        .and(ProductFinder.validTime().eq(適用年月("202403"))))
// 2024年2月のA商品の価格を120円に変更します
A商品3月以降.price = 120

この時点でデータベースのテーブル内の情報がどうなっているかみてみましょう。期間範囲指定のシナリオの時にはvalid_time_from/valid_time_toの2カラムだけが追加されていましたが、今回はtransaction_time_in/transaction_time_outの2カラムも追加されています。このように2種類の時制データモデルを扱えるのがバイテンポラルデータモデルという形式になります。

product_code product_name price valid_time_from valid_time_to transaction_time_in transaction_time_out
1 A商品 100 2024-01-01 00:00:00.000000 2024-02-01 00:00:00.000000 2024-04-29 16:34:23.200000 9999-12-01 23:59:00.000000
1 A商品 110 2024-02-01 00:00:00.000000 2024-03-01 00:00:00.000000 2024-04-29 16:34:23.200000 9999-12-01 23:59:00.000000
1 A商品 120 2024-03-01 00:00:00.000000 9999-12-01 23:59:00.000000 2024-04-29 16:34:23.200000 9999-12-01 23:59:00.000000

さて、ここから、特定の記録(2024年2月付のA商品の単価)に対して変更を加えます。変更前のタイムスタンプを取得しておいて、2024年2月付のA商品の単価を115円に変更します。

val 変更前タイムスタンプ = currentTimestamp

val A商品2月再取得 = ProductFinder.findOneBypassCache(
    ProductFinder.productName().eq("A商品")
        .and(ProductFinder.validTime().eq(適用年月("202402"))))

// 2024年2月のA商品の価格を115円に変更します
A商品2月再取得.setPriceUntil(115, 適用終了年月("202402"))

ここで、2月付で再取得したオブジェクトに対して価格をセットしているのがかなり違和感を感じる方もいらっしゃるかもしれません。Reladomoではデータベースとメモリ上のオブジェクトを透過的に扱っており、メモリ上のオブジェクトでセットされた情報はそのままデータベースに浸透します。このあたりは現代のアプリケーション上で表現するデータモデルはなるべくイミュータブルに扱いたい場合に大きな違和感として残る観点かと思います(本記事ではいったんこの違和感については論じません)。

アプリケーション側のコードとしては上記だけで、データベース側にはバイテンポラルデータに適切な変更が加わります。

何が起きているかというと、裏側では以下のようなクエリが走ります。既存の2月付の110円の行を無効化(TRANSACTION_TIME_OUT = '2024-04-29 16:36:20.380')して、新たに115円の行を追加しているのがわかります。

update PRODUCT
set TRANSACTION_TIME_OUT = '2024-04-29 16:36:20.380'
where PRODUCT_CODE = 1
AND VALID_TIME_TO = '2024-03-01 00:00:00.000'
AND TRANSACTION_TIME_OUT = '9999-12-01 23:59:00.000'

insert into PRODUCT(PRODUCT_CODE,PRODUCT_NAME,PRICE,VALID_TIME_FROM,VALID_TIME_TO,TRANSACTION_TIME_IN,TRANSACTION_TIME_OUT)
values (1,'A商品',115,'2024-02-01 00:00:00.000','2024-03-01 00:00:00.000','2024-04-29 16:36:20.380','9999-12-01 23:59:00.000')

データベースのテーブルの内容は以下のようになります。

product_code product_name price valid_time_from valid_time_to transaction_time_in transaction_time_out
1 A商品 100 2024-01-01 00:00:00.000000 2024-02-01 00:00:00.000000 2024-04-29 16:36:20.310000 9999-12-01 23:59:00.000000
1 A商品 120 2024-03-01 00:00:00.000000 9999-12-01 23:59:00.000000 2024-04-29 16:36:20.310000 9999-12-01 23:59:00.000000
1 A商品 110 2024-02-01 00:00:00.000000 2024-03-01 00:00:00.000000 2024-04-29 16:36:20.310000 2024-04-29 16:36:20.380000
1 A商品 115 2024-02-01 00:00:00.000000 2024-03-01 00:00:00.000000 2024-04-29 16:36:20.380000 9999-12-01 23:59:00.000000

さて、データベース側がこの状態になったのち、アプリケーションコードで変更前と変更後の商品情報は以下のように取得することができます。

変更前の商品情報の取得

val A商品1月変更前 = ProductFinder.findOne(
    ProductFinder.productName().eq("A商品")
        .and(ProductFinder.validTime().eq(適用年月("202401")))
        .and(ProductFinder.transactionTime().eq(変更前タイムスタンプ))
        .and(ProductFinder.transactionTime().equalsEdgePoint())
)

val A商品2月変更前 = ProductFinder.findOne(
    ProductFinder.productName().eq("A商品")
        .and(ProductFinder.validTime().eq(適用年月("202402")))
        .and(ProductFinder.transactionTime().eq(変更前タイムスタンプ))
        .and(ProductFinder.transactionTime().equalsEdgePoint())
)

val A商品3月変更前 = ProductFinder.findOne(
    ProductFinder.productName().eq("A商品")
        .and(ProductFinder.validTime().eq(適用年月("202403")))
        .and(ProductFinder.transactionTime().eq(変更前タイムスタンプ))
        .and(ProductFinder.transactionTime().equalsEdgePoint())
)

最新(変更後)の商品情報の取得

val A商品1月最新 = ProductFinder.findOne(
    ProductFinder.productName().eq("A商品")
        .and(ProductFinder.validTime().eq(適用年月("202401")))
)

val A商品2月最新 = ProductFinder.findOne(
    ProductFinder.productName().eq("A商品")
        .and(ProductFinder.validTime().eq(適用年月("202402")))
)

val A商品3月最新 = ProductFinder.findOne(
    ProductFinder.productName().eq("A商品")
        .and(ProductFinder.validTime().eq(適用年月("202403")))
)

変更前と変更後では、2月の単価が110円から115円に変化していることが確認できます。

assertEquals(100, A商品1月変更前.price)
assertEquals(110, A商品2月変更前.price)
assertEquals(120, A商品3月変更前.price)

assertEquals(100, A商品1月最新.price)
assertEquals(115, A商品2月最新.price)
assertEquals(120, A商品3月最新.price)

上記のようにReladomoを使うとDB上のテンポラルデータモデルの扱いをフレームワークにまかせ、アプリケーションコード上では素直に「記録対象の変化に関する履歴(1月→2月→3月)」と「記録自体の変化に関する履歴(変更前→変更後)」のCRUD操作をすることが可能となります。

終わりに

いかがだったでしょうか。Reladomoを活用することで「適用期間」や「履歴」といった、煩雑になりがちな偶有的複雑性を適切にフレームワーク内に隠蔽し、コード上ではドメインの関心ごとに集中できることを感じていただけると幸いです。

注意しなければいけないのは、もしReladomoを活用するのであれば、これらの「適用期間」や「履歴」の管理はすべてReladomoに任せるべきであるという点です。Reladomo自体は枯れた技術であり、Reladomoを通じてテンポラルデータモデルのCRUD操作を行なっている限りはデータの不整合等が起きることはありません。しかし、マニュアルでのDB更新や他のORMからの操作をReladomoが扱うデータに対して行なった場合には途端にテンポラルデータモデルの複雑性が牙を向くことがあります。このあたりは既存のコードベースとの相性等、考えるべきことはあると思うので、「適用期間」や「履歴」を扱いたいからといってReladomoを手放しで活用すべきかというとそうではないかと思います。皆さんの道具箱の一つとして頭の片隅に置いておいていただけると幸いです。

Reladomoは使っている技術が少し古くなってきている(xmlを用いた設定ファイル、antでのビルド等)こともあり、大元のコードベースへのコミットに貢献してみるというのも面白いかもしれません。

また、改めまして、「データモデリングでドメインを駆動する」を執筆し、良質な知見と考え方の枠組みを提供いただいた杉本氏に御礼を申し上げます。ありがとうございました!今回取り扱ったような「偶有的複雑性」の話に加えて、書籍内には基幹系システムのデータモデリングに関する本質的な話題が散りばめられているので、ぜひ皆さんもご一読することをお勧めします。

株式会社ログラス テックブログ

Discussion