2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

グロースエクスパートナーズAdvent Calendar 2024

Day 20

新卒一年目、 入社後におけるドメイン駆動設計(DDD)の実践体験

Last updated at Posted at 2024-12-20

こんにちは!GXPの新人エンジニアの楊(ヨウ)です。今回はGXP Advent Calendar 2024の20日目の記事を担当させていただきます!

入社してから半年が経ち、研修でDDD(ドメイン駆動開発)を学んでから実務でも使っているので、新人の視点から見たDDDについて書いてみようと思います。正直、最初は難しくて戸惑うことも多かったのですが、少しずつ理解が深まってきた気がします。

実務で感じたDDDのいいところと難しいところ

Prismaとの出会い

研修では理論を学んでいただけでしたが、実際のプロジェクトではPrismaというORMを使っています。最初は戸惑いましたが、特にrelationship機能がすごく便利だなと感じています。

例えば、注文システムでよくある「注文と商品」の関係を以下のように簡単に定義できます。このようなスキーマ定義は、DDDでいう「集約」の概念を表現しています:

// スキーマ定義
model Order {
  id        Int       @id @default(autoincrement())
  createdAt DateTime  @default(now())
  status    String    @default("pending")
  products  Product[] // 1つの注文に複数の商品
}

model Product {
  id       Int     @id @default(autoincrement())
  name     String
  price    Float
  orderId  Int
  order    Order   @relation(fields: [orderId], references: [id])
}

このスキーマ定義だけで、以下のように直感的にデータを取得できます:

// 注文データと関連商品の取得
const orderWithProducts = await prisma.order.findUnique({
  where: { id: orderId },
  include: { 
    products: {
      select: {
        id: true,
        name: true,
        price: true
      }
    }
  },
});

if (!orderWithProducts) {
  throw new Error('注文が見つかりません');
}

console.log(orderWithProducts);

以前の開発では複雑なSQLのJOINを書いていたので、このシンプルな書き方には本当に助かっています。

実装で苦労したこと:層の分離

DDDの層構造については研修で学びましたが、実際の実装では悩むことが多かったです。「価格の表示」に関する処理をどの層に置くべきかという例で説明します。

最初に書いたコードの例はこちらです:

// 単なるデータクラス
class Order {
  id: number;
  userId: number;
  items: OrderItem[];
  status: string;
  totalAmount: number;
  constructor(data: OrderData) {
    Object.assign(this, data);
  }
}

// サービス層に処理が集中していた
class OrderService {
  calculateTotalAmount(order: Order): number {
    return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }
}

この実装の問題点は:

  1. Orderクラスが単なるデータの入れ物になっている
  2. ビジネスロジックがサービス層に漏れている

改善後のコードはこうなりました:

// OrderStatus: 状態管理のロジックをドメインモデルに集約
enum Status {
  TEMPORARY = 'TEMPORARY',
  CONFIRMED = 'CONFIRMED',
  SHIPPED = 'SHIPPED',
}

  // 値オブジェクト:注文ステータス
class OrderStatus {
  private constructor(private readonly status: Status) {}

  // 状態を取得するメソッド
  getStatus(): Status {
    return this.status;
  }
  
  // 状態チェックメソッド
  isTemporary(): boolean {
    return this.status === Status.TEMPORARY;
  }
  
  isConfirmed(): boolean {
    return this.status === Status.CONFIRMED;
  }

  isShipped(): boolean {
    return this.status === Status.SHIPPED;
  }

  confirm(): OrderStatus {
    if (!this.isTemporary()) {
      throw new Error('仮登録状態の注文のみ確定できます');
    }
    return new OrderStatus(Status.CONFIRMED);
  }
  
  // ファクトリーメソッド
  static temporary(): OrderStatus {
    return new OrderStatus(Status.TEMPORARY);
  }

  equals(other: OrderStatus): boolean {
    return this.status === other.status;
  }
}

// ドメインモデル:注文
class Order {
  private constructor(
    private readonly id: OrderId,
    private readonly items: OrderItem[],
    private status: OrderStatus,
  ) {}

  static createNew(items: OrderItem[]): Order {
    if (items.length === 0) {
      throw new Error('注文には最低1つの商品が必要です');
    }
    return new Order(OrderId.generate(), items, OrderStatus.temporary());
  }

  confirm(): void {
    this.status = this.status.confirm();
  }

  calculateTotal(): number {
    return this.items.reduce(
      (sum, item) => sum + item.calculateSubtotal(),
      0,
    );
  }
}

// サポートクラス例
class OrderId {
  private constructor(private readonly value: string) {}

  static generate(): OrderId {
    return new OrderId(Math.random().toString(36).substring(2));
  }

  toString(): string {
    return this.value;
  }
}

class OrderItem {
  constructor(private readonly price: number, private readonly quantity: number) {}

  calculateSubtotal(): number {
    return this.price * this.quantity;
  }
}

// サービス層で業務処理する
class OrderService {
  createOrderAndCalculateAmount(items: OrderItem[]): number {
    const order = Order.createNew(items);
    return order.calculateTotal();
  }
}

改善後のポイント:

  1. 値オブジェクトによるドメインルールの表現
  2. ドメインモデルの責務の明確化
  3. サービス層のシンプル化

ユビキタス言語について学んだこと

変数やクラスの命名も大きな課題でした。特に英語での命名に悩むことが多くありました。たとえば:

  • 「注文」→ OrderPurchase
  • 「お客様」→ CustomerClient
  • 「配送先」→ ShippingAddressDeliveryAddress

これらの選択に迷う中で、現場で実際に使われている用語をそのままコードに反映することが、命名以上に重要だと気づきました。

例えば、注文の状態を示す「仮登録」「確定」「キャンセル」という業務用語をコードに取り入れることで、チーム全体で仕様の意図を共有しやすくなりました。

// 改善前:あいまいな命名
type Status = 'temp' | 'done' | 'cancel'; // 業務用語に紐づいておらず、意図が不明瞭

// 改善後:ビジネス用語を反映
type OrderStatus = 
  | 'TEMPORARY_REGISTERED' // 注文入力直後の状態
  | 'CONFIRMED'           // 在庫確認済みで確定した状態
  | 'CANCELLED';          // キャンセルされた状態

class PurchaseOrder { // 「発注」を表現
  private readonly orderItems: OrderItem[]; // 「発注品目」を表現
  private status: OrderStatus;

  confirm() { // 「確定」という業務アクションを表現
    if (this.status !== 'TEMPORARY_REGISTERED') {
      throw new Error('仮登録状態の注文のみ確定できます');
    }
    this.status = 'CONFIRMED';
  }
}

これからの目標

まだまだ理解が浅い部分も多いですが、以下のことに挑戦していきたいと思います:

  1. イベント駆動設計について学ぶ(特にドメインイベントの活用方法)
  2. CQRSパターンの実践的な適用方法を理解する
  3. よりクリーンな設計ができるようになる

最後に

入社して半年、DDDを通じてドメイン設計の面白さを知ることができました。特に、ビジネスロジックを適切な場所に配置することの重要性を学べたのは大きな収穫でした。

先輩方にはいつも丁寧にレビューしていただき、本当に感謝しています。まだまだ分からないことだらけですが、一歩ずつ成長していきたいと思います。

同じように学習されている方や、アドバイスをお持ちの方がいらっしゃいましたら、ぜひコメントをお願いします!

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?