はじめに

弊社では多様な広告媒体を扱います。
その中で 「Aという媒体のオブジェクトを、Bの媒体へ移動したい」ようなケース があります。

素直に考えると、「AオブジェクトをBオブジェクトへ変換する Mapper」を実装すればよさそうです。
しかし、この方法は媒体が増えるほど大きな負債になります。

問題点: 対応する媒体が増えた時にコードが膨れ上がる

対応する媒体が AB だけであれば、必要な Mapper は以下の2つです。

  • A → B へコンバートする Mapper
  • B → A へコンバートする Mapper

コンバート図

現状の実装であれば Mapper は2つで済みます。
しかし、新たに C、D に対応する必要が出てきた場合、状況が変わります。

この場合だと、以下のMapperを実装することになります。

  • A → B へコンバートする Mapper
  • A → C へコンバートする Mapper
  • A → D へコンバートする Mapper
  • B → A へコンバートする Mapper
  • B → C へコンバートする Mapper
  • B → D へコンバートする Mapper
  • C → A へコンバートする Mapper
  • C → B へコンバートする Mapper
  • C → D へコンバートする Mapper
  • D → A へコンバートする Mapper
  • D → B へコンバートする Mapper
  • D → C へコンバートする Mapper

Mapper 数が膨れ上がっていることがわかります。

さらに、「Aオブジェクト」の仕様変更が入った場合も影響範囲も広いです。

まとめると、以下の点で負債になります。

  • Mapper の数が増え続ける
  • 媒体ごとの差を補完する処理が各 Mapper に散らばる
  • 媒体都合の仕様変更時の影響範囲が広くなる

素直に実装するとコードが膨れ上がりそうです。

解決策: Mapper を抽象オブジェクトに依存させ、汎用的に扱えるようにする

現状の問題は、媒体同士が直接依存しているため、変更の影響が広がる構造になっています。

これを解決するために、独自に定義した抽象オブジェクトを実装します。

  • 抽象オブジェクト本体
  • Aを抽象へコンバートするMapper
  • Bを抽象へコンバートするMapper
  • Cを抽象へコンバートするMapper
  • Dを抽象へコンバートするMapper
  • 抽象をAへコンバートするMapper
  • 抽象をBへコンバートするMapper
  • 抽象をCへコンバートするMapper
  • 抽象をDへコンバートするMapper

特徴は「全媒体に共通の中間表現(抽象)を持つ」、「具象同士を直接依存させず、抽象を経由してコンバートする」ことです。

図にすると以下です。

コンバート図

数値で比較した場合

以下は具体的な数値に落とし込んだ表です。

ビフォー

媒体数 必要なMapper数 仕様変更時に影響するMapper数
2 2 2
3 6 4
4 12 6
5 20 8

アフター

媒体数 抽象でコンバートした際のMapper数 仕様変更時に影響するMapper数
2 4 2
3 6 2
4 8 2
5 10 2

この設計のメリット & デメリット

メリット

  • 媒体が 3 つを超えるあたりから Mapper 数の優位が逆転し、媒体が増えるほど差が広がる
  • 媒体の仕様変更時の影響を受ける数が少ない
  • 抽象オブジェクトの仕様を読めば、コンバートできるプロパティを把握できる
  • 特定の媒体オブジェクトを抽象オブジェクトに変換さえすれば、他の媒体へコンバートしやすい

デメリット

  • 媒体数が少ない場合「2〜3」の場合は過剰な設計になりやすい
  • 抽象オブジェクトを管理するコストが掛かる
  • 変換先媒体に存在しない項目は扱わない仕様にする必要がある
  • 抽象モデルの設計を誤ると、逆に複雑性が中央に集中する
  • 抽象モデルに存在しない情報は失われる(情報ロスが発生する)

実装例

以下はコンバート例です。 ニュアンスが伝わるように最小限の構成にしています。

<?php

declare(strict_types=1);

/**
 * 共通フォーマット
 */
class AbstractAd
{
    public function __construct(
        public readonly string $title,
    ) {
    }
}

/**
 * A 媒体
 */
class AVendorAd
{
    public function __construct(
        public readonly string $headline,
    ) {
    }
}

/**
 * B 媒体
 */
class BVendorAd
{
    public function __construct(
        public readonly string $adTitle,
    ) {
    }
}

/**
 * A → 抽象 Mapper
 */
function aVendorAdToAbstractAd(AVendorAd $ad): AbstractAd
{
    return new AbstractAd(
        title: $ad->headline,
    );
}

/**
 * 抽象 → B Mapper
 */
function abstractAdToBVendorAd(AbstractAd $ad): BVendorAd
{
    return new BVendorAd(
        adTitle: $ad->title,
    );
}

/**
 * A → B へコンバートする例
 */
$aVendorAd = new AVendorAd(
    headline: '春のセール開催中',
); // コンバート対象の A のオブジェクト

$abstractAd = aVendorAdToAbstractAd($aVendorAd); // 一度抽象化する
$bVendorAd = abstractAdToBVendorAd($abstractAd); // 抽象 -> B へ

// 拡張例:抽象化してしまえば、今後増える媒体へコンバートが容易になる
$cVendorAd = abstractAdToCVendorAd($abstractAd); // 抽象 -> C へ
$dVendorAd = abstractAdToDVendorAd($abstractAd); // 抽象 -> D へ

まとめ

「A→B」「B→C」みたいに直接変換するのをやめて、
一度「共通フォーマット」に変換してから別の媒体に変換しよう、という設計でした。

これにより、媒体同士が直接依存しなくなり、変更が他の媒体に波及しにくくなります。

記事を書きながら知りましたが、
この設計は「Canonical Data Model パターン」と言うようです。

正規化したモデルを使ってやりとりしようというデザインパターンのようで、今回の記事とやっていることは大体同じようです。

感想

実際に担当しているプロダクトに、このデザインパターンを導入しました。

導入当初は、「YAGNI ではないか?」と思うこともありました。
しかし、媒体追加や仕様変更が発生した際に、既存コードへの影響を最小限に抑えられる構造を事前に用意しておくことは、長期的に見ると重要だと感じています。

特に、媒体数が増え続けるようなシステムでは、
「今の実装コスト」よりも「将来の変更コスト」を下げる価値の方が大きくなると、 数年間、リスティング広告というドメインに触れていると思うことが多いです。

それに、極端な話ですが、広告媒体のモデルとは全く関係なくても、
抽象モデル(正規化したモデル)に変換さえすれば、他媒体のオブジェクトへコンバートできます。

生成 AI との相性が良さそうだと思いませんか?