12
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

DDDをJavaScriptでやってみる PART 1

Last updated at Posted at 2020-10-13

はじめに

Domein Driven Development (ドメイン駆動設計)はEvansの
Domain-Driven Design: Tackling Complexity in the Heart of SoftwareやVernonの実践ドメイン駆動設計で紹介されているように、大規模システムを作るときにとても有効な設計手法です。
これらの本ではJavaのコード例を紹介しています。

一方で、近年Java以外にも様々な言語でバックエンドのシステムは設計されるようになり、JavaScript(node)で書くという企業も増えてきています。

ところが、DDDのコード例は本を見てもネットを見てもJavaScriptのものはほとんどないです。
JavaScriptでAPI作らなきゃいけないけど、DDDは実現したい!
っていうときにどうすれば良いの? というときに役立つような実装例をこの記事では紹介していこうと思います。

読んで欲しい方

  • JavaScriptでDDDを実現したい人
  • DDDをなんとなくでも分かっている人

目標

  • DDDで設計されたシステムをJavaScript(TypeScript)で作ること、作れるようになっていること。

目次

PART1 ドメインモデルの実装方法(本記事)

  1. DDDとは何かをざっくり知り、ドメインモデルとドメインオブジェクトを知る。
  2. ValueObject
  3. Entity

PART2 ドメインサービス、リポジトリ、ファクトリーの実装方法(次回記事で書きます)

  1. Domain Service
  2. Repository
  3. Factory

PART3 集約の実装方法(次回記事で書きます)

  1. 集約とは
  2. 集約の実装方法

DDDで設計するメリット

DDDが何かということを説明する前に、DDDを使うメリットを紹介します。
DDDの最大のメリットは保守的であることです。

誰が見ても、ビジネスサイドのルールが何かということが容易に理解できるため、仕様追加や仕様変更を反映が簡単にできます。

ビジネスサイドのルールには例えば、ECサイトを考えた時にのサイトのユーザーができることがあげられます。
会員登録をしないと、購入ができないサイトもあれば、ゲストとして購入ができるECサイトもあります。このユーザーがどういう条件なら購入することができるのかというルールがビジネスサイドのルールです。

「誰が見ても」というのはそのシステムを作ったエンジニアだけでなく、例えば、そのシステムを知らない新しく入ってきたエンジニアはもちろん、もしかしたら、ビジネスサイドの人ですら理解できるレベルのわかりやすいコードになっているかもということです。

増税の時に困った話

例えば、2019年に増税がありました。
消費税率が8%から10%に変わるってだけなのに、めちゃくちゃ苦労したそこのあなた!(僕もなんですけど)、


const tax = Math.floor(price * 0.08);
const amount = price + tax;

みたいなコードが、値段を表示するために計算するAPIの中や、決済レコードをINSERTする処理が書いてあるところや、クレカ以外の決済手段による決済処理の部分、etc...に散在していて、それをいちいちgrepして探しに行って全部直ったのか自信を持って言えないからひたすらテストし続ける、なんてことがありませんでしたか?

税率が一括で変わる場合、定数管理しているtaxRateを0.08から0.1にすれば終わった話かもしれません。
しかし、軽減税率というものがありました。
これは、簡単に言えば商品によって税率が8%のものだったり10%のものだったりが混在しているという状況です。

つまり、全ソースコードの中からtaxRateを使っている箇所を見つけ出し、8%なのか10%なのかを判定(または判定する処理を追加)しなくてはいけないということになりました。

DDDの良いところ

DDDをちゃんとやればこんなことはなくなります。
変更箇所が一箇所というわけにはいかないかもしれませんが、どこに税を計算する処理が入っているかは明確だし、それをDDDがわかっている人なら、例え新人でも、ビジネスサイドの人間でもわかるという状況を作り出すことができます。

モデリングの仕方にもよりますが、例えばあるユーザーの支払いを表現するPaymentモデルを作れば、表示用にも、決済レコード作成時にも、クレカ以外の決済にもこのモデルを使えます。

