株式会社ヘンリー エンジニアブログ

株式会社ヘンリーのエンジニアが技術情報を発信します

実装手順書よりもコンパイルエラー

株式会社ヘンリーでエンジニアをしている okbee です。
直近は製品のフルリニューアルを行なっており、詳細は省きますが、私もこの開発に参加しています。社内では「コスト連携」と呼ばれる機能の開発を主に担当していました。 「コスト連携」をざっくりと説明するならば、患者に対して医師が作成した指示(オーダー)を元にして、実際の金額を算出するためのワークフローです。詳細はコンテキストで紹介します。

さて、今回は「コスト連携」の実装を通して感じた反省を元に、より良い設計のヒントとして「コンパイルエラーを活用した手順不足の検知」を考えていきます。

コンテキスト

コスト連携には臨床・会計の2つの大きなコンテキストが背景にあります。
患者に対して医師が作成する指示(以降はオーダーと表記)は臨床と呼ばれるコンテキストで作成されます。ちょうど、皆さんがクリニックなどで診察を受ける際に医師が薬を処方したり、注射の指示を行ったりします。これが臨床側のコンテキストです。

その後、医師の指示を元に患者が実際に支払う金額を算出します。
ここでは初診料などが追加されますが、元となるのは臨床側で作成されたオーダーです。例えば処方箋が出ていれば、薬価(薬の値段)や調剤料などを会計時に支払う必要があります。 これが会計側のコンテキストです。(普段は見えない部分)

言ってみれば、コスト連携は臨床・会計のコンテキストを連携する機能です。
ヘンリーでも同じように臨床・会計と大きく2つの開発チームが存在しています。

コスト連携の大変さ

コスト連携では臨床側で作られたオーダーを中間データに変換して、最終的にコストと呼ばれるデータに変換します。 コンテキスト境界ゆえの難しさはありますが、特別に複雑な実装が必要なわけではありません。

しかし、最終的なコストに変換するためには、マスタや電子点数表といった基金などが公開している多種多様なデータが必要です。オーダーごとに必要なデータは異なり共通化が難しく、どうしてもオーダーごとの対応が必要になります。

中間データの作成時にも同じ問題が起こります。オーダーごとに参照するプロパティが全く異なるため、共通化が難しいです。 オーダーの種類が2・3つであれば苦労しませんが、現時点では6つのオーダーがコスト連携の対象となっています。そして、今後も対象となるオーダーは増えていきます。

graph LR
    subgraph 臨床コンテキスト
        処方オーダー
        注射オーダー
    end
    subgraph 会計コンテキスト
        処方マスタ[(処方用マスタ)] --> 処方コスト
        処方オーダー --> 処方中間データ
        処方中間データ --> 処方コスト

        注射オーダー --> 注射中間データ
        注射マスタ[(注射用マスタ)] --> 注射コスト
        注射中間データ --> 注射コスト

        注射コスト --> 総額
        処方コスト --> 総額
    end

コスト連携の大変さは「手数 x 対象のオーダー数」になります。

実装手順書を作ったが...

私が別機能の開発を行うことになり、今後のオーダー拡張に備えて、実装手順書を作成しました。 実際の中身はお見せできませんが、結構なボリュームがあります。また、最新のコードは常に変化しているので、実装手順書を常にコードへ同期させる必要があります。 しかし、残念ながらこういったドキュメントの維持管理は後手に周り、忘れ去られがちです。

実際に、私が行ったコード変更を実装手順書に反映し忘れた結果、他の方に対応してもらった際に対応漏れが発生しました。PRレビュー時には、私自身、対応漏れに気づきませんでした。 人間はミスを必ずします。実装手順書では、対応漏れが発生しても気づけない可能性が十分に考えられます。

理想: 対応漏れはコンパイルエラーとなる

理想を考える上でビジネスロジックを「型」で表現するOOPのための関数型DDDがヒントになりました。 また近い時期に販売された「関数型ドメインモデリング」も参考にしています。どちらも素晴らしい内容なので、ぜひご覧ください。

特に秀逸なのは「代数型データを使ってデータ不整合を取り除く & 網羅性を担保する」箇所です。 網羅性が不十分なためコンパイルエラーとなるシンプルな例を見てみます。エラー内容からも網羅性が不十分なのは自明です。

// sealed interfaceを用いて直和型を定義
sealed interface Animal {
  object Dog : Animal
  object Cat : Animal
  object Bird : Animal
}

fun sound(animal: Animal): String {
  return when (animal) {
    Animal.Dog -> "Woof!"
    Animal.Cat -> "Meow!"
    // ↓ Animal.Bird を忘れていると、コンパイルエラーになる
    // 'when' expression must be exhaustive, add necessary 'is Bird' branch or 'else' branch instead
  }
}

言ってみれば「Birdの条件分岐を追加する」という必要な手順が足りていないため、コンパイルエラーとなります。つまり、定められた手順を満たせていないということです。
同じことが、コスト連携の対象オーダー拡張でも出来ないかな?と考えました。

実装例

まず、設計に関して大きく2つの制約があります。

  1. コスト連携はワークフローとして実装しており、全てのオーダーは同じステップをもつ
  2. 臨床側で作成される全てのオーダーが、コスト連携の対象となるわけではない

また、臨床側ではオーダーの定義が以下のようにされているとします。

sealed interface Order {
  data class 処方(...) : Order
  data class 注射(...) : Order
  data class 検体検査(...) : Order
  :
}

ワークフローのステップを定義

再掲ですが、コスト連携は臨床側で作られたオーダーを中間データに変換して、最終的にコストと呼ばれるデータに変換する一連のワークフローです。 制約の通り、全てのオーダーで同じステップをもつため、素直にステップをインターフェースとして定義します。

どのオーダーでも実装は共通だったため、デフォルトの実装まで書いています。

/**
 * コスト連携のワークフローのステップを定義したIF。
 * オーバーライド先で実装の詳細を書くとファットなクラスになってしまうため、
 * Dependency Injectionを利用して、実装の詳細は別で行うことを期待している。
 */
interface CostSyncWorkflow<CostSyncTargetOrder : Order> {
  /**
   * マスタ取得が必要な項目を抽出する
   */
  fun getMasterRequirements(
    orders: List<CostSyncTargetOrder>,
    extractor: (List<CostSyncTargetOrder>) -> MasterRequirements
  ): MasterRequirements = extractor(orders)

  /**
   * オーダーを中間データに変換する
   */
  fun to中間データ(
    orders: List<CostSyncTargetOrder>,
    converter: (List<CostSyncTargetOrder>) -> List<中間データ>
  ): List<中間データ> = converter(orders)
}

コスト連携の対象となるオーダーの定義

2つ目の制約から、臨床側で定義されるオーダーとコスト連携の対象となるオーダーの定義は別にしておくのが良さそうです。 また、臨床と会計のコンテキスト境界でもあるため、同じ定義を使わずに分けておいた方が良いでしょう。合わせて、先ほど定義したインターフェースを実装するようにします。

試しに検体検査はコスト連携の対象外として、サブクラスとして定義しません。

/**
 * コスト連携の対象となるオーダーの一覧。
 * ここに連携対象としたいオーダーをサブクラスとして追加していく事を期待している。
 * パフォーマンスの都合上(N+1を回避するため)、オーダーの一覧をプロパティに持たせている。
 */
sealed interface CostSyncSourceType {
  val orders: List<Order>

  data class 処方(override val orders: List<Order.処方>) : Order, CostSyncWorkflow<Order.処方>
  data class 注射(override val orders: List<Order.注射>) : Order, CostSyncWorkflow<Order.注射>
}

ワークフローの実装例

これまでの定義を用いて、ワークフローを実装してみます。
CostSyncSourceType.注射に関する実装をあえてしないと、網羅性が不十分なため、期待通りにコンパイルエラーとなります。 また、検体検査に関してはコスト連携の対象ではないため、網羅性チェックには含まれません。
(実際に動くコードではありませんが、イメージの共有が目的)

/**
 * コスト連携を実行するワークフロー。
 * 対象となるオーダーを受け取り、中間データを経て、最終的にコストへ変換をする。
 */
class CostSyncWorkflowV2(
  private val masterFetcher: MasterFetcher,
  private val 処方MasterExtractor: 処方MasterExtractor,
  private val 処方To中間データConverter: 処方To中間データConverter,
) {
  fun syncCost(sources: List<CostSyncSourceType>) {
    val masters = fetchMasters(sources)
    val costInvocations = to中間データ(sources, masters)
    :
  }

  /**
   * 対処となるオーダーのプロパティをそれぞれ参照して、マスタ取得に必要な情報を集める。
   */
  private fun fetchMasters(sources: List<CostSyncSourceType>): Masters {
    val masterRequirements = sources
      .map { source ->
        when (source) {
          is CostSyncSourceType.処方 -> {
            source.getMasterRequirements(source.orders) { orders ->
              処方MasterExtractor.extract(orders)
            }
          }
          // 網羅性が不十分なためコンパイルエラーとなる
          // 'when' expression must be exhaustive, add necessary 'is 注射' or 'else' branch
        }
      }
      .reduce { acc, master -> acc + master }
    return masterFetcher.fetchFromRequirements(masterRequirements)
  }

  /**
   * コスト連携の対象となるオーダーを中間データに変換する。
   */
  private fun to中間データ(
    sources: List<CostSyncSourceType>,
    masters: Masters
  ): List<中間データ> {
    return sources.map { source ->
      when (sources) {
        is CostSyncSourceType.処方 -> {
          source.toCostInvocations(source.orders) { orders ->
            処方To中間データConverter.convert(orders, masters)
          }
        }
        // 網羅性が不十分なためコンパイルエラーとなる
        // 'when' expression must be exhaustive, add necessary 'is 注射' or 'else' branch
      }
    }
  }
}

