コードで考えるDDDにおけるBulk Update

2022-12-08

はじめに

こんにちは。Belong Inc.で Backend Engineer を担当している niwa です。
この記事を書いている時点でちょうど Belong Inc.へ入社して 3 ヶ月が経過しました。
弊社ではプロダクト開発の主要な言語として golang を採用していますが、私自身はほぼほぼ golang は未経験の状態で入社したので、毎日先輩方からの愛のある Review をいただく日々が続いております。

今回は DDD における各レイヤーと大量データ処理の両立について考える機会があったので、そのアプローチを紹介したいと思います。

DDD

DDD(ドメイン駆動開発)とは、「ドメインモデルを可能な限りソフトウェアの構造に反映し、継続して改善を行う」ことを目的とした開発手法です。
DDD を実現しやすくするためのアーキテクチャに関しては、

  • Layered Architecture
  • Onion Architecture
  • Clean Architecture

など複数が提唱されていますが、基本的にどれも「ドメインモデルに関しては、外界の処理と独立しているべき」という思想が根底に存在しています。 本エントリにおいては、Onion Architectureを前提として話を進めます。

Onion Architecture Onion Architecture

DDD における Repository Pattern

DDD においては、Repository はAggregationの格納、および取得を担当します。 その際に重要になるのは、Repository は「ドメイン知識を持たない」 という特徴です。

詳細はさまざまな書籍で紹介などされておりますが、簡潔に述べると、Repository の設計は下記のようにあるべき、と考えられます。

  • Repository の Interface は、特定の技術要素に依存すべきではない
    • 実際にデータが保存される場所が Memory / NoSQL / RDB であっても、透過的にアクセスされるべき
  • Repository は、業務知識を持つべきではない
    • 平易かつ汎用的な、save() / find() / delete()のような操作のみを提供するべき
    • Repository にfindDevicesSoldByNicosuma()のような処理を定義するべきではない
      • Repository にNicosumaから販売された端末という知識を持たせてしまうため
  • Aggregationは、制約を担保した状態で構成されなければならない
    • 保管場所在庫から構成されるAggregationが存在する場合、在庫は必ず保管場所オブジェクトから更新される
    • 在庫を更新したい場合には、一旦保管場所保管場所に置かれている在庫の一覧をまとめて取得したうえで、更新操作を行う必要がある

現実解としてのアプローチ

とはいえ、現実として我々が携わるシステムには、前提として RDB によるデータ保存を行なっており、要件として 「メモリの使用量が閾値以下であること」や、「大量データの更新が現実時間内に終わること」など、性能に関する制約が定義されることはとても多いです。 先に述べた例では、仮に 保管場所に置かれている在庫の数が大きい場合、性能面で要件を満たせなくなってしまうことが考えられます。 その場合に、どのようなアプローチが取れるでしょうか?

そんなことを考えている折に、以下の記事で DDD における Bulk Update に関する興味深い手法が紹介されているのを発見しました。 https://enterprisecraftsmanship.com/posts/ddd-bulk-operations

詳しくはリンク先の記事を参照してもらえればと思いますが、要点としては

  • DDD の文脈におけるSpecification Objectに「Bulk Update の際の条件指定」という新たな役割を追加する
  • GoF のCommand PatternSpecification Objectを組み合わせて、Bulk Update Operation をコードオブジェクトとして表現する
    • Bulk Update Operation は対応する SQL 表現に変換できるように作成するため、宣言的かつ効率な Update 処理を行うことができる

というアイデア、および実装が紹介されています。 一見有用そうに思えますが、上記の記事で紹介されているものはSpecificationおよびCommandが Domain 層のモデルでありながら SQL としての知識を持ってしまっているため、Repository Interface が SQL 前提になってしまう、という問題があります。

今回はこのアイデアをもとに、SQL の知識を Infrastructure 層に委譲しつつ golang を用いて同様のパターンが実現可能かどうかを検証してみたいと思います。

Review By Code

今回検証するコードの概況は、下記のようになります。

├── domain
│   ├── command
│   │   ├── command.go
│   │   └── update_device_price.go
│   ├── model
│   │   └── device.go
│   ├── repository
│   │   └── device.go
│   └── spec
│       ├── device_price.go
│       ├── device_published.go
│       └── spec.go
├── go.mod
├── infrastructure
│   └── mysql
│       ├── converter
│       │   └── converter.go
│       └── repository
│           └── mysql_device_repository.go
├── main.go
└── usecase
    └── price_down_old_device.go

