コードで学ぶCQRS〜DDD実践のためのきほん〜

はじめに

今回はポイント管理システムをテーマに

DDD×クリーンアーキテクチャでサンプルコードを作ったので、

それを使いながら、

CQRSとその使い所についてまとめました。


CQRSのサンプルコード

今回テーマにしているポイントシステムでは、

店舗からレシート情報とカード、ポイント利用などを連携され、

付与ポイントの加算、ポイント利用の減算、

クーポンの発行とクーポン利用によるポイント付与率の制御ができる

ポイント管理システムを想定しています。

ポイント管理システムのイメージ図


まず支払登録ユースケースにおけるモデル(CQRSのCommand側)を見ていきましょう。

支払登録ユースケースに登場するドメインオブジェクト


支払登録ユースケースに登場するドメインオブジェクト(支払集約の一部)
class Payment(
    val receiptNumber: ReceiptNumber,
    private val paymentDateTime: PaymentDateTime,
    private val paymentMember: PaymentMember,
    private val companyCode: MemberCompanyCode,
    private val shopCode: ShopCode,
    private val couponCode: CouponCode?,
    private val paymentMethod: List<PaymentMethod>,
    private val paymentPurchase: List<PaymentPurchase>
) {
    init {
        if (paymentMethod.sumOf { it.paymentAmount.value } != paymentPurchase.sumOf { it.goodsPrice.value }) throw IllegalArgumentException("支払金額と購入金額が不整合です")
    }
    ・・・・
}



