はじめに
仕事で洋服の物流システムを作ることになり、プロジェクトチームでDDDで設計しようということになりました。
諸々の事情でNestJSというTypeScriptのフレームワークを使うことになりました。
そんな中で出てきた悩みを紹介します。ちなみにこのシステムは開発中です。
作ろうとしている物
ECサービスの物流システム。
機能としては大きく3つです。
- 在庫管理: 倉庫の在庫を管理する
- 入荷: 新しい在庫を入荷して、在庫として登録する
- 出荷: ユーザーから出荷依頼を受けて出荷する
これらの機能を提供するWebアプリケーションを作ることになりました。
設計
- 更新系についてはDDD
- 取得系に関してはCQRSの考えで、queryModelを使う(ドメインモデルは使わない)
技術選定
- サーバサイド: NestJS
- ORM: TypeORM
- DB: MySQL
- フロントエンド: React, Flutter(あんまり関係ないけど)
コンテキストを分けるべきではないのか?
前提として、新規サービスなのでできるだけドメイン設計もわかりやすくするために、「ドメイン(サブドメイン)の境界とコンテキストの境界は一致する」ような設計をすることにしました。
これは『実践ドメイン駆動設計』第2章にならっていますが、ドメインの境界とコンテキストの境界が一致すると設計はすごくわかりやすくなります。新規開発の時は絶対にこうした方がいいと個人的には思います。(やったことないけど、逆に、外部サービスが密に関わってくるとドメインとコンテキストが一致しなくなり、難易度がいきなりそうな感じしかしない。)
選択肢1 出荷、在庫管理、入荷でコンテキストを分ける
在庫管理、入荷、出荷という業務は倉庫の中でも独立していて作業者も違います。
そこでそれぞれでサブドメイン的にサービスを作る選択肢が出てきました。
メリット: サービスごとの責務分離ができる。
デメリット: 現実世界では同じ個品モデルがコンテキストによって別モデルとして定義するので、共通の振る舞いを3カ所で定義しなくてはいけない。サーバとgithubレポジトリを3個作るのがめんどくさい。
選択肢2 全部同じコンテキスト
一方で、在庫管理、入荷、出荷という業務全てに、管理している「洋服の個品」という概念は関わってくるため、1個の大きな物流コンテキストとしてしまうという選択肢もありました。
メリット: 今後、3コンテキストをまたがるドメインルールが出てきたときに対応できる。サーバとgithubレポジトリが1個ですむ。
デメリット: 個品モデルの振る舞いが多すぎて肥大化する。マイクロサービスの逆を行っている。
結局どうしたか
選択肢2を選びました。DB的には個品を1個のステータスで管理しようとしていたので、ドメインも揃えた方が良いのではないかということや、開発メンバーもそんなに多くなくサーバのメンテナンスが簡単な方がよいというのが理由です。
『実践ドメイン駆動設計』には1個のプロジェクトメンバーグループが1個のコンテキストを開発するというのが理想!というのがあったので、妥当な選択だったのではないかと思います。
ディレクトリ構成
src
├ domain
│ ├ entity
│ │ └ item.ts
│ ├ value-object
│ │ └ item-status.ts
│ ├ service
│ │ └ item.ts
│ ├ factory
│ │ └ item.ts
│ ├ factory-interface
│ │ └ item.ts
│ └ repository-interface
│ └ item.ts
│
├ infrastructure
│ ├ repository
│ │ └ item-repository.ts
│ ├ factory
│ │ └ base-factory.ts
│ └ query-model-repository
│ └ item-qm-repository.ts
├ presentation
│ └ repository-interface
│
└ usecase
├ item-search.ts
├ item-register.ts
├ dto
├ query-dto
│ └ item-dto.ts
│
├ query-dto-factory
│ └ item-dto-factory.ts
│
└ query-model-repository-interface
└ item-query-model.ts
どんなアプリケーションでもこの構成で困らないんじゃないかと勝手に思っています。
ドメイン層
ドメイン層にはドメインモデルであるentity, value-objectを定義します。ドメインサービスもここにあります。
ドメインモデルのインスタンス化にはclass-transformerを使っています。
import { Type, Expose } from 'class-transformer';
export default class Item {
id?: number;
statusCode: statusCodeVo;
itemCode: string;
// アイテムコード発行 10文字のランダム英数字
generateSerial() {
return Math.random().toString(32).substring(2, 11);
}
// 洋服を入荷するときに倉庫で撮影、採寸するかどうかのparameterがある
// 撮影,採寸要否からどのstatusにするか判断してstatus移動
checkAndChangeStatus(params: Params) {
const {
requiredToSatsuei,
requiredToSaisun,
} = params;
if (requiredToSatsuei && requiredToSaisun) {
this.toSatsueiSaisunStatus();
} else if (!requiredToSatsuei && requiredToSaisun) {
this.toSaisun();
} else if (requiredToSatsuei && !requiredToSaisun) {
this.toSatsuei();
}
}
toSaisun() {}
toSatsuei() {}
toSatsueiSaisunStatus() {}
}
また、repositoryのインターフェイスは必ずドメイン層に定義します。間違っても、インフラ層のrepositoryの実装を直参照するのはNGです。
なぜなら、ドメインに全てを依存させたく、ドメインがインフラに依存してしまうのを避けたいからです。依存関係の逆転についてはこちら。
export abstract class IItemRepository {
save: (itemEntity: ItemEntity) => Promise<void>;
getById: (id: number) => Promise<ItemEntity>;
getBySerial: (serial: string) => Promise<ItemEntity>;
}
factoryはドメイン層においています。factoryはドメイン層におくべきだという意見とインフラ層におくべきだという意見がありますが、今回はファクトリはドメインモデルのインスタンス化に専念させており、その中でドメインルールを書いたりするのでドメイン層におきます。
Eric Evansの『ドメイン駆動設計』 第2部第6章にあるような、ドメインモデルの新規インスタンス化とrepository内からの再構築の際のインスタンス化にfactoryを用います。
export default class ItemFactory extends BaseFactory implements IItemFactory {
createRegisterItem(params: ItemFactoryParams) {
const seed = {
...params,
serial: params.barcode,
statusCode: params.status?.code,
};
// ドメインルールをかく
return this.createEntity(ItemEntity, seed);
}
reconstructRegisteredItem(params: RegisterParams) {
// ドメインルールをかく
return this.createEntity(ItemEntity, params);
}
}
import { plainToClass } from 'class-transformer';
import { ClassType } from 'class-transformer/build/package/ClassTransformer';
import { IBaseFactory } from '../../../domain/factory/base';
export abstract class BaseFactory implements IBaseFactory {
createEntity<E, P>(entity: ClassType<E>, plainObject: P) {
return plainToClass(entity, plainObject, { excludeExtraneousValues: true });
}
createEntityArray<E, P>(entity: ClassType<E>, plainObjectArray: P[]) {
return plainToClass(entity, plainObjectArray, {
excludeExtraneousValues: true,
});
}
}
class-transformerというplainObjectをclassのインスタンス化を簡単にやってくれるライブラリがあり、それを用いています。
base-factoryをインフラ層に定義し、そこでclass-transformerによるインスタンス化を行っています。
しかし、このライブラリを使うと、classのconstructorにドメインルールをかけないという欠点があります。
例えば以下が困るパターンです。
class Hoge {
name: string;
constructor({ name: string }) {
if (name.length > 5) throw new Error('名前長すぎ!')
this.name = name;
}
}
plainToClass(Hoge, '長すぎる名前') -> インスタンス化できちゃう!!!
そこで、このようなドメインルールは全部factoryに書くことでドメインルールに沿っていないモデルのインスタンス化は失敗させるようにします。
class HogeFactory extends BaseFactory {
createRegisteringHoge({ name: string }) {
if (name.length > 5) throw new Error('名前長すぎ!')
this.createEntity(Hoge, { name });
}
}
const hogeFactory = new HogeFactory();
hogeFactory.createRegisteringHoge({ name: '長すぎる名前' }); -> インスタンス化できない
factoryにルールを書くことでもう一つメリットがあります。
新規作成時のインスタンス化と再構築時のインスタンス化でfactoryのメソッドを変えることができ、それぞれのインスタンス化の際のドメインルール(バリデーション)を記述できるようになります。
インフラストラクチャ層
repositoryの実装があります。このrepositoryはドメイン層のrepository-interface
に従います。これによってインフラがドメインに依存している形を作れます。コードは雰囲気だけ見てもらえれば良いです。
export default class ItemRepository extends Repository<ItemEntity> implements IItemRepository {
async getById(id: number) {
const itemRepository = getRepository(Item);
const item = await itemRepository.findOne({
where: { id },
});
if (!item) return null;
return itemFactory.create(item);
}
async save(itemEntity: ItemEntity): Promise<void> {
const itemRepository = getRepository(Item);
const [itemSkuId, status] = await Promise.all([
this.getItemSkuId(itemEntity),
this.getStatus(itemEntity),
]);
item.itemSkuId = itemSkuId;
item.status = status;
await itemRepository.save(item);
return;
}
}
ユースケース層
ユースケースでは業務的なユースケースを管理します。item-search.ts
で個品検索を行うユースケース、item-register.ts
で個品の入荷を行うユースケースを書いています。
また、CQRSパターンを採用しているため、更新系についてはusecase/dto/
にdtoを定義し、取得系についてはusecase/query-dto/
に定義しています。
取得系については、queryModelはusecaseに依存するのでquery-dtoのfactoryとrepositoryもusecaseの中に定義しています。
export default class ItemSearch {
async search(params: SearchItemsParams): Promise<SearchedItemsDto> {
const { itemIdList, statusCodeList } = params;
if (!itemIdList?.length && !rfidList?.statusCodeList) {
httpError(HTTP_STATUS.UNPROCESSABLE_ENTITY, '個品id, ステータスコードのいずれかを指定してください');
}
const items = await itemQMRepository.searchItems(params);
return {
itemList: items,
};
}
}
出てきた迷いたち
Repositoryのinterfaceをファイルを分けてまでドメインにおくのか?
ディレクトリ構成で紹介したようにドメイン層にrepositoryのインターフェイスをおき、実装はインフラ層におきました。
これはやってみるとめちゃくちゃめんどくさいです。
interface IItemRepository {
saveItem: (params: Params) => Promise<void>;
}
class ItemRepository implements IItemRepository {
saveItem(item) {
const repository = getRepository(Item);
return repository.save(item);
}
}
このようにinterfaceも同じファイルで定義した方が遥かに楽です。
しかし、これだとどうしてもドメイン層内のファイルからインフラ層のファイルを参照することになります。
これを避けるため、めんどくさくてもインターフェイスは独立させてドメイン層におくべきです。
集約をまたがったクエリを発行したくなる
例えば、個品とは別にSKUという概念があります。
これは、洋服の型、色、サイズで一意になる概念で、例えば、ユニクロとかで同じ棚で重なっておいてあるジーンズは全部同じ型、色、サイズなので同じIDのSKUということになります。このSKUがいくつもあり、その一つ一つを個品という扱いをしています。
今回、個品とSKUは独立した振る舞いをいろいろするので、集約は分けました。
すると、skuRepository
とitemRepository
が登場します。
例えば、業務ロジックが
- 個品の存在チェック
- 個品の色が赤色かどうかチェック
という流れの時ユースケースは
class ItemCheckUsecase {
check(itemId: number) {
// 存在チェック
const item = itemRepository.find({ id });
if (!item) return false;
// SELECT * FROM items WHERE id = 1;
// 赤色かどうかチェック
const sku = skuRepository.find({ id: item.skuId });
if (sku.color !== 'RED') return false;
// SELECT * FROM skus WHERE id = 2;
・・・
}
}
のようになります。
発行されているクエリは、
SELECT * FROM items WHERE id = 1;
SELECT * FROM skus WHERE id = 2;
になります。
それなら
SELECT * FROM items item INNER JOIN skus sku on sku.id = item.sku_id WHERE item.id = 1;
でとって来て欲しいという欲望にかられます。
しかし、DDDのモデリングの上ではitemsとskuは別集約で、ここでは業務ルールにしたがってitemRepository
とskuRepository
を別々に使うのが良いという結論に至りました。
別の集約に対して同じトランザクションで更新してはいけない?
『実践ドメイン駆動設計』には集約の決め方を以下のように述べられています。
- 集約とは常に同じトランザクションで扱われるドメインモデルの集まりである。
- ビジネス要件としては、全てのドメインモデルが同じ一つの集約であることが望ましい。
- しかし、これではシステム的に破綻する。(高トラフィック時に全ての集約を永続化するためにDBにアクセスしてたら死ぬよねっていう話)
- だから、整合性を常に取っておきたい範囲でトランザクションを張るようにする。
- 整合性を常に保てないところでは結果整合性が保たれれば良い。
この話では、集約の境界とトランザクションの境界が一致するという話でした。
例えば、「個品を在庫として新規登録して、入荷履歴を残す」というユースケースを考えます。
個品集約と入荷履歴集約を定義しているとします。
class ItemRegistrationUsecase {
register(params: Params) {
const { skuId, count } = params;
// 個品を新規登録するトランザクション
const registeredItems = TransactionManager.transaction(async (transaction: Transaction) => {
return itemRepository.save({ skuId, count });
});
// 個品登録履歴を保存するトランザクション
TransactionManager.transaction(async (transaction: Transaction) => {
for (const item of registeredItems) {
historyRepository.save({ itemId: item.id });
}
});
}
}
これだと個品登録と履歴登録を別トランザクションになります(なぜなら、別集約だから)。
しかし、これは結構都合悪いことになります。
入荷履歴が無いのに登録されてしまう個品が出てくる可能性があるからです。
このパターンは
class ItemRegistrationUsecase {
register(params: Params) {
const { skuId, count } = params;
// 個品を新規登録し、履歴も登録するトランザクション
TransactionManager.transaction(async (transaction: Transaction) => {
const registeredItems = await itemRepository.save({ skuId, count });
for (const item of registeredItems) {
historyRepository.save({ itemId: item.id });
}
});
}
}
と書きたいパターンはたくさんあるのでは無いでしょうか!
このパターンは本に書いてあることに違反しているのですが、コードを書くときは許しています。
トランザクション整合性が保てているならば結果整合性は保てているため、この書き方は問題ないというのが結論です。
結構いろいろな方に相談してこの結論を得たので、良いと思っていますw
逆に、結果整合性が保てているならばトランザクション整合性は保てているとは言えないので、
同じ集約を更新するときは絶対にトランザクションは分けてはいけない、というのは守るべきルールです。
ORMから取得したデータをfactoryに渡すための整形がどうするか
個品情報をidを元に取得する場合を考えてみます。
個品のドメインモデルは
class Item {
id?: number;
statusCode: string;
skuId: number;
}
だとします。
データベースから取得したitemの情報をdomain層のfactoryに渡します。
ここで、データベースから取得したデータの型はORMに依存した型(ItemORMModelとします)です。
class ItemRepository {
getById(id: number) {
const repository = getRepository(Item);
const item: ItemORMModel = repository.createQueryBuilder('items')
.innerJoinAndSelect('items.item_status_masters', 'status')
.where('items.id = :id', { id })
.getOne();
return itemFactory.reconstructItem(item); // 型が死ぬ
}
}
一方で、factoryはインフラに依存してはいけないので、plainObjectを受け取るインターフェイスになっています。
そのため、ItemORMModel -> plainObjectの型変換をかます必要があります。
選択肢1 ItemORMModelをそのままfactoryに渡す
これは、ドメインがインフラに依存するので論外
選択肢2 factoryのインターフェイスに合うオブジェクトを作る
class ItemRepository {
getById(id: number) {
const repository = getRepository(Item);
const _item: ItemORMModel = repository.createQueryBuilder('items')
.innerJoinAndSelect('items.item_status_masters', 'status')
.where('items.id = :id', { id })
.getOne();
const item: IItemFactoryParams = {
id: _item.id,
statusCode: _item.statusCode,
skuId: _item.skuId,
};
return itemFactory.reconstructItem(item);
}
}
これが正攻法だと思います。が、やってみると結構めんどくさいです。
選択肢3 Type Assertion
class ItemRepository {
getById(id: number) {
const repository = getRepository(Item);
const _item: ItemORMModel = repository.createQueryBuilder('items')
.innerJoinAndSelect('items.item_status_masters', 'status')
.where('items.id = :id', { id })
.getOne() as unknown as IItemFactoryParams;
return itemFactory.reconstructItem(item);
}
}
as unknown as
で無理やり型を変えちゃう方法。忙しいとき、このぐらい許してーーってときはありそうです。
まとめ
- 取得系にqueryModel、更新系にDDDの構成で作った。
- Entityのconstructorではなくfactoryでインスタンス化するときのルールを書くのがclass-transformerとの相性が良い。(JSとの相性が良い)。
- 別集約でも同じトランザクションで更新するのはあり。
- 結構JSでもいける