コードで考えるDDDにおけるBulk Update
はじめに
こんにちは。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
DDD における Repository Pattern
DDD においては、Repository はAggregation
の格納、および取得を担当します。
その際に重要になるのは、Repository は「ドメイン知識を持たない」 という特徴です。
詳細はさまざまな書籍で紹介などされておりますが、簡潔に述べると、Repository の設計は下記のようにあるべき、と考えられます。
- Repository の Interface は、特定の技術要素に依存すべきではない
- 実際にデータが保存される場所が Memory / NoSQL / RDB であっても、透過的にアクセスされるべき
- Repository は、業務知識を持つべきではない
- 平易かつ汎用的な、
save()
/find()
/delete()
のような操作のみを提供するべき - Repository に
findDevicesSoldByNicosuma()
のような処理を定義するべきではない- Repository に
Nicosumaから販売された端末
という知識を持たせてしまうため
- Repository に
- 平易かつ汎用的な、
Aggregation
は、制約を担保した状態で構成されなければならない保管場所
と在庫
から構成されるAggregation
が存在する場合、在庫
は必ず保管場所
オブジェクトから更新される在庫
を更新したい場合には、一旦保管場所
と保管場所に置かれている在庫
の一覧をまとめて取得したうえで、更新操作を行う必要がある
現実解としてのアプローチ
とはいえ、現実として我々が携わるシステムには、前提として RDB によるデータ保存を行なっており、要件として 「メモリの使用量が閾値以下であること」や、「大量データの更新が現実時間内に終わること」など、性能に関する制約が定義されることはとても多いです。
先に述べた例では、仮に 保管場所に置かれている在庫
の数が大きい場合、性能面で要件を満たせなくなってしまうことが考えられます。
その場合に、どのようなアプローチが取れるでしょうか?
そんなことを考えている折に、以下の記事で DDD における Bulk Update に関する興味深い手法が紹介されているのを発見しました。 https://enterprisecraftsmanship.com/posts/ddd-bulk-operations
詳しくはリンク先の記事を参照してもらえればと思いますが、要点としては
- DDD の文脈における
Specification Object
に「Bulk Update の際の条件指定」という新たな役割を追加する - GoF の
Command Pattern
とSpecification 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 に一家言ある方など、興味がある方はぜひカジュアル面談でお会いしましょう!
以下リンクもご参考いただければ幸いです。エンジニアリングチーム紹介ページ