コンテンツにスキップ

フロントエンドにおけるDomain-Driven Design (DDD)

はじめに

Domain-Driven Design (DDD)は、ビジネスドメインのモデリングに焦点を当て、技術実装をビジネスロジックと整合させるソフトウェア設計アプローチです。伝統的にバックエンドシステムに適用されてきましたが、DDD の概念はフロントエンド開発に適用することで、コードの整理、保守性、チーム間のコミュニケーションを改善できます。

なぜフロントエンドでDDDなのか?

メリット

  • 明確な責務の分離: UIレイヤーとビジネスロジックが明確に分離される
  • 保守性の向上: ドメインロジックが集中化され再利用可能になる
  • チーム間コミュニケーションの改善: ビジネス言語(ユビキタス言語)が開発者とステークホルダーをつなぐ
  • スケーラビリティ: 一貫性を維持しながら機能を拡張しやすい

適用すべき場合

ユースケース 推奨事項
小規模スタートアップ / MVP 軽量DDD (概念のみ)
中規模チームプロジェクト 機能ベース + DDDパターン
大規模エンタープライズB2B 境界づけられたコンテキストを含む完全なDDD

DDDの核となる概念

1. Entity

状態の変化を超えて同一性を維持する、一意の識別子を持つオブジェクト。

// domain/entities/user.ts
export class User {
  constructor(
    public readonly id: string,
    public name: string,
    public email: Email // Value Object
  ) {}

  updateName(newName: string): void {
    if (!newName || newName.length < 2) {
      throw new Error("Invalid name");
    }
    this.name = newName;
  }
}

ポイント: - 一意のidを持つ - 変更を超えて同一性が維持される - ビジネスロジックメソッドを含む

2. Value Object

同一性ではなく値によって定義されるオブジェクト。不変で交換可能。

// domain/valueObjects/email.ts
export class Email {
  constructor(public readonly value: string) {
    if (!this.isValid(value)) {
      throw new Error("Invalid email format");
    }
  }

  private isValid(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }
}

ポイント: - 一意の識別子がない - 不変(変更には新しいインスタンスを作成) - 検証ロジックを含む - 値で比較される

3. Aggregate

単一のユニットとして扱われる、関連するエンティティとValue Objectのクラスター。

// domain/aggregates/order.ts
export class Order {
  private items: OrderItem[] = [];

  constructor(
    public readonly id: string,
    public readonly customerId: string
  ) {}

  addItem(product: Product, quantity: number): void {
    const item = new OrderItem(product, quantity);
    this.items.push(item);
  }

  getTotalPrice(): Price {
    return this.items.reduce(
      (total, item) => total.add(item.getSubtotal()),
      new Price(0)
    );
  }
}

ポイント: - ルートエンティティがメンバーへのアクセスを制御 - 一貫性の境界を維持 - 外部からはAggregateルートのみがアクセスされる

4. Repository

データの永続化と取得操作を抽象化します。

// domain/repositories/userRepository.ts
export interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
  delete(id: string): Promise<void>;
}

// infrastructure/repositories/apiUserRepository.ts
export class ApiUserRepository implements UserRepository {
  async findById(id: string): Promise<User | null> {
    const response = await fetch(`/api/users/${id}`);
    const data = await response.json();
    return data ? this.toDomain(data) : null;
  }

  async save(user: User): Promise<void> {
    await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(this.toDTO(user))
    });
  }

  private toDomain(dto: any): User {
    return new User(dto.id, dto.name, new Email(dto.email));
  }

  private toDTO(user: User): any {
    return {
      id: user.id,
      name: user.name,
      email: user.email.value
    };
  }
}

ポイント: - ドメイン層のインターフェース - インフラストラクチャ層の実装 - データソース(API、DB、キャッシュ)を抽象化

5. Domain Service

エンティティやValue Objectに自然には属さないビジネスロジックをカプセル化します。

// domain/services/priceCalculationService.ts
export class PriceCalculationService {
  calculateDiscount(
    originalPrice: Price,
    discountRate: number,
    customerLevel: CustomerLevel
  ): Price {
    let finalRate = discountRate;

    if (customerLevel === CustomerLevel.Premium) {
      finalRate += 0.1; // プレミアム会員には追加10%
    }

    return originalPrice.applyDiscount(finalRate);
  }
}

6. Application Service (Use Case)