そうすれば、税率が変更になっても、「あー、支払いのあのモデルのあのルールだけ変えれば良いな」っていうのがすぐわかります。

また、決済ルールがよくわからんっていう開発者がここのコードを読むだけで、「あー値段は税の他にも何か割引かれるような振る舞いもあるんだな」ということがわかります。

class Payment {
  id: number;
  userId: number;
  price: number;
  discount: number;
  taxRate: number;

  get tax() {
    return Math.floor(this.price * this.taxRate);
  }

  get amount() {
    return this.price + this.tax + this.discount;
  }
}

上のクラスはメンバー変数にtaxRateを持っている例です。この場合、インスタンス化する時にtaxRateを与える必要があります。
一方で、軽減税率に対応させたPaymentモデルの実装例もこの記事の後の方で紹介します。

ではどうやってDDDをJavaScriptで実現していけば良いかを説明していきます。

DDDとは何か

ドメイン駆動設計(DDD)とは名前のとおり、ドメインを元に設計していく手法です。ここでいうドメインとは業務領域のことです。DDDの考え方は、「必ず解決したいビジネス的な問題が存在し、その問題の解決のためにアプリケーションが存在している」というものであり、当然その解決方法は問題に寄り添ったものでなくてはいけない」というものです。
 
DDDの中では現実世界をモデル化した抽象概念である「ドメインモデル」を考え、それを実装するために具体化した「ドメインオブジェクト」というオブジェクトが主体になります。そのドメインモデルがあらゆる業務のなかに出てくる振る舞いをします。例えば、Userモデルがemailを変更する、みたいなことがあります。

DDDには戦略的設計と戦術的設計の大きく2つがあり、その両方が最終的に必要になります。
現実世界をモデル化し、ドメインモデルを作るところまでが戦略的設計と呼ばれ、「サブドメイン」や「コンテキスト」を考えるような作業があります。これらについては、JavaでもJavaScriptでも変わりません。
ですので、この部分はどの言語で実装するかは関係なしに本や記事を読めばOKです。

一方、ドメインモデルをドメインオブジェクトとしてコード上で表し実装するという部分が戦術的設計になります。
この戦術的設計をJavaScriptでやっていこう!というのがこの記事のメインになります。
JavaScriptでこのドメインオブジェクトをどう実装していくのかということを考えていきます。

ドメインモデルとドメインオブジェクト

DDDでは現実世界の業務で登場する人や物をモデル化する。これがドメインモデルだ。ドメインモデルそのものは「ドメインに含まれる概念を抽象化した存在」です。
ドメインモデル、ドメインオブジェクトについてはドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本で非常にわかりやすく説明されています。

ドメインオブジェクトとは

ドメインオブジェクトはドメインモデルという抽象概念を実装するためにモデル化したオブジェクトです。
例えば、ユーザーというドメインモデルを考えたとすれば、これはUserクラスというモデルができ、これはJavaScriptの上ではObjectとして扱われるようになります。

ドメインオブジェクトを使うメリット

なぜ、ドメインオブジェクトを使うかというと、一つは現実世界の業務と対応させることができるからです。
実際にユーザー(User)や商品(Product)という物は現実世界に存在していて、ユーザーがECサイトで買い物をしたらUserがProdctを購入するという表現ができた方が良いですよね?

例えば、購入するときの処理が以下のように、リクエストパラメータからuserとproductをdbから探してきて、外部の決済サービスにリクエストして成功したら決済レコードを作り、商品のステータスを変更し、メールを送るという処理だったとします。

悪い例

そのままかくと以下のようになります。
この中の処理全体にtransactionが張られていることとします。

buy.ts
import _ from 'lodash';

interface BuyingParams {
  productIds: number[];
  userId: number;
}

export async function buyProduct(params: BuyingParams) {
  const { userId, productIds } = params;
 // DBから商品情報を取得
  const products = await db.products.findAll({
    where: {
      id: {
        $in: productIds,
      },
    },
  });

  // 在庫があり、購入できるかチェック
 if (products.length < productIds.length) {
    throw new Error('存在しない商品があります');
  }
  if (products.every(product => product.stockCount > 0)) {
    throw new Error('買えない商品が含まれています');
  }

 // DBからユーザー情報を取得
  const user = await db.users.findOne({
    where: {
      id: userId,
    },
  });

  // 購入できるユーザーかチェック
  if (!user || user.status === USER_STATUS.INVALID) {
    throw new Error('不正なユーザーです');
  }

  const amount = _.sum(products.map(product => product.price));

  // 外部決済サービス(GMO, pay.jp, stripe などなど)に決済レコードを作る
  // tradingIdという決済IDでやり取りするとする。
  const res = await axios.post('hogeUrl', {
    amount,
    tradingId: user.tradingId,
  });

 // 自社DBに決済レコードを作る
  const paymentPromiseList = [];
  for (const productId of productIds) {
    const req = db.payments.create({
      user_id: userId,
      product_id: productId,
      amount: product.price,
      paid_status: PAID_STATUS.COMPLETE,
      payment_service_transaction_id: res.payment_service_transaction_id,
    }]);
    paymentPromiseList.push(promise);
  }
  await Promise.all(paymentPromiseList);

  // 商品情報をupdate
  const productPromiseList = [];
  for (const product of products) {
    const promise = db.products.update({
      user_id: userId,
      purchased_date: res.paid_date,
      stock_count: product.stock - 1,
    });
    productPromiseList.push(promise);
  }
  await Promise.all(productPromiseList);

  // メールを送る
  mailer.send({
    to: userId,
    subject: '購入ありがとうございました',
    body: 'hogehoge',
  });
  return true;
}

のようになります。

何がダメか

これはやっていることはわかりますが、めちゃくちゃ手続き型です。
ただ単に、手続きを書いているだけです。

これを初めて読んだ新人エンジニアやビジネスサイドの人が仕様がわかるかと言われればNoです。

なぜか。

それは、現実世界に対応した振る舞いをしていないからです。
このbuyProductという関数が、購入商品のステータスを変更する処理やクレカ決済を外部APIに依頼する処理やユーザーにメールを送信する処理を全部行っています。

しかしこれでは「UserがProductを買う」という現実世界の振る舞いをそのまま反映しているわけではありません。
UserはProductを買うという振る舞いはするが、決済レコードを作るという振る舞いはしないし、productのステータスを変更するという振る舞いもしません。

これらは全て、システム的にDBにどういうレコードを作るかという話で、業務内容とは関係ありません。
一方で下のようにかくとどうでしょうか。

良い例
buy.ts

interface BuyingParams {
  productId: number;
  userId: number;
}

export async function buyProduct(params: BuyingParams) {
  const { userId, productIds } = params;
  
  const products = await productsRepository.findByIds(productIds);
  await productsService.checkExistence(products);
  
  if(!products.every(product => product.canBePurchased)) {
    throw new Error('買えない商品が含まれています');
  }

  const user = await userRepository.findById(userId);
  if(!user) {
    throw new Error('ユーザーが存在しません');
  }
  if(!user.canBuy) {
    throw new Error('不正なユーザーです');
  }
  await userBuyingService.buyProduct({ user, products });
  return true;
}

データベースをどう更新しているとか、どの外部決済サービスにアクセスしているかということなど、細かいことはわかりませんが、「とにかく、ユーザーが商品が買える状態かを確認して、OKならユーザーが購入する処理をしている」ということはわかります。
これが、中身は何をしているか詳しいことは知らないが、抽象的には何をしているかだいたいわかるということです。

実際はそれぞれuserRepositoryやuserBuyingServiceの中でより細かい処理をしています。
userBuyingServiceは以下のようにかけます。(userBuyingServiceはドメインサービスです。ドメインサービスについては後に述べます。)

userBuyingService.ts
import _ from 'lodash';

