🏥

予約システムの機能拡張で感じた設計の難しさと面白さ

に公開

この記事は MICIN Advent Calendar 2025 の 22 日目の記事です。
前回は 山崎 さんの、「『プログラマのためのSQL』DB読書会参加レポ」 でした。


はじめに

MICINオンライン医療事業部の大里です。

患者さんがクリニックを受診する際、受付・会計をスマホひとつで完結し、手ぶらで通院が可能なサービスであるクロンスマートパスの開発を担当しています。
https://smartpass.curon.co

今年は、昨年末にリリースし現在一部の医療機関でご利用いただいている「順番待ち受付」と、新しく今年の夏にリリースした「時間帯予約」の開発にフルコミットした一年でした。

その中でも特に、順番待ち受付をリリースした後に、新たに提供することになった時間帯予約の開発において、既存実装の上で新しいビジネス要求を実現するための設計の難しさと面白さの両方を感じました。

この記事では、私が経験した開発の取り組みを振り返ってみます。

今回の前置き

サービスについて

クロンスマートパスの追加機能として開発した「順番待ち受付」と「時間帯予約」は、以下の特徴を有しています。

特徴 順番待ち受付 時間帯予約
仕組み 患者さんが当日受付をし順番に診療を受ける 患者さんが特定の時間枠を予約し診療を受ける
予約内容 当日の午前診療の10番目(10:00頃の呼び出し) 12/1 10:00~10:30

順番待ち受付は昨年末にリリース済みで、時間帯予約は順番待ち受付に並ぶ新しい予約の仕組みを提供するために、約半年後の今年の夏に開発がスタートしました。

ビジネス要求について

時間帯予約の開発にあたり、主なビジネス要求は以下の通りです。
既存実装の上でこれらを実現するために、チーム内で複数回にわたり設計議論を行いました。

  • 両方の仕組みに対応することで、様々な医療機関のニーズに応えたい
  • 順番待ち受付で提供している機能の多くは、時間帯予約でもそのまま使いたい

DBの構成変更の方針

前述の要求もあり、順番待ち受付のテーブル構成を大きく変更することなく、時間帯予約に必要な情報を保持できるようにすることを目的としました。

以降は説明のためにテーブル名等を一般化して記載しています。
また、クロンスマートパスではバックエンドにRuby on Railsを採用しており、参考にサンプルコードを記載します。

Before

当初は時間帯予約を想定せずに設計していたため、設定テーブル(queue_settings)や時間枠テーブル(time_slots)は、これから開発する時間帯予約では不要になるカラムを保持している状況でした。

After

外部キーやカラムはできるだけnullableにしない設計にし、関心ごとに責務を分けつつ中間テーブルや専用テーブルに分離することで、NOT NULL制約を維持しつつデータの整合性を担保する形にしました。

主に行った変更は以下の通りです。

1. 中間テーブルの導入

各設定と時間枠の関係を中間テーブルで管理しつつ、中心となる受付/予約を示すテーブル(standbys)は共通とし、変更範囲を限定的にしました。

2. 専用テーブルへの分離

順番待ち受付・時間帯予約に共通しないカラムは別テーブル(time_slot_queues、time_slot_reservations)に切り出しました。
また、時間枠テーブルには順番待ち受付と時間帯予約のどちらで利用しているかを示すカラム(service_type)を用意することで、中間テーブルをJOINせずにフィルタリング可能にし、容易に識別できるようにしました。

リリースの手順

順番待ち受付は本番稼働中のサービスのため、誤った対応はデータの破損やサービス障害に繋がります。

以下の対応を4回に分けてリリースし、各ステップで順番待ち受付にデグレが発生していないか、万が一に備えて適切にロールバックが行えるか等、入念にテスト及びリリースリハーサルを行いました。

  1. 新規テーブル作成・データ同期
  2. 参照先の移行(新規テーブルを使うようコード変更)
  3. カラムを削除する準備(カラムをnullable化 + ignored_columns設定)
  4. カラム削除

Step1 新規テーブル作成・データ同期

