はじめに
この記事はHow to Design & Persist Aggregates - Domain-Driven Design w/ TypeScriptを翻訳したものです。
設計、アーキテクチャ、フロントエンド、ブロックチェーンに興味ある方是非Twitter(@show_clements)フォローしていただけると嬉しいです!
設計に関する記事
集約の設計と永続化の方法
この記事では、アグリゲートのルートを特定し、関連するエンティティの境界をカプセル化する方法を学びます。また、オープンソースのVinyl TradingアプリであるWhite Label上でSequelize ORMを使用して集約を構造化し、永続化する方法についても学びます。
これは、Domain-Driven Design w/ TypeScript & Node.jsコースの一部です。この記事が気に入ったらチェックしてみてください。
私たちのアプリケーションがシンプルだったことを覚えていますか?バックエンドにCRUD APIを呼び出して、アプリケーションの状態を変更できましたよね。コントローラから直接SequelizeやMongooseのORMモデルを使うことで、簡単にそれができたのです。
それは古き良き時代の話だ。
今の私たちを見てください。私たちは、ビジネスのソフトウェア実装に近いコードを書いています。オブジェクト指向プログラミングの原理を使って、リッチなドメインモデルを作成しています。
現実のビジネスに存在するルールやコンセプトが、エンティティや値オブジェクトとしてコードに存在します。ユースケースを使って、システム内のさまざまなアクター(ユーザーのグループ)がそれぞれのサブドメイン内でできることを表現しています。
チャレンジ
ソフトウェアに共通して見られるテーマは「関係性」です。
プログラミング(特にオブジェクト指向プログラミング)の大部分は、関係性についてです。巨大な問題を小さなクラスやモジュールに分解することで、複雑な問題に一口サイズで取り組むことができるようになります。
この分解は、検証ロジックをカプセル化するために値オブジェクトで行ってきたものです。
interface GenreNameProps {
value: string
}
/**
* @class GenreName
* @description ジャンル名クラスは、ジャンル名の指定に必要な検証ロジックをカプセル化した値オブジェクトです。
* @see Genre entity
*/
export class GenreName extends ValueObject<GenreNameProps> {
private constuctor (props: GenreNameProps) {
super(props);
}
public static create (name: string) : Result<GenreName> {
const guardResult = Guard.againstNullOrUndefined(name, 'name');
if (!guardResult.isSuccess) {
return Result.fail<GenreName>(guardResult.error);
}
if (name.length <= 2 || name.length > 100) {
return Result.fail<GenreName>(
new Error('Name must be greater than 2 chars and less than 100.')
)
}
return Result.ok<GenreName>(new Name({ value: name }));
}
}
↑単なる「string」ではなく、GenreNameクラスを使用して、ジャンル名が2文字以上100文字以下でなければならないという検証ロジックをカプセル化しています。
また、モデルの不変性を確保するためにエンティティを使用し、大きな問題自体をさらに分解します。
interface ArtistProps {
genres: Genre[];
name: string;
}
export class Artist extends Entity<ArtistProps> {
public static MAX_NUMBER_OF_GENRES_PER_ARTIST = 5;
private constructor (props: ArtistProps, id?: UniqueEntityId) : Artist {
super(props, id);
}
get genres (): Genre[] {
return this.props.genres;
}
...
/**
* @method addGenre
* @desc このクラスは、ArtistとGenreの1対1の関係に関する重要なビジネスルールをカプセル化していることに注目してください。
* Artistに追加できるGenresの数は決まっています。
*/
addGenre (genre: Genre): Result<any> {
if (this.props.genres.length >= Artist.MAX_NUMBER_OF_GENRES_PER_ARTIST) {
return Result.fail<any>('Max number of genres reached')
}
if (!this.genreAlreadyExists(genre)) {
this.props.genres.push(genre)
}
return Result.ok<any>();
}
}
↑1人のアーティストが持つことのできるジャンルは最大で5つ。
分離されたドメイン層のエンティティがお互いどのように関係するか(1対1、1対多、多対多)、どの操作がどのタイミングで有効かなどのルールや制約を定義する際に、いくつかの疑問が生じます。
- このエンティティのクラスタをどのようにしてデータベースに(カスケードして)保存するのか?
- クラスター内のすべてのエンティティの境界をどのように決定するか?
- これらのエンティティごとにリポジトリが必要なのか?
この記事では、集合体を使用して、単一のユニットとして扱うエンティティのクラスタの周囲に境界を作成する方法を紹介します。また、それらをデータベースに永続化する方法も紹介します。
集約とは
アグリゲートの最も優れた定義は、Eric EvansのDDDの本に書かれています。
「集約」とは、関連するオブジェクトの集まりのことで、データ変更の目的で1つの単位として扱います。" - Evans 126
少し分解してみましょう。
実際の集約自体は、接続されたオブジェクトの塊全体を指します。
White Labelの(半)完璧なVinylクラスをご覧ください。
import { AggregateRoot } from "../../core/domain/AggregateRoot";
import { UniqueEntityID } from "../../core/domain/UniqueEntityID";
import { Result } from "../../core/Result";
import { Artist } from "./artist";
import { TraderId } from "../../trading/domain/traderId";
import { Guard } from "../../core/Guard";
import { VinylId } from "./vinylId";
import { VinylNotes } from "./vinylNotes";
import { Album } from "./album";
interface VinylProps {
traderId: TraderId;
artist: Artist;
album: Album;
vinylNotes?: VinylNotes;
dateAdded?: Date;
}
export type VinylCollection = Vinyl[];
export class Vinyl extends AggregateRoot<VinylProps> {
get vinylId(): VinylId {
return VinylId.create(this.id)
}
get artist (): Artist {
return this.props.artist;
}
get album (): Album {
return this.props.album;
}
get dateAdded (): Date {
return this.props.dateAdded;
}
get traderId (): TraderId {
return this.props.traderId;
}
get vinylNotes (): VinylNotes {
return this.props.vinylNotes;
}
private constructor (props: VinylProps, id?: UniqueEntityID) {
super(props, id);
}
public static create (props: VinylProps, id?: UniqueEntityID): Result<Vinyl> {
const propsResult = Guard.againstNullOrUndefinedBulk([
{ argument: props.album, argumentName: 'album' },
{ argument: props.artist, argumentName: 'artist' },
{ argument: props.traderId, argumentName: 'traderId' }
]);
if (!propsResult.succeeded) {
return Result.fail<Vinyl>(propsResult.message)
}
const vinyl = new Vinyl({
...props,
dateAdded: props.dateAdded ? props.dateAdded : new Date(),
}, id);
const isNewlyCreated = !!id === false;
if (isNewlyCreated) {
// TODO: Dispatch domain events
}
return Result.ok<Vinyl>(vinyl);
}
}
White Lavelでは、レコードはそれを所有するトレーダーによって掲載されます。「Vinyl」は、「Artist」と「Album」との関係を持っています。
つまり、Vinylは他のエンティティに対して3つの異なる関係を持っています。
- レコードは、トレーダーに属す(1-1)
- レコードはアーティストに属す(1-1)
- レコードはアルバムに属す(1-1)
- アルバムは多くのジャンルに属す(1-多)
- アーティストは多くのジャンルに属す(1-多)
この関係はVinylを真ん中に置き、Vinylをこの塊の中の主役にしています: 集約ルート
- 集約とは、データ変更の際に1つの単位として扱う、関連するエンティティの塊のことです。
- 集約ルートは、他のエンティティへの参照を保持するメインのエンティティです。集約の中で唯一、直接検索に使用されるエンティティです。
あなたがまだ私と一緒にいてくれて、集約が何であるかをよりよく理解していることを願っています。
エバンス氏の説明のうち、集約についての第2の部分、特に「データ変更の目的で(集約を)1つの単位として扱う」という部分については、まだ話し合わなければなりません。
境界線の把握
エバンス氏が、データ変更の際に集約を1つの単位として扱うと言っているのは何のことでしょうか?
今話しているデータの変化とはなんのことでしょうか?
それは、特にCREATE
、DELETE
、UPDATE
のような操作です。ドメインに対して違法なものがドメインモデルを破損したままにすることを許さないようにしたいのです。
では、これらのデータ変更はどこから発生するのでしょうか?
データの変更は、アプリケーションが満たすべきユースケースに由来します。つまり、機能ですね。アプリケーションの存在理由のすべてです。
White Labelをもう一度見てみましょう。Vinylエンティティ(実際にはAggregate Rootであると判断している)のユースケースの大半を特定しました。
ユースケースの中には、データベースにデータの変更(コマンド)を行うものもあれば、単にデータベースからの読み取り(クエリ)を行うものもあります。
-
Catalog
: 個人のカタログに掲載されているレコードのユースケース-
addVinyl
: 既存レコードの新規追加 -
createNewVinyl
: 新しいレコードの作成 -
getRecommendations
: 現在のレコードを元におすすめのレコードを取得 -
getAllVinyl
: カタログ内の全てのレコードを取得 -
getVinylByVinylId
: カタログ内の特定のレコードを取得 -
removeVinyl
: カタログ内の特定のレコードを削除 -
searchCatalogForVinyl
: カタログ内のレコードを検索 -
updateVinyl
: カタログ内のレコードを更新する
-
-
Marketplace
: パブリックな市場でのレコードのユースケース-
searchMarketplaceForVinyl
: 市場のレコードを検索 -
getRecommendationsByVinylId
: 特定のレコードを持っている他のユーザーに基づいたおすすめのレコードを取得
-
素晴らしい、Vinylのユースケースがわかりました。次はどうしますか?
モデルの不変性を保護しながら、(コマンドのような)ユースケースをすべて実行できるように、集約を設計することができます。
ここにトリックがあるのです。
そしてここで、集約の境界を決定することができます。それが集約設計の目標です。
境界を定義することで、Vinyl
のCOMMAND
ユースケースのすべてを実行することができ、どの操作もビジネス・ルールを破ることがないように、境界内で十分な情報が提供されます。
しかし、それは最初からうまくいくとは限らないのです。
新しいビジネスルールが導入されることもあります。
新しいユースケースが追加されることもあります。
結果的に少しずれてしまい、集計の境界線を変更しなければならないことも結構あります。
効果的な集約の設計については、他にも数え切れないほどのエッセイ、文書、書籍、資料などがありますが、それは正しい方向に導くのが非常に難しいからです。考慮すべきことがたくさんあります。
集約設計で考慮すべきこと
集約設計の目標:
- 境界内でモデルの不変性を実現するために十分な情報の提供
- ユースケースの実行
また、過度に大きな集計の境界を作ることで、データベースやトランザクションにどのような影響を与えるかも考慮しなければなりません。
データベース/トランザクションのPerformance
この時点では、Vinyl
の境界で、1つのVinyl
を取得するために、いくつのテーブルを結合する必要があるかを考えてみてください。
集約設計の目標リストに、もう一つ加えよう。
- 境界内でモデルの不変性を実現するために十分な情報の提供
- ユースケースの実行
- データベースの適切なパフォーマンスを確保
DTOs
DTOの場合もあります。ドメインのエンティティをDTOやビューモデルにマッピングしてユーザーに返す必要がある場合がよくあります。
今回のWhite LabelのVinyl
のケースでは、以下のようなものをユーザーに返す必要があるかもしれません。
実際のアーティスト名
、作品
、リリースされた年
などがあります。
フロントエンドでこのビューを構築するためには、DTOは以下のようにする必要があります。
interface GenreDTO {
genre_id: string;
name: string;
}
interface ArtistDTO {
artist_id: string;
name: string;
artist_image: string;
genres?: GenreDTO[];
}
interface AlbumDTO {
name: string;
yearReleased: number;
artwork: string;
genres?: GenreDTO[];
}
export interface VinylDTO {
vinyl_id: string;
trader_id: string;
title: string;
artist: ArtistDTO;
album: AlbumDTO;
}
その結果、Artist
とAlbum
のエンティティ全体をVinyl
の集約の境界に含めることが必要になるかもしれません。
これで、集合体のデザインにもう一つの目標ができました。
- 境界内でモデルの不変性を実現するために十分な情報の提供
- ユースケースの実行
- データベースの適切なパフォーマンスを確保
- (任意。推奨しない)Domain EntityをDTOに変換するための十分な情報の提供
これが任意の目標である理由は、CQS(コマンド・クエリ分離)の原則に由来します。
コマンドクエリ分離
CQSでは、あらゆる操作はコマンド
かクエリ
のいずれかであるとしています。
そのため、関数がCOMMANDを実行した場合、戻り値がない(void)、というように。
// Valid
function createVinyl (data): void {
... // create and save
}
// Valid
function updateVinyl (vinylId: string, data): void {
... // update
}
// Invalid
function updateVinyl (vinylId: string, data): Vinyl {
... // update
}
アグリゲートに変更を加えるとき(UPDATE
、DELETE
、CREATE
)、私たちはCOMMAND
を実行しています。
このシナリオでは、COMMAND
を承認する前に、あるいはビジネスルールが満たされていないためにCOMMAND
を拒否する前に、あらゆる不変のルールを実施するために、完全なアグリゲートをメモリに取り込む必要があります。
理にかなっている。
しかし、クエリは違います。QUERY
は単純に値を返しますが 副作用も全くありません
// Valid
function getVinylById (vinylId: string): Vinyl {
// returns vinyl
}
// Invalid
function getVinylById (vinylId: string): Vinyl {
const vinyl = this.vinylRepo.getVinyl(vinylId);
await updateVinyl(vinyl) // bad, side-effect of a QUERY
// returns vinyl
return vinyl;
}
DTOで利用できるようにするために追加情報を集約に追加することは、パフォーマンスを低下させる可能性があるため、行わないでください。
DTOには、ユーザーインターフェイスを満たすための厳しい要件があります。そのため、すべての情報でアグリゲートを埋めるのではなく、必要なデータをリポジトリ/リポジトリから直接取得してDTOを作成します。
集約設計にはちょっとした工夫が必要なことを理解していただけたでしょうか。
ソフトウェア開発では、ほとんどの場合無料の昼食はありません。シンプルさとパフォーマンスのトレードオフを考慮する必要があります。
私は常に、最初はシンプルにして、必要に応じて後からパフォーマンスに対応することをお勧めします。
「Add Vinyl」ユースケース
集約の設計と永続化のコツをつかむために、何か作ってみましょう。
私は現在、Domain Driven Design w/ TypeScript CourseでVinyl Tradingアプリケーションを構築しています。
このプロジェクトのコードは、こちらのGitHubで確認できます。
ユースケースの1つとして、現在自宅でコレクションしているレコードを追加して、取引をしたり、オファーを受けたりすることができます。
何もないページからスタートすると、「レコードを追加する」という機能が表示されます。
「Add Vinyl」をクリックすると、アーティストから始まるフォームに記入することができます。
アーティストがシステムに登録されていない場合は、ユーザーは新しいアーティストを作成する必要があります。
「+ Create new artist [Artist name]」をクリックすると、このアーティストのジャンルなど、ユーザーが記入できる詳細情報が表示されます。
それが終わると、アルバムの詳細を追加することができます。
また、アルバムがまだプラットフォームに追加されていない場合は、すべての詳細を手動で入力する必要があります。
最後に、送信ボタンを押す前に、自分のコピーしたレコードに関する関連情報を追加することができます。
そして、それがダッシュボードに表示されるはずです。アルバム・アートワークは音楽メタデータAPIから取得し、VinylCreatedEvent
をリッスンするバックエンドのアプリケーション・サービスを介してVinyl
に反映させることができます。
ユースケース開発
Vinyl
が集約ルートになることは確かなので、AggregateRoot
クラスに必要なものについて説明します。
基本的な集約ルートクラス
まず、集約ルートはEntity
であることに変わりはないので、既存のEntity<T>
クラスを単純に拡張しましょう。
import { Entity } from "./Entity";
import { UniqueEntityID } from "./UniqueEntityID";
export abstract class AggregateRoot<T> extends Entity<T> {
}
集約ルートクラスは、他に何を担当すべきでしょうか?
ドメインイベントのディスパッチです。
ここでは説明しませんが、今後の記事では、関連することが起こったときにドメインレイヤー自体から直接シグナルを送るために、observerパターンをどのように適用するかをご紹介します。
しかし、今のところ、これは単にクラス名に意図を持たせるだけで十分です。
import { AggregateRoot } from "../../core/domain/AggregateRoot";
...
export class Vinyl extends AggregateRoot<VinylProps> {
...
}
このクラスを飛ばして、ドメインイベントを接続したときの様子を見たい場合は、こちらのコードをご覧ください。
それでは、ユースケースをご紹介します。
カタログにレコードを追加するユースケース
ここでは、一般的なアルゴリズムを考えてみましょう。
- リクエストDTO:
- 現在のユーザーID
- レコードの詳細
- アーティストの詳細
- 既にアーティストが存在する場合、アーティストID
- 存在しない場合、名前とジャンル
- アーティストの詳細
- アルバムの詳細
- 既にアルバムが存在する場合、アルバムID
- 存在しない場合、名前と年とジャンル
- それぞれのジャンル:
- 既にジャンルが存在する場合、ジャンルID
- 存在しない場合、名前
- 欲しいもの:
- アーティスを作成、探す
- アルバムを作成、探す
- レコードを作成する(アーティスト、アルバム、トレーダーID)
- 新しいレコードを保存
いい感じですね。まずはリクエストDTOから始めましょう。APIコールから何を必要としているでしょう。
リクエストDTO
このAPIコールでは、Genres
が存在する場合と存在しない場合の状況を考慮して、GenresRequestDTO
を2つの部分に分けています。
interface GenresRequestDTO {
new: string[];
ids: string[];
}
このアプローチの内訳は以下の通りです。
-
new: string[]
: 新しいジャンルのテキストを含む -
ids: string[]
: リンクしたいジャンルIDを含む
Album
とArtist
は共にGenres
を取るので、メインのペイロードにそれぞれのキーを与えます。
interface AddVinylToCatalogUseCaseRequestDTO {
artistNameOrId: string;
artistGenres: string | GenresRequestDTO;
albumNameOrId: string;
albumGenres: string | GenresRequestDTO;
albumYearReleased: number;
traderId: string;
}
また、アーティスト名とアルバム名を[albumName/artistName]orId
としていることにも気づくでしょう。これは、アルバムがすでに存在している場合、IDを含めるだけでよいからです。Artistについても同様です。
それでは、ユースケースの作成についての以前の記事で行ったように、これらすべてを AddVinylToCatalogUseCase
にフックしてみましょう。
AddVinylToCatalogUseCase
クラスの作成
// Lots of imports
import { UseCase } from "../../../../core/domain/UseCase";
import { Vinyl } from "../../../domain/vinyl";
import { IVinylRepo } from "../../../repos/vinylRepo";
import { Result } from "../../../../core/Result";
import { TextUtil } from "../../../../utils/TextUtil";
import { IArtistRepo } from "../../../repos/artistRepo";
import { Artist } from "../../../domain/artist";
import { TraderId } from "../../../../trading/domain/traderId";
import { UniqueEntityID } from "../../../../core/domain/UniqueEntityID";
import { ArtistName } from "../../../domain/artistName";
import { ParseUtils } from "../../../../utils/ParseUtils";
import { GenresRepo, IGenresRepo } from "../../../repos/genresRepo";
import { Genre } from "../../../domain/genre";
import { Album } from "../../../domain/album";
import { IAlbumRepo } from "../../../repos/albumRepo";
import { GenreId } from "../../../domain/genreId";
interface GenresRequestDTO {
new: string[];
ids: string[];
}
interface AddVinylToCatalogUseCaseRequestDTO {
artistNameOrId: string;
artistGenres: string | GenresRequestDTO;
albumNameOrId: string;
albumGenres: string | GenresRequestDTO;
albumYearReleased: number;
traderId: string;
}
export class AddVinylToCatalogUseCase implements UseCase<AddVinylToCatalogUseCaseRequestDTO, Result<Vinyl>> {
private vinylRepo: IVinylRepo;
private artistRepo: IArtistRepo;
private albumRepo: IAlbumRepo;
private genresRepo: IGenresRepo;
// Make sure to dependency inject repos that we
// need, only referring to their interface. Never
// their concrete class.
constructor (
vinylRepo: IVinylRepo,
artistRepo: IArtistRepo,
genresRepo: GenresRepo,
albumRepo: IAlbumRepo
) {
this.vinylRepo = vinylRepo;
this.artistRepo = artistRepo;
this.genresRepo = genresRepo;
this.albumRepo = albumRepo;
}
private async getGenresFromDTO (artistGenres: string) {
// TODO:
}
private async getArtist (request: AddVinylToCatalogUseCaseRequestDTO): Promise<Result<Artist>> {
// TODO:
}
private async getAlbum (request: AddVinylToCatalogUseCaseRequestDTO, artist: Artist): Promise<Result<Album>> {
// TODO:
}
public async execute (request: AddVinylToCatalogUseCaseRequestDTO): Promise<Result<Vinyl>> {
const { traderId } = request;
let artist: Artist;
let album: Album;
try {
// Get the artist
const artistOrError = await this.getArtist(request);
if (artistOrError.isFailure) {
return Result.fail<Vinyl>(artistOrError.error);
} else {
artist = artistOrError.getValue();
}
// Get the album
const albumOrError = await this.getAlbum(request, artist);
if (albumOrError.isFailure) {
return Result.fail<Vinyl>(albumOrError.error);
} else {
album = albumOrError.getValue();
}
// Create vinyl
const vinylOrError = Vinyl.create({
album: album,
artist: artist,
traderId: TraderId.create(new UniqueEntityID(traderId)),
});
if (vinylOrError.isFailure) {
return Result.fail<Vinyl>(vinylOrError.error)
}
const vinyl = vinylOrError.getValue();
// Save the vinyl
// This is where all the magic happens
await this.vinylRepo.save(vinyl);
return Result.ok<Vinyl>(vinyl)
} catch (err) {
console.log(err);
return Result.fail<Vinyl>(err);
}
}
}
この時点で、先ほど定義したアルゴリズムの大まかな形が見えてきました。
このユースケースの目的は、Vinyl
の作成に必要なものをすべてメモリ上に取り出し、それをVinylRepo
に渡して、保存する必要のあるものをすべてカスケードします。
このクラスでは、あとはどのようにしてすべてを取り出すかを書くだけです。
ソースコード全体を見ることができることも忘れないように。
見てみてください。
import { UseCase } from "../../../../core/domain/UseCase";
import { Vinyl } from "../../../domain/vinyl";
import { IVinylRepo } from "../../../repos/vinylRepo";
import { Result } from "../../../../core/Result";
import { TextUtil } from "../../../../utils/TextUtil";
import { IArtistRepo } from "../../../repos/artistRepo";
import { Artist } from "../../../domain/artist";
import { TraderId } from "../../../../trading/domain/traderId";
import { UniqueEntityID } from "../../../../core/domain/UniqueEntityID";
import { ArtistName } from "../../../domain/artistName";
import { ParseUtils } from "../../../../utils/ParseUtils";
import { GenresRepo, IGenresRepo } from "../../../repos/genresRepo";
import { Genre } from "../../../domain/genre";
import { Album } from "../../../domain/album";
import { IAlbumRepo } from "../../../repos/albumRepo";
import { GenreId } from "../../../domain/genreId";
interface GenresRequestDTO {
new: string[];
ids: string[];
}
interface AddVinylToCatalogUseCaseRequestDTO {
artistNameOrId: string;
artistGenres: string | GenresRequestDTO;
albumNameOrId: string;
albumGenres: string | GenresRequestDTO;
albumYearReleased: number;
traderId: string;
}
export class AddVinylToCatalogUseCase implements UseCase<AddVinylToCatalogUseCaseRequestDTO, Result<Vinyl>> {
private vinylRepo: IVinylRepo;
private artistRepo: IArtistRepo;
private albumRepo: IAlbumRepo;
private genresRepo: IGenresRepo;
constructor (
vinylRepo: IVinylRepo,
artistRepo: IArtistRepo,
genresRepo: GenresRepo,
albumRepo: IAlbumRepo
) {
this.vinylRepo = vinylRepo;
this.artistRepo = artistRepo;
this.genresRepo = genresRepo;
this.albumRepo = albumRepo;
}
private async getGenresFromDTO (artistGenres: string) {
return (
await this.genresRepo.findByIds(
((ParseUtils.parseObject(artistGenres) as Result<GenresRequestDTO>)
.getValue()
.ids
// existing ids, we're converting them into genreIds so
// that we can pass them into genresRepo.findByIds(genreIds: GenreId[])
.map((genreId) => GenreId.create(new UniqueEntityID(genreId))
))
))
// Join both groups of ids together. New and old.
.concat(
((ParseUtils.parseObject(artistGenres) as Result<GenresRequestDTO>)
.getValue()
.new // new genres.. let's create 'em
).map((name) => Genre.create(name).getValue())
)
}
private async getArtist (request: AddVinylToCatalogUseCaseRequestDTO): Promise<Result<Artist>> {
const { artistNameOrId, artistGenres } = request;
const isArtistIdProvided = TextUtil.isUUID(artistNameOrId);
if (isArtistIdProvided) {
const artist = await this.artistRepo.findByArtistId(artistNameOrId);
const found = !!artist;
if (found) {
return Result.ok<Artist>(artist);
} else {
return Result.fail<Artist>(`Couldn't find artist by id=${artistNameOrId}`);
}
}
else {
return Artist.create({
name: ArtistName.create(artistNameOrId).getValue(),
genres: await this.getGenresFromDTO(artistGenres as string)
})
}
}
private async getAlbum (request: AddVinylToCatalogUseCaseRequestDTO, artist: Artist): Promise<Result<Album>> {
const { albumNameOrId, albumGenres, albumYearReleased } = request;
const isAlbumIdProvided = TextUtil.isUUID(albumNameOrId);
if (isAlbumIdProvided) {
const album = await this.albumRepo.findAlbumByAlbumId(albumNameOrId);
const found = !!album;
if (found) {
return Result.ok<Album>(album)
} else {
return Result.fail<Album>(`Couldn't find album by id=${album}`)
}
} else {
return Album.create({
name: albumNameOrId,
artistId: artist.artistId,
genres: await this.getGenresFromDTO(albumGenres as string),
yearReleased: albumYearReleased
})
}
}
public async execute (request: AddVinylToCatalogUseCaseRequestDTO): Promise<Result<Vinyl>> {
const { traderId } = request;
let artist: Artist;
let album: Album;
try {
const artistOrError = await this.getArtist(request);
if (artistOrError.isFailure) {
return Result.fail<Vinyl>(artistOrError.error);
} else {
artist = artistOrError.getValue();
}
const albumOrError = await this.getAlbum(request, artist);
if (albumOrError.isFailure) {
return Result.fail<Vinyl>(albumOrError.error);
} else {
album = albumOrError.getValue();
}
const vinylOrError = Vinyl.create({
album: album,
artist: artist,
traderId: TraderId.create(new UniqueEntityID(traderId)),
});
if (vinylOrError.isFailure) {
return Result.fail<Vinyl>(vinylOrError.error)
}
const vinyl = vinylOrError.getValue();
// This is where all the magic happens
await this.vinylRepo.save(vinyl);
return Result.ok<Vinyl>(vinyl)
} catch (err) {
console.log(err);
return Result.fail<Vinyl>(err);
}
}
}
この記事では、かなりのところまで進みました。ここまでのユースケースを振り返って、次のステップに進みましょう。ここまでのユースケースの内容を振り返り、次の展開を考えてみましょう。
- 既存または新規のジャンル、アルバム、アーティストを使用してレコードを作成できるDTOを作成しました。
- レコードの作成に必要なすべてのリソース(アルバム、アーティスト、ターゲットID)を取り込むコードを作成しました。
- 次に、レコードの集約を見て、その一部を適切なリポジトリに渡すことで、それを永続化しなければなりません。
- リポジトリは集約を保存し、残りの複雑な永続化コードのカスケードを管理します。
集約の永続化
最後の仕上げの準備はできましたか?
では、この集約を永続化する方法を見てみましょう。
VinylRepo
クラスの、特にsave()
メソッドを見てみましょう。
VinylRepo (saves vinyl)
import { Repo } from "../../core/infra/Repo";
import { Vinyl } from "../domain/vinyl";
import { VinylId } from "../domain/vinylId";
import { VinylMap } from "../mappers/VinylMap";
import { TraderId } from "../../trading/domain/traderId";
import { IArtistRepo } from "./artistRepo";
import { IAlbumRepo } from "./albumRepo";
export interface IVinylRepo extends Repo<Vinyl> {
getVinylById (vinylId: VinylId): Promise<Vinyl>;
getVinylCollection (traderId: string): Promise<Vinyl[]>;
}
export class VinylRepo implements IVinylRepo {
private models: any;
private albumRepo: IAlbumRepo;
private artistRepo: IArtistRepo;
constructor (models: any, artistRepo: IArtistRepo, albumRepo: IAlbumRepo) {
this.models = models;
this.artistRepo = artistRepo;
this.albumRepo = albumRepo;
}
private createBaseQuery (): any {
const { models } = this;
return {
where: {},
include: [
{
model: models.Artist, as: 'Artist',
include: [
{ model: models.Genre, as: 'ArtistGenres', required: false }
]
},
{
model: models.Album, as: 'Album',
include: [
{ model: models.Genre, as: 'AlbumGenres', required: false }
]
}
]
}
}
public async getVinylById (vinylId: VinylId | string): Promise<Vinyl> {
const VinylModel = this.models.Vinyl;
const query = this.createBaseQuery();
query.where['vinyl_id'] = (
vinylId instanceof VinylId ? (<VinylId>vinylId).id.toValue() : vinylId
);
const sequelizeVinylInstance = await VinylModel.findOne(query);
if (!!sequelizeVinylInstance === false) {
return null;
}
return VinylMap.toDomain(sequelizeVinylInstance);
}
public async exists (vinylId: VinylId | string): Promise<boolean> {
const VinylModel = this.models.Vinyl;
const query = this.createBaseQuery();
query.where['vinyl_id'] = (
vinylId instanceof VinylId ? (<VinylId>vinylId).id.toValue() : vinylId
);
const sequelizeVinylInstance = await VinylModel.findOne(query);
return !!sequelizeVinylInstance === true;
}
public async getVinylCollection (traderId: TraderId | string): Promise<Vinyl[]> {
const VinylModel = this.models.Vinyl;
const query = this.createBaseQuery();
query.where['trader_id'] = (
traderId instanceof TraderId ? (<TraderId>traderId).id.toValue() : traderId
);
const sequelizeVinylCollection = await VinylModel.findAll(query);
return sequelizeVinylCollection.map((v) => VinylMap.toDomain(v));
}
private async rollbackSave (vinyl: Vinyl) {
const VinylModel = this.models.Vinyl;
await this.artistRepo.removeArtistById(vinyl.artist.artistId);
await this.albumRepo.removeAlbumById(vinyl.artist.artistId);
await VinylModel.destroy({
where: {
vinyl_id: vinyl.vinylId.id.toString()
}
})
}
public async save (vinyl: Vinyl): Promise<Vinyl> {
const VinylModel = this.models.Vinyl;
const exists: boolean = await this.exists(vinyl.vinylId);
const rawVinyl: any = VinylMap.toPersistence(vinyl);
try {
await this.artistRepo.save(vinyl.artist);
await this.albumRepo.save(vinyl.album);
if (!exists) {
await VinylModel.create(rawVinyl);
} else {
await VinylModel.update(rawVinyl);
}
} catch (err) {
this.rollbackSave(vinyl);
}
return vinyl;
}
}
VinylRepo
では、Vinyl
をIDで検索したり、存在を確認したり、Trader
のためにコレクション全体を取得するメソッドがあります。
それでは、save()メソッドについて説明します。
export class VinylRepo implements IVinylRepo {
...
public async save (vinyl: Vinyl): Promise<Vinyl> {
// 1. Sequelize ORM vinylモデルへアクセス。
// 私たちのレポは、これへのアクセスを単純にカプセル化したものです。
const VinylModel = this.models.Vinyl;
// 2. レコードがすでに存在しているかどうかを確認。
// もし存在すれば更新します。
// 存在しなければ作成します。
const exists: boolean = await this.exists(vinyl.vinylId);
// 3. VinylMapは、Sequelize VinylモデルがDBに保存するために必要なJSONオブジェクトを作成します。
// Vinylモデルは、DBに保存するために必要です。
// チェックしてみてください:
// https://github.com/stemmlerjs/white-label/blob/master/src/catalog/mappers/VinylMap.ts
const rawVinyl: any = VinylMap.toPersistence(vinyl);
...
}
}
VinylMap(vinylを様々なフォーマットに変える)
VinylMap
を見てみましょう。このクラスは、データベースからDTOに至るまで、Vinyl
クラスの変換を行う唯一の役割を担っています。
import { Mapper } from "../../core/infra/Mapper";
import { Vinyl } from "../domain/vinyl";
import { UniqueEntityID } from "../../core/domain/UniqueEntityID";
import { ArtistMap } from "./ArtistMap";
import { AlbumMap } from "./AlbumMap";
import { TraderId } from "../../trading/domain/traderId";
export class VinylMap extends Mapper<Vinyl> {
public static toDomain (raw: any): Vinyl {
const vinylOrError = Vinyl.create({
traderId: TraderId.create(raw.trader_id),
artist: ArtistMap.toDomain(raw.Artist),
album: AlbumMap.toDomain(raw.Album)
}, new UniqueEntityID(raw.vinyl_id));
vinylOrError.isFailure ? console.log(vinylOrError) : '';
return vinylOrError.isSuccess ? vinylOrError.getValue() : null;
}
public static toPersistence (vinyl: Vinyl): any {
return {
vinyl_id: vinyl.id.toString(),
artist_id: vinyl.artist.artistId.id.toString(),
album_id: vinyl.album.id.toString(),
notes: vinyl.vinylNotes.value
}
}
public static toDTO (vinyl): VinylDTO {
return {
vinyl_id: vinyl.id.toString()
trader_id: vinyl.traderId.id.tostring(),
artist: ArtistMap.toDTO(vinyl.artist),
album: AlbumMap.toDTO(vinyl.album)
}
}
}
VinylRepo
のsave()
メソッドに戻ります。
export class VinylRepo implements IVinylRepo {
...
public async save (vinyl: Vinyl): Promise<Vinyl> {
const VinylModel = this.models.Vinyl;
const exists: boolean = await this.exists(vinyl.vinylId);
const rawVinyl: any = VinylMap.toPersistence(vinyl);
try {
// 4. artistRepoにアーティストを保存を委ねる(存在しない場合)
// レコードを保存する前にこれをしなければならないことは、
// レコードが1対1の関係で依存からです。
await this.artistRepo.save(vinyl.artist);
// 5. albumRepoにアルバムの保存を委ねる(アルバムが存在しない場合)
// レコードはアルバムにも依存しています。
await this.albumRepo.save(vinyl.album);
if (!exists) {
// 6. レコードがまだ存在していないのであれば、作ります。
await VinylModel.create(rawVinyl);
} else {
// 7. もし存在するのであれば、更新します。
await VinylModel.update(rawVinyl);
}
} catch (err) {
// 8. 何か失敗があれば、手動でロールバックを行います。
this.rollbackSave(vinyl);
}
return vinyl;
}
}
AlbumRepo
とArtistRepo
は、VinylRepo
で説明したものと同様のアルゴリズムを採用していることは間違いありません。
AlbumRepo (アルバムの永続化を委任)
ここでは、ファイル全体を紹介していますが、save()
メソッドに注目してみてください。
import { Repo } from "../../core/infra/Repo";
import { Album } from "../domain/album";
import { AlbumId } from "../domain/albumId";
import { AlbumMap } from "../mappers/AlbumMap";
import { IGenresRepo } from "./genresRepo";
import { Genre } from "../domain/genre";
export interface IAlbumRepo extends Repo<Album> {
findAlbumByAlbumId (albumId: AlbumId | string): Promise<Album>;
removeAlbumById (albumId: AlbumId | string): Promise<Album>;
}
export class AlbumRepo implements IAlbumRepo {
private models: any;
private genresRepo: IGenresRepo
constructor (models: any, genresRepo: IGenresRepo) {
this.models = models;
this.genresRepo = genresRepo;
}
private createBaseQuery (): any {
const { models } = this;
return {
where: {},
include: [
{ model: models.Genre, as: 'AlbumGenres', required: false }
]
}
}
public async findAlbumByAlbumId (albumId: AlbumId | string): Promise<Album> {
const AlbumModel = this.models.Album;
const query = this.createBaseQuery();
query['album_id'] = (
albumId instanceof AlbumId ? (<AlbumId>albumId).id.toValue() : albumId
);
const album = await AlbumModel.findOne(query);
if (!!album === false) {
return null;
}
return AlbumMap.toDomain(album);
}
public async exists (albumId: AlbumId | string): Promise<boolean> {
const AlbumModel = this.models.Album;
const query = this.createBaseQuery();
query['album_id'] = (
albumId instanceof AlbumId ? (<AlbumId>albumId).id.toValue() : albumId
);
const album = await AlbumModel.findOne(query);
return !!album === true;
}
public removeAlbumById (albumId: AlbumId | string): Promise<Album> {
const AlbumModel = this.models.Artist;
return AlbumModel.destroy({
where: {
artist_id: albumId instanceof AlbumId
? (<AlbumId>albumId).id.toValue()
: albumId
}
})
}
public async rollbackSave (album: Album): Promise<any> {
const AlbumModel = this.models.Album;
await this.genresRepo.removeByGenreIds(album.genres.map((g) => g.genreId));
await AlbumModel.destroy({
where: {
album_id: album.id.toString()
}
})
}
private async setAlbumGenres (sequelizeAlbumModel: any, genres: Genre[]): Promise<any[]> {
if (!!sequelizeAlbumModel === false || genres.length === 0) return;
return sequelizeAlbumModel.setGenres(genres.map((d) => d.genreId.id.toString()));
}
public async save (album: Album): Promise<Album> {
const AlbumModel = this.models.Album;
const exists: boolean = await this.exists(album.albumId);
const rawAlbum: any = AlbumMap.toPersistence(album);
let sequelizeAlbumModel;
try {
await this.genresRepo.saveCollection(album.genres);
if (!exists) {
sequelizeAlbumModel = await AlbumModel.create(rawAlbum);
} else {
sequelizeAlbumModel = await AlbumModel.update(rawAlbum);
}
await this.setAlbumGenres(sequelizeAlbumModel, album.genres);
} catch (err) {
this.rollbackSave(album);
}
return album;
}
}
アルゴリズムはほとんど同じですが、setAlbumGenres()
でAlbum
とGenres
を保存する方法が少し違います。
Sequelize Associations
Sequelizeでは、associationsを設定する機能があります。
モデルを定義すると、次のようなことができます。
Album.belongsToMany(models.Genre,
{ as: 'AlbumGenres', through: models.TagAlbumGenre, foreignKey: 'genre_id'}
);
Genres
へのbelongsToMany
関連付けにより、sequelize Album
インスタンスにsetGenres()
メソッドが追加され、アルバムの現在のジャンルを簡単に設定できるようになりました。
ロールバック
最後に、トランザクションのロールバックについて説明します。
C#はUnit Of Workパターンを普及させました。
しかし、自分たちでそれを行うには、すべてのリポジトリにsequelizeトランザクションを渡して、ユースケースの実行を1つのデータベーストランザクションに結びつける必要があります。
理論的には美しい響きですが それを実装するのは、ちょっとした悪夢なようなものです。
私は手動でロールバックを行うことにしました。
例えば、AlbumRepo
でアルバムをロールバックするには、次のようにします。
export class AlbumRepo implements IAlbumRepo {
public async rollbackSave (album: Album): Promise<any> {
const AlbumModel = this.models.Album;
await this.genresRepo.removeByGenreIds(album.genres.map((g) => g.genreId));
await AlbumModel.destroy({
where: {
album_id: album.id.toString()
}
})
}
}
これはかなり現実的なアプローチだと思いますが、もしあなたに合っていると思われるなら、Unit of Workを試してみてはいかがでしょうか。
考察
この記事をここで締めくくります。もし、あなたがここまで続けてくれたなら、あなたは真の騎兵であり、ドメインモデリングにおいて可能な限りの成功を得るに値します。
振り返ってみると、以下のような内容でした。
- エンティティと値オブジェクトは、集約にまとめられます。
- 「集約」とは、データの変更を目的として1つの単位として扱う、関連するオブジェクトの集まりのことです。
- 境界線は、完全に構成されたエンティティがどこまで集合しているかです。
- 集約内のすべての完全に構成されたエンティティでは、集約の状態が変化したときに、集約ルートが集約内のすべての不変性を実行することが可能です。
- 集約は、常に永続から完全に構成された状態で返されなければなりません。そのため、パフォーマンスの制約や、データベースから完全に引き出すために何が本当に必要なのかをしっかりと考える必要があります。
- 私たちの集約設計の目標は以下の通りです。
- 境界内でモデルの不変性を実現するために十分な情報の提供
- ユースケースの実行
- データベースの適切なパフォーマンスを確保
- Domain EntityをDTOに変換するための十分な情報の提供
- リポジトリは、複雑な集約の永続化ロジックのすべてを処理する責任があります。
- マッパーは、集約をリポジトリで保存するために必要なフォーマットにマッピングするために使用されます。