Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

【Kaigi on Rails 2025 Day2】履歴 on Rails : Bitemporal Data Modelで実現する履歴管理

ogp

1. はじめに

こんにちは!クラウドハウス事業部でエンジニアインターンをしているMatsu-Nobuです!

2025年9月27日に開催されたKaigi on Rails 2025にて、hypermktさんによる「履歴 on Rails : Bitemporal Data Modelで実現する履歴管理」というセッションが行われました。

このセッションでは、引っ越しによる住所変更や所属部署の異動など、時間とともに変化する情報を正確に扱う履歴管理について、Bitemporal Data Modelと呼ばれるデータモデルと、その実装を支える自社開発のOSSであるactiverecord-bitemporalを中心に、実務での試行錯誤を率直に共有してくださいました。

本記事では、このセッションの内容を紹介していきます。

2. 履歴管理の基本

履歴管理の代表的な2つの手法

履歴管理とは、「いつ・何を・どのように変えたか」を後から追跡できる仕組みのことを言います。 履歴管理の代表的な手法として、本セッションでは2つの手法が紹介されました。

1つ目の手法は、レコードにバージョンをつけて、バージョンごとに全てのレコードを保持する方式です。

例としては以下のようなものが挙げられます。以下のテーブルでは、1人の従業員に対して部署の異動が発生するたびに、バージョン番号をインクリメントして新しいレコードを保存しています。

# テーブル構造例
id | version | name | department
1  | 1       | 山田 | 総務部
2  | 2       | 山田 | 営業部
3  | 3       | 山田 | 営業部

この手法のメリットとしては、最新の情報も過去の情報も同じテーブルで取得できることが挙げられます。 反対に、デメリットとしては、データを取得する際には常に最新バージョンを取得する条件をつけなければならないことが挙げられます。

2つ目の手法は、各レコードに「いつの情報か」を示す期間を持たせて管理する方式です。

例としては以下のようなものが挙げられます。 以下のテーブルでは、有効期間の開始日(valid_from)と、有効期間の終了日(valid_to)の列を持たせることで、各レコードがいつからいつまで有効だったかという情報を持たせています。

# テーブル構造例
id | name | department | valid_from | valid_to
1  | 山田 | 総務部     | 2024-04-01 | 2024-06-30
2  | 山田 | 営業部     | 2024-07-01 | 9999-12-31

この手法のメリットは、特定時刻においての状態を再現しやすい点が挙げられます。 反対に、デメリットとしては、「情報がいつ登録されたか」が記録されないため、履歴情報が後から登録されたものであるかどうかを判別できないことが挙げられます。

実際のシステムで求められる履歴管理

ここまで履歴管理の2つの手法を紹介しましたが、実際のシステムではどのような履歴管理が求められるのでしょうか。

人事労務システムにおいては、主に従業員の情報に関して、変化の推移を追跡したいという要求があります。 変化の例としては、異動によって従業員の部署が変更されたり、引越しによって住所が変更されたりする場合が挙げられます。

これらの変化を扱う要求として、本セッションでは以下の3つが挙げられていました。

  • ある時点の状態を知りたい
  • あとから分かった出来事も正しい日付で反映したい
  • 過去にどう変わってきたかを振り返りたい

ここまで紹介した2つの手法では、これらの要求全てを満たすことはできません。

3. Bitemporal Data Modelとは

先ほど述べたように、従来の手法では、過去の情報の再現と、時間を遡っての情報の登録の両立が困難という課題があります。

この課題を解決するのが、Bitemporal Data Modelです。

Bitemporal Data Modelは、有効期間とシステム期間という、2つの時間軸を持つ履歴管理モデルです。

有効期間とは、現実世界でその事実が有効であった期間を表現します。 例えば、ある従業員が実際に総務部に所属していた期間がこれにあたります。4月1日から総務部に配属され、7月1日付で営業部に異動したならば、有効期間は4月1日から6月30日までとなります。

システム期間とは、システム上でデータとして有効だった期間を表現します。 例えば、営業部への異動情報が人事システムに記録されていた期間がこれにあたります。3月25日にシステムに登録されたなら、システム期間の開始は3月25日です。

