ecサイトをモデリングしてscalaで実装してみる
以前行っていたecサイトのモデリングについて実際にScalaで実装してみたいと思います。
今回は以下のユースケースに対応できるつくりにしたいと思います。
・ユーザはログイン画面でユーザ名、パスワードを入力してパスワード認証が行える。 ・ユーザはショッピングカートに商品を入れることができる。 ・ユーザはショッピングカートに入れた商品の品目を更新できる。 ・ユーザはショッピングカートに入れた商品をクレジット、または銀行振り込みで購入できる。 ・ユーザは商品の購入手続き時にクーポンコードを入力できる。 ・クレジットカード支払いの場合、購入時に支払いが行われて配達予定日を表示する。
実装はこちらになります。Play Frameworkを使用しDBアクセスのライブラリにはslickを使っています。 先に動作を確認してみたいと思います。
起動(h2のウェブコンソールも同時に起動)
sbt h2-browser run
ログイン
~$ RESULT=`curl -i -XPOST -H 'Content-Type:application/json' -d '{"name": "user1", "password": "password" }' http://localhost:9000/login` % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 55 100 13 100 42 590 1909 --:--:-- --:--:-- --:--:-- 2500 ~$ SESSION=`echo $RESULT | grep -E "PLAY_SESSION\=[^ ]+" -o` ~$ echo $SESSION PLAY_SESSION=eyJhbGciOiJIUzI1NiJ9.eyJkYXRhIjp7ImxvZ2luX3VzZXJfaWQiOiIxIn0sIm5iZiI6MTU2MzY4ODkwMSwiaWF0IjoxNTYzNjg4OTAxfQ.sQH-Oi0npEfUeGheEE6q1CnA6o6elOtodHOm8uohUTQ;
- 商品検索
~$ curl -i -XGET -b "$SESSION" http://localhost:9000/product/search?name=product1 HTTP/1.1 200 OK Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin X-Frame-Options: DENY X-XSS-Protection: 1; mode=block X-Content-Type-Options: nosniff X-Permitted-Cross-Domain-Policies: master-only Date: Sun, 21 Jul 2019 06:02:47 GMT Content-Type: application/json Content-Length: 85 [{"product_id":1,"name":"product1","price":100,"description":"product1 description"}]
- 商品をカートに追加
:~$ curl -i -XPOST -b "$SESSION" -H 'Content-Type:application/json' -d '{"product_id": 1, "number": 3 }' http://localhost:9000/order/updateItem HTTP/1.1 200 OK Set-Cookie: PLAY_SESSION=eyJhbGciOiJIUzI1NiJ9.eyJkYXRhIjp7ImxvZ2luX3VzZXJfaWQiOiIxIn0sIm5iZiI6MTU2MzY4OTEyMCwiaWF0IjoxNTYzNjg5MTIwfQ.cuuElb3XjhQcpC5TaQM8KxME1c6pHeEUD7nS61UNId0; SameSite=Lax; Path=/; HTTPOnly Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin X-Frame-Options: DENY X-XSS-Protection: 1; mode=block X-Content-Type-Options: nosniff X-Permitted-Cross-Domain-Policies: master-only Date: Sun, 21 Jul 2019 06:05:20 GMT Content-Type: text/plain; charset=UTF-8 Content-Length: 1 1
- 商品購入
$ curl -i -XPOST -b "$SESSION" -H 'Content-Type:application/json' -d '{"bank_account": "123-4567" }' http://localhost:9000/order/bankConfirm HTTP/1.1 200 OK Set-Cookie: PLAY_SESSION=eyJhbGciOiJIUzI1NiJ9.eyJkYXRhIjp7ImxvZ2luX3VzZXJfaWQiOiIxIn0sIm5iZiI6MTU2MzY5MDEwNiwiaWF0IjoxNTYzNjkwMTA2fQ.LL4scL4eN8x-bsD5lewq5pn_PIG1wCNa-Jf4JK-W2lk; SameSite=Lax; Path=/; HTTPOnly Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin X-Frame-Options: DENY X-XSS-Protection: 1; mode=block X-Content-Type-Options: nosniff X-Permitted-Cross-Domain-Policies: master-only Date: Sun, 21 Jul 2019 06:21:46 GMT Content-Type: text/plain; charset=UTF-8 Content-Length: 1 1
- ログアウト
$ curl -i -XPOST -b "$SESSION" http://localhost:9000/logout HTTP/1.1 200 OK Set-Cookie: PLAY_SESSION=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/ Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin X-Frame-Options: DENY X-XSS-Protection: 1; mode=block X-Content-Type-Options: nosniff X-Permitted-Cross-Domain-Policies: master-only Date: Sun, 21 Jul 2019 06:23:07 GMT Content-Type: text/plain; charset=UTF-8 Content-Length: 6 logouta
モデリングとコンテキスト境界によるトランザクション整合性
↑の画像には記入していないのですが、トランザクションの整合性を維持するためにコンテキストを境界付けるようにしていますここでは注文と商品、ユーザーの3つのコンテキストで以下のように分類しています。
- ユーザー
- ユーザーアカウント
- 個人情報
- 注文
- 注文
- 品目セット
- クーポン
- 注文状態
- 支払い方法
- クレジット
- 銀行振り込み
- 商品
- 商品
- 商品詳細
自分の実装ではコンテキストの境界は以下のようになっています。
app/core/domain
コンテキストの境界を分けることはモデルの整合性を保ちやすくするために関連のあるグループでまとめて何かしら変更を行う場合はそのグループ全体に対して操作を行うためのインターフェースを持つぐらいの認識ですが、エヴァンスのDDD本では以下のように書いてありました。
・複数のモデルはどんな巨大なプロジェクトにも存在する。だが、別々のモデルに基づく コードが組み合わされると、ソフトウェアはバグの温床となり、信頼できなくなり、理解 しにくくなる。チームメンバ間のコミュニケーションは混乱し始めアルモデルのコンテキスト で適用すべきでないのかについては、ほとんどの場合不明瞭である。 ・モデルが適用されるコンテキストを明示的に定義すること。明示的な境界は、チーム編成、 そのアプリケーションに特有の部分が持つ用途、コードベースやデータベーススキーマなどの 物理的な表現などの観点から設計すること。その境界内ではモデルを厳密に一貫性のあるもの に保つこと。ただし、教会の外部の問題によって注意をそらされたり、混乱させられたりする ものを避けること。
コンテキスト境界内のオブジェクトのインスタンス生成とルートのオブジェクトへの集約
コンテキスト境界内で整合性を保つためにはコンテキスト境界内のオブジェクトに対しての共通のインターフェースを持っていてそれ経由で操作を行えるようにしたほうが良いと思います。例えば注文のコンテキストであればカートの更新や購入コンテキストの外側から呼び出せて、呼び出し元は結果だけ受け取り内部のデータを気にしなくても良い。このインターフェースを持たせる対象は集約のオブジェクトと言い、デザインパターンのコンポジットパターンでルートとなるオブジェクトを決めている場合に近いのかと思います。
自分の実装では注文のコンテキストでの集約として以下を使っています。
app/core/domain/order/entity/OrderEntity
case class OrderEntity(var order: Order, var items: List[Item], var paymentInfo: Option[PaymentInfo]) { def updateItem(product: Product, number: Int): OrderEntity = { this.items = items.zipWithIndex.find(_._1.productId == product.productId.get).map(_._2) match { case Some(x) => items.slice(0, x) ++ items.slice(x + 1, items.length) :+ items(x).copy(price = product.price, number = number, updateDate = LocalDateTime.now()) case _ => items :+ Item(None, product.productId.get, product.price, number, LocalDateTime.now) } this } def bankPay(bankAccount: String): Either[Int, OrderEntity] = { confirm(PaymentType.Bank, BankPay(None, bankAccount)) } def creditPay(): Either[Int, OrderEntity] = { confirm(PaymentType.Credit, Credit(None)) } private def confirm(payType: PaymentType, payDetail: PayDetail): Either[Int, OrderEntity] = { order.status match { case x if x == OrderStatus.Shopping && items.filter(_.number > 0).length > 0 => { this.order = this.order.copy(status = OrderStatus.Confirm) this.paymentInfo = Some(PaymentInfo(None, payType, 0, price, LocalDateTime.now.plusDays(7), None, payDetail)) Right(this) } case _ => Left(1) } } private def price: Int = items.foldLeft(0)((acc, cur) => acc + cur.sum) }
クラスのインスタンス引数として注文状態やカート内の品目などを持っていて、このクラス内にコンテキスト領域内のオブジェクトをたどることができます。 エヴァンスの本では以下のようにありますが、先の話と同じでコンテキスト領域をきちんと分けておくことが重要ということだと思います。
・複雑な関連を伴うモデルでは、オブジェクトに対する変更の一貫性を保証するのは難しい。 維持すべき不変条件には個々のオブジェクトに適用されるものだけでなく、密接に関連する オブジェクトのグループに適用されるものもある。だが慎重にロックしすぎると、今度は 複数のユーザが指針もなく相互に干渉しあい、システムが使い物にならなくなる。
集約のルートを決めることはコンテキストの境界を決めるのに近いと思うのですが、考え方としてはユースケースをもとに操作の対象となるオブジェクトを洗い出せばよいのかと思います。以下のスライドは車を例にとってエンジンに対してのユースケースがあるのなら車とエンジンで集約を分けるという風に述べられています。
集約の設計と実装 - Speaker Deck
集約のオブジェクトを生成し更新を行うリポジトリ
データの登録、更新、削除などを行う場合はDB上に保存された情報を取り出してインスタンスを生成するのですが、この時気を付ける点として集約のルート以外のオブジェクトを生成するメソッドがインターフェースにあるとコンテキストに対しての操作の起点が複数にまたがってしまい複雑になります。実用性を考えるとコンテキスト内の一部のみ保存とかは必要になりますが、インスタンスの生成で集約以外を取り出したい場合はおそらくデータの更新、削除がしたいわけでなく参照がしたいと思うのでデータ参照用のリポジトリを別で作るなどします。 とりあえず、データの登録、更新、削除に使うリポジトリとしてapp/core/domain/order/repositoryを作成しました。
trait OrderRepository { def userCart(userId: Int): Future[OrderEntity] def save(orderEntity: OrderEntity): Future[Either[Int, OrderEntity]] } @Singleton class OrderRepositoryImpl @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit executionContext: ExecutionContext) extends OrderRepository with OrderDao with ItemDao with PaymentInfoDao with BankPayDao with CreditPayDao { import profile.api._ override def userCart(userId: Int): Future[OrderEntity] = { db.run(for{ order <- Orders.filter(o => o.userId === userId && o.orderStatus === OrderStatus.Shopping.code).result.headOption items <- Items.filter(_.orderId === order.map(_.orderId.get).getOrElse(-1)).result paymentInfo <- PaymentInfos.filter(_.orderId === order.map(_.orderId.get).getOrElse(-1)).result.headOption bank <- BankPays.filter(_.paymentId === paymentInfo.map(_.paymentId.get).getOrElse(-1)).result.headOption credit <- CreditPays.filter(_.paymentId === paymentInfo.map(_.paymentId.get).getOrElse(-1)).result.headOption } yield (order, items, paymentInfo, bank, credit)).map{ case (order, items, paymentInfo, bank, credit) => { order match { case None => OrderEntity(Order(None, userId, OrderStatus.Shopping), List(), None) case Some(x) => recordToEntity(order.get, items, paymentInfo, bank, credit) } } } } override def save(orderEntity: OrderEntity): Future[Either[Int, OrderEntity]] = { val order = orderEntity.order val items = orderEntity.items val paymentInfo = orderEntity.paymentInfo val query = for { order <- saveOrderQuery(OrderSchema(order.orderId, order.status.code, order.userId)) item <- saveItem(order.orderId.get, items) payment <- savePaymentInfo(order.orderId.get, paymentInfo) } yield (order, item, payment) db.run(query transactionally).map(a => { Right(recordToEntity(a._1, a._2, a._3._1, a._3._2, a._3._3)) }) } private def recordToEntity(order: OrderSchema, items: Seq[ItemSchema], paymentInfo: Option[PaymentInfoSchema], bankPay: Option[BankPaySchema], creditPays: Option[CreditPaySchema]): OrderEntity = { val o = Order(order.orderId, order.userId, OrderStatus(order.orderStatus)) val i = items.map(k => Item(k.itemId, k.productId, k.price, k.number, k.updateDate.toLocalDateTime)) val p = paymentInfo.map(p => { PaymentInfo( p.paymentId, PaymentType(p.paymentType), p.isPayed, p.price, p.dueDate.toLocalDateTime, p.paymentDate.map(_.toLocalDateTime), if(p.paymentType == PaymentType.Bank.code) { bankPay.map(b => BankPay(b.bankPayId, b.bankAccount)).get } else { creditPays.map(c => Credit(c.creditPayId)).get }) }) OrderEntity(o, i.toList, p) } private def saveOrderQuery(order: OrderSchema) = if(order.orderId.isEmpty) { (Orders returning Orders.map(_.orderId) into ((order, orderId) => order.copy(orderId = Some(orderId)))) += order } else { for { rowsAffected <- Orders.filter(_.orderId === order.orderId.get).map(a => (a.userId, a.orderStatus)).update(order.userId, order.orderStatus) result <- rowsAffected match { case _ => DBIO.successful(order) } } yield result } private def saveItem(orderId: Int, items: List[Item]) = { val insertQuery = (item: ItemSchema) => (Items returning Items.map(_.itemId) into ((item, itemId) => item.copy(itemId = Some(itemId)))) += item val updateQuery = (item: ItemSchema) => for { rowsAffected <- Items.filter(_.itemId === item.itemId.get).map(a => (a.orderId, a.productId, a.price, a.number, a.updateDate)). update(item.orderId, item.productId, item.price, item.number, item.updateDate) result <- rowsAffected match { case _ => DBIO.successful(item)} } yield result val deleteQuery = (item: ItemSchema) => for { rowsAffected <- Items.filter(_.itemId === item.itemId.get).delete result <- rowsAffected match { case _ => DBIO.successful(item)} } yield result DBIO.sequence(items.filter(i => !(i.itemId.isEmpty && i.number == 0)). map(i => ItemSchema(i.itemId, orderId, i.productId, i.price, i.number, Timestamp.valueOf(i.updateDate))).map(i => { if(i.itemId.isEmpty) { insertQuery(i) } else if(i.number > 0) { updateQuery(i) } else { deleteQuery(i) } })) } private def savePaymentInfo(orderId: Int, paymentInfo: Option[PaymentInfo]) = { def syncPamentInfo = (orderId: Int, paymentInfo: Option[PaymentInfoSchema]) => { paymentInfo match { case None => DBIO.successful(None) case Some(p) if p.paymentId.isEmpty => (PaymentInfos returning PaymentInfos.map(_.paymentId)) into ((paymentInfo, paymentId) => Some(paymentInfo.copy(paymentId = Some(paymentId)))) += p case Some(p) if p.paymentId.isDefined => { for { rowsAffected <- PaymentInfos.filter(_.paymentId === p.paymentId). map(r => (r.orderId, r.isPayed, r.paymentType, r.price, r.dueDate, r.paymentDate)). update(orderId, p.isPayed, p.paymentType, p.price, p.dueDate, p.paymentDate) result <- rowsAffected match { case _ => DBIO.successful(Some(p))} } yield result } } } def syncBankPay = (orderId: Int, paymentInfo: Option[PaymentInfoSchema], bankPay: Option[PayDetail]) => { if(paymentInfo.isEmpty || bankPay.isEmpty || !bankPay.get.isInstanceOf[BankPay]) { DBIO.successful(None) } else { bankPay.get match { case x: BankPay if x.bankPayId.isEmpty => { val record = BankPaySchema(x.bankPayId, paymentInfo.get.paymentId.get, x.bankAcount) (BankPays returning BankPays.map(_.bankPayId)) into ((bankPay, bankPayId) => Some(bankPay.copy(bankPayId = Some(bankPayId)))) += record } case x: BankPay if x.bankPayId.isDefined => { val record = BankPaySchema(x.bankPayId, paymentInfo.get.paymentId.get, x.bankAcount) for { rowsAffected <- BankPays.filter(_.bankPayId === record.bankPayId.get). map(r => (r.paymentId, r.bankAccount)). update(record.paymentId, record.bankAccount) result <- rowsAffected match { case _ => DBIO.successful(Some(record))} } yield result } case _ => DBIO.successful(None) } } } def syncCreditPay = (orderId: Int, paymentInfo: Option[PaymentInfoSchema], bankPay: Option[PayDetail]) => { if(paymentInfo.isEmpty || bankPay.isEmpty || !bankPay.get.isInstanceOf[BankPay]) { DBIO.successful(None) } else { bankPay.get match { case x: Credit if x.creditId.isEmpty => { val record = CreditPaySchema(x.creditId, paymentInfo.get.paymentId.get) (CreditPays returning CreditPays.map(_.creditPayId)) into ((creditPay, creditPayId) => Some(creditPay.copy(creditPayId = Some(creditPayId)))) += record } case x: Credit if x.creditId.isDefined => { val record = CreditPaySchema(x.creditId, paymentInfo.get.paymentId.get) for { rowsAffected <- CreditPays.filter(_.creditPayId === record.creditPayId.get). map(_.paymentId).update(record.paymentId) result <- rowsAffected match { case _ => DBIO.successful(Some(record))} } yield result } case _ => DBIO.successful(None) } } } for { order <- syncPamentInfo(orderId, paymentInfo.map(p=> PaymentInfoSchema(None, orderId, p.isPayed, p.paymentType.code, p.price, Timestamp.valueOf(p.dueDate), p.paymentDate.map(Timestamp.valueOf(_))))) bank <- syncBankPay(orderId, order, paymentInfo.map(_.payDetail)) credit <- syncCreditPay(orderId, order, paymentInfo.map(_.payDetail)) } yield (order, bank, credit) } }
今回はデータの取得と保存のみで削除は行はなかったので関数は用意していないです。DBへのアクセスとしてslickを使っているのですがノンブロッキングでDBにアクセスしてきてFutureを返すので複数のdbへアクセスする場合はfor式でいろいろやる必要が出てきてややこしいことになっていますが、DB保存時の整合性(シーケンスIDを生成してそれを使うようにするとか)はここで制御するようにしています。
エヴァンスの本ではリポジトリについて以下のように述べられています。
・グローバルアクセスを必要とするオブジェクトの各型に対してあるオブジェクトを生成し、その型の すべてのオブジェクトで構成されるコレクションが、メモリ上にあると錯覚させることができるように すること。よく知られているグローバルインターフェースを経由してアクセスできるようにすること。 オブジェクトの追加と削除を行うメソッドを提供し、データストアにおける実際のデータの挿入や削除 をカプセル化すること。また、ある条件に基づいてオブジェクトを選択し、属性値が条件地一致する ような、完全にインスタンス化されたオブジェクトかオブジェクトのコレクションを戻すメソッドを 提供すること。それによって、実際のストレージや問い合わせの技術をカプセル化すること。実際に 直接的なアクセスを必要とする州略ルートに対してのみリポジトリを提供すること。クライアントを モデルに集中させ、あらゆるオブジェクトの格納とアクセスをリポジトリに委譲すること
ここで注意してもらいたいのはドメインオブジェクトは登録、更新、削除を行うときに生成するもので、参照のみを行ってdtoを返したい場合は別という点です。
以下の記事では組織とユーザでそれぞれ別の集約があるけど、組織には所属可能ユーザの上限数、ユーザには有効、無効、所属部署変更が行えるけどユーザを有効にする場合、集約間でまたがった処理をしなければいけずどうするか考えられています。集約がdbのトランザクションの対象となるので集約間でまたがるのは結構課題になるようです。一旦ユーザの更新が完了させたうえで上限を超えてないかチェックする結果整合性などについて述べられていますが、最終的には組織の集約の方に有効なユーザIDのリストを持たせるようにしDBのトランザクションが集約をまたぐことがないようにしたようです。
「集約の境界と整合性(略」に対して頂いたアイデアの分類と現状での僕の回答らしきもの - kbigwheelのプログラミング・ソフトウェア技術系ブログ
集約のオブジェクトのインスタンスを生成し利用するアプリケーションサービス
ここまでで集約のオブジェクトを生成するまでの流れを説明しました。次は集約のオブジェクトを生成し処理の調整を行う部分についてで実践ドメイン駆動設計におけるアプリケーションサービスと呼ばれるレイヤーになるかと思います。ここではリポジトリを使ってドメインオブジェクトを生成し、ドメインオブジェクトの処理の呼び出しの調整等を行いドメインロジック自体がここに含まれないように気を付けます。実践ドメイン駆動設計ではアプリケーションサービスのほかにドメインサービスと言ってドメインオブジェクトの外にドメインロジックを記述するレイヤーについて言及されていますが、使いすぎるとドメインモデル貧血症といいドメインオブジェクトにあるべきロジックが無くメンテがしづらくなるので避けたほうが良いとのことです。 自分の実装ではコンテキストの境界毎でアプリケーションサービスを用意しており注文のアプリケーションサービスはapp/core/service/OrderServiceになります。
trait OrderService { def updateItem(userId: Int, productId: Int, number: Int): Future[Either[Int, Int]] def bankConfirm(userId: Int, bankAccount: String): Future[Either[Int, Int]] def creditConfirm(userId: Int): Future[Either[Int, Int]] } class OrderServiceImpl @Inject()(orderRepository: OrderRepository, productRepository: ProductRepository)(implicit ec: ExecutionContext) extends OrderService { def updateItem(userId: Int, productId: Int, number: Int): Future[Either[Int, Int]] = { (for{ cart <- orderRepository.userCart(userId) product <- productRepository.find(productId) } yield (cart, product)) map { case (cart, Some(product)) => { Await.result(orderRepository.save(cart.updateItem(product, number)), Duration.Inf) match { case Right(x) => Right(1) case _ => Left(1) } } case _ => Left(1) } } def bankConfirm(userId: Int, bankAccount: String): Future[Either[Int, Int]] = { orderRepository.userCart(userId).map{ case cart => { cart.bankPay(bankAccount) match { case Right(x) => { Await.result(orderRepository.save(x), Duration.Inf) match { case Right(j) => Right(1) case _ => Left(1) } } case _ => Left(1) } } case _ => Left(1) } } def creditConfirm(userId: Int): Future[Either[Int, Int]] = { orderRepository.userCart(userId).map{ case cart => { cart.creditPay match { case Right(x) => { Await.result(orderRepository.save(x), Duration.Inf) match { case Right(j) => Right(1) case _ => Left(1) } } case _ => Left(1) } } case _ => Left(1) } } }
for式がややこしいですがユーザIDから注文の集約とproductIdから商品の集約を取り出し、case (cart, Some(product)) でともに見つかったら注文のオブジェクトを更新し保存するようにしています。
def updateItem(userId: Int, productId: Int, number: Int): Future[Either[Int, Int]] = { (for{ cart <- orderRepository.userCart(userId) product <- productRepository.find(productId) } yield (cart, product)) map { case (cart, Some(product)) => { Await.result(orderRepository.save(cart.updateItem(product, number)), Duration.Inf) match { case Right(x) => Right(1) case _ => Left(1) } } case _ => Left(1) } }
アプリケーションの処理を呼び出すコントローラ
DDDは関係なくフレームワーク等に依存するコントローラー部分で注文のコントローラはapp/controllers/OrderControllerになります。
@Singleton class OrderController @Inject()(userRepository: UserRepository, orderService: OrderService, cc: ControllerComponents) (implicit executionContext: ExecutionContext) extends AbstractController(cc) with MySession with OrderForm { def updateItem() = Action.async { implicit request: Request[AnyContent] => checkUpdateItemForm() match { case Some(form) => { getLoginUserId match { case Some(userId) => { orderService.updateItem(userId, form.productId, form.number) map { case Right(x) => Ok(x.toString).withSession(request.session) case _ => BadRequest(1.toString) } } case _ => Future.successful(BadRequest(1.toString)) } } case _ => Future.successful(BadRequest(1.toString)) } } def bankConfirm() = Action.async { implicit request: Request[AnyContent] => checkBankPayForm() match { case Some(form) => { getLoginUserId match { case Some(userId) => { orderService.bankConfirm(userId, form.bankAccount) map { case Right(x) => Ok(1.toString).withSession(request.session) case _ => BadRequest(1.toString) } } case _ => Future.successful(BadRequest(1.toString)) } } case _ => Future.successful(BadRequest(1.toString)) } } def creditConfirm() = Action.async { implicit request: Request[AnyContent] => getLoginUserId match { case Some(userId) => { orderService.creditConfirm(userId) map { case Right(x) => Ok(1.toString).withSession(request.session) case _ => BadRequest(1.toString) } } case _ => Future.successful(BadRequest(1.toString)) } } }
DDDはドメイン層を隔離するようにレイヤを分けていますので、フレームワークが変わったとしても以降はしやすいつくりになるのかと思います。
データ参照のためのリクエスト
一覧を取得したい場合などはいちいちドメインオブジェクトを生成するのはコストがかかるのでさっさとdtoにセットして返したいかと思います。CQRSではデータを操作するコマンドと参照のみのクエリで分けて実装を進める方針になっておりよさそうです。今回であれば商品一覧取得のリクエストなどがこれにあたると思いましてdbにアクセスして柔軟なデータを返す役割としてapp/core/service/query/ProductQueryを使っています。
trait ProductQuery { def findProductDtoByName(name: String): Future[Seq[ProductDto]] } class ProductQueryImpl @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit executionContext: ExecutionContext) extends ProductQuery with ProductDao with ProductInfoDao { import profile.api._ override def findProductDtoByName(name: String): Future[Seq[ProductDto]] = db.run( Products.filter(_.name like ('%'+name+'%')).join(ProductInfos).on(_.productId === _.productId).result ).map(_.map{case (p, i) => ProductDto(p._1, p._2, p._3, i._2)}) }
ここではProductsテーブルとProductDtoテーブルをjoinしレスポンスで返す形のProductDtoのSeqにしています。
エンティティと値オブジェクト
DDDといえばエンティティと値オブジェクトについてよく言及されている気がしますが、まずはエヴァンスの本でどう言及されているか確認してみましょう。 まずエンティティについて
・あるオブジェクトが属性ではなく同一性によって識別されるのであれば、モデルでこのオブジェクト を定義する際には、その同一性を第一とすること。クラスの定義をシンプルに保ち、ライフサイクル の連続性と同一性に集中すること。形式や履歴に関係なく、各オブジェクトを識別する手段を定義 すること。オブジェクト同士を突き合せる際に、属性を持ちるよう求めてくる要件には注意すること。 各オブジェクトに対して結果が一意となることが保証されえる操作を定義すること。これは、一意 であることが保証された記号を添えることでおそらく実現できる。この識別手段は外部に由来する 場合もあれば、システムによってシステムのために作成される任意に識別子の場合もあるが、 モデルにおける同一性の区別とは一致しなければならない。モデルは同じものであるということ が何を意味するかを定義しなければならない。
長いですが要点としては属性ではなく識別子により個々を区別できれば良いのかと思います。 次に値オブジェクト
・アルモデル要素について、その属性しか関心の対象とならないのであれば、その要素を 値オブジェクトとして分類すること。値オブジェクトに自分が伝える属性の意味を表現させ、 関係した機能を与えること。値オブジェクトを不変なものとして扱うこと。同一性を与えず、 エンティティが維持するために必要となる複雑な設計を避けること。
エンティティのように同一性を識別する必要がないものは値オブジェクトとするとのことらしい
例えば「住所」を値オブジェクトとするかエンティティにするかは以下のように変わってくる
・通販のシステムで宛先の住所について、複数の人物が同じ住所に送ったがそれらの人物が同じ住所に住んでいるかどうか重要でない場合は住所は値オブジェクト
・郵便サービスのシステムで配送経路を地方、都市、郵便区、街区を管理する。住所オブジェクトは階層における郵便番号を導き出す。郵便サービスが郵便区を割り当てなおす場合、その中のすべての住所が一緒に移動する。ここでは住所はエンティティ
また値オブジェクトは不変と言われているけど、値が頻繁に変化する場合などは可変性を認めるとのことらしい。
自分の実装だとapp/core/domain/order/modelのあたりに注文のコンテキストのエンティティと値オブジェクトをまとめています。
値オブジェクトといえそうなのは大体以下の通りで他はエンティティにしています。
sealed abstract class PaymentType(value: Int) { def code = value } object PaymentType { case object None extends PaymentType(-1){ override def toString: String = "該当なし" } case object Credit extends PaymentType(1) { override def toString: String = "クレジット" } case object Bank extends PaymentType(2) { override def toString: String = "銀行振込" } def apply(code: Int) = {code match { case x if x == Credit.code => Credit case x if x == Bank.code => Bank case _ => None }} } sealed abstract class OrderStatus(value: Int) { def code = value } object OrderStatus { case object None extends OrderStatus(-1){ override def toString: String = "該当なし" } case object Shopping extends OrderStatus(1){ override def toString: String = "ショッピング" } case object Confirm extends OrderStatus(2){ override def toString: String = "確定" } case object Cancel extends OrderStatus (100){ override def toString: String = "キャンセル" } def apply(code: Int) = code match { case x if x == Shopping.code => Shopping case x if x == Confirm.code => Confirm case x if x == Cancel.code => Cancel case _ => None } }
とりあえずステータスコードとかをIntなどの基本型ではなく専用のクラスで表現力を持たせれば値オブジェクトなのかなくらいで認識しています。
値オブジェクトについては以下のスライドが参考になりそうです。値自体にロジックを持たせるようにするのであれば、例えば↑のOrderStatusについてはショッピング中のみカートの中身を変更できるとかであればcanCartEditとかでカート編集可能かどうかどうかのメソッドを追加してcode=1(ショッピング中)のみtrueを返すとかはあっても良い気がします。
Scalaでのドメインモデリングのやりかた - Speaker Deck