Domain

以下のように、price (販売価格), published (販売開始日)を持つモデルを想定します。

package model

type Device struct {
	id        int
	price     int
	published time.Time
}

func (d Device) GetPrice() int {
    return d.price
}

func (d Device) GetPublished() time.Time {
    return d.published
}

ここに、アプリケーション要件として、「販売開始日が 2022/10/1 以前かつ、販売価格が 10000 円以上の端末に関しては、一律で 1000 円引きしたい」というユースケースを考えます。
まずは、仕様をコード上で表現するためのSpecificationを下記のように定義します。

package spec

type Spec interface {
	And(Spec) Spec
	IsSatisfied(any) bool
}

// AndSpec supports composited specs by 'AND' condition.
type AndSpec struct {
    s1 Spec
    s2 Spec
}
func (s AndSpec) IsSatisfied(v any) bool {
    return s.s1.IsSatisfied(v) && s.s2.IsSatisfied(v)
}
func (s AndSpec) And(to Spec) Spec {
    return AndSpec{s1: s, s2: to}
}

var _ Spec = (*AndSpec)(nil)

上記で定義した Interface をもとに、

  • Device の Price を検証する Spec ( DevicePrice )
  • Device の Published を検証する Spec ( DevicePublished ) を作成します。
type DevicePrice struct {
    price int
    greaterThan bool
}

func (s *DevicePrice) IsSatisfied(v any) bool {
    d, ok := v.(model.Device)
    if !ok {
        return false
    }
    dp := d.GetPrice()
    if greaterThan {
        return dp > s.price
    } else {
        return dp < s.price
    }
}
func NewDevicePrice() *DevicePrice {
    return &DevicePrice{}
}

func (s *DevicePrice) And(to Spec) Spec {
    return AndSpec{s1: s, s2: to}
}

func (s *DevicePrice) Price(price int) *DevicePrice {
    s.price = price
    return s
}
func (s *DevicePrice) GreaterThan(flag bool) *DevicePrice {
    s.greaterThan = flag
    return s
}

var _ Spec = (*DevicePrice)(nil)

type DevicePublished struct {
    date time.Time
    newerThan bool
}

func (s *DevicePublished) IsSatisfied(v any) bool {
    d, ok := v.(model.Device)
    if !ok {
        return false
    }
    published := d.GetPublished()
    if newerThan {
        return published.After(d.date)
    } else {
        return published.Before(d.date)
    }
}
func NewDevicePublished() *DevicePublished {
    return &DevicePublished{}
}

func (s *DevicePublished) And(to Spec) Spec {
    return AndSpec{s1: s, s2: to}
}

func (s *DevicePublished) Published(date time.Time) *DevicePublished {
    s.date = date
    return s
}
func (s *DevicePublished) NewerThan(flag bool) *DevicePublished {
    s.newerThan = flag
    return s
}

var _ Spec = (*DevicePublished)(nil)

次に、「一律で 1000 円引きする」という仕様を表現するために、Command として、販売価格の更新を行う Command を定義します。 また、上記で定義した Spec を利用して、要求仕様をコードで表した Command AdjustPriceByConditionCommand を作成します。

package command

type UpdateDevicePrice struct {
	value int
	condition spec.Spec
}

func AdjustPriceByConditionCommand(price int, thresholdPrice int, publishedAt time.Time) UpdateDevicePrice {
    return UpdateDevicePrice{
        value: price,
        condition: spec.NewDevicePrice().Price(thresholdPrice).GreaterThan(true).
            And(
                spec.NewDevicePublished().Published(publishedAt).NewerThan(false),
            ),
    }

}

Repository

Repositoryに、上記で定義したUpdateDevivcePriceを引数とする BulkUpdate function を実装します。

package repository

type DeviceRepository interface {
    Find(id int) (*model.Device, error)
    Save(device *model.Device) (error)
    BulkUpdate(cmd command.UpdateDevicePrice) error
}

Infrastructure

今回は Backend として MySQL の使用を想定します。 上記DeviceRepositoryを実装する struct は、Infrastructure 層に定義されます。 この Layer であれば実際に使用する技術要素がわかっているので、本 Layer で Command, および Specification と SQL の関連付けを行います。