この2つの時間軸を組み合わせることで、ある時点の情報を正確に再現できたり、後から履歴データの登録・変更が行えるという利点があります。 加えて、「いつ、誰が、何を登録・変更したか」という情報が、システム期間として完全に残るために、監査や調査に強いという利点もあります。

以下で具体例を示しながら説明します。

Bitemporal Data Modelのテーブル例は以下のようになります。

  • bitemporal_id: 同じ事実の履歴をまとめるためのID
  • valid_from: 有効期間の開始日
  • valid_to: 有効期間の終了日
  • transaction_from: システム期間の開始日
  • transaction_to: システム期間の終了日

例えば、山田さんが4月1日に入社し、6月1日に営業部に異動した場合、以下のようなレコードが生成されます:

入社時(4月1日):

id bitemporal_id name department valid_from valid_to transaction_from transaction_to
1 1 山田 総務部 2024-04-01 9999-12-31 2024-04-01 9999-12-31
  • valid_from(有効期間の開始日) は入社日の4月1日
  • valid_to(有効期間の終了日) は9999-12-31という未来の日付で、現在も有効であることを表現
  • transaction_from(システム期間の開始日) も4月1日
  • transaction_to(システム期間の開始日) も9999-12-31で、現在もシステムに存在していることを表現

営業部に異動(6月1日):

id bitemporal_id name department valid_from valid_to transaction_from transaction_to
1 1 山田 総務部 2024-04-01 9999-12-31 2024-04-01 2024-06-01
2 1 山田 総務部 2024-04-01 2024-06-01 2024-06-01 9999-12-31
3 1 山田 営業部 2024-06-01 9999-12-31 2024-06-01 9999-12-31

更新時の処理は、以下のような流れで行われます。

  1. 既存レコード(id=1)のtransaction_toを更新(システム上は6月1日まで存在)

  2. 更新前の状態を示す履歴レコード(id=2)を生成(総務部は4月1日〜6月1日まで有効)

  3. 更新後の状態を示す新レコード(id=3)を生成(営業部は6月1日から有効)

このように、「いつからいつまで総務部にいたか」(有効期間)と「いつその情報がシステムに記録されたか」(システム期間)を明確に分離して管理できます。

4. ActiveRecord::Bitemporal

activerecord-bitemporal gemとは

それでは、実際のRailsアプリケーションでBitemporal Data Modelを扱いたいときにはどうすればよいのでしょうか?

SmartHRは、RailsのActiveRecordでBitemporal Data Modelを扱うためのgemであるactiverecord-bitemporalをOSSとして公開しています。

このgemは、複雑なBitemporal Data Modelの仕組みをRailsアプリケーションで簡単に利用できるようにするためのライブラリです。

activerecord-bitemporalには以下のようなメリットがあります。

  • 導入が簡単
  • ActiveRecord互換
  • 履歴を操作する様々なメソッドが提供されている
  • 複雑なSQLを意識せずにBitemporal Data Modelを扱える

以下では、activerecord-bitemporalを実際に使ってみた際の様子を示します。

ActiveRecord::Bitemporalを実際に使ってみた

まずは、gemfileにactiverecord-bitemporalを追加します。

gem 'activerecord-bitemporal'

続いて、マイグレーションでBitemporal Data Modelに必要なカラムを追加します。

class CreateEmployees < ActiveRecord::Migration[8.0]
  def change
    create_table :employees do |t|
      t.string   :name
      t.string   :department

      # BTDMに必要なカラム
      t.integer  :bitemporal_id
      t.datetime :valid_from
      t.datetime :valid_to
      t.datetime :transaction_from
      t.datetime :transaction_to

      t.timestamps
    end
  end
end

モデル側ではActiveRecord::Bitemporalをincludeします。 これだけで、EmployeeモデルがBitemporal Data Modelとして動作するようになります。

class Employee < ApplicationRecord
  include ActiveRecord::Bitemporal
end

それでは、実際の動作を確かめてみましょう。

以下のように、create, update, destroyといったメソッドがサポートされており、普段ActiveRecordを使うのと全く同じように使用できる点がactiverecord-bitemporalの大きな特徴です。

# レコードの作成
employee = Employee.create(name: "山田太郎", department: "総務部")