このように不足している手順(実装)を、コンパイルエラーによって安全に把握することが可能となりました。 コンパイルエラーがあるので、もちろんコンパイルに失敗し、ビルドもできません。つまり、対応漏れのコードがリリースされることはありません。

トレードオフ

実装例を示したところで、最後に設計に関するトレードオフについて考えてみます。

型・操作(関数)のどちらを追加しやすくするか

Expression problem で議論されているように、型と操作(関数)の追加はトレードオフの関係になります。 V2の実装では、ワークフローに大きな変更(操作の追加)が発生する可能性は低いが、対象となるオーダー追加(型の追加)は頻繁にされる前提で行なっています。

そのため、ワークフローの頻繁な更新、ステップの大改修が起きた場合、V2実装では対応のコストが非常に高くなってしまいます。

操作の追加時

// interface に操作を定義 & ワークフローの変更 が必要 -> コストが高い
interface CostSyncWorkflow<CostSyncTargetOrder : Order> {
  :
  fun doSomething()
}

型の追加時

// サブクラスを追加 & whenへの条件追加が必要 -> コストが低い
sealed interface CostSyncSourceType {
  :
  class 新たなオーダー(
    override val orders: List<HogeOrder>
  ) : CostSyncSourceType, CostSyncWorkflow<HogeOrder>
}

総合的なコスト

今後も新たなオーダーが追加されますが、何百と追加されることはありません。
オーダー拡張に対するコストを設計で下げたとしても、愚直にそれぞれ実装した場合のコストと比較すると、総合的なコストはあまり変わらないかもしれません。
設計時点で対象となるオーダーがどれぐらいになるのかを把握しているかどうかで、最終的にどのような設計にすべきかの選択肢は変わってくるはずです。

早すぎる最適化

今回のアイディアが思い浮かんだのは、現在の実装を通して共通性が見えてきたからです。 早い段階でワークフローを整理していれば、設計はまた違ったかもしれませんが、私は実装の初期段階で強い確証がなければ、変更・拡張に対して強いコードを素直に書くべきだと考えています。

例えば、一本の木をじっと見つめていても、それが森かどうかは分かりません。高いところから俯瞰して初めて、広大な森だと気づくことがあります。 実装に関しても同じアプローチである方が自然ではないでしょうか。

早すぎる最適化は、不要な複雑さと制約を生み出す可能性があります。

まとめ

  • 実装手順書の管理は難しい上、人間はミスをする
  • 手順に不備があることをコンパイルエラーとして表現したい
  • Kotlinでは網羅性チェックを用いることで、コンパイルエラーとすることが可能
    • 網羅性チェックを用いて手順の不備を検知することができるかも?
  • 今回の実装例では大きく2つの定義を用いた
    • コスト連携のステップを定義したインターフェース
    • コスト連携の対象となるオーダーの定義
  • V2の実装ではコンパイルエラーにより手順に不備があることを検知できた


ヘンリーでは医療業界の課題に向き合い、電子カルテ・レセコンの開発に取り組むソフトウェアエンジニアを募集しています。 今回の記事の感想など、お気軽にカジュアル面談でお話ししましょう👍

jobs.henry-app.jp


おまけ: コスト連携 対象オーダーの取得

コスト連携の対象となるオーダを全種類、取得する実装について考えてみます。
以前の実装では、手順書に従う他なく、対象オーダーの拡張時に対応漏れがあっても、コンパイルエラーとなることはありませんでした。ここでも同じようにコンパイルエラーが発生するようにしてみます。

本当はCostSyncSourceTypeから動的に判断したいところですが、シンプルな仕組みで実装するのが難しかったため、愚直に手でenumを定義しました。
CostSyncSourceTypeCostSyncSourceTypesをオーダーの拡張時に更新することを期待しています。

もっと良い方法をご存知の方がいれば、教えて頂きたいです。

enum class CostSyncSourceTypes(val klass: KClass<out CostSyncSourceType>) {
  処方(CostSyncSourceType.処方::class),
  注射(CostSyncSourceType.注射::class),
}

fun fetchOrders() {
    CostSyncSourceTypes.entries.map { sourceType ->
        when (sourceType) {
            CostSyncSourceTypes.処方 -> println("処方オーダーの取得")
            CostSyncSourceTypes.注射 -> println("注射オーダーの取得")
        }
    }
}