package repository
type MySQLDeviceRepository struct {
	qc repository.QueryConverter
}

func (d DeviceRepository) Find(id int) (*model.Device, error) {
    // skip
    ...
}
func (d DeviceRepository) Save(device *model.Device) (error) {
    // skip
    ...
}

func (d DeviceRepository) BulkUpdate(cmd command.UpdateDevicePrice) error {
	sql := d.qc.FromCommand(cmd)

    // 今回は実際のDBへの接続は行わず、SQLをPrintするのみにします
	fmt.Printf("%s\n", sql)

	return nil
}

var _ repository.DeviceRepository = (*MySQLDeviceRepository)(nil)

Converter の実装は下記のようになります。 コードの中で Command、および Spec と SQL の対応付けをガリガリ定義します。
今回は Raw SQL を吐いているため、SQL Injection への耐性が全くありません。(良い子は真似しちゃダメなコードですね)
実際にプロジェクトに適用する際には、Converter の出力は使用する Framework に依存する形となります。

package converter

type QueryConverter struct{}

func (q QueryConverter) FromCommand(cmd command.UpdateDevicePrice) string {
	condition := q.fromSpec(cmd.PreConditions())
    price := cmd.Price()
    if price >= 0 {
	    return fmt.Sprintf(`
	    	UPDATE device SET price = (price + %d) where %s
	    `, cmd.Price(), condition)
    } else {
	    return fmt.Sprintf(`
	    	UPDATE device SET price = (price - %d) where %s
	    `, cmd.Price(), condition)

    }
}

func (q QueryConverter) fromSpec(s spec.Spec) string {
	e, ok := s.(spec.AndSpec)
	if ok {
		return fmt.Sprintf("(%s AND %s)", q.fromSpec(e.S1()), q.fromSpec(e.S2()))
	}
	dp, ok := s.(*spec.DevicePrice)
	if ok {
		price := dp.Price()
		statement := "<"
		if dp.IsGreaterThan() {
			statement = ">"
		}

		return fmt.Sprintf("price %s %d", statement, price)
	}
	publish, ok := s.(*spec.DevicePublished)
	if ok {
		time := publish.DateTime().Format("2006-01-02 15:04:05")
		statement := "<"
		if publish.Before() {
			statement = ">"
		}
		return fmt.Sprintf("published %s '%s'", statement, time)
	}

    panic("unknown spec specified")
}

Usecase

「古い端末の値下げをする」というユースケースを定義し、先に定義したAdjustPriceByConditionCommandを使用して、要求仕様に沿うように Command を生成します。

package usecase
type PriceDownOldDevice struct {
	repo repository.DeviceRepository
}

...

func (d PriceDownOldDevice) Invoke() error {
    // 10000円以上、販売日が2022/10/10 15:00:00以前の端末を1000円引きにする
	cmd := command.AdjustPriceByConditionCommand(
        -1000, 10000, time.Date(2022, 10, 10, 15, 0, 0, 0, time.UTC),
    )

	d.repo.device.BulkUpdate(cmd)
}

実際の出力

PriceDownOldDevice.Invoke()を実行した結果、下記のような SQL が Print されると思います。

UPDATE device SET price = (price - 1000) where (price > 10000 AND published < '2022-10-10 15:00:00')

Converter の実装にやや骨が折れる感じはありますが、このような構成をとることによって下記のような Benefit を得ることができました。

  • Command / Specification として  Bulk Operation の操作をドメイン知識としてコード内で表現
  • Command to SQL の Converter を用意することで、パフォーマンス面への配慮が可能

おわりに

今回は DDD における Bulk Operation を考える上での1アイデアを紹介しました。今回の例では、ドメイン知識のコードへの表現と、性能にフォーカスしましたが、反面オブジェクトの整合性については容易に失われてしまう形となっています。どのような設計が正しいかは、プロダクトの性質によって大きく左右されることかと思いますが、ぜひフィードバックがあればお寄せいただきたいと思います!

最後に、Belong では、共に働くエンジニアを募集しています。とにかく go が書きたい方、DDD に一家言ある方など、興味がある方はぜひカジュアル面談でお会いしましょう!
以下リンクもご参考いただければ幸いです。https://entrancebook.belonginc.dev/