# レコードの更新
employee.update(department: "営業部")

# レコードの削除
employee.destroy

これらを使って、レコードの作成時や更新時に実際にどんなSQLが発行されるかを確認してみました。

まずは、先ほどの例と同様、4月1日に入社した際のEmployeeを新規作成します。

> e = Employee.create(name: '山田', department: '総務部', valid_from: '2025-04-01')
TRANSACTION (0.0ms)  BEGIN immediate TRANSACTION 
  Employee Create (0.3ms)  INSERT INTO "employees" ("name", "department", "bitemporal_id", "valid_from", "valid_to", "transaction_from", "transaction_to", "created_at", "updated_at") VALUES ('山田', '総務部', NULL, '2025-05-01 00:00:00', '9999-12-31 00:00:00', '2025-10-18 09:55:23.223751', '9999-12-31 00:00:00', '2025-10-18 09:55:23.223751', '2025-10-18 09:55:23.230015') RETURNING "id"
  Employee Update (0.1ms)  UPDATE "employees" SET "bitemporal_id" = 1 WHERE "employees"."id" = 1
  TRANSACTION (1.2ms)  COMMIT TRANSACTION
=> 
#<Employee:0x0000000134afc3e8
 id: 1,
 name: "山田",
 department: "総務部",
 bitemporal_id: 1,
 valid_from: "2025-04-01 00:00:00.000000000 +0000",
 valid_to: "9999-12-31 00:00:00.000000000 +0000",
 transaction_from: "2025-10-18 09:55:23.223751", # 自動で操作を行った時刻が入る
 transaction_to: "9999-12-31 00:00:00.000000000 +0000",
 created_at: 2025-10-18 09:55:23.223751000 +0000,
 updated_at: 2025-10-18 09:55:23.230015000 +0000>

続いて、6月1日に営業部に異動した際に、updateで部署を更新します。

> e.update(department: '営業部', valid_from: '2025-06-01')
  
   ... (省略) ...

  Employee Update (3.3ms)  UPDATE "employees" SET "transaction_to" = '2025-10-18 09:19:11.569956' WHERE "employees"."id" = 1 
  TRANSACTION (0.0ms)  SAVEPOINT active_record_3 
  Employee Create (0.8ms)  INSERT INTO "employees" ("name", "department", "bitemporal_id", "valid_from", "valid_to", "transaction_from", "transaction_to", "created_at", "updated_at") VALUES ('山田', '総務部', 1, '2025-04-01 00:00:00', '2025-10-18 09:19:11.569956', '2025-10-18 09:19:11.569956', '9999-12-31 00:00:00', '2025-10-18 09:19:11.569956', '2025-10-18 09:19:11.594848') RETURNING "id" 
  TRANSACTION (0.0ms)  RELEASE SAVEPOINT active_record_3 
  TRANSACTION (0.0ms)  SAVEPOINT active_record_3 
  Employee Create (2.0ms)  INSERT INTO "employees" ("name", "department", "bitemporal_id", "valid_from", "valid_to", "transaction_from", "transaction_to", "created_at", "updated_at") VALUES ('山田', '営業部', 1, '2025-10-18 09:19:11.569956', '9999-12-31 00:00:00', '2025-10-18 09:19:11.569956', '9999-12-31 00:00:00', '2025-10-18 09:19:11.569956', '2025-10-18 09:19:11.606273') RETURNING "id" 
  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_3 
  TRANSACTION (0.1ms)  COMMIT TRANSACTION 
=> true

updateの際に発行されるクエリを見ると、更新時には、UPDATE文によってシステム上有効だったレコードの無効化処理と、2回のINSERT文によって更新前・更新後の状態を示すレコードの追加が行われていることが分かります。

最後に、この操作によってどのようなレコードが作成されたか見てみましょう。

以下のようにfindwhereで検索する際には、自動的にidがbitemporal_idに読み替えられたり、有効期間内かつシステム有効期間内のレコードが絞り込まれたりします。

> Employee.find(1)
  Employee Load (0.2ms)  SELECT "employees".* FROM "employees" WHERE "employees"."transaction_from" <= '2025-10-18 10:07:13.225614' AND "employees"."transaction_to" > '2025-10-18 10:07:13.225614' AND "employees"."valid_from" <= '2025-10-18 10:07:13.225614' AND "employees"."valid_to" > '2025-10-18 10:07:13.225614' AND "employees"."bitemporal_id" = 1 LIMIT 1