const externalPaymentService = new ExternalPaymentService();
export class UserBuyingService {
  buyProduct(params: Params) {
    const { user, products } = params;
 
    const payments = products.map(product => 
      new Payment({
        userId: user.id;
        price: product.price;
        taxRate: product.taxRate;
     product: Product;
      });
    }

   const amount = _.sum(payments.map(payment => payment.amount)); // payment.amountで税込計算
    
    // 外部決済サービス(GMO, pay.jp, stripe などなど)に決済レコードを作る
    await externalPaymentService({
      amount,
      tradingId: user.tradingId,
    });

  for (const payment of payments) {
     payment.complete();
   );

  // 自社DBに決済レコードを作る(のをrepositoryに依頼)
  await paymentRepository.create(payments);

   // 商品情報をupdate
   product.sell();
   await productRepository.save(product);

   // メールを送る
   mailerService.sendPurchaseComplete({
     to: userId,
   });
}

そして、productが購入されても良い商品なのか、userが商品を購入できる状態にあるのかをチェックするのはそのオブジェクト自身の振る舞いとして表されます。

例えばこの場合Productを表すドメインオブジェクトは下のように表すことができます。

product.ts

enum ProductType {
  normal = 'normal',
  reducedTaxTarget = 'reduced_tax_target',
}

class Product {
  id: number;
  price: number;
  stock_count: number;
  product_type: ProductType;

  get canBePurchased() {
    return this.stock_count > 0;
  }

  get isReducedTaxTarget() {
    return this.product_type === ProductType.reducedTaxTarget;
  }
  
  sell() {
    this.stock_count = this.stock_count - 1;
  }
}

このProduct classにはcanBePurchasedというgetterで購入可能かどうかを判定したり、sellというmethodで購入される振る舞いを表現しています。
このmethodはドメインサービスの中で使われています。
このモデルさえみればこの商品というドメインモデルがどのような振る舞いをしてどういう業務に使われるかを知ることができます。

このように現実に存在するproductというモデルに現実の業務上の振る舞いを表現することで、直感的にわかりやすい(仕様を理解しやすい)設計にすることができます。
引いては保守的なシステムを作ることができるというのがドメインオブジェクトを使う最大のメリットです。

また、ドメインのルールがドメインオブジェクトを定義したファイルに集約して書かれていることがもう一つの大きなメリットになります。

アイテム買えるかの条件はcanBePurchasedに集約されるため、ここの部分だけ変更すればいいですし、開発者はここの部分だけ見ればその仕様を把握することができます。
先に述べた手続き型のコードスタイルでは消費税のケースと同じようにgrepして全ての箇所を直さないといけないため漏れが発生するかもしれません。また、モジュール化して共通化させたとしてもどこに共通処理を書くかは開発ルールとして決め、それを守られるように運用しなくてはいけなくなります。
DDDでは現実世界の実体をモデル化したドメインオブジェクトに共通処理を書くような設計になっているため、コード規約として決めなくても、どこに何を書くのかということが明確になっています。(ドメインロジックはモデルに書くという決めごとがすでになされています。)

税率の問題を考え直してみる

例えば、冒頭のtaxRateの問題をもう一度考えてみます。
taxRateをいろいろなところで計算していて、軽減税率が入ってきた時にいちいち税率を確認しなくてはいけなくなっていた、というのが問題でした。
そこで、以下のようにPaymentモデルを定義して、そのモデルの中でtaxRateの計算ロジックを持たせます。
決済レコードを操作したり、値段を表示する時にインスタンス化したpaymentを使えば、必ずtaxRateはこの一か所で計算されることになります。
ビジネスロジックをまとめたことで、税率が変更になった場合もこのmethodだけを直せば良いことがわかります。

Payment.ts
class Payment {
  id: number;
  userId: number;
  price: number;
  discount: number;
  product: Product;

  get tax() {
    return Math.floor(this.price * this.taxRate);
  }

  get taxRate() {
    if(this.product && this.product.isReducedTaxTarget) {
      return TAX_RATE.EIGHT_PERCENT;
    }
    return TAX_RATE.TEN_PERCENT;
  }

