JMDC TECH BLOG

JMDCのエンジニアブログです

C#のプロダクトをGoにリプレイスした話

みなさん、こんにちは!プロダクト開発部の吉川(@yoshiyu0922)です。

現在、JMDCが保有している医療ビッグデータを活用して生活者や医療に新しい価値を提供するプロダクト開発チームのバックエンドを担当しております。

今回は、C# で開発されたプロダクトを Go にリプレイスをした話をします。

リプレイスをしたプロダクトについて

まずは、Go にリプレイスしたプロダクト『JMDC Data Mart』(以下、JDM)についてご紹介します。JDMは製薬企業向けにヘルスビッグデータを活用した分析サービスを提供するプロダクトです。膨大なヘルスビッグデータを基盤に、マーケティング業務や医薬品の安全性監視業務をサポートする多様な機能を提供しています。2008年に健康保険組合由来のデータを搭載してローンチした後、長い歴史の中で様々な機能を追加・改修し、現在は製薬企業のパートナープロダクトとして独自の立ち位置を築いています。

きっかけ

10年以上にわたって機能追加を続けてきたことで、次第にいくつかの課題が見えてきました。

  • コードが複雑になって開発スピードの低下が顕著になっている
  • iBatisを使ってXMLでSQLを組み立てているため実装コストが高い
  • JDMのビジネスロジックを全て理解しているメンバーがいない

こうした状況でJDMチームは、「今のまま開発を続けるよりも、システムをリプレイスした方が長期的に見てメリットが大きい」と判断しました。また、保険者DB・調剤DB・医療機関DBといった個別に開発してきたJDMシリーズを、1つのプロダクトに統合するプロジェクトの始動を控えていたため、「このタイミングでリプレイスを進めるのが最適」と判断しました。Goは主に静的型付け言語であり、シンプルな言語仕様で学習コストが比較的低めという理由から採用をしました。

取り組んだこと

主に取り組んだことを紹介します。

ガイドラインを作成

単純にC#をそのままGoに書き換えるのではなく、ソフトウェアアーキテクチャを根本から見直しました。それに伴い、開発メンバーにとっては今までとは違ったやり方で実装をするため、APIの仕組みを理解し、実装できるようにするため以下の観点でガイドラインを作成してレクチャーをしました。

  • 基本実装方針
  • ディレクトリ構成
  • リクエストが来てからレスポンスを返すまでの処理イメージ
  • Domain ModelやUsecaseなどの各レイヤーの責務
  • ユニットテスト
  • OpenAPIを活用したスキーマ駆動開発

ボリュームが多く、1回のレクチャーでは理解できる量ではないため、実装しながらレクチャーをしていくことで理解を深めていただきました。自分の考えているアーキテクチャや実装方針を言語化しておくことで、必要なときに誰でも確認できるようになり、新しく入ったメンバーのキャッチアップにも役立ったと感じています。また、JDMとは別のプロダクトをGoで開発することになったときも、このドキュメントを参照することでスムーズに進めることができました。

OpenAPIを活用したスキーマ駆動開発の導入

従来はOpenAPIが無い状態で開発をしていました。C#のコードからOpenAPIを生成していたのですが、一部の機能に適用する動きがありました。しかし、品質面で課題があり、今回のリプレイスでOpenAPIを定義してフロントエンド・バックエンドのコードを生成するスキーマ駆動開発を導入しました。

しかし、openapi.yamlを開発メンバー全員が編集をするとコンフリクトが発生するのでyamlファイルを分割して swagger-merger を利用してopenapi.yamlを生成する方針を採用しました。そうすることでコンフリクトがほぼ発生せずに開発ができました。フォルダの構成は以下のようにしています。

/path/to/openapi/
├── endpoints/ : エンドポイントのリクエストとレスポンスを定義したyamlを管理
├── schemas/ : スキーマを定義したyamlを管理
├── openapi.yaml : 生成ファイル
└── original_openapi.yaml : openapi.yamlを生成する元のファイル

Goの開発では oapi-codegen を採用しました。swagger-codegen を採用しようかと考えていましたが、生成したコードを見ると当時アーカイブされていた gorrila/mux が使われていたためoapi-codegenを採用しました。また、機能も充実していてOpenAPIのExtensionsを利用できたり、chi routing ベースでコード生成ができたという点も採用する理由になりました。oapi-codegenのツールで生成されたコードをベースにmiddlewareを追加したり、handlerを実装しています。

Bunを採用した柔軟なSQLの作成

分析処理では、大量のSQLを組み立てて実装しており、実行するSQLもかなり複雑になることを考慮し、Bunを採用することにしました。理由はクエリビルダのようにSQLの構文に近いコードが書けて、柔軟にSQLを作成したいためです。

C#では、iBATISを使ってxmlでSQLを組み立てていました。

※以下のコードはサンプルです

<?xml version="1.0" encoding="utf-8" ?>
<sqlMap namespace="ReceiptSample" xmlns="http://ibatis.apache.org/mapping">
  <statements>
    <select id="receipt" resultClass="Hashtable">
      SELECT 
        *
      FROM
        RECEIPT_SAMPLES
      WHERE
        1 = 1
        <isNotNull property="patientId">
          AND PATIENT_ID = $patientId$
        </isNotNull>
      ORDER BY
        PATIENT_ID,
        <isEqual property="shouldOrderByDate" compareValue="true">
          RECEIPT_YEAR DESC
        </isEqual>
    </select>
  </statements>
</sqlMap>

Goでは、以下のように書くことができ、可読性が上がり、動的にSQLを作成することができる柔軟なコードになります。

※以下のコードはサンプルです

db := bun.NewDB(nil, nil)

query := db.NewSelect().
    ColumnExpr("*").
    ModelTableExpr("RECEIPT_SAMPLES").
    Apply(func(selectQuery *bun.SelectQuery) *bun.SelectQuery {
        // bun.Apply()で動的なSQLを組み立てられる
        if parentID != nil {
            // 患者 ID を指定した場合は患者 ID で絞り込む
            return selectQuery.Where("PATIENT_ID = ?", parentID)
        }
        return selectQuery
    }).
    Order("PATIENT_ID")

if shouldOrderByDate {
    // bun.Apply()を使わなくても動的なSQLを組み立てられる
    query = query.
        OrderExpr("RECEIPT_YEAR DESC")
}

bun.Apply()を使えば、動的な条件追加やサブクエリも fluent interface で実装でき、SQLのフォーマットに近いコードを書くことができます。

また、クエリログの機能も充実しており、通常の実行ログやエラーログの出力レベルを指定できます。さらに、スロークエリのしきい値を設定することも可能です。ログのフォーマットもカスタマイズできるため、実行時間などの詳細な情報を含めて出力できます。いくつかのログライブラリに対応していますが、slogとの連携にも対応しています。

※以下のコードはサンプルです

dbClient := bun.NewDB(db, &CustomDialect{pgdialect.New()})
dbClient.AddQueryHook(bunslog.NewQueryHook(
    bunslog.WithLogger(logger),
    bunslog.WithQueryLogLevel(slog.LevelInfo), // クエリのログレベルを指定できる
    bunslog.WithSlowQueryLogLevel(slog.LevelWarn), // スロークエリのログレベルを指定できる
    bunslog.WithErrorQueryLogLevel(slog.LevelError), // エラーログのログレベルを指定できる
    bunslog.WithSlowQueryThreshold(10*time.Second), // スロークエリと判断する実行時間の閾値を指定できる
    bunslog.WithLogFormat(func(event *bun.QueryEvent) []slog.Attr {
        // ログに詳細な情報を埋め込むことができる
        duration := time.Since(event.StartTime)
        return []slog.Attr{
            slog.Any("error", event.Err),
            slog.String("operation", event.Operation()),
            slog.String("query", event.Query),
            slog.String("start_time", event.StartTime.String()),
            slog.String("duration", duration.String()),
        }
    }),
))

go-sqlmockを活用したユニットテスト

データソースはRedshiftを採用していますが、ローカル環境での構築が難しいため、Repositoryのユニットテストでは実際にDBと接続するのではなく、SQLが正しく実行されているかどうかを確認する方針を採用しました。そのため、go-sqlmockを使ってテストを実施しています。

リプレイスのフェーズでは、既存システムのSQLを移行する作業が中心だったため、テストデータを使った検証よりも、SQLが既存仕様通りに記述されているかを重視しました。このアプローチにより、QAやリリース以降も大きな不具合は発生せず、高い品質を保つことができました。

一方で、移行フェーズが終わり、新規機能の開発フェーズに入ると、SQLを1から作成するケースが増えるため、go-sqlmockだけでは十分な品質担保が難しいと感じています。今後の課題として、より実データに近い環境でのテスト戦略を検討する必要があります。

継続的なリファクタリングの環境づくり

リプレイス前のプロダクトではテストコードがないため、簡単な修正も手作業での動作確認が必須でした。しかし、Goへリプレイスを機にテストコードの作成をチームのルールとしたことで、開発メンバー全員にテストコードを書く習慣が根付きました。その結果、2025/05時点でテストカバレッジ率は「86.55%」と高い水準を維持しており、今まで人手に頼っていた多くの動作確認が自動テストでカバーできるようになりました。改修と動作確認のコストがかなり改善したとチームメンバーから評価を得ています。

さらに、テストコードが充実したことでリファクタリングも格段にしやすくなりました。そこで、リファクタリング用のカンバンボードを用意し、どんな些細な改善点でも気軽にボードに載せる運用をしました。この取り組みによって、メンバーがコード品質向上への意識を高め、日常的にリファクタリングを行う習慣が身につきました。実際に機能開発と並行してリファクタリングを進めることで、品質を維持しながらスムーズな機能開発を実現しています。

まとめ

他にも様々な取り組みを経て、無事にGoプロダクトのリリースを迎えることができました。単にGoで書き直しただけでなく、これまで抱えていた技術的負債もあわせて解消する良い機会となったと考えています。とはいえ、まだ課題は残っているため、改善を続けながら、この事業をより大きく発展させていきたいと考えています。

最後まで読んでいただきありがとうございました!