Featured image of post 過度な抽象化・共通化のアンチパターン

過度な抽象化・共通化のアンチパターン

見た目の類似性ではなく、ドメイン境界を基準に共通化を判断すべきである。DRY 原則の誤用と偶然の一致について

要点

  • 共通化の判断基準は「フィールドが同じかどうか」ではなく「同じドメイン概念かどうか」である。
  • 異なるドメインのレスポンスを 1 つの型階層に束ねると、片方の仕様変更が他方に波及する。
  • 見た目の一致は偶然の一致(Accidental Duplication)であり、DRY 原則の適用対象ではない。

問題: 似ているから共通化したコード

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Serializable
sealed class PaymentResponse {
    abstract val success: Boolean
    // ...

    @Serializable
    data class CreditCard(
        @SerialName("success")
        override val success: Boolean,
        // ...
        @SerialName("transaction_id")
        val transactionId: String,
        @SerialName("card_last4")
        val cardLast4: String,
    ) : PaymentResponse()

    @Serializable
    data class BankTransfer(
        @SerialName("success")
        override val success: Boolean,
        // ...
    ) : PaymentResponse()
}

// Retrofit2
interface PaymentApiClient {
    @POST("/payment/credit_card/charge")
    suspend fun chargeCreditCard(
        @Body request: PaymentRequest.CreditCard,
    ): PaymentResponse.CreditCard

    @POST("/payment/bank_transfer/execute")
    suspend fun executeBankTransfer(
        @Body request: PaymentRequest.BankTransfer,
    ): PaymentResponse.BankTransfer
}

問題は sealed class という構文ではない。異なるドメイン(クレジットカード決済と銀行振込)を 1 つの型階層に束ねている点 が問題である。

サーバー API がエンドポイントを分けて設計しているということは、クレジットカードと銀行振込は別のドメイン領域である。サーバー側が分離している境界を、クライアント側で統合してしまっている。

解決: ドメインごとに分離する

共通化した型階層を解体し、API クライアントごとに Request / Response を閉じて定義する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
interface CreditCardPaymentApi {
    @POST("/payment/credit_card/charge")
    suspend fun charge(
        @Body request: Request,
    ): Response

    @Serializable
    data class Request(/* ... */)

    @Serializable
    data class Response(/* ... */)
}

こうすることで、ドメイン境界が明確になり、不要な共通化が入り込む余地がなくなる 1

なぜ共通化すべきでないのか

DRY 原則の誤用

DRY 原則の本質は「同じ知識を二重に持たないこと」である。フィールド構造の一致は、知識の一致を意味しない。

クレジットカード決済のレスポンスと銀行振込のレスポンスは、別の API エンドポイントのレスポンスであり、別の責務を持つ。たまたまフィールドが同じでも、それは同じドメイン領域の知識ではない。これは 偶然の一致(Accidental Duplication) である。

YAGNI 原則

「将来コンビニ決済が追加されるかもしれないから、共通の親クラスを作っておこう」— これが YAGNI 原則への違反となる。

  • コンビニ決済が追加されるかどうかは、その時点ではわからない。
  • 追加されたとしても、レスポンスのフィールドが同じとは限らない。
  • 追加されたとしても、継承で解決すべき問題とは限らない。

先回りした抽象化が、かえって設計を複雑にし、メンテナンスコストを上げることになる。

SOLID 原則の観点

SRP・ISP の観点からも、異なるドメインを 1 つの型階層に束ねることは問題になる。これらの原則については以下の記事で扱っている。

is-a 関係が成立していても継承が正しいとは限らない。interface の多重継承にも設計原則を適用すべき理由を具体例で示す
cover.webp

加えて、開放閉鎖の原則(OCP) の観点でも問題がある。コンビニ決済を追加する際、when (response) の網羅チェックにより既存の利用箇所にも修正が波及する。プロバイダごとにレスポンス型を独立させていれば、追加は新しい型と処理を足すだけで済む。

何を共通化すべきで、何をすべきでないか

判断基準は「同じドメイン概念か否か」であり、フィールドの一致ではない。

ケース 判断 理由
data class Book が複数の API レスポンスに含まれる 共通化 OK 「書籍」はドメイン概念そのもの
クレジットカードと銀行振込のレスポンスが同じフィールドを持つ 共通化 NG 異なるドメインの偶然の一致

FeaturedBook のケース

data class FeaturedBook は Book と同じプロパティに加え、特集掲載用のプロパティを持つ。「FeaturedBook は書籍である」というドメイン上の関係は実在する。

しかし、API レスポンスは API の構造を写すものであり、ドメインモデルではない。 異なる API のレスポンスを継承関係にすると、片方の API 仕様変更が他方に波及するリスクがある。

ドメイン上の関係は、ドメインモデルで合成により表現すれば良い。

1
2
3
4
5
data class FeaturedBook(
    val book: Book,       // 継承ではなく合成(has-a)
    val featureStartDate: LocalDate,
    val displayPriority: Int,
)
原義は Favor Composition over Inheritance である。合成・委譲・転送の用語を整理する
cover.webp

まとめ

  • 共通化の判断基準は「フィールドの一致」ではなく「ドメイン概念の同一性」である。
  • API レスポンスは API の構造を写すものであり、ドメインモデルではない。
  • ドメイン境界を越えた共通化は、将来的な変更コストを増大させる。

  1. Request / Response を API client interface 内にネストする方法は一例である。API ごとにパッケージを分けて独立した class として定義するアプローチも有効である。重要なのは「クレジットカードと銀行振込のレスポンスが独立していること」であり、具体的な配置は設計方針に従えばよい。 ↩︎

Hugo で構築されています。
テーマ StackJimmy によって設計されています。