  get amount() {
    return this.price + this.tax + this.discount;
  }
}

ドメインオブジェクトの種類

ドメインオブジェクトには2種類のオブジェクトがあります。
一つは値オブジェクト、もう一つはエンティティです。
値オブジェクトとは「システム(サービス)固有の値を表したオブジェクト」で、例えばuserNameや、statusのように値として扱うが、業務の中のルールによって制約を持ったオブジェクトとして扱われる。(例えばuserNameが5文字以内でなくてはいけない、など)。

userNameの実装例としては以下のようにオブジェクト化できます。

userName.ts
class UserName extends String {
  constructor(params: string) {
    super(params);
    // 5文字以内
    if (params.length > 5) {
      throw new Error('5文字以内にしてください');
    }
  }
  isEqual(params: userName) {
    return this.toString() === params.toString();
  }
}

値オブジェクトの性質は以下があります。

  • 不変である
  • 代入できる
  • 等価比較できる

あくまで、userNameも一つの値を示すので、このような値の性質があります。
isEqualメソッドによって等価比較することができ、あたかも値のように扱うことができます。

const userName1 = new UserName('hoge');
const userName2 = new UserName('hoge');
const userName3 = new UserName('fuga');

userName1.isEqual(userName2) // true
userName1.isEqual(userName3) // false

逆に、changeStatusみたいなメソッドを持たせて、自身のpropertyを変えることはできません。

もう一つのエンティティとは「一意なキーによって識別されるドメインモデルを実装したオブジェクト」です。
上であげたUserやProductがまさにそれです。
現実世界の業務上で登場する人や物がこのエンティティで表されることが多いです。

例えばuserNameが同じUserも違うUserで、これは一見同じpropertyをもつオブジェクトだが、実際は違う存在であるということを表現するために一意なキーを持たせて区別します。(だいたいidを持たせます。)
この点が値オブジェクトと異なる点です。

User エンティティの実装例を示します。

user.ts

interface UserParams {
  id: UserId;
  userName: UserName;
  email: Email;
}

class User {
  id: UserId;
  userName: UserName;
  email: Email;
  
  constructor(params: UserParams) {
    this.id = params.id;
    this.userName = params.userName;
    this.email = params.email;
  }

  changeUserName(name: UserName) {
    this.userName = newUserName;
  }
}

このようにエンティティにはidを持たせているので、例えば、userName, emailが同じuserでも違うid(一意なキー)を付与することで違うオブジェクトであることを表現します。
また、値オブジェクトと違い、自身のpropertyを変更したり、状態が変わることがあります。

changeUserNameのようなメソッドで振る舞いを持たせることができ、そのモデルが状態を変えることができます。
このようにエンティティの特徴として

  • idを除く全てのプロパティが同じエンティティは存在し得る。
  • 同じエンティティが属性を変更することができる。

ということが挙げられます。

ドメインオブジェクトの使い方

では、実際に値オブジェクトやエンティティを使っていけば良いでしょうか。

例えば、userのuserNameを変更する処理を考えます。
userはすでに登録してあるとして、DBにレコードがあるとします。

また、UserエンティティとuserName値オブジェクトは上で示したものを使うとします。


interface ChangeUserNameParams {
  name: string;
  userId: number;
}

const userRepository = new UserRepository();

async changeUserName(params: ChangeUserNameParams) {
  const { name, userId } = params;
  const user = await userRepostory.findById(userId);
  user.changeName(name);
  return userRepository.save(user);
}

class UserRepository {
  findById(id: number) {
    // DBから取得
    const _user = await db.users.findOne({
      attributes: ['user_name', 'id', 'email'],
      where { id },
    });
    
    // エンティティのインスタンスを作る
    const userId = new UserId(_user.id);
    const email = new Email(_user.email);
    const userName = new UserName(_user.user_name);

    const user = new User({
      email,
      id: userId,
      userName,
    });
    
    return user
  }