=> 
#<Employee:0x0000000140f776c8
 id: 1,
 name: "山田",
 department: "営業部",
 bitemporal_id: 1,
 valid_from: "2025-10-18 09:19:11.569956000 +0000",
 valid_to: "9999-12-31 00:00:00.000000000 +0000",
 transaction_from: "2025-10-18 09:19:11.569956000 +0000",
 transaction_to: "9999-12-31 00:00:00.000000000 +0000",
 created_at: "2025-10-18 09:19:11.569956000 +0000",
 updated_at: "2025-10-18 09:19:11.606273000 +0000">

> Employee.where(name: '山田')
  Employee Load (0.1ms)  SELECT "employees".* FROM "employees" WHERE "employees"."transaction_from" <= '2025-10-18 10:07:51.680673' AND "employees"."transaction_to" > '2025-10-18 10:07:51.680673' AND "employees"."valid_from" <= '2025-10-18 10:07:51.680673' AND "employees"."valid_to" > '2025-10-18 10:07:51.680673' AND "employees"."name" = '山田'
=> 
[#<Employee:0x0000000140f7c6c8
  id: 1,
  name: "山田",
  department: "営業部",
  bitemporal_id: 1,
  valid_from: "2025-10-18 09:19:11.569956000 +0000",
  valid_to: "9999-12-31 00:00:00.000000000 +0000",
  transaction_from: "2025-10-18 09:19:11.569956000 +0000",
  transaction_to: "9999-12-31 00:00:00.000000000 +0000",
  created_at: "2025-10-18 09:19:11.569956000 +0000",
  updated_at: "2025-10-18 09:19:11.606273000 +0000">]

そのため、今回のように有効期間を無視して全てのレコードを検索したい場合には、ignore_bitemporal_datetimeを使うと良さそうです。 以下のように、先ほど例で示した3つのレコードが作成されていることが確認できます。

> Employee.ignore_bitemporal_datetime
  Employee Load (0.2ms)  SELECT "employees".* FROM "employees"
=> 
[#<Employee:0x0000000140f7e888
  id: 1,
  name: "山田",
  department: "総務部",
  bitemporal_id: 1,
  valid_from: "2025-04-01 00:00:00.000000000 +0000",
  valid_to: "9999-12-31 00:00:00.000000000 +0000",
  transaction_from: "2025-10-18 09:16:51.286498000 +0000",
  transaction_to: "2025-10-18 09:19:11.569956000 +0000",
  created_at: "2025-10-18 09:16:51.286498000 +0000",
  updated_at: "2025-10-18 09:16:51.288316000 +0000">,
 #<Employee:0x0000000140f7e748
  id: 1,
  name: "山田",
  department: "総務部",
  bitemporal_id: 1,
  valid_from: "2025-04-01 00:00:00.000000000 +0000",
  valid_to: "2025-10-18 09:19:11.569956000 +0000",
  transaction_from: "2025-10-18 09:19:11.569956000 +0000",
  transaction_to: "9999-12-31 00:00:00.000000000 +0000",
  created_at: "2025-10-18 09:19:11.569956000 +0000",
  updated_at: "2025-10-18 09:19:11.594848000 +0000">,
 #<Employee:0x0000000140f7e608
  id: 1,
  name: "山田",
  department: "営業部",
  bitemporal_id: 1,
  valid_from: "2025-10-18 09:19:11.569956000 +0000",
  valid_to: "9999-12-31 00:00:00.000000000 +0000",
  transaction_from: "2025-10-18 09:19:11.569956000 +0000",
  transaction_to: "9999-12-31 00:00:00.000000000 +0000",
  created_at: "2025-10-18 09:19:11.569956000 +0000",
  updated_at: "2025-10-18 09:19:11.606273000 +0000">]

5. 履歴運用の課題と向き合い方

ここまでBitemporal Data Modelとactiverecord-bitemporal gemの優れた点を紹介してきましたが、セッションの中では実際の運用での辛い部分についても触れられました。