ドメイン操作を調整し、トランザクションを調整します。

// application/usecases/createOrderUseCase.ts
export class CreateOrderUseCase {
  constructor(
    private orderRepository: OrderRepository,
    private productRepository: ProductRepository,
    private priceService: PriceCalculationService
  ) {}

  async execute(input: CreateOrderInput): Promise<Order> {
    // 1. 必要なデータを取得
    const customer = await this.customerRepository.findById(input.customerId);
    if (!customer) throw new Error("Customer not found");

    // 2. ドメインオブジェクトを作成
    const order = new Order(generateId(), customer.id);

    // 3. アイテムを追加
    for (const item of input.items) {
      const product = await this.productRepository.findById(item.productId);
      if (!product) throw new Error(`Product ${item.productId} not found`);

      order.addItem(product, item.quantity);
    }

    // 4. ビジネスルールを適用
    const totalPrice = this.priceService.calculateDiscount(
      order.getTotalPrice(),
      0.05,
      customer.level
    );

    // 5. 永続化
    await this.orderRepository.save(order);

    return order;
  }
}

フロントエンドDDDのフォルダ構造

推奨される構造

src/
├── app/                    # Next.js App Router
│   ├── (auth)/
│   │   ├── login/
│   │   └── register/
│   └── (main)/
│       ├── dashboard/
│       └── products/
├── features/               # 機能ベースのモジュール
│   ├── auth/
│   │   ├── domain/
│   │   │   ├── entities/
│   │   │   ├── valueObjects/
│   │   │   └── services/
│   │   ├── application/
│   │   │   └── usecases/
│   │   ├── infrastructure/
│   │   │   └── repositories/
│   │   └── presentation/
│   │       ├── components/
│   │       └── hooks/
│   ├── products/
│   │   ├── domain/
│   │   ├── application/
│   │   ├── infrastructure/
│   │   └── presentation/
│   └── orders/
├── shared/
│   ├── domain/             # 共有ドメイン概念
│   ├── ui/                 # 共有UIコンポーネント
│   └── utils/
└── core/
    ├── types/
    └── config/

フロントエンド向け軽量DDD

ほとんどのフロントエンドプロジェクトでは、完全なDDDは過剰です。軽量DDDを適用しましょう:

保持すべき要素

  1. EntityとValue Objectの概念 - TypeScript型またはZodスキーマを使用
  2. Repositoryパターン - API呼び出しを抽象化
  3. Use Caseパターン - ビジネス操作を整理
  4. Domain events - 機能間の通信に使用

スキップすべき要素

  1. 複雑なAggregate - フロントエンドでは完全なAggregate複雑性は必要ない場合が多い
  2. Domain Eventsインフラストラクチャ - 代わりにContextやZustandを使用
  3. 重厚な戦術的パターン - シンプルに保つ

TypeScript実装

// 型としての簡略化されたEntity
export type User = {
  id: string;
  name: string;
  email: string;
}

// ZodによるValue Object
import { z } from 'zod';

export const EmailSchema = z.string().email();
export type Email = z.infer<typeof EmailSchema>;

// インターフェースとしてのRepository
export interface UserRepository {
  getById(id: string): Promise<User>;
  save(user: User): Promise<void>;
}

// カスタムHookとしてのUseCase
export function useRegisterUser() {
  const userRepo = useUserRepository();

  return async (name: string, email: string) => {
    const user = {
      id: generateId(),
      name,
      email: EmailSchema.parse(email)
    };
    await userRepo.save(user);
    return user;
  };
}

Next.js App Router でのDDD

ドメインロジックのためのServer Components

// app/products/page.tsx (Server Component)
import { getProducts } from '@/features/products/application/usecases/getProducts';

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

コマンドのためのServer Actions

// features/products/application/actions/createProduct.ts
'use server';

import { productRepository } from '@/features/products/infrastructure/repositories';
import { Product } from '@/features/products/domain/entities/product';

export async function createProduct(formData: FormData) {
  const name = formData.get('name') as string;
  const price = parseFloat(formData.get('price') as string);

  const product = new Product(generateId(), name, new Price(price));

  await productRepository.save(product);

  revalidatePath('/products');
  return { success: true };
}

Domain Events

シンプルなイベントシステム

// shared/domain/events/domainEventBus.ts
type EventHandler<T> = (event: T) => void;

