💊

Branded Typeを活用したドメイン知識の表現

メディカルフォースでCTOをしている畠中です。
メディカルフォースでは新規事業として警備業界向けのSaaS「警備フォース」を開発しています。
「警備フォース」ではDDDおよびオニオンアーキテクチャを積極的に取り入れており、そちらで用いられているtipsについて共有できればと思っております。

要旨

かなりの長期間システムを運用する場合、DDDは強力な武器になると考えています。
コードを高凝集・低結合を保つことができ、変更がある際の変更箇所が明確になるという点と変更を行なった際の影響範囲が明確になるという点で特に価値を発揮します。

DDDにおいてドメイン層の中で不正なentityが発生しないようにすることは非常に重要です。
しかし、一部のドメイン知識はコードで表現をするのに工夫が必要です。
そこでドメイン知識を型として表現することで、特定の型あるいは特定の処理を通じた値やオブジェクトのみをentityが保持することを許容することによってドメイン知識を簡単に、かつ十分な制限をかけた状態で表現できます。

背景

DDDについて

DDD(ドメイン駆動設計)は、複雑なビジネスドメインを扱うシステムを設計・実装するためのアプローチです。DDDの中心的な考え方は、ビジネスの専門知識をソフトウェアに正確に反映させることです。これにより、システムのメンテナンス性と拡張性を大きく向上させることが可能になります。

オニオンアーキテクチャについて

オニオンアーキテクチャは、システムの依存関係を内側(コア)から外側(インフラストラクチャ)に向かって定義するアーキテクチャスタイルです。ドメインモデルやアプリケーションサービスなどの本質的なロジックは内側の層に配置され、外部システムとの接続やインフラ層の変更に強い構造を持ちます。この設計は、コードの再利用性やテスト容易性を高める点でも有効です。

ポイントとしてはドメイン知識を中心に構築することで、データベースやインフラなどに例え変更があったとしてもドメイン知識を破壊せずに変更を行うことが可能となる点です。

ドメイン知識について

「ドメイン知識」とは、ソフトウェアが解決しようとしている現実の問題領域に関する深い理解のことを指します。この知識は、システムが動作する業務や対象領域(ドメイン)に直接関連しており、DDDにおいては非常に重要な要素とされています。

entityについて

DDDにおけるentityは、識別子によって一意に区別されるオブジェクトを指します。エンティティは、そのライフサイクル全体で一貫性を持ち、ビジネスロジックや状態を保持します。不正なentityが発生すると、システム全体の整合性が損なわれる可能性があるため、厳密な管理が求められます。

特に今回の記事では不正なentityと言う点に着目していきたいと思います。
例えば、不正なエンティティとは以下のようなケースを指します。

・識別子が欠損している
エンティティは識別子によって一意に識別される必要がありますが、識別子が欠損していたり、不正な形式で生成された場合、エンティティとしての一貫性が損なわれます。

・ビジネスルールを満たしていない
エンティティのプロパティがドメインルールを満たしていない場合、業務に不整合が生じる可能性があります。たとえば、金額が負数であったりする場合です。

・ライフサイクル管理の問題
エンティティの状態が正しく遷移していない場合も不正なエンティティとみなされます。たとえば、注文エンティティが「キャンセル済み」の状態にもかかわらず、「配送中」として更新されているケースです。

このような例においては比較的簡単に不正なentityが生まれないようにドメイン知識を表現することができます。

order.ts
class Order {
  private constructor(
    public readonly id: string,
    public readonly status: 'created' | 'shipped' | 'cancelled',
    public readonly totalPrice: number
  ) {}

  // ファクトリメソッドで作成
  public static create({
    id,
    status,
    totalPrice,
  }: {
    id: string;
    status: 'created' | 'shipped' | 'cancelled';
    totalPrice: number;
  }): Order {
    // 識別子の検証
    if (!id) {
      throw new Error('Order ID is required.');
    }

    // 状態の検証
    if (!['created', 'shipped', 'cancelled'].includes(status)) {
      throw new Error('Invalid status for Order.');
    }

    // 金額の検証
    if (totalPrice < 0) {
      throw new Error('Total price cannot be negative.');
    }

    return new Order(id, status, totalPrice);
  }

  // 状態遷移を管理
  public ship(): void {
    if (this.status !== 'created') {
      throw new Error('Only orders in "created" status can be shipped.');
    }
    this.status = 'shipped';
  }

  public cancel(): void {
    if (this.status !== 'created') {
      throw new Error('Only orders in "created" status can be cancelled.');
    }
    this.status = 'cancelled';
  }
}

Branded Typeについて

unique symbol型は、一意なシンボルを表すTypeScriptの特殊な型です。同じシンボルを間違って扱わないよう、型レベルで安全性を保証してくれます。

例えば

newType.ts
type UserId = string & { __brand: "UserId" }
type OrderId = string & { __brand: "OrderId" }

type User = {
  id: UserId
  name: string
}
type Order = {
  id: OrderId
  name: string
}

const createUserId = () => createId() as UserId
const createOrderId = () => createId() as OrderId

const order: Order = {
  id: createOrderId(),
  name: "order"
}

const hoge = (user: User) => {
  return user
}

hoge(order)

と言うコードでは、UserとOrderの型が同じ構造をしていたとしてもunique symbolを使ったbranded typeを使うことでUser型とOrder型のobjectは別の型として認識されます。

実践

前述の通り、基本的にはEntity自体にドメインの制約を表現することは可能です。
しかし、一部の例ではドメインの制約をEntityにかけることは一筋縄では行きません。
例をまず示して、解決策を提示します。

制約をかけるのが難しい例

例1: Entity単体ではかけられない制約をかけたい場合

例えばOrderEntityに一意制約のあるnameと言うカラムを足すことを考えてみます。
一意性のチェックはEntityが知るべき知識ではない(Entity単体ではなく、他のEntityも交えたvalidationが必要なため)一方で、ドメインの制約ではあるためドメイン知識となります。

つまり、一意性のチェックのロジック自体はEntityに記述してはいけないが、一意性の保証はEntityで行う必要があります。

単純な方法としては、関数をEntityに受け渡しEntity内でその関数を発火すると言うことが考えられますが、可読性やテストのしやすさの観点からあまり取りたい方法ではありません。

order.ts
class Order {
  private constructor(
    public readonly id: string,
    public readonly name: string
  ) {}

  // ファクトリメソッドで作成
  public static async create({
    id,
    name,
    nameUniqueChecker
  }: {
    id: string;
    name: string;
    nameUniqueChecker: (name: string) => Promise<boolean> | boolean;
  }): Promise<Order> {
    // 識別子の検証(本当はvalueObjectにして、valueObject内でvalidationした方が良い)
    if (!id) {
      throw new Error('Order ID is required.');
    }

    // 状態の検証
    const isUnique = await nameUniqueChecker(name);
    if (!isUnique) {
      throw new Error(`Order name "${name}" is already taken.`);
    }

    return new Order(id, name);
  }
}

例2: 実行順序に制約をかけたい場合

例えば、Orderの入金処理の後に発送処理が続く場合のことを考えてみます。
こちらも素直にやれば以下のような実装になります。

order.ts
class Order {
  private constructor(
    public readonly id: string,
    public readonly isPaid: boolean
  ) {}

  // 支払い処理
  public pay(): Order {
    return new Order(this.id, true);
  }

  // 発送処理
  public ship(): void {
    if (!this.isPaid) {
      throw new Error("Order must be paid before shipping.");
    }
    console.log(`Order ${this.id} has been shipped.`);
  }
}

しかし、こちらもドメイン知識を実際に実行するまで知ることができません。
特にusecaseなどで使う場合に、ship処理にはpay処理をまず行う必要がある、と言う知識を知ることはできないためです。
この場合、この制約は保証はされているものの実際にプログラムを動かすまでこの制約を破っていることに気づけないでしょう。

不正なentityを生まないためのアプローチ

以上のような例をunique symbolを用いたBranded Typeで解決する手法について提案できればと思います。
とりあえずコードを書いてみます。

例1: Entity単体ではかけられない制約をかけたい場合

orderNameUniqueChecker.ts
export type UniqueOrderName = string & { __brand: "UniuqeOrderName" }

class OrderNameUniqueChecker {
  public async exec(name: string): Promise<UniqueOrderName> {
    const existing = await orderRepository.findByName(name)
    if (existing) {
      throw new Error(`Order name "${name}" is already taken.`);
    }
    return name as UniqueOrderName;
  }
}

order.ts
class Order {
  private constructor(
    public readonly id: string,
    public readonly name: UniqueOrderName,
  ) {}

  // ファクトリメソッドで作成
  public static async create({
    id,
    name,
  }: {
    id: string;
    name: UniqueOrderName;
  }): Promise<Order> {
    return new Order(id, name);
  }
}

例2: 実行順序に制約をかけたい場合

order.ts
export type PaidOrder = {
    readonly id: string;
} & {
    __brand: 'PaidOrder'
}

class Order {
  private constructor(
    public readonly id: string
  ) {}

  // 支払い処理
  public static pay(order: Order): PaidOrder {
    return new Order(this.id);
  }

  // 発送処理
  public static ship(order: PaidOrder): void {
    console.log(`Order ${order.id} has been shipped.`);
  }
}

まとめ

以上の二つの例ですっきりとドメイン知識を型により表現できることがわかっていただけると思います。
また、type safeになることでドメインの制約を静的にチェックすることができますし、ドメイン知識としてusecaseなどで使う際にも制約がわかりやすくなったと思います。

結論

ドメイン駆動設計(DDD)とオニオンアーキテクチャを活用することで、長期間にわたるシステムの運用において強力な効果を発揮します。特に、不正なエンティティの排除やドメイン知識の表現に関しては、型を活用することでシステム全体の整合性を保ちながら、ビジネスルールを適切に強制できます。Branded Types(ユニークシンボル型)を用いた手法は、ドメイン知識を型として表現し、静的に制約をチェックすることを可能にし、実行時のエラーを未然に防ぐことができます。

また、DDDにおける「ドメイン層における一貫性」を確保するために、実行順序やユニーク制約などの制約を型によって強制することで、コードの可読性とテスト容易性が向上し、将来的な変更にも強いシステム設計が実現できます。これにより、ソフトウェアの品質向上と、ビジネス要求の変化に対する柔軟性を両立させることができます。

最終的に、DDDやオニオンアーキテクチャに基づくシステム設計は、複雑なビジネスドメインに対応するための強力なアプローチであり、型を利用した制約強化はその実現において非常に有効です。

Discussion