Open8

DDDについて調べる

Ryota OnumaRyota Onuma

値オブジェクト

特徴

  • イミュータブルであること

    • 生成後に状態(プロパティの値)が変化しない
    • 状態変化が必要な場合は「新しいインスタンス」を生成する
  • 副作用を持たない

    • 値オブジェクトのメソッドは副作用を持たず、外部状態を書き換えない
    • 全操作は新しい値オブジェクトを返す
  • 等価性は属性の値で判断する(同一性は問わない)

    • 参照やIDではなく、「値が同じなら同じもの」と見なす
    • 「同じ値を持つ2つのインスタンス」は区別できない(IDを持たせてはならない)
  • ドメイン的な不変条件・整合性を保証する

    • 不正な値や状態のインスタンスを絶対に作れないよう、バリデーションをコンストラクタやファクトリで必ず実施
    • 例:金額は0以上、メールアドレスは@を含む等
  • 自己完結したドメインロジックを持つ

    • その値に関連する意味的操作(例:合算、フォーマット、比較など)は値オブジェクト内で完結
    • 「値そのものに関する操作」は値オブジェクト自身の責務

サンプルコード

data class Money(val amount: Int, val currency: String) : Comparable<Money> {
    init {
        require(amount >= 0) { "金額は0以上でなければいけません: $amount" }
        require(currency.isNotBlank()) { "通貨が未指定です" }
    }

    operator fun plus(other: Money): Money {
        require(this.currency == other.currency) { "通貨が異なります" }
        return copy(amount = this.amount + other.amount)
    }

    override fun compareTo(other: Money): Int {
        require(this.currency == other.currency) { "通貨が異なるため比較できません" }
        return this.amount.compareTo(other.amount)
    }
}
Ryota OnumaRyota Onuma

エンティティ

特徴

  • ID(同一性)が絶対的に必要
    • UUIDでも、数値でも、ナチュラルキーでもいい。唯一さえ保証すれば。
    • 値が全て同じでも「別ID」なら別物
  • 属性は可変
    • 属性(状態)は変化しうる(可変)
    • イミュータブルである必要はない(むしろ現実世界のモデリングでは状態変化が本質)
  • 等価性はIDで判定
    • 値が違ってもIDが同じなら同一エンティティ
    • なので、比較時はIDだけ見ればいい
  • 業務的なライフサイクルを持つ
    • 生成、更新、削除など、長期的なライフサイクルを管理
  • ドメインでの「存在そのもの」を表現
    • その「存在自体」にドメイン上の意義がある
    • 例:同じ商品情報(名前、価格)があっても、在庫や履歴管理で「個体」として管理したい
  • 複雑な構造や履歴を持ちやすい
  • IDによる永続化・参照が前提
    • RDBMSなら主キー、NoSQLでもID必須

エンティティは「誰」であるか(what it is)に注目する。また、「履歴」「バージョン管理」「変更ログ」など、IDで追いかけたい類のものもエンティティ。

サンプルコード

import java.util.UUID

data class UserId(val value: UUID) // 値オブジェクトとしてのID

data class User(
    val id: UserId,               // エンティティの同一性
    var name: String,             // 可変属性(変更可)
    var email: String             // 可変属性
) {
    // 業務ロジック例
    fun changeEmail(newEmail: String) {
        require(newEmail.contains("@")) { "不正なメールアドレス: $newEmail" }
        email = newEmail
    }

    // 等価性はidだけで判定
    override fun equals(other: Any?): Boolean =
        other is User && this.id == other.id

    override fun hashCode(): Int = id.hashCode()
}
Ryota OnumaRyota Onuma

集約

特徴

  • ドメインモデルの一貫性境界(Consistency Boundary)を定める設計パターン
    • 一連のエンティティや値オブジェクトを「まとまり」として管理し、外部から直接内部に触らせず、一貫性を保証する単位
    • トランザクション境界や永続化単位にもなる
  • 「集約ルート(Aggregate Root)」という代表エンティティが1つ存在し、そのルートを介してのみ集約内の操作・参照が許される
    • 内部の状態変化やルールはルートで保証
    • 内部の値オブジェクトや子エンティティはルートエンティティを通してしか外部からアクセスできない(カプセル化)
    • ルートからしか触らせない、という徹底した守りが本質
  • 集約外部からはルートIDで参照・操作
  • リポジトリの入出力の単位にもなる。