最初に、新しいテーブルを作成し、既存データを移行します。

やること

  • 新規テーブル(中間テーブル等)を作成
  • 既存データを新規テーブルにコピーするRakeタスクを実行
  • アプリケーション上でも既存・新規テーブルの両方に書き込みを行うよう変更
  • データの整合性チェックの実施

ポイント

  • この段階ではコードは既存テーブルを参照したまま

ロールバック

  • 新規テーブルの削除

Step2 参照先の移行

コード上の参照を新規テーブルに切り替えます。

やること

  • アソシエーションの変更(直接参照 → through経由)
  • API層での対応
# Before: 直接参照
class TimeSlot < ApplicationRecord
  belongs_to :setting
end

# After: 中間テーブル経由
class TimeSlot < ApplicationRecord
  has_one :queue_setting_time_slot
  has_one :setting, through: :queue_setting_time_slot
end

ポイント

  • この段階では既存・新規テーブルの両方への書き込みは継続

ロールバック

  • コードを元に戻す

Step3 カラムを削除する準備

削除予定のカラムへの依存をなくし、削除可能な状態にします。
ActiveRecordは起動時にスキーマをキャッシュするため、ignored_columnsで事前にカラムを認識対象から外しておく必要があります[1]。

やること

  • 削除予定のカラムへの書き込みを停止
  • 削除予定のカラムをnullableに変更
  • 削除予定のカラムの外部キー制約の削除
  • ignored_columnsの設定
class TimeSlot < ApplicationRecord
  self.ignored_columns += %w(setting_id B)
end

ロールバック

  • ignored_columnsを削除
  • 削除予定のカラムに関する制約を戻す
  • コードを元に戻す

Step4 カラム削除

最後に、カラムを削除します。

やること

  • カラムを削除
  • ignored_columnsの設定を削除

結果

リハーサルは行っていたものの、このような大きな変更をしたことがなかったため内心ドキドキでしたが、障害等が発生することなく無事リリースが完了しました。
また、その後の時間帯予約の開発においても、設計レベルで致命的な手戻り作業を起こすことなく、リリースできました。

現在も一部の医療機関でご利用いただいている中で、自分が中心となって設計・実装した機能が実際にユーザーに使われているのを見ると嬉しく思います。

設計で感じた面白さ

今回の取り組みで特に面白いと感じたのは、既存の実装を活かしながら最適解を探すプロセスです。
レガシーコードと向き合う際は「全部作り直したい」という誘惑に駆られることもあるかもしれませんが、今回はいくつかの既存のテーブルをそのまま活かしながら新機能にも対応できる設計を目指しました。既存機能の安定性を損なわずに新しい価値を追加できる設計を見つけられた点は、パズルを解くような達成感がありました。

また、チームでの設計議論も面白い経験でした。「中間テーブルを導入するか」「service_typeカラムを追加するか」といった設計判断において、チームメンバーから様々な意見が出てきました。どの意見も一理あり、100%正しい答えがあるわけではありません。正規化を重視するか、クエリのパフォーマンスや可読性を重視するか、一長一短ある中でどの設計が今回のケースに最適かをチームで議論するプロセスは、設計の奥深さを実感できる良い機会でした。

最後に

弊社サービスの開発で、既存実装の上で新しいビジネス要求を実現するための設計の難しさと面白さの両方を実感できた取り組みについて振り返ってみました。

エンジニアが上流工程に積極的に関わることでビジネス要求を早めにキャッチし、将来的な拡張も含めて「この要求を実現するためにはどのようなモデリングにすると良いか?」を考えながらチームで設計議論をする重要性を感じました。
開発期間が短く大変ではありましたが、個人的には良い経験になりました。

今後もユーザーにより良い医療体験を届けるべく、開発に取り組みたいと思います。


MICINではエンジニアを募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!

https://recruit.micin.jp/

脚注
  1. 参考: https://api.rubyonrails.org/classes/ActiveRecord/ModelSchema/ClassMethods.html#method-i-ignored_columns-3D ↩︎

株式会社MICIN

Discussion