コンテンツにスキップ

KotlinでのSpecificationパターン実装例

SpecificationをKotlinで表現する方法を、固定条件、パラメーター化、Compositeの順に紹介します。Specificationの役割と実装方法の選び方は、先に次の記事で確認できます。

Specificationは候補の型を受け取り、条件を満たすかを返します。

interface Specification<T> {
fun isSatisfiedBy(candidate: T): Boolean
}

Eric EvansとMartin Fowlerの原資料で示される中心的な操作も isSatisfiedBy です。候補のオブジェクトと、その候補への要求を分けて扱います。

例では、キャンペーン対象の商品を判定します。エンティティのIDや永続化の詳細は省き、条件に使う値だけを示します。

enum class ProductStatus {
ON_SALE,
DISCONTINUED
}
enum class ProductCategory {
BOOKS,
ELECTRONICS,
OTHER
}
@JvmInline
value class Money(val yen: Long) {
init {
require(yen >= 0) {
"金額は0円以上で指定してください。"
}
}
}
data class Product(
val status: ProductStatus,
val category: ProductCategory,
val price: Money
)

キャンペーン条件が固定されている場合は、一つの名前付きクラスに isSatisfiedBy の実装をまとめられます。

今回の条件は、販売中であり、1,000円以上で、さらに「書籍」または「5,000円以上の家電」であることです。

class CampaignProductSpecification : Specification<Product> {
override fun isSatisfiedBy(candidate: Product): Boolean {
if (candidate.status != ProductStatus.ON_SALE) {
return false
}
if (candidate.price.yen < 1_000) {
return false
}
return when (candidate.category) {
ProductCategory.BOOKS -> true
ProductCategory.ELECTRONICS -> candidate.price.yen >= 5_000
ProductCategory.OTHER -> false
}
}
}

CampaignProductSpecificationが条件の詳細を持つため、利用するドメイン側は条件式を重ねて書かずに済みます。この形は単純で読みやすい一方、固定条件の種類が増えるとSpecificationクラスも増えます。

最低価格のように、条件の形を変えず値だけを変えたい場合は、値をコンストラクターで受け取ります。

class MinimumPriceSpecification(
private val minimumPrice: Money
) : Specification<Product> {
override fun isSatisfiedBy(candidate: Product): Boolean =
candidate.price.yen >= minimumPrice.yen
}

同じルールを異なる価格で利用できます。

val booksOver1000Yen = MinimumPriceSpecification(Money(1_000))
val electronicsOver5000Yen = MinimumPriceSpecification(Money(5_000))

条件値が変わるたびに専用クラスを追加する必要がありません。反対に、一つのSpecificationへ多くの独立したパラメーターを詰めると、何を判定するのか分かりにくくなるため、意味の異なるルールは分けます。

複数の条件を実行時に組み立てる場合は、Compositeとして合成できます。基本インターフェースを複雑にせず、合成用の関数が新しいSpecificationを返す形にします。

private class AndSpecification<T>(
private val left: Specification<T>,
private val right: Specification<T>
) : Specification<T> {
override fun isSatisfiedBy(candidate: T): Boolean =
left.isSatisfiedBy(candidate) && right.isSatisfiedBy(candidate)
}
private class OrSpecification<T>(
private val left: Specification<T>,
private val right: Specification<T>
) : Specification<T> {
override fun isSatisfiedBy(candidate: T): Boolean =
left.isSatisfiedBy(candidate) || right.isSatisfiedBy(candidate)
}
private class NotSpecification<T>(
private val specification: Specification<T>
) : Specification<T> {
override fun isSatisfiedBy(candidate: T): Boolean =
!specification.isSatisfiedBy(candidate)
}
fun <T> Specification<T>.and(other: Specification<T>): Specification<T> =
AndSpecification(this, other)
fun <T> Specification<T>.or(other: Specification<T>): Specification<T> =
OrSpecification(this, other)
fun <T> Specification<T>.not(): Specification<T> =
NotSpecification(this)

and / or / notは、元のSpecificationを変更せず、新しい合成Specificationを作ります。これらはCompositeを実現するための拡張であり、固定条件のSpecificationに必ず実装する必要はありません。

合成に使う小さなSpecificationを定義します。

class OnSaleProductSpecification : Specification<Product> {
override fun isSatisfiedBy(candidate: Product): Boolean =
candidate.status == ProductStatus.ON_SALE
}
class CategorySpecification(
private val category: ProductCategory
) : Specification<Product> {
override fun isSatisfiedBy(candidate: Product): Boolean =
candidate.category == category
}

書籍または5,000円以上の家電、というカテゴリー条件を先にまとめ、キャンペーン全体のSpecificationを作ります。

val booksSpecification = CategorySpecification(ProductCategory.BOOKS)
val highPriceElectronicsSpecification =
CategorySpecification(ProductCategory.ELECTRONICS)
.and(MinimumPriceSpecification(Money(5_000)))
val campaignCategorySpecification =
booksSpecification.or(highPriceElectronicsSpecification)
val campaignSpecification =
OnSaleProductSpecification()
.and(MinimumPriceSpecification(Money(1_000)))
.and(campaignCategorySpecification)

条件を段階ごとに名前付けしているため、深い式をそのまま入れ子にせずに読めます。この合成は、条件部品を選択・再利用する必要がある場合に使います。固定条件であれば、前の節のCampaignProductSpecificationのように一クラスへまとめる形で十分です。

Specificationは、候補となるオブジェクトを使うドメインオブジェクトから呼び出せます。

class Campaign(
private val eligibleProductSpecification: Specification<Product>
) {
fun canApplyTo(product: Product): Boolean =
eligibleProductSpecification.isSatisfiedBy(product)
}

固定条件なら専用クラスを、組み立てた条件を使うならCompositeを渡します。

val fixedCampaign = Campaign(CampaignProductSpecification())
val configurableCampaign = Campaign(campaignSpecification)
val canApply = configurableCampaign.canApplyTo(product)

Campaignはキャンペーン対象かどうかの業務判断をSpecificationに委ね、呼び出し側にはcanApplyToというドメイン上の意図を示します。条件が固定なら専用Specificationを、組み合わせを変えたいならCompositeを渡せます。これにより、キャンペーン条件を利用箇所ごとに複製せずに済みます。

Specificationで判定した結果を、集約の不変条件の唯一の防衛線にしないようにします。たとえば「確定済み注文は変更できない」というルールは、すべての変更経路で守られるよう集約ルートのメソッドが保証します。

Specificationは条件の分離や再利用に使い、状態変更の不変条件は集約の振る舞いでも守ります。また、既存データの取得が必要な判断は、SpecificationにRepositoryを直接持たせず、ドメインサービスなどが情報取得と判断を調整します。

  • 条件が固定され、業務上の名前を付けたい場合は、固定条件のSpecificationを使う
  • 閾値などの値だけが変わる場合は、パラメーター化する
  • 条件部品を実行時に組み立てたり、複数の組み合わせで再利用したりする場合は、Compositeを使う

Compositeは柔軟ですが、合成クラスや評価方法を保守する必要があります。まずは条件を表す単純なSpecificationから始め、柔軟な合成が必要になったときに追加します。