サンプルコード

data class OrderId(val value: String)
data class ProductId(val value: String)

// Productはエンティティとして定義(区別する必要がある想定)
data class Product(
    val id: ProductId,
    val name: String,
    val price: Int
)

// OrderLineは値オブジェクトとして定義
data class OrderLine(
    val product: Product,
    val quantity: Int
)

// Orderが集約ルート
class Order(
    val id: OrderId,
    status: OrderStatus = OrderStatus.CREATED
) {
    private val _orderLines: MutableList<OrderLine> = mutableListOf()
    var status: OrderStatus = status
        private set

    fun addProduct(product: Product, quantity: Int) {
        require(quantity > 0) { "数量は1以上である必要があります: $quantity" }
        _orderLines.add(OrderLine(product, quantity))
    }

    fun getOrderLines(): List<OrderLine> = _orderLines.toList()

    fun pay() {
        require(status == OrderStatus.CREATED) { "注文は既に支払い済み、またはキャンセル済みです" }
        status = OrderStatus.PAID
    }

    fun cancel() {
        require(status == OrderStatus.CREATED) { "注文が未作成でないとキャンセルできません" }
        status = OrderStatus.CANCELED
    }
}

enum class OrderStatus {
    CREATED, PAID, CANCELED
}
fun main() {
    val orderId = OrderId("ORDER-001")
    val productA = Product(ProductId("PROD-001"), "りんご", 100)
    val productB = Product(ProductId("PROD-002"), "みかん", 80)

    val order = Order(orderId)
    order.addProduct(productA, 2)
    order.addProduct(productB, 5)

    println("注文内容: " + order.getOrderLines())
    order.pay()
    println("注文ステータス: " + order.status)
    // order.cancel() // 例外: 支払い済みなのでキャンセル不可
}
Ryota OnumaRyota Onuma

ドメインサービス

定義・本質

  • エンティティや値オブジェクトに所属しない純粋なドメインロジック(業務ロジック)を表現するオブジェクト
  • 複数のエンティティ・値オブジェクトにまたがる操作や、1つのオブジェクトに責務を持たせると不自然になる業務処理を担当
  • 「手続き的なビジネスルール」を型として分離
    (例:送金、在庫引き当て、割引適用、本人確認…)

特徴

  • 状態(プロパティ)を原則持たない(ステートレス)
  • 振る舞い(メソッド)に特化
  • 引数にドメインオブジェクト (集約) を受け取り、ドメインロジックを実行

エンティティ/値オブジェクトとの違い

エンティティ/値オブジェクト

  • 主に「もの」や「概念」の属性+責務を持つ
  • それだけでは表現できない「複数オブジェクト横断の業務処理」は持たせにくい

ドメインサービス

  • 上記から「はみ出る」ルールや振る舞いを分離して実装
  • 「はみ出る」ルールだけをいれること。本来ドメインモデルにいれるべき振る舞いもドメインサービスに入れこんでしまうと、ドメインモデル貧血症になる。

具体例

  • ユーザーの重複チェック
  • 「顧客のランクに応じた割引を計算する」
  • 「A口座からB口座に振り込む」

サンプルコード

送金ドメインサービス

data class AccountId(val value: String)

class Account(
    val id: AccountId,
    var balance: Int
) {
    fun withdraw(amount: Int) {
        require(amount > 0) { "出金額は1円以上" }
        require(balance >= amount) { "残高不足" }
        balance -= amount
    }

    fun deposit(amount: Int) {
        require(amount > 0) { "入金額は1円以上" }
        balance += amount
    }
}
object TransferService {
    fun transfer(from: Account, to: Account, amount: Int) {
        require(from.id != to.id) { "同一口座間では送金できません" }
        from.withdraw(amount)
        to.deposit(amount)
    }
}
fun main() {
    val accountA = Account(AccountId("A001"), 5000)
    val accountB = Account(AccountId("B001"), 3000)

    TransferService.transfer(accountA, accountB, 2000)

    println("A残高: ${accountA.balance}") // 3000
    println("B残高: ${accountB.balance}") // 5000
}
Ryota OnumaRyota Onuma

アプリケーションサービス

定義・本質

  • 「ユースケース実現のための指揮者」
    • ユースケースごとに「何をどう動かすか」を指示し、ドメイン層やインフラ層を繋ぐ調整役
    • APIリクエストやバッチリクエストなど、外部からの要求を受け、必要なドメイン操作を組み合わせて実行
  • ビジネスロジックは持たず、「段取り」「オーケストレーション」のみを担う
    • 実際の業務ルールや整合性はドメイン層(エンティティ、集約、ドメインサービス)に委譲
    • DBトランザクションや認可、例外処理、メール送信など周辺的な制御もここで担当することが多い

特徴

  • ユースケースごとに1クラス1メソッドが典型(例:注文登録、ユーザー作成)
  • ステートレス
  • リポジトリを使い集約の取得・保存を担当
  • ドメイン層(エンティティ・集約・ドメインサービス)を操作し、I/Oや外部サービスとも連携
  • トランザクション管理、例外ハンドリング、認可など周辺制御を担当することも多い
  • DTO(Data Transfer Object)との変換ややり取りもここで行う

サンプルコード

data class OrderProductDto(val productId: String, val quantity: Int)

class OrderApplicationService(
    private val orderRepository: OrderRepository,
    private val productRepository: ProductRepository
) {
    /**
     * 新しい注文を作成するユースケース
     */
    fun createOrder(orderId: OrderId, products: List<OrderProductDto>): Order {
        // 1. プロダクトの取得
        val productList = products.map { dto ->
            val product = productRepository.findById(ProductId(dto.productId))
                ?: throw IllegalArgumentException("商品が存在しません: ${dto.productId}")
            Pair(product, dto.quantity)
        }

        // 2. 集約ルート(Order)の生成
        val order = Order(orderId)
        productList.forEach { (product, quantity) ->
            order.addProduct(product, quantity)
        }

        // 3. 永続化
        orderRepository.save(order)

        return order
    }

    /**
     * 注文の支払い
     */
    fun payOrder(orderId: OrderId) {
        val order = orderRepository.findById(orderId)
            ?: throw IllegalArgumentException("注文が存在しません: $orderId")
        order.pay()
        orderRepository.save(order)
    }
}
fun main() {
    val orderRepo = InMemoryOrderRepository()
    val productRepo = InMemoryProductRepository()
    val appService = OrderApplicationService(orderRepo, productRepo)

    // 商品追加
    val prodA = Product(ProductId("PROD-001"), "りんご", 100)
    productRepo.save(prodA)

    val orderId = OrderId("ORDER-001")
    val order = appService.createOrder(
        orderId,
        listOf(OrderProductDto("PROD-001", 2))
    )
    println("注文内容: ${order.getOrderLines()}")

    // 支払い
    appService.payOrder(orderId)
    println("注文ステータス: ${order.status}")
}
Ryota OnumaRyota Onuma

ドメインサービスとアプリケーションサービスの違い

  • 業務フロー=アプリケーションサービスの責務
  • 業務ルール・ビジネス知識 =ドメインサービスや集約の責務

アプリケーションサービス

個々のビジネスルール(=集約やドメインサービス)をどう組み合わせて流れにするか。
ユースケースごとに「どの手続きを、どの順番で実行するか」「どの集約をどのタイミングで取得・保存するか」

例)

  • 顧客登録 → ポイント付与 → メール送信
  • 注文作成 → 支払い処理 → 在庫引当

ドメインサービス

「純粋な業務ルールや判定、計算」などドメイン知識そのもの。

  • 単独集約や複数集約にまたがるルール
  • エンティティや値オブジェクトに持たせると不自然な複雑なビジネスロジック

手続きの流れは担当しない。

  • あくまでもルールの塊、知識の表現体として振る舞う。

例)

  • 「A口座からB口座に送金できるかどうかの判定」
  • 「商品価格の割引計算」
  • 「注文が有効かどうかのバリデーション」
Ryota OnumaRyota Onuma

リポジトリ

定義・本質

-「ドメインオブジェクトのコレクションを擬似的に表現するストレージのゲート」
- 本質的には「ドメイン層にストレージ(DB、ファイル、外部API等)の存在を隠し、
ドメインオブジェクトをリストやMapのように出し入れするインターフェース」

  • 集約ルート単位で管理するのが原則
    • 集約やエンティティの保存・取得・削除・検索などを一手に引き受ける
    • 集約外の子エンティティや値オブジェクト単体でのCRUDは許容しない(カプセル化のため)

設計パターン

  • ドメイン層にインターフェイスを用意する
  • インターフェイスの実装は、インフラストラクチャ層に定義する。DIで差し替える。
  • アプリケーションサービスから、インターフェイス経由でリポジトリを呼び出す

サンプルコード

data class OrderId(val value: String)

class Order(val id: OrderId, var status: OrderStatus = OrderStatus.CREATED) {
    fun pay() {
        require(status == OrderStatus.CREATED) { "既に支払い済み、またはキャンセル済みです" }
        status = OrderStatus.PAID
    }
}

enum class OrderStatus { CREATED, PAID, CANCELED }
interface OrderRepository {
    fun save(order: Order)
    fun findById(orderId: OrderId): Order?
    fun delete(orderId: OrderId)
    fun findAll(): List<Order>
}
class InMemoryOrderRepository : OrderRepository {
    private val store = mutableMapOf<OrderId, Order>()

    override fun save(order: Order) {
        store[order.id] = order
    }

    override fun findById(orderId: OrderId): Order? = store[orderId]

    override fun delete(orderId: OrderId) {
        store.remove(orderId)
    }

    override fun findAll(): List<Order> = store.values.toList()
}

class RdbOrderRepository(private val dataSource: DataSource) : OrderRepository {
    override fun save(order: Order) {
        // SQLでorderとorderLinesをinsert/update
        // ここで集約内の子エンティティも分解して保存
    }

    override fun findById(orderId: OrderId): Order? {
        // SQLでordersとorder_linesからデータを取得し、Orderを組み立てて返す
    }

    override fun delete(orderId: OrderId) {
        // SQLで該当Orderと子エンティティを削除
    }

    override fun findAll(): List<Order> {
        // 全件取得例
    }
}
class OrderApplicationService(private val orderRepository: OrderRepository) {

    fun createOrder(orderId: OrderId): Order {
        // 新規Order作成
        val order = Order(orderId)
        // 保存
        orderRepository.save(order)
        return order
    }

    fun payOrder(orderId: OrderId) {
        // 取得
        val order = orderRepository.findById(orderId)
            ?: throw IllegalArgumentException("注文が存在しません: $orderId")
        // ドメイン操作
        order.pay()
        // 状態を保存
        orderRepository.save(order)
    }

    fun getAllOrders(): List<Order> {
        // 全件取得
        return orderRepository.findAll()
    }

    fun deleteOrder(orderId: OrderId) {
        orderRepository.delete(orderId)
    }
}
fun main() {
    val repo = InMemoryOrderRepository()
    val appService = OrderApplicationService(repo)

    // 作成
    val orderId = OrderId("ORDER-001")
    val order = appService.createOrder(orderId)
    println("作成: $order")

    // 支払い
    appService.payOrder(orderId)
    println("支払い後: " + repo.findById(orderId))

    // 全件取得
    val orders = appService.getAllOrders()
    println("全注文: $orders")

    // 削除
    appService.deleteOrder(orderId)
    println("削除後: " + repo.findById(orderId)) // null
}
Ryota OnumaRyota Onuma

イベントストーミング

特徴

  • 「システムや業務で起こる イベント(出来事) を時系列でひたすら並べ、関係者全員で議論しながら全体像を共有・発見する」手法
    • ビジネスドメインで“意味のある出来事”を付箋やカードで壁に貼り出す
      • 例)
        • 「注文が作成された」
        • 「在庫が引き当てられた」
        • 「請求書が発行された」
        • 「支払いが完了した」
    • 業務プロセスの中で発生した出来事を業務イベントとして識別し、それらを引き起こすコマンドや、コマンドを発動するアクター(エンティティ) などを視覚的に整理
  • 「ドメインの“暗黙知”や“抜け漏れ”をあぶり出す」「現場の業務とITのギャップを埋める」ことが狙い