  save(user: User) {
    return db.users.update({
      user_name: user.userName,
      email: user.email,
    }, {
      where: { id: user.id },
    });
  }
}

changeUserNameという関数はusecaseです。
ここを見れば、userのuserNameを変えているんだな、とわかります。
UserId、 EmailはUserNameと同じような値オブジェクトです。

const user = await userRepostory.findById(userId);

という処理でUserエンティティのインスタンスを構成しています。
userRepostoryはDDDのRepositoryの役割(モデルの永続化と再構築)を果たします。(後々説明します。)

注目して欲しいのはuserRepositoryでUserモデルを再構築する部分です。
User classが保持するそれぞれの値オブジェクトをインスタンス化して、それらをもとにUserインスタンスを作成しています。
userNameを変更するためにまずUserName classをインスタンス化します。
その後、そのuserNameインスタンスをUser classに渡してuserインスタンスを作っています。

例えば、userNameを'Jonathan'に変更しようとすると、

const userName = new UserName(_user.user_name);

のところで5文字以内のルールを守れず、userNameインスタンスを作ることに失敗し、errorをthrowします。
このように、各モデルにおいてルールは集約化されており、インスタンス化できるということはそのモデルの全てが正しいデータ状態にあるということを約束してくれます。
、userインスタンスができたということはドメインルールに合ったモデルを作ることができているということになります。

これいちいちnewするの?

ここで気づいた方もいると思いますが、このUserインスタンスを作るのは結構めんどくさいです。
Userモデルの保持する値オブジェクトをいちいち全部インスタンス化しなくてはいけないからです。

上のuserRepositoryの中でも、userName, userId, Emailをインスタンス化しなくてはいけません。
このぐらいの小さいモデルでもめんどくさいのに、例えば、ユーザーが注文(Order)を複数持っているという現実のドメインを表現しようとすると、User classは以下のようになります。(これはUserとOrderを同じ集約として設計したことに対応する。)

class User {
  id: userId;
  email: Email;
  userName: UserName;
  orders: Order[];
  
  constructor(params: Params) {
    this.id = params.userId;
    this.email = params.email;
    this.userName = params.userName;
    this.orders = params.orders;
  }
}

class Order {
  id: OrderId;
  deliveryNumber: DeliveryNumber;
  
  constructor(params: Params) {
    this.id = params.orderId,
    this.deliveryNumber = params.deliveryNumber;
  }
}

こういう状況だと、Orderのインスタンスも作る必要があり、Orderのpropertyについてもnew Hogehoge()みたいにnewしまくる必要が出てきます。
上の例だとuserをインスタンス化する処理は以下のようになります。


class UserRepository {
  findByUserId(userId: UserId) {
    // databaseから取得
    const userWithOrder = await db.users.findOne({
      attributes: ['id', 'email', 'user_name'],
      include: [{
        model: db.orders,
        attributes: ['id', 'delivery_number'],
      }],
      where: {
        id: userId,
      },
    });

  // userWithOrderには
  //{ 
   //   id: 1,
   //   user_name: 'Tom',
   //   email: 'hoge@qmail.com',
   //   orders: [
   //     { id: 1, delivery_number: 111 },
   //     { id: 2, delivery_number: 222 },
   //   ],
   // },

    // のようなオブジェクトが入ってくる。


   // userWithOrderオブジェクトからUserモデルをインスタンス化
   // Userのプロパティの値オブジェクトのインスタンスを作る
   const userId = new UserId(userWithOrder.id);
   const email = new Email(userWithOrder.email);
   const userName = new UserName(userWithOrder.user_name);

   const seedOrders = [];
   for (const _order of user.orders) {
     // orderのインスタンスを作るためのorderのプロパティの値オブジェクトのインスタンスを作る
     const orderId = new OrderId(_order.id);
     const deliveryNumber = new DeliveryNumber(_order.delivery_number);
     
     // orderのインスタンスを作る
     const order = new Order({
       id: orderId,
       deliveryNumber,
     });
     seedOrders.push(order);
   }

   // そしてやっとUserをインスタンス化
   cosnt user = new User({
     id: userId,
     email,
     userName,
     orders: seedOrders,
   });
   return user;
  }
}

new が何回出てきたでしょうか。
これはやってみるとわかりますがめちゃくちゃ面倒です。  

ではどうするかというとJavaScriptにはこの問題を解決してくれるclass-transformerというライブラリがあります。
class-transformerによってこのようにネストされた集約モデルについても簡単に扱うことができます。

class-transformerを使った値オブジェクトとエンティティの実装

先に結論をいうと、値オブジェクトとエンティティの実装は以下の構成で行うのが良いでしょう。

・ class-transformerを使ってドメインオブジェクトのクラスを定義する
・ class-validatorを使ってモデルのメンバー変数のドメインルールを記述する。
・ class-transformer-validatorを使ってドメインオブジェクトをインスタンス化する。

class-transformer, class-validator, class-transformer-validatorの3個のライブラリを紹介します。
なぜこの構成にするのが良いのか、なぜこのライブラリを使うのかを説明していきます。

class-transformerを使ってドメインオブジェクトのクラスを定義する・

class-transformerはJavaScriptにおけるplain object とclass objectの変換機能を持つライブラリです。
例えば、

class User {
  firstName: string;
  lastName: string;
  email: string;
  nickName: string;
}

というクラスに対して、plainなobject

const plainTaro = {
  firstName: 'Taro',
  lastName: 'Tanaka',
  email: 'hoge@hoge.com',
  nickName,
};

に対して

import { plainToClass } from "class-transformer";

const taro = plainToClass(User, plainTaro);

と書けば、勝手にclassにしてくれます。
class-transformerはネストされたオブジェクトを扱う時にとても便利です。
先ほどのUserがOrderをいくつも持っている場合を考えます。
class-transformerを用いて以下のように表現できます。

import { plainToClass, Type } from 'class-transformer';

class User {
  id: number;
  name: string;
  
