domainをsharedに入れてはいけない理由

に公開

背景

  • feature/subscription/domain で作った Subscription オブジェクト(振る舞いあり)を、
    他の場所で使おうとした。
    ※振る舞い=そのオブジェクトが行う処理やロジック。例: cancel(), upgrade(), withTax()
  • ユーザーのサブスク状態を返すときにHono の context に domain を突っ込んで返せば一瞬やんと思ってAIに修正命令
  • めちゃくちゃlintエラーが出ておかしいと思って設計を見直した。

結論

  • domain を shared に昇格させるのは境界違反。
  • shared は DTO(形)だけを置き、振る舞いは feature 内に閉じる。

振る舞いの共通化で起こる問題

1. 境界漏れ

  • Domain は「プラン仕様」や「不変条件」など内側のルールを持つ。
  • それをそのまま返すと、外部API契約が内部実装と直結してしまう。
  • 例: Subscription の構造を返していると、プラン体系の変更(FREE/PRO → STARTER/PRO/ENTERPRISE)がそのまま API 破壊になる。

2. シリアライズ不安定

  • class を返すと、getter / private フィールド / メソッドが JSON 化に混じる。
  • Date や BigInt を持っているとシリアライズに失敗したり、意図しない文字列に変換される。
  • 実際に lint エラー(「JSONにできないフィールドを返している」系)が発生するなど、型と実際の出力がズレやすい。

3. 意味論の波及

  • Domain に canAccess(featureKey) のようなメソッドを置いたとする。

  • これを shared に昇格させると、player / editor / billing など複数の feature から呼ばれるようになる。

  • 一見「どれもアクセス可否を知りたい」ので同じ意味に見えるが、実際には異なる:

    • player → 「UIのボタンを出すか」
    • editor → 「高画質エクスポートを許すか」
    • billing → 「課金対象に含めるか」
  • 共通メソッドにまとめてしまうと、ある境界に合わせた仕様変更が他の境界を壊す

  • 問題は「呼ばれていること」ではなく、境界ごとに意味が違うものを1つにしてしまったこと


4. テスト・進化の足かせ

  • Domain を返すと、テストが クラス内部の構造やメソッドに依存してしまう。
  • 仕様を差し替えたり内部表現を変えたくても、返却形式が固定されているため後方互換の縛りになる。
  • DTO に落として返せば、テストは安定した形(プレーンオブジェクト)の比較で済み、domain は自由に進化できる。

「メソッド増やせば解決?」の落とし穴

「用途ごとにメソッドを増やせば済むのでは?」と一瞬思った。

class Subscription {
  toDtoForApi(): SubscriptionDTO {}
  toDtoForBilling(): BillingDTO {}
  toDtoForReporting(): ReportDTO {}
}

問題

  1. 神クラス化

    • Subscription が API, Billing, Reporting すべての出力仕様を抱えることになる。
    • 本来 feature ごとに独立すべき仕様が 1 クラスに集中してしまう。
  2. 誤用リスク

    • toDtoForBilling() を API 側で呼んでしまうなど、間違ったメソッドを選んでも型では防げない
    • 境界ごとの正しい意味を守れなくなる。
  3. 更新負荷

    • 新しい境界(例: 分析や通知)が増えるたびに、このクラスにメソッドを追加する必要がある。
    • 変更のたびに全機能へ波及する「破壊的共有ポイント」になってしまう。

正しいアプローチ

  • shared: DTO だけ(安定した形)

    type SubscriptionDTO = {
      id: string
      userId: string
      plan: string
      status: string
      // …他はシンプルにデータのみ
    }
    
  • feature/subscription/domain:
    不変条件や内部のルール(キャンセル、アップグレードなどの処理)を保持する。
    外に出さない。

  • mapper / presenter:
    domain → DTO への変換を担い、context やレスポンスに載せる。
    境界ごとに独自の Mapper を持てば、API と Billing が別々の契約を保てる。


学び

  • AIに修正してもらってlintが大量発生したらまず設計を疑おう!!!!

Discussion