Bitemporal Data Modelは強力である反面、運用においては課題も存在します。発表中では主に3つの課題が紹介されました。

課題1:履歴に関わるデータの調査が困難

ある日、顧客から履歴管理機能が期待した挙動をしないという問い合わせがありました。 原因は、不具合によって想定外の履歴データが作られているというものでした。

しかし、この不具合の調査は極めて困難なものでした。 Bitemporal Data Modelを用いた履歴の実データは複雑であり、問題となっているデータを特定することには時間を要するためです。 実際に、調査に丸一日かかることもあるそうです。

従業員のデータを見ても、膨大なレコードの中から「いつ、何が、どう変わったのか」を一目で理解するのは非常に困難です。 この課題に対して、SmartHRでは専用の可視化ツールを開発しました。

それがActiveRecord::Bitemporal::Visualizer.visualizeメソッドです。 このメソッドを使うと、レコードの履歴を視覚的に確認することができます。 以下に示したように、横軸に有効期間、縦軸にシステム期間をとって、あるレコードがいつ変更されたのか、また現在有効なレコードはいつから有効なのかを確認することができます。

# employee = Employee.find_by(name: '山田')
# puts ActiveRecord::Bitemporal::Visualizer.visualize(employee)

transaction_datetime    | valid_datetime
                        | 2025-04-01 00:00:00.000
                        |                   | 2025-10-18 09:19:11.569
                        |                   |                   | 9999-12-31 00:00:00.000
2025-10-18 09:16:51.286 +---------------------------------------+
                        |                                       |
                        |                                       |
                        |                                       |
                        |                                       |
2025-10-18 09:19:11.569 +-------------------+-------------------+
                        |                   |*******************|
                        |                   |*******************|
                        |                   |*******************|
                        |                   |*******************|
9999-12-31 00:00:00.000 +-------------------+-------------------+

課題2:履歴に関するシステムの複雑化

データ構造の複雑化

異動や身上変更など、従業員情報の変更は基本的に日単位で発生します。 しかし、内部では時分秒まで保持していたために、実装が複雑化し、開発者・利用者の双方にとって扱いづらいものとなってしまっていました。

このような問題を解消するため、有効期間のデータの型をtimestamp型からdate型に変更するプロジェクトが行われたそうです。 このプロジェクトは、検討開始から完了まで5年近くを要した、大変なプロジェクトだったそうです。 その詳細についてはこちらのブログで詳細に説明されています。

課題3:履歴が不要なデータもbitemporal化

いくつかのモデルでは、Bitemporal Data Modelによって履歴を管理する必要性がないものもあります。 そうしたものに対してもBitemporal Data Model化が成されている場合もあるそうです。 こうしたモデルでは非Bitemporal Data Model化を行いたいですが、データ移行の困難さなどの課題があり、移行が行われた実例は未だ存在しないそうです。

Bitemporal Data Modelを扱う際に注意すべきこと

Bitemporal Data Modelを扱う際の課題としては、データが肥大化すること、実装が複雑化しやすいこと、導入時に学習コストがかかること、一度Bitemporal Data Modelを適用したものを戻すことは困難であることが挙げられます。

そのため、Bitemporal Data Modelを利用する際には、本当に必要かどうか、十分検討することが重要です。

履歴管理が不要、または過剰なケースとしては、以下のような場合が挙げられます。 - マスターデータで変更頻度が極めて低いもの - 参照のみで更新がほとんど発生しないもの - リアルタイム性が重要で、履歴管理の必要性が低いもの - ログ的なデータで、そもそも履歴の概念がないもの

6. まとめ

本セッションでは、人事労務システムで重要となる履歴情報を、有効期間とシステム期間という2つの時間軸を持つBitemporal Data Modelを用いて表現する方法について具体例を交えて説明し、実際のRailsアプリケーションでActiveRecordを扱うのと同じようにBitemporal Data Modelを扱えるactiverecord-bitemporal gemについても紹介されました。

さらに、実際の現場においてのBitemporal Data Modelを用いた履歴管理システムの運用の課題や、現場の経験から得られた運用のプラクティスについても紹介いただき、大変興味深い内容でした。


Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。

jp.techouse.com