class DomainEventBus {
  private handlers = new Map<string, EventHandler<any>[]>();

  subscribe<T>(eventName: string, handler: EventHandler<T>) {
    if (!this.handlers.has(eventName)) {
      this.handlers.set(eventName, []);
    }
    this.handlers.get(eventName)!.push(handler);
  }

  publish<T>(eventName: string, event: T) {
    const handlers = this.handlers.get(eventName) || [];
    handlers.forEach(handler => handler(event));
  }
}

export const eventBus = new DomainEventBus();

// ドメインエンティティ内での使用
class Order {
  complete() {
    this.status = 'completed';
    eventBus.publish('OrderCompleted', { orderId: this.id });
  }
}

// 別の機能でリスン
eventBus.subscribe('OrderCompleted', (event) => {
  console.log('Order completed:', event.orderId);
  // 通知、分析などをトリガー
});

ユビキタス言語

共通語彙の構築

技術チームとビジネスチーム両方が理解できる用語集を作成します:

ビジネス用語 技術実装 定義
顧客 User Entity 製品を購入する人
カート ShoppingCart Aggregate 購入前のアイテムの一時的なコレクション
注文 Order Aggregate 支払いが確認された購入
商品カタログ ProductRepository 購入可能な商品
チェックアウト CheckoutUseCase カートを注文に変換するプロセス

ベストプラクティス

すべきこと

  • 軽量DDDから始める(型 + repository + use cases)
  • ドメインロジックを純粋に保つ(ドメイン層にReact hooksを入れない)
  • 型安全性のためにTypeScriptを使用する
  • 外部依存性を抽象化する(API呼び出し、ストレージ)
  • ドメインロジックのテストを書く(フレームワークに依存しない)

すべきでないこと

  • 小規模プロジェクトを完全なDDDで過度に設計しない
  • UI関心事とドメインロジックを混在させない
  • ドメイン層にReactコンポーネントを配置しない
  • すべてにAggregateを作成しない
  • シンプルなコードで十分な場合にDDDパターンを強制しない

DDDコードのテスト

// ドメインロジックは簡単にテストできる(フレームワーク依存性なし)
describe('Order', () => {
  it('calculates total price correctly', () => {
    const order = new Order('1', 'customer-1');
    order.addItem(new Product('p1', 'Laptop', new Price(1000)), 2);
    order.addItem(new Product('p2', 'Mouse', new Price(50)), 1);

    expect(order.getTotalPrice().value).toBe(2050);
  });

  it('throws error when adding invalid quantity', () => {
    const order = new Order('1', 'customer-1');
    const product = new Product('p1', 'Laptop', new Price(1000));

    expect(() => order.addItem(product, -1))
      .toThrow('Quantity must be positive');
  });
});

移行パス

非構造化からDDDへ

  1. ドメイン概念を特定 - 核となるビジネスエンティティは何か?
  2. 型を抽出 - エンティティのTypeScript型/インターフェースを作成
  3. Repositoryを作成 - API呼び出しをRepositoryパターンに抽象化
  4. Use Caseを整理 - 関連する操作をグループ化
  5. 段階的にリファクタリング - すべてを一度に書き直さない

移行例

移行前:

// components/UserProfile.tsx
export function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch('/api/users/me')
      .then(r => r.json())
      .then(setUser);
  }, []);

  return <div>{user?.name}</div>;
}

移行後:

// features/users/domain/entities/user.ts
export type User = {
  id: string;
  name: string;
  email: string;
}

// features/users/application/usecases/getCurrentUser.ts
export async function getCurrentUser(): Promise<User> {
  return userRepository.getCurrent();
}

// components/UserProfile.tsx
export function UserProfile() {
  const { data: user } = useQuery(['currentUser'], getCurrentUser);
  return <div>{user?.name}</div>;
}

リソース

まとめ

フロントエンドにおけるDDDは以下についてです: - 関心事の分離 - ドメインロジック vs UIロジック - ユビキタス言語 - 共通の用語 - 境界づけられたコンテキスト - 機能ベースの整理 - 実用的なアプローチ - フロントエンドのニーズにパターンを適応

プロジェクトの複雑さに基づいてDDDの概念を選択的に適用してください。目標はすべてのパターンへの完璧な準拠ではなく、よりクリーンで保守性の高いコードです。