  @Type(() => Order)
  orders: Order[];
}

class Order {
  id: number;
  deliveryNumber: number;
}

const plainUser = {
  id: 1,
  name: 'Taro',
  orders: [
    { id: 1, deliveryNumber: 1234 },
  ],
}

const user = plainToClass(User, plainUser);

@Typeアノテーションを使うことで、メンバー変数のクラスを指定することができます。
これによって、plainToClassを1回使うだけで、1個の集約をインスタンス化することができます。

しかしこれにはいくつか注意しないといけない点があります。

  • 型定義が効かない
  • class-transformeを使うとconstructorにドメインルールを記述できない

という点です。

型定義できない問題とは、例えば、上のUserクラスに対して

import { plainToClass } from 'class-transformer';
const taro = plainToClass(User, { lastName: 123 });

としてもTSのエラーが出ません。
ここで定義したUserのlastNameはstringでなくてはいけない、というのがドメインのルールです。
ドメインのルールに沿っていないならば、エラーを出す必要があります。

ライブラリの中を見ると

plainToClass(cls, plain, options) {
  const executor = new TransformOperationExecutor_1.TransformOperationExecutor(enums_1.TransformationType.PLAIN_TO_CLASS, options || {});
  return executor.transform(undefined, plain, cls, undefined, undefined, undefined);
}

このように書いてあり、
このexecutorのtransformメソッドが受け取るplain object のvalueはというと

value: Record<string, any> | Record<string, any>[] | any,

となっています。
つまり、なんでも受け取れてしまいます。
plainToClassの中では内部的に第1引数で渡したclassのmember変数のkeyを元に新しいオブジェクトを生成して返すが、ここで型チェックは行われないようになっています。

もう一つの、class-transformeを使うとconstructorにドメインルールを記述できない問題についてです。
例えば、Userクラスに「nickNameは5文字以内でなくてはいけない」という制約を追加するとします。

interface userProps {
  firstName: string;
  lastName: string;
  email: string;
  nickName: string;
}
class User {
  firstName: string;
  lastName: string;
  email: string;
  nickName: string;