class PaymentMethod private constructor(
    val paymentMethodCode: PaymentMethodCode,
    val paymentAmount: PaymentAmount,
    private val grantPoint: Point
) {
    companion object {
        private const val PAYMENT_METHOD_POINT_RATE = 0.05
        fun create(
            paymentMethodCode: PaymentMethodCode,
            paymentAmount: PaymentAmount
        ): PaymentMethod {
            val grantPoint = floor(paymentAmount.value * PAYMENT_METHOD_POINT_RATE).toInt()

            return PaymentMethod(
                paymentMethodCode,
                paymentAmount,
                Point(grantPoint)
            )
        }
        ・・・・
}



class PaymentPurchase private constructor(
    val memberCompanyGoodsCode: MemberCompanyGoodsCode,
    private val purchaseQuantity: PurchaseQuantity,
    val goodsPrice: GoodsPrice,
    private val grantPoint: Point
) {
    companion object {
        private const val PAYMENT_PURCHASE_POINT_RATE = 0.05
        fun create(
            memberCompanyGoodsCode: MemberCompanyGoodsCode,
            purchaseQuantity: PurchaseQuantity,
            goodsPrice: GoodsPrice
        ): PaymentPurchase {
            val grantPoint = floor(purchaseQuantity.value * goodsPrice.value * PAYMENT_PURCHASE_POINT_RATE).toInt()

            return PaymentPurchase(
                memberCompanyGoodsCode,
                purchaseQuantity,
                goodsPrice,
                Point(grantPoint)
            )
        }
        ・・・
}



class PaymentMember(
    val memberCode: MemberCode,
    private val usePoints: Point = Point(0),
    private val remainingPoints: Point
) {

    fun usePoints(point: Point): PaymentMember {
        if (remainingPoints.value < point.value) throw IllegalArgumentException("ポイントが不足しています")
        return PaymentMember(memberCode, point, Point(remainingPoints.value - point.value))
    }
    ・・・
}


支払登録時に利用するドメインオブジェクトでは、

付与ポイントを算出したり、

支払集約内で整合性をとったり、

会員が利用しようとするポイントが残ポイントより大きくないかチェックしたり

などができるように作っています。


では次に、

会員を指定すると、

その会員のポイント取引(利用、付与)履歴が一覧取得できる

クエリモデルとクエリサービスを見ていきます(CQRSのQuery側)

レシート番号ポイント取引履歴タイプポイント数
0000000001付与100
0000000002付与200
0000000002利用100
0000000003付与50
ポイント履歴一覧取得イメージ


ポイント取引履歴一覧取得クエリサービス
interface IListPointLogByMemberQueryService {

    data class Dto(
        val items: List<Item>
    ) {
        data class Item(
            val memberCode: MemberCode,
            val pointLogType: PointLogType,
            val transferPoints: Point
        )
    }

    enum class PointLogType {
        USE, ADD
    }

    fun list(memberCode: MemberCode): Dto
}



@Service
class ListPointLogByMemberQueryService(private val dslContext: DSLContext) : IListPointLogByMemberQueryService {

    override fun list(memberCode: MemberCode): IListPointLogByMemberQueryService.Dto {
        val resultRecords = dslContext.selectFrom(POINT_TRANSACTION_LOG)
            .where(
                POINT_TRANSACTION_LOG.RECEIPT_NUMBER.`in`(
                    dslContext.select(
                        PAYMENT.RECEIPT_NUMBER
                    )
                        .from(PAYMENT)
                        .where(PAYMENT.MEMBER_CODE.eq(memberCode.value.toInt()))
                        .fetch()
                )
            )
            .fetch()
        return convertToDto(resultRecords)
    }

    private fun convertToDto(records: Result<Record>): IListPointLogByMemberQueryService.Dto {
        val items = records.map {
            IListPointLogByMemberQueryService.Dto.Item(
                memberCode = MemberCode(it[POINT_TRANSACTION_LOG.RECEIPT_NUMBER].toString()),
                pointLogType = IListPointLogByMemberQueryService.PointLogType.valueOf(it[POINT_TRANSACTION_LOG.POINT_LOG_TYPE]),
                transferPoints = Point(it[POINT_TRANSACTION_LOG.TRANSACTION_POINTS])
            )
        }
        return IListPointLogByMemberQueryService.Dto(items)
    }
}


支払登録とは別のモデルを利用してポイント取引履歴一覧を取得しています。


CQRSの使い所と効果

DDDで支払登録ユースケースに登場した

ドメインオブジェクトをポイント取引履歴で

利用した場合を考えてみましょう。

このような懸念が挙げられるかと思います。

  • ポイント取引履歴の戻り値の型への詰め替えがややこしい(集計・ページングなど)
  • 利用しない余計な情報取得やオブジェクト生成によるパフォーマンス劣化
  • ポイント取引履歴の仕様変更に支払登録が影響を受ける(かも)


DDD×クリーンアーキテクチャを採用すると、

DB登録など何かしら副作用を起こすCommand側の方は、

ドメインロジックも綺麗に整理でき、

変更に強い作りにできるかと思いますが、


そのドメインオブジェクトを、

副作用がないQuery側にそのまま利用するのは、

上記のような懸念もあります。


CQRS(Command Query Responsibility Segregation)では

これに対応するため、


DDDで用意したドメインオブジェクト(更新用モデル)とは別に、

参照専用のモデルと、

特定のユースケースに特化したQueryServiceを用意します。

コマンドとクエリを分離したイメージ図


これによって、

ポイント取引履歴取得がクエリ一発で行え、

パフォーマンス面の無駄もなく、

モデルへの詰め替えもシンプルにできます。


また、

ポイント取引履歴の方の仕様変更に支払登録が

引きずられることもなくなります。



Command側が非常に複雑で業務ロジックを持つケースなどは、

CQRSを検討する価値はあるでしょう。


一方で、Command側が業務ロジックを持たず、

シンプルな登録で、

Queryもシンプルな検索の場合などは、

ここまですると逆にCommandとQueryが分かれて

複雑になる割にメリットがあまりないので、

その辺りは注意しながらCQRSの導入は必要な部分のみに

適用していくのが良いです。


今回のテーマについては以上です。

DDD実践のためのきほんシリーズのまとめ記事を作っているので

是非ご覧ください。

初めてDDDを実践する方に向けて〜DDD実践のためのきほん〜


参考文献

第8章 CQRS

ドメイン駆動設計 モデリング/実装ガイド


Chapter 13 複雑な条件を表現する「仕様」

ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください