ピンポイントタイム散布(PTS)でのバックエンド改善の軌跡 ~5. CQRS ~

こんにちは、農業系プロダクトの開発を担当している糸井です。

弊社ではピンポイントタイム散布(以下、PTS)という防除のデジタル化サービスを展開しております。

www.optim.co.jp

PTSシステムのバックエンドではSpringBootを使用しております。
立ち上げ期より、既存の農業系プロダクトの課題感をもとに改善を進めてきましたので、その軌跡をご紹介します。

前回までの記事はこちら


前回の記事では、Repositoryに対する事例をご紹介しました。
今回はCQRSについてご紹介します。

5. CQRSの導入

前回の記事 4-1. Repository の引数と戻り値の型を統一する, 4-2. RepositoryImpl の導入 を行うと、以下のような場面が出てきます

  1. RepositoryImplの中で複数のテーブルに別々のQueryを実行する
  2. ループの中でQueryを実行する

結果として以下のような問題が発生します。

  1. API Responseを返すのが遅くなる
  2. DatabaseとのConnectionが枯渇しやすくなる

上記のような問題を解決するためにCQRSというパターンがあります。
PTSでもCQRSパターンを取り入れたので、その構成や背景、導入結果について説明していきます。


より詳細なCQRSパターンの解説は以下の記事をご参照ください。

CQRSパターンを取り入れたPackage構成

特定のUsecaseでしか使用しないことが分かっているので、 関連する実装はUsecaseのPackageの中にまとめています。

app
└── src
    ├── main
    │   ├── java/...
    │   │   ├── domain
    │   │   │   ├── entity
    │   │   │   ├── value_object
    │   │   │   └── repository
    │   │   ├── presentation
    │   │   │   └── controller
    │   │   └── usecase
    │   │       ├── query_service
    │   │       │   ├── XxxYyyMapper.java  ← このUsecase専用のSQLのためのInterface
    │   │       │   └── XxxYyy.java        ← このUsecase専用のMapperのための引数や戻り値のClass
    │   │       ├── XxxYyyUsecase.java
    │   │       ├── XxxYyyParam.java
    │   │       └── XxxYyyDto.java
    │   └── resources
    │       ├── domain
    │       │   └── repository
    │       └── usecase/query_service
    │           └── XxxYyyMapper.xml       ← このUsecase専用のQuery
    └── test




PTSでCQRSパターンを取り入れた背景は2つあります。

PTSでCQRSパターンを取り入れた背景1つ目

4-1. Repository の引数と戻り値の型を統一する では、
Domain Modelとは全く関係なく、実質DTO状態になっていたEntityや、
そのEntityを引数・戻り値に使っていたSQLの置き場所をどうしたかについて触れてませんでした。
可能な限りDomain Modelに従ったEntityに統合しましたが、
統合が難しかったClassについては特定のUseCaseでしか使わないので、Query Service というPackageを切って、
そのUseCase専用のSQL、Classとして扱うようにしました。

PTSでCQRSパターンを取り入れた背景2つ目

こちらは最初に記載した2つの問題点を解決するという素直な目的から来ています。

複数の集約Rootを跨いだダッシュボード的な画面表示のためにこんなQueryを実行することがあります。

SELECT
    table_a.1 ,
    table_a.2 ,
    table_b.3 AS aaa ,
    table_b.4 AS bbb ,
    table_a.5 ,
    table_a.6 ,
    table_b.7 ,
    exists ( SELECT 1 FROM table_f WHERE 8 = table_a.9) AS ccc ,
    table_g.10 ,
    table_g.11 ,
    (IFNULL(table_g.12, 1) + IFNULL(table_h.13, 0)) AS ddd ,
    table_a.14 ,
    table_a.15 ,
    table_a.16
FROM table_a
LEFT JOIN table_b
    ON table_b.17 = table_a.18
LEFT JOIN (
    SELECT
        table_c.19 ,
        SUM(table_c.20) AS eee ,
        SUM(table_g.21) AS fff ,
        SUM(table_g.22) AS ggg
    FROM
        table_c
    LEFT JOIN (
        SELECT
            table_d.23 ,
            COUNT(table_e.24) AS hhh ,
            COUNT(table_e.25 = 2 OR table_e.26 = 3 OR NULL) AS iii
        FROM
            table_d
        LEFT JOIN table_e
            ON table_e.27 = table_d.28
        GROUP BY table_d.29
    ) table_g ON table_g.30 = table_c.31 GROUP BY table_c.32
) table_g ON table_g.33 = table_a.34
LEFT JOIN (
    SELECT table_i.35 ,
        COUNT(DISTINCT table_i.36) AS jjj
    FROM table_i
    WHERE table_i.37 NOT IN (
        SELECT
            table_e.38
        FROM table_e
        LEFT JOIN table_d
            ON table_d.39 = table_e.40
        LEFT JOIN table_c
            ON table_c.41 = table_d.42
        WHERE
            (table_e.43 = 2 OR table_e.44 = 3)
        AND table_c.45 = table_i.46
    ) GROUP BY table_i.47
) table_h ON table_h.48 = table_a.49
WHERE table_a.50 IN (kkk, ...)
ORDER BY table_a.51 DESC

SUM, COUNT が3つずつあったり、Sub QuerySub Query の中で LEFT JOIN していたりと、
Domain Model に対して RepositoryImpl で使う Query には見えないですね。

このように通常のRepositoryでは扱いきれないQueryに対して、CQRSパターンを導入しています。

CQRSを取り入れた結果

4-1. Repository の引数と戻り値の型を統一する, 4-2. RepositoryImpl の導入 でも触れましたが、
Repositoryの引数と戻り値は集約RootのEntityのままにし、同じ spec (仕様) を守れるようになりました。

似たような実質DTO状態のEntityがポコポコ増えていくということも無くなりました。

つづきはこちら (2025/4/04 追記)

ピンポイントタイム散布(PTS)でのバックエンド改善の軌跡 ~6. メンバー変数への再代入禁止 ~ - OPTiM TECH BLOG

最後に

OPTiMではチームで協力し、難しく大きな課題を楽しみながらチャレンジしていきたいというメンバーを大募集しております。 ご興味がありましたら、下記フォームよりご応募ください。

www.optim.co.jp