 constructor(props: userProps) {
    if(props.nickName.length > 5) {
      throw new Error('nickNameが長すぎます');
    }
  }
}

plainToClass(User, { nickName: 'tooLongName' }); // -> インスタンス化される

しかし、このように書いても、plainToClassはうまくUserの「nickNameは5文字以内でなくてはいけない」という制約を破ったインスタンスを作ってしまいます。

class-validatorを使ってモデルのメンバー変数のドメインルールを記述する

この2つの問題を解決するためにclass-validatorを使うと良いでしょう。
class-validatorを使うことで、様々なvalidationを定義でき、ドメインルールを記述することができます。

import { MaxLength } from 'class-validator';
class User {
  firstName: string;
  lastName: string;
  email: string;
  @MaxLength(5, {
    message: 'nickNameが長すぎます',
  })
  nickName: string;
}

このようにclass-validatorが提供しているデコレータを使えば、nickNameが5文字以内でなくてはいけないことを表現できます。
このとき

import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';

const jonathan = plainToClass(User, { nickName: 'Jonathan' });
validate(jonathan).catch(err => console.log(err));

のようにインスタンス化したUserがルールに沿っているかを判定することができます。
しかし、これはインスタンス化するときに弾いて欲しいですよね。
class-transformerを使わない場合、モデルのclassのconstructorでエラーを出せば良いのですが、それに相当することをclass-transformerはやってくれません。

class-transformer-validatorを使ってドメインオブジェクトをインスタンス化する

そこで、class-transformerとclass-validatorを組み合わせたclass-transformer-validatorというライブラリを追加で使います。
使いたいのはtransformAndValidateというmethodです。

plainToClassの代わりにtransformAndValidateを使って以下のようにかくことができます。
(class-transformer-validatorのtransformAndValidateはplainToClassの代替になります。その他の@Typeなどの機能はclass-transformerが必要なので、class-transformer-validatorがclass-transformerの代わりになるわけではありません。)

import { transformAndValidate } from "class-transformer-validator";

const jonathan = await transformAndValidate(User, { nickName: 'Jonathan' });
// error発生 インスタンス化できない。

これは、nickName値オブジェクトのconstructorのなかでnickNameのルールを確認して、ルールに反していればerrorを出す代わりにnickNameを使うUserエンティティにルールをかき、Userモデルのインスタンス化の時にルールをチェックするということをしていることになります。

つまり、今までに紹介したJavaScriptのStringやNumberを継承したprimitiveな値オブジェクトは定義せず、それを使う、エンティティでドメインルールを管理することになります。
Objectである値オブジェクトについてはそのままclass-transformerを使うことができます。

また、class-validatorが提供するvalidationにIsStringやIsIntのようなアノテーションがあり、型で制御できない部分をカバーすることができます。

import { plainToClass } from 'class-transformer';
import { IsString } from 'class-validator';
import { transformAndValidate } from "class-transformer-validator";

class User {
  id: number;
  @IsString()
  name: string;
}

const user = transformAndValidate(User, { nickName: 123 });
// -> error発生

また、集約をインスタンス化する時には、@ValidateNestedアノテーションを使います。

import { Type } from 'class-transformer';
import { IsString, ValidateNested } from 'class-validator';
import { transformAndValidate } from "class-transformer-validator";

class Task {
  id: number;
  @IsString()
  name: string;
}

class User {
  id: number;
  name: string;
  
  @Type(() => Task)
  @ValidateNested()
  tasks: Task[];
}

const plainUser = {
 id: 1,
 name: 'Taro',
 tasks: [
   { id: 1, name: 'hogetask' }
 ],
};

const invalidPlainUser = {
 id: 1,
 name: 'Taro',
 tasks: [
   { id: 1, name: 123 }
 ],
};
  
const userAggregation = await transformAndValidate(User, plainUser);
// -> ok
const userAggregation = await transformAndValidate(User, invalidPlainUser);
// -> error発生

これで、集約に対して、plain objectを一気にインスタンス化することができるというclass-transformerの強みを生かすことができます。

まとめ

  • DDDとは現実世界の業務を忠実にコード上に再現しようとする設計手法である。
  • そのために、ドメインオブジェクトで業務上に登場する人や物をモデルとして定義する。
  • このモデルを定義したclassにあらゆるドメインのルールが記載される。
  • JavaScriptにおいてはclass-transformerを用いることで簡単にモデルの定義とルールの記載ができ、インスタンス化も少ない記述量でできる。

次回予告

PART2ではドメインサービス、リポジトリ、ファクトリーについて説明していきます!

12
14
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
12
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?