今回は株式会社TAIANのフロントエンド開発(React)で採用している、Yagyu.js について紹介したいと思います。前編では戦術的DDDの内容を中心に紹介し、後編では前編で紹介した内容を実際に UI とどう紐づけていくかといった内容を紹介していこうと思います。
今回の記事は戦術的DDDの内容が中心になりますが、これを武器に戦略的DDDにぜひ取り組んでいただければと思っています。超大作になってしまいましたが、ぜひ最後まで読んでいただけると嬉しいです。
なぜYagyu.jsが必要になったか
私たちがサービスを提供している「婚礼業界」の業務は非常に複雑であり、多くのビジネスロジックが存在します。以前の私たちのチームも、機能ごとに実装の仕方が違ったり、開発者によってコードの品質がバラバラになりがちで、フロントエンドチームとしてこの複雑なビジネスロジックとどのように向き合うかが課題でした。そこで我々は、バックエンドで多く採用されているDDD(ドメイン駆動設計)やCQRS(Command Query Responsibility Segregation)の仕組みをヒントに、チーム全員が共通の認識を持って開発するためのアーキテクチャ Yagyu.js を構築しました。
こちらのフロントエンドDDDの記事や、中〜大規模向けフロントエンドアーキテクチャ “Yagyu.js” 爆誕の記事も合わせてご覧ください。
なぜこの記事を公開しようと思ったか
それっていわゆる「オラオラフレームワーク」とか「オラオラアーキテクチャ」の類なんじゃないの?っていう声が聞こえてきそうです。確かに世の中的にはオラオラのカテゴリに分類されるかもしれません。この手の記事を書くとまず間違いなく否定的な意見が飛んでくると思います。私たちが大事にしているのは、厳格な戦術的DDDやCQRSを実践することではありません。ドメイン知識と向き合い、コードでドメインを表現することに対する努力は惜しみませんが、教科書通りにすることでかえってコスパの悪い実装が増えてしまうような場合には、チームで話し合った上で、あえて教科書のやり方を崩すこともあります。株式会社TAIANでは、このYagyu.jsを2年ほど運用しています。その上で、チームとしてこのやり方でほとんど全ての機能が開発できる実感があり、自信を持って開発できています。同じ悩みを抱えている人たちにとって少しでも役に立てるのであればと思い、公開することに決めました。
どんな効果がある?
「フロントエンドエンジニアでないとできない領域」と「フロントエンドエンジニアでなくてもできる領域」を明確に区別することができます。
前者は例えば、スタイルの適用であったり、状態管理などのReactにまつわる技術が必要になる領域です。
それに対して、後者はデータ設計やビジネスロジックやそのテストコード、APIの通信部分などが挙げられます。
Yagyu.js導入前は、tsxやhooksにビジネスロジックが入り込んでしまうことで、状態管理とビジネスロジックが密結合な状況に陥ってしまい、テストコードも書きづらく、バックエンドエンジニアは作業を手伝うことができませんでした。
Yagyu.js導入後は、描画とビジネスロジックを完全に分離することができるようになり、バックエンドエンジニアがフロントエンドのタスクを手伝うことができるようになったり、単体テストが簡単に書けるようになったり、指標ができたことによってコードレビューの負荷が下がったり、エンジニア同士の価値観が揃ったり、目に見えてエンジニアチームとしての会話のレベルや開発力の底上げがされた実感がありました。
まずは Yagyu.js のディレクトリ構成です。
.
├── App.css
├── App.tsx
├── domain
│ ├── models
│ ├── repositories
│ ├── services
│ └── valueObjects
├── infrastructure
│ ├── factories
│ └── repositories
├── main.tsx
├── ui
│ ├── pages
│ ├── root
│ └── routes
└── usecases
├── commands
└── queries
円の外側が内側を参照するのはOKですが、逆はNGです。クリーンアーキテクチャと同じですね。
Repository に関しては「依存関係逆転の法則」を利用して実装を注入しています。
それでは、それぞれの章について見ていきましょう。
Domain
DDDで最も重要な部分です。DDDの専門的なお話は素晴らしい記事がたくさんありますのでそちらにお任せすることにして、ここでは実装を中心に紹介していきます。
ValueObject
まずは値オブジェクトです。値オブジェクトは
- idがない(一意性がない)
- 不変である
- 他の値オブジェクトと比較できる
- 固有のビジネスロジックの知識を持つ
などの特徴があります。Scala の case class
や、Kotlin の data class
が TypeScript にもあれば、コード量を大幅に減らすことができるのですが、TypeScript にはそういった言語サポートはないため、自力でそれ相当のものを用意しないといけません。Yagyu.js では以下のようなクラスを定義しました。
type JsonPrimitive = string | number | boolean | null
type JsonArray = JsonPrimitive[] | JsonObject[]
export type JsonObject = {
[key: string]: JsonPrimitive | JsonObject | JsonArray
}
export interface JSONSerializable {
toJSON(): JsonObject
}
interface AnyEqualable {
anyEquals(o: any): boolean
}
interface EqualableE> {
equals(o: E): boolean
}
export abstract class ValueObjectT extends string, E extends EqualableE>> implements EqualableE>, AnyEqualable, JSONSerializable {
private _CLASS_NAME: T | undefined
abstract equals(o: E): boolean
abstract toJSON(): JsonObject
anyEquals(o: any): boolean {
if (o === null) return false
if (typeof this !== typeof o) return false
if (this.constructor.name !== o.constructor.name) return false
return this.equals(o)
}
}
ここでフロントエンドエンジニアの方なら、クラス使うのか、、、と嫌悪感を抱く人もいらっしゃるかもしれません。しかし、値と振る舞い(メソッド)を同じクラスに定義することができるので、同じようなビジネスロジックをあちこちに書かずに済んだり、無数に utils を定義する必要もなくなります。
工夫したところとしては、TypeScriptは構造的部分型が採用されているため、
const companyNameOf = (companyId: CompanyId): string => ...
const companyId = new CompanyId('xxx')
const userId = new UserId('yyy')
companyNameOf(companyId)
companyNameOf(userId)
なんてことが発生します。これだとせっかく型を使っているのに今一つ物足りない感じです。そこで
private _CLASS_NAME: T | undefined
を定義することで、TypeScriptが、CompanyIdとUserIdを区別してくれるようになります。
companyNameOf(companyId)
companyNameOf(userId)
それでは、この ValueObject を使用したサンプルをいくつか紹介していきます。
値の性質を表現するパターン
type Option = {
displayName?: string
}
export class NonNegativePrice
extends ValueObject'NonNegativePrice', NonNegativePrice>
{
private constructor(readonly value: number, { displayName }: Option) {
super()
if (Number.isNaN(value) || !Number.isInteger(value) || value 0)
throw new InvalidParameterError(NonNegativePrice.errorMessages.number(displayName ?? '金額'))
}
static errorMessages = {
required: (displayName: string): string => `${displayName}は必須です。`,
number: (displayName: string): string => `${displayName}は0以上の整数を入力してください。`,
}
static zero = NonNegativePrice.withValidate(0)
static withValidate = (value: number, option: Option = {}): NonNegativePrice => new NonNegativePrice(value, option)
toJSON = (): JsonObject => ({ value: this.value })
toString = (): string => this.value.toLocaleString()
toStringWithUnit = (): string => `${this.toString()}円`
add = (price: NonNegativePrice): NonNegativePrice => NonNegativePrice.withValidate(this.value + price.value)
multiply = (quantity: NonNegativeInteger): NonNegativePrice =>
NonNegativePrice.withValidate(this.value * quantity.value)
isPositive = (): boolean => this.value > 0
isZero = (): boolean => this.value === 0
gt = (o: NonNegativePrice): boolean => this.value > o.value
gte = (o: NonNegativePrice): boolean => this.value >= o.value
lt = (o: NonNegativePrice): boolean => this.value o.value
lte = (o: NonNegativePrice): boolean => this.value o.value
equals = (o: NonNegativePrice): boolean => this.value === o.value
}
このタイプの ValueObject は以下のようなメリットがあります。
- コンストラクタによるバリデーション
- インスタンス化ができたということは値が期待している状態であることが担保できる
- 値の性質を宣言できる
- number型で宣言された変数と比べて、この変数が0円以上の金額表していることがわかります。
- 値が小数である可能性やマイナスの値が入る可能性を考える必要もありません
- 振る舞いを定義できる
- 例えば、
1,000,000円
と出力するようなメソッドを定義できます
- 例えば、
- 条件分岐にメソッドを使える
- 例えば、0円の時は表示せず、1円以上の場合は金額を表示するといった場合
{ price.isPositive() && span>{price.toStringWithUnit()}/span>
といった表現が可能になります。フロントエンドのドメインロジックとはでも紹介されているように、Yagyu.jsではフロントエンドの描画にまつわる仕様も フロントエンドのドメインロジック として扱っています。
ライブラリをラップするパターン
import { format, addYears, addMonths, addDays, differenceInCalendarDays, endOfMonth, getDate, getMonth, getYear, startOfMonth } from 'date-fns'
import { utcToZonedTime } from 'date-fns-tz'
export class MyDate extends ValueObject'MyDate', MyDate> {
constructor(readonly value: Date, readonly option?: Option, readonly locale: string = 'Asia/Tokyo') {
super()
if (Number.isNaN(value.getTime())) throw new InvalidParameterError(MyDate.errorMessages.format)
}
static errorMessages = {
format: '日付のフォーマットが正しくありません',
}
static withValidate = (value: string, option: Option = {}): MyDate => new MyDate(new Date(value), option)
static today = (): MyDate => new MyDate(new Date())
toJSON = (): JsonObject => ({ value: this.toString() })
toDateTime = (): MyDateTime =>
new MyDateTime(new Date(this.value.getFullYear(), this.value.getMonth(), this.value.getDate(), 0, 0, 0, 0))
toString = (): string => {
const zonedDate = utcToZonedTime(this.value, this.locale)
return format(zonedDate, 'yyyy-MM-dd')
}
toStringAsJaWithWeek = (): string => {
const zonedDate = utcToZonedTime(this.value, this.locale)
return format(zonedDate, 'yyyy年M月d日 (E)', { locale: ja })
}
equals = (o: MyDate): boolean => this.toStringByISO8601() === o.toStringByISO8601()
isAfter = (o: MyDate): boolean => {
if (this.equals(o)) return false
return this.value.getTime() > o.value.getTime()
}
addDays = (i: Integer): MyDate => new MyDate(addDays(this.value, i.value))
daysUntil = (o: MyDate): number => differenceInCalendarDays(o.value, this.value)
year = (): number => getYear(this.value)
month = (): number => getMonth(this.value) + 1
date = (): number => getDate(this.value)
}
このタイプの ValueObject は以下のようなメリットがあります。
- ライブラリーを隠蔽化できる
- 例えばDate系のライブラリーを date-fns から別のライブラリーに変更しなければならなくなったとしても、全ての実装が MyDate に依存した実装になっていれば、ライブラリーを変更したとしても、MyDateの単体テストが通ればOKとなり、修正範囲を局所化できます。
- よく使うビジネスロジックをメソッド化することができる
- コードのあちこちで
format(date, 'yyyy-MM-dd')
みたいなことを書かなくてすみます。
- コードのあちこちで
定数を表現するパターン
export type ProposalStatusValue =
| 'lead'
| 'tentativeDecision'
| 'decision'
| 'cancelBeforeDecision'
| 'cancelAfterDecision'
export class ProposalStatus extends ValueObject'ProposalStatus', ProposalStatus> {
private constructor(readonly value: ProposalStatusValue, readonly displayName: string) {
super()
}
private static statuses = {
lead: new ProposalStatus('lead', '新規'),
tentativeDecision: new ProposalStatus('tentativeDecision', '仮予約'),
decision: new ProposalStatus('decision', '成約'),
cancelBeforeDecision: new ProposalStatus('cancelBeforeDecision', '成約前キャンセル'),
cancelAfterDecision: new ProposalStatus('cancelAfterDecision', '成約後キャンセル'),
} as const satisfies RecordProposalStatusValue, ProposalStatus>
static allStatuses: ProposalStatus[] = Object.valuesProposalStatus>(ProposalStatus.statuses)
static get lead(): ProposalStatus {
return this.statuses.lead
}
static get tentativeDecision(): ProposalStatus {
return this.statuses.tentativeDecision
}
static get decision(): ProposalStatus {
return this.statuses.decision
}
static get cancelBeforeDecision(): ProposalStatus {
return this.statuses.cancelBeforeDecision
}
static get cancelAfterDecision(): ProposalStatus {
return this.statuses.cancelAfterDecision
}
static withValidate = (value: string): ProposalStatus => {
const status = ProposalStatus.allStatuses.find((it) => it.value === value)
if (!status) throw new UnexpectedError(`${value}は登録されていない施行ステータスです`)
return status
}
isLead = (): boolean => this.equals(ProposalStatus.lead)
isTentativeDecision = (): boolean => this.equals(ProposalStatus.tentativeDecision)
isDecision = (): boolean => this.equals(ProposalStatus.decision)
isCancelBeforeDecision = (): boolean => this.equals(ProposalStatus.cancelBeforeDecision)
isCancelAfterDecision = (): boolean => this.equals(ProposalStatus.cancelAfterDecision)
isCanceled = (): boolean => this.isCancelBeforeDecision() || this.isCancelAfterDecision()
toJSON = (): JsonObject => ({ value: this.value })
toString = (): string => this.displayName
equals = (o: ProposalStatus): boolean => this.value === o.value
nextStatuses = (): ProposalStatus[] => {
switch (this.value) {
case 'lead':
return [ProposalStatus.tentativeDecision, ProposalStatus.decision, ProposalStatus.cancelBeforeDecision]
case 'tentativeDecision':
return [ProposalStatus.decision, ProposalStatus.cancelBeforeDecision]
case 'decision':
return [ProposalStatus.cancelAfterDecision]
case 'cancelBeforeDecision':
case 'isCancelAfterDecision':
return []
default: {
const _: never = this.value
throw new UnexceptedError(`Unexpected ItemOrderStatus: ${this.value}`)
}
}
}
}
このタイプの ValueObject は以下のようなメリットがあります。
- UI層でセレクトメニューなどで表示する際の、labelとvalueをセットで定義できる
- 「キャンセル」系のステータスの場合だけ条件分岐が必要な場合は
if (status.isCanceled())
のように書ける - 現在のステータスによって次に選べる選択肢が限られる場合は
select>
{ status.nextStatuses().map((nextStatus) =>
option value={nextStatus.value}>{nextStatus.displayName}/option>
)}
/select>
のように書くことができます。
もう少し複雑な例
ここまでは1つの値だけで構成された ValueObject の例を紹介してきましたが、実務では2つ以上の ValueObject を組み合わせたものに対してビジネスロジックを書きたくなることもあります。そこで、先ほどの ValueObject を少し拡張した ValueObjects を用意します。
export abstract class ValueObjects
T extends string,
E extends ValueObjectsT, E, A>,
A extends { [key in string]: AnyEqualable & JSONSerializable },
> extends ValueObjectT, E> {
constructor(protected readonly args: A) {
super()
}
equals = (other: E): boolean => {
const thisArgs: A = this.args
const otherArgs: A = other.args
return Object.keys(thisArgs).every((key: string) => {
const thisValue = thisArgs[key]
const otherValue = otherArgs[key]
return (thisValue === undefined && otherValue === undefined) || (thisValue && thisValue.anyEquals(otherValue))
})
}
toJSON = (): JsonObject => {
return Object.keys(this.args).reduce((acc: JsonObject, key: string) => {
const valueObject = this.args[key]
acc[key] = valueObject.toJSON()
return acc
}, {} as JsonObject)
}
toString = (): string => {
return Object.keys(this.args)
.map((key: string) => `${key}: ${this.args[key]}`)
.join(', ')
}
}
この ValueObjects を使用すると、
type MyDateRangeArgs = { from: MyDate; to: MyDate }
export class MyDateRange extends ValueObjects'MyDateRange', MyDateRange, MyDateRangeArgs> {
constructor(
protected readonly args: MyDateRangeArgs,
readonly options?: { allowSameDate?: boolean; displayText?: string },
) {
super(args)
const { from, to } = args
const { allowSameDate = true } = options ?? {}
const dateDisplayText =
from.option?.displayText && to.option?.displayText
? {
from: from.option.displayText,
to: to.option.displayText,
}
: { from: '開始日', to: '終了日' }
if (allowSameDate && !to.isSameOrAfter(from))
throw new InvalidParameterError(
MyDateRange.errorMessages.sameOrAfter(options?.displayText ?? '期間', dateDisplayText),
)
if (!allowSameDate && !to.isAfter(from))
throw new InvalidParameterError(
MyDateRange.errorMessages.after(options?.displayText ?? '期間', dateDisplayText),
)
}
static errorMessages = {
sameOrAfter: (
displayText: string,
dateDisplayText: { from: string; to: string } = { from: '開始日', to: '終了日' },
): string =>
`${displayText}の${dateDisplayText.from}と${dateDisplayText.to}は同じないし${dateDisplayText.from}より後にしてください`,
after: (
displayText: string,
dateDisplayText: { from: string; to: string } = { from: '開始日', to: '終了日' },
): string => `${displayText}の${dateDisplayText.to}は${dateDisplayText.from}より後にしてください`,
}
get from(): MyDate {
return this.args.from
}
get to(): MyDate {
return this.args.to
}
difference = (): NonNegativeInteger => {
return NonNegativeInteger.withValidate(this.from.daysUntil(this.to))
}
isWithinDays = (days: NonNegativeInteger): boolean => {
return this.difference().lte(days)
}
toString = (): string => {
return `${this.from.toString()}〜${this.to.toString()}`
}
toStringAsJaWithWeek(): string {
const { from, to } = this.args
return `${from.toStringAsJaWithWeek()}〜${to.toStringAsJaWithWeek()}`
}
}
このように、開始日、終了日にまつわるビジネスロジックを簡単に共通化することができます。その他にも、単価、数量、値引などもつ ValueObjects を作ることで、合計金額を算出できるメソッドを持つ ValueObjects を作ることなどもできます。
Model
次にモデルです。DDDではエンティティと呼ばれています。Yagyu.jsではCQRSの概念も導入されているため、ドメインモデル(コマンドモデル)とクエリモデルの2種類があるのですが、ここではドメインモデルについて紹介します。(クエリモデルについてはUsecaseのセクションで紹介します)
ドメインモデルは値オブジェクトとは違い、以下のような性質があります。
- idがある(一意性がある)
- 可変である
- ライフサイクルがある
- 固有のビジネスロジックの知識を持つ(これは値オブジェクトと同じです)
Yagyu.js ではモデルは以下のように定義しています。
interface id {
value: string
}
export abstract class IdT extends string, ID extends EqualableID>> extends ValueObjectT, IdT, ID>> {
constructor(readonly value: string) {
super()
}
toString = (): string => this.value
toJSON = (): JsonObject => ({ value: this.value })
equals = (o: IdT, ID>): boolean => this.value === o.value
}
export abstract class Model
T extends string,
ID extends id,
E extends ModelT, ID, E, A>,
A extends { [key in string]: AnyEqualable & JSONSerializable },
> extends ValueObjectsT, E, A> {
abstract get id(): ID
}
ここで鋭い人は、 ModelがValueObjectsを継承しているのはおかしい と思うでしょう。確かに教科書通りではないです。冒頭のセクションでも書いた通り、Yagyu.jsは厳密なDDDではありません。実践していく上で
- 構造的部分型の型エラー対策で使用している
private _CLASS_NAME: T | undefined
の仕組みが同様にほしい - ユニットテストを書く際に、equals メソッドがあった方が、期待値を定義できてテストが書きやすい
- プロダクションコードでも変更の有無のチェックで equals メソッドが使えると嬉しい
- toJSON があると便利
などがあり、Model が ValueObjects を継承いる方が都合が良いことが多かったので Yagyu.js では継承させることにしています。
とはいえ、継承以外の方法で共通化することもできると思いますし、本来はそうすべきだと思うので、この部分に関しては今後改善の余地はあると思っています。
また、Entity ではなく Model という名前にしているのは、class はあくまで設計図(仕様)であり、classからインスタンス化されたものがエンティティであると解釈しているためです。簡略的に説明すると
class User extends Model {...}
const entity = new User({...})
このような関係になります。
それでは、モデルを使った例を紹介します。
モデルの例
例えば、結婚式の業務システムで「婚礼施行」を表すモデルは以下のように表現できます。
export class ProposalId extends Id'ProposalId', ProposalId> {}
export class ProposalStatus extends ValueObject'ProposalStatus', ProposalStatus> {
...
}
type ProposalArgs = {
id: ProposalId
name: Name
contractDate?: MyDate
weddingDate?: MyDate
status: ProposalStatus
}
export class Proposal extends Model'Proposal', ProposalId, Proposal, ProposalArgs> {
constructor(protected readonly args: ProposalArgs) {
super(args)
}
get id(): ProposalId {
return this.args.id
}
get name(): Name {
return this.args.name
}
get contractDate(): MyDate | undefined {
return this.args.contractDate
}
get weddingDate(): MyDate | undefined {
return this.args.weddingDate
}
get status(): ProposalStatus {
return this.args.status
}
change = ({name, weddingDate}: { name: Name, weddingDate: MyDate | undefined }): Proposal =>
new Proposal({ ...this.args, name, weddingDate })
changeStatus = (status: ProposalStatus): Proposal => {
if (!this.status.nextStatuses().some((nextStatus) => nextStatus.equals(status))) throw new InvalidParameterError(`${status.displayName}ステータスに変更することはできません。`)
return new Proposal({ ...this.args, status })
}
}
このモデルでは以下のような仕様が表現されています。
- 施行名と挙式日は同時に編集される
- ステータスは他の項目とは別で、単独で変更される
- 変更前のステータスによって、変更できるステータスは限られている
- 契約日は変更することができない
※ あくまでサンプルであり、株式会社TAIANが提供しているサービスの仕様ではありません。
「エンティティは可変である」と書いてあったのに、new して別インスタンスを返しているじゃん!と思う方がいらっしゃるかもしれません。これもYagyu.jsで教科書から外れている部分になりますが、プログラムはできる限りイミュータブルな設計になっている方が安全で可読性が高いので、エンティティに変更を加える場合はメソッドで変更後のインスタンスを返す設計にしています。
change = (args: ProposalArgs) => new Proposal({ ...this.args, ...args})
のこれは以下の理由で悪い例です
- id は変更できてはいけないのに変更できてしまう
- 「ステータスは他の項目とは別で、単独で変更される」という仕様を表現できていない
画面がなくても、モデルを見ればどのような仕様なのかがわかるよう、ドメイン層はコードが仕様書になっている必要があります。
作成時のモデル(Seed)
上記のモデルでは id: ProposalId
が必須になっていました。しかし、通常のWebシステムでは、IDの発番はバックエンド側で行われます。つまり「婚礼施行作成画面」ではIDがない状態です。モデルのidの定義を id: ProposalId | undefined
にすることを考えるかもしれませんが、id がない状態というのは新規作成画面だけの話であって、作成後に undefined になることはありません。挙式日は weddingDate: MyDate | undefined
となっていますが、一度設定されたあとに挙式日未定(undefined)に変更されることは仕様としてありえるので、これは正しい定義です。また、新規作成時はステータスは指定することができない(バックエンド側で必ず「新規」ステータスで作成される)などの条件がある場合もあります。
このように、作成時とそれ以降で関心ごとが異なる場合はよくあります。Yagyu.jsでは作成時のモデルをSeedと呼び、ValueObjectsで表現することにしています。
type ProposalSeedArgs {
name: Name
contractDate?: MyDate
weddingDate?: MyDate
}
export class ProposalSeed extends ValueObjets'ProposalSeed', ProposalSeed, Proposal, ProposalSeedArgs> {
constructor(protected readonly args: ProposalSeedArgs) {
super(args)
}
get name(): Name {
return this.args.name
}
get contractDate(): MyDate | undefined {
return this.args.contractDate
}
get weddingDate(): MyDate | undefined {
return this.args.weddingDate
}
}
FirstClassCollection
これはDDDとは関係ないですが、Yagyu.jsで頻出するデザインパターンなので合わせて紹介しておきます。ドメインオブジェクトを扱う上で、配列を扱うことは多いです。ValueObject[]
や Model[]
のようにしても良いのですが、特定の条件でフィルターしたかったり、条件に一致する要素があるかを知りたかったり、配列に対してビジネスロジックが施されることはとても多いです。Yagyu.jsでは配列も FirstClassCollection のオブジェクトとして扱うことでビジネスロジックをメソッドとして定義しています。
株式会社TAIANでは FirstClassCollection にたくさんのメソッドが定義されていますが、ここではその一部をご紹介します
export abstract class FirstClassCollection
T extends string,
E extends AnyEqualable & JSONSerializable,
C extends FirstClassCollectionT, E, C>,
>
extends ValueObjectT, C>
implements IterableE>
{
constructor(protected readonly values: E[]) {
super()
}
abstract instantiate(values: E[]): C
[Symbol.iterator](): IteratorE> {
let pointer = 0
return {
next: (): IteratorResultE> => {
if (pointer this.values.length) {
return { done: false, value: this.values[pointer++] }
} else {
return { done: true, value: undefined }
}
},
}
}
toJSON = (): JsonObject => ({ values: this.values.map((it) => it.toJSON()) })
equals = (o: C): boolean => {
if (this.values.length !== o.values.length) return false
return this.values.map((v, i) => [v, o.values[i]]).every(([a, b]) => a.anyEquals(b))
}
map = U>(callbackfn: (value: E, index: number, array: E[]) => U, thisArg?: any): U[] => {
return this.values.map(callbackfn, thisArg)
}
filter = (callbackfn: (value: E, index: number, array: E[]) => boolean, thisArg?: any): C => {
return this.instantiate(this.values.filter(callbackfn, thisArg))
}
find = (callbackfn: (value: E, index: number, array: E[]) => boolean, thisArgs?: unknown): E | undefined => {
return this.values.find(callbackfn, thisArgs)
}
isEmpty = (): boolean => {
return this.values.length === 0
}
例えばこのように使います
export class Guests extends FirstClassCollection'Guests', Guest, Guests> {
instantiate = (values: Guest[]): Guests => new Guests(values)
findById = (id: GuestId): Guest | undefined =>
this.find((guest) => guest.id.equals(id))
filterByChild = (): Guests => this.filter((guest) => guest.isChild())
}
{ guests.filterByChild().isEmpty() ?
p>子供のゲストはいません/p> :
ul>
{guests.filterByChild().map((guest) => (
li key={guest.id.value}>
{guest.fullNameWithHonorificTitle()}
/li>
))}
/ul>
}
※ fullNameWithHonorificTitle
は、ゲストの lastName
と firstName
を半角スペースで繋ぎ、「様」をつけるビジネスロジックをメソッドにしています。他の画面でも使われるような頻出のビジネスロジックをメソッドにしています。
Service
ドメインサービスは一連のビジネスロジックを手続的に処理する必要がある場合に使用します。例えば、画像を紐づけたデータを保存する際、
- バックエンドに対してS3にアップロードするための Presigned URL と BlobId を要求する
- 発行された Presigned URL に対してフロントエンドから直接画像をアップロードする
- BlobId をパラメータに指定して画像を紐づけたデータを作成する API を呼び出す
といった手続きが発生することがあります。このような一連の手続きをドメインサービスが担当します。
ドメインサービスのサンプルは省略します。
Repository
リポジトリーはデータの永続化の責務を負います。Yagyu.jsでは依存関係逆転の法則を使用しており、ドメイン層のリポジトリーはインターフェースのみが存在します。例えば先ほどの「婚礼施行」のリポジトリーは以下のようになります。
interface IProposalRepository {
create(seed: ProposalSeed): PromiseProposal>
update(proposal: Proposal): PromiseProposal>
}
バックエンドのDDDであれば、モデルの作成時にUUIDやULIDでランダムなIDを発番してcreateの引数の型も Proposal
型にすることができます。フロントエンドで同じようなことをすると、バックエンドはフロントエンドが発行したIDをそのまま保存する形になってしまいます。IDの発行はバックエンドの責務にしたかったため、作成時はModelではなくSeedを使用するようにしています。
また、Modelのセクションでも軽く触れていますが、Yagyu.jsではCQRSの概念も導入されています。
ドメイン層のリポジトリ-はドメインモデル(コマンドモデル)の永続化を担います。(データの取得を行うクエリモデル用のリポジトリーはUsecaseのセクションで紹介します)
Infrastructure
先ほどのドメインリポジトリーで紹介した、インターフェースの実装を行います。
OpenAPIやGraphQLなどでAPI通信の型を自動生成する場合、自動生成の型はこのinfrastructureのディレクトリ配下に配置します。API通信に必要な型定義はinfrastructureのみで参照され、そのほかのディレクトリでは参照してはいけません。ESLintのルールで縛ることができると望ましいです。
なぜ依存関係逆転の法則が必要か
データの永続化処理を抽象化
フロントエンドにおいて(バックエンドもそうですが)、データを永続化しない事はまずあり得ません。永続化先はAPIでRESTfulなエンドポイントかもしれないし、GraphQLのエンドポイントかもしれません。もしくはS3かもしれませんし、ローカルストレージかもしれません。ドメインロジックでは、データの永続化処理は抽象(interface)に依存させ、具体的な永続化処理はinfrastructureに分離します。
自動生成された型の利用と注意点
バックエンドのAPIを利用する際、OpenAPIやGraphQLなどでAPI通信に必要な型を自動生成していることも多いでしょう。この自動生成された型を、画面描画のtsxを含むフロントエンド全体で使用するのがコスパが良いと考えるかもしれません。しかし、この自動生成されたコードはあくまでRESTfulなAPIや、GraphQLのAPIが指定した型、つまりInfrastructureに依存した型であり、ドメインモデルやクエリモデルではありません。ビジネスロジックがInfrastructureに依存してしまうと、例えばバックエンドのAPIが返すデータ構造が大きく変更された場合、フロントエンドは壊滅的な被害が出てしまいます。そのため、自動生成された型かどうかに関係なく、API通信に使用する型をフロントエンド全体で使い回すのは良くありません。ビジネスロジックがドメインモデルやValueObjectに依存する形になっていれば、小規模な改修であれば、変更範囲はInfrastructure配下だけ済むでしょう。
最適なデータ構造とは
そもそも、画面の描画に都合の良いデータ構造と、API通信で求められるデータ構造はシンプルな画面であれば一致するかもしれませんが、複雑な機能になると一致しなくなる可能性は十分あります。API通信で求められるデータ構造に引きずられてビジネスロジックが歪んだ実装になってしまっては本末転倒です。多少冗長に感じたとしても、API通信の型とドメインモデルは区別するべきです。(例え全く同じ構成だったとしても、別途定義するべきです)
依存性の注入
依存関係逆転の法則で実装されていると、バックエンドの開発ができるまでの間モックで動作確認したり、テストの際はインメモリーで動作するリポジトリ-に差し替えなども簡単に行うことができます。
Repository
infrastructure層のリポジトリーでは実際のAPI通信処理を実装します。例えばバックエンドがGraphQLで urql
ライブラリーを採用している場合、このような基底クラスを準備します。
import {
Client,
AnyVariables,
DocumentInput,
OperationContext,
OperationResultSource,
OperationResult,
CombinedError,
fetchExchange,
} from 'urql'
const defaultRequestHeaders = {}
const client = new Client({
url: '/graphql',
exchanges: [fetchExchange],
fetchOptions: () => {
return {
headers: defaultRequestHeaders,
}
},
})
export abstract class BaseGraphQLRepository {
protected getGraphQLClient = async (): PromiseClient> => client
protected async queryOneData = any, Variables extends AnyVariables = AnyVariables>(
query: DocumentInputData, Variables>,
variables: Variables,
context?: PartialOperationContext>,
): PromiseOperationResultSourceOperationResultData | null, Variables>>> {
const graphqlClient = await this.getGraphQLClient()
const response = await graphqlClient.queryData, Variables>(query, variables, context)
if (response.error) {
BaseGraphQLRepository.throwErrorIgnoreRecordNotFound(response.error)
}
return response
}
protected async queryListData = any, Variables extends AnyVariables = AnyVariables>(
query: DocumentInputData, Variables>,
variables: Variables,
context?: PartialOperationContext>,
): PromiseOperationResultSourceOperationResultData, Variables>>> {
const graphqlClient = await this.getGraphQLClient()
const response = await graphqlClient.queryData, Variables>(query, variables, context)
if (response.error) {
BaseGraphQLRepository.throwError(response.error)
}
return response
}
protected async mutationData = any, Variables extends AnyVariables = AnyVariables>(
query: DocumentInputData, Variables>,
variables: Variables,
context?: PartialOperationContext>,
): PromiseOperationResultSourceOperationResultData, Variables>>> {
const graphqlClient = await this.getGraphQLClient()
const response = await graphqlClient.mutationData, Variables>(query, variables, context)
if (response.error) {
BaseGraphQLRepository.throwError(response.error)
}
return response
}
static throwErrorIgnoreRecordNotFound(error: CombinedError): void {
if (error.response?.status >= 500) throw new UnexpectedError('予期せぬエラーが発生しました。')
if (error.graphQLErrors[0].extensions['code'] !== 'RECORD_NOT_FOUND') {
this.throwError(error)
}
}
static throwError(error: CombinedError): never {
if (error.response?.status >= 500) throw new UnexpectedError('予期せぬエラーが発生しました。')
if (error.graphQLErrors[0].extensions['code'] === 'AUTHENTICATION_ERROR')
throw new AuthenticationError('ログイン期限が過ぎました。再度ログインしてください。')
throw new InvalidParameterError(
error.graphQLErrors[0].message,
error.graphQLErrors[0].extensions['code'] as string | undefined,
)
}
}
その上で、先ほどの IProposalRepository の GraphQL での実装例はこのようになります。
export class ProposalGraphQLRepository extends BaseGraphQLRepository implements IProposalRepository {
async create = (seed: ProposalSeed): PromiseProposal> => {
const variables: ProposalCreateMutationVariables = {
input: {
name: seed.name.value,
contractDate: seed.contractDate?.toStringByISO8601() ?? null,
weddingDate: seed.weddingDate?.toStringByISO8601() ?? null
},
}
await super.mutationProposalCreateMutation>(ProposalCreateDocument, variables)
return ProposalGraphQLRepository.toModel(response.data!.proposal!.create!.proposal!)
}
async update = (proposal: Proposal): PromiseProposal> => {
const variables: ProposalUpdateMutationVariables = {
input: {
id: proposal.id.value,
name: proposal.name.value,
status: proposal.status.value,
contractDate: seed.contractDate?.toStringByISO8601() ?? null,
weddingDate: seed.weddingDate?.toStringByISO8601() ?? null
},
}
await super.mutationProposalUpdateMutation>(ProposalUpdateDocument, variables)
return ProposalGraphQLRepository.toModel(response.data!.proposal!.update!.proposal!)
}
static toModel = (dto: ProposalFragment): Proposal => {
return new Proposal({
id: new ProposalId(dto.id),
name: Name.withValidate(dto.name),
status: ProposalStatus.withValidate(dto.status),
contractDate: dto.contractDate ? MyDate.withValidate(dto.contractDate) : undefined,
weddingDate: dto.weddingDate ? MyDate.withValidate(dto.weddingDate) : undefined
})
}
}
export const proposalGraphQLRepository = new ProposalGraphQLRepository()
フロントエンドの性質を考えると、データを作成した後は詳細画面に遷移したり、一覧画面に戻って画面を再描画したりする関係で、APIの戻り値が必要ないもしくは、idだけあれば十分というケースがよくあります。従って Repository のメソッドの戻り値としては Promise
である必要はなく、 Promise
だったり、 Promise
で十分なこともあります。実際、モデルを組み立てるのは手間がかかることが多く、呼び出し元で必要ないならば実装を省略してしまいたいこともあります。そのようなケースは開発チームで話し合って、戻り値の型を必要最低限にしてしまうのも一つのアイディアでしょう。(必ずしも教科書通りでなくても良い)
Factory
「なぜ依存関係逆転の法則が必要か」のセクションでも触れた通り、画面の描画に都合の良いデータ構造と、API通信で求められるデータ構造は必ずしも一致しません。状況によっては腐敗防止層の処理が必要になることもあるでしょう。また、集約(Aggregate)を使用している場合や、ある程度複雑なValueObjectsを複数のリポジトリで生成することもあります。その際にAPIのレスポンスの型からドメインモデルやValueObjectsの生成ロジックを使い回すことができるよう、必要に応じてinfrastructure配下にFactoryを配置します。サンプルは省略します。
Usecase
Yagyu.jsではCQRSの概念を導入しているため、登録・更新系の Command Usecase と、取得系の Query Usecase の2種類が存在します。Usecase は依存関係逆転の法則を使用して、永続化処理の内容は Interface (抽象) に依存させます。依存性を注入した Usecase 、例えば GraphQL のエンドポイントを使用して永続化する場合は、GraphQLRepository (infrastructure) を注入した Usecase を export し、ui ではその Usecase を使用します。
まずは Command Usecase から紹介します。
Command Usecase
多くの場合、入力Formの値を受け取り、登録・更新処理を行います。先ほどの「婚礼施行」の場合はのサンプルは以下の通り
登録時のUsecase
type Args = {
name: string
contractDate?: Date
weddingDate?: Date
}
export class CreateProposalUsecase {
constructor(private readonly proposalRepository: IProposalRepository) {}
async run({
name,
contractDate,
weddingDate
}: Args): PromiseProposal> {
const seed = new ProposalSeed({
name: Name.withValidate(name),
contractDate: contractDate ? new MyDate(contractDate) : undefined,
weddingDate: weddingDate ? new MyDate(weddingDate) : undefined,
})
return await this.proposalRepository.create(seed)
}
}
export const createProposalUsecase = new CreateProposalUsecase(proposalGraphQLRepository)
更新時(ステータス以外)のUsecase
type Args = {
proposal: Proposal
name: string
weddingDate: Date | undefined
}
export class UpdateProposalUsecase {
constructor(private readonly proposalRepository: IProposalRepository) {}
async run({
proposal,
name,
weddingDate
}: Args): PromiseProposal> {
const modified = proposal.change({
name: Name.withValidate(name),
weddingDate: weddingDate ? new MyDate(weddingDate) : undefined,
})
return await this.proposalRepository.update(modified)
}
}
export const updateProposalUsecase = new UpdateProposalUsecase(proposalGraphQLRepository)
更新時(ステータスのみ)のUsecase
type Args = {
proposal: Proposal
status: ProposalStatusValue
}
export class UpdateProposalStatusUsecase {
constructor(private readonly proposalRepository: IProposalRepository) {}
async run({
proposal,
status,
}: Args): PromiseProposal> {
const modified = proposal.changeStatus(ProposalStatus.withValidate(status))
return await this.proposalRepository.update(modified)
}
}
export const updateProposalStatusUsecase = new UpdateProposalStatusUsecase(proposalGraphQLRepository)
登録・更新系のUsecaseのポイントとしては、原則、引数の型はプリミティブな型(素のTypeScriptの型)を使用します(文字列リテラルのユニオン型などはOK)。ただし、更新時のUsecaseに限り、第一引数は更新対象のドメインモデルとしています。IDだけ受け取り、Usecaseの中でRepositoryを介してモデルを取得することもできますが、更新のUsecaseを実行する場合、往々にして画面側で更新対象のモデルの取得が既に行われているため、それを引数で受け取ることで、API通信を削減しています。
Query Usecase
何かのリソースの詳細画面と編集画面では必要な情報量が異なることがよくあります。例えば、先ほどの「婚礼施行」に「担当プランナー」の概念を追加する場合、描画時には「担当プランナー名」が必要だが、更新APIでは「担当プランナー名」は必要なく、代わりに「担当プランナーID」が必要になります。
このように、データの取得と更新は関心ごとが異なるケースが多いので、登録系と取得系のモデルを分離させ、処理も完全に分離しようというのがCQRSの考え方です。この通りに行うとコードの量はかなり増えることになります。しかし、画面ごとにクエリモデルを定義し、抽象度の高い概念と具象度の高い概念を切り分けていけば、ビジネスロジックは整理されていきます。
クエリモデルやクエリリポジトリーはUsecase毎に最適化した形を取るため、これらの定義はUsecase配下に配置します。
QueryModel
クエリモデルは「モデル」という名の通り、一意な id を持ちます。ドメインモデルとの違いは、 ライフサイクルを持たない 点で、詳細画面などの描画の際に使用します。
あとで記載する QueryRepository に id を渡してクエリモデルを取得します。フィールドに関しては、idから辿る範囲に限ります。(idと無関係な情報は持たせてはいけません)
また、ライフサイクルは持ちませんが、振る舞い(メソッド)は持たせることができます。
export abstract class QueryModel
T extends string,
ID extends id,
E extends ModelT, ID, E, A>,
A extends { [key in string]: AnyEqualable & JSONSerializable },
> extends ValueObjectsT, E, A> {
abstract get id(): ID
}
先ほどまでの「婚礼施行」に、担当プランナーの概念を付与したクエリモデルを考えます
type ProposaQueryArgs = {
id: ProposalId
name: Name
contractDate?: MyDate
weddingDate?: MyDate
status: ProposalStatus
plannerId: PlannerId
plannerName: Name
}
export class ProposalQueryModel extends QueryModel'Proposal', ProposalId, Proposal, ProposaQueryArgs> {
constructor(protected readonly args: ProposaQueryArgs) {
super(args)
}
get id(): ProposalId {
return this.args.id
}
get name(): Name {
return this.args.name
}
get contractDate(): MyDate | undefined {
return this.args.contractDate
}
get weddingDate(): MyDate | undefined {
return this.args.weddingDate
}
get status(): ProposalStatus {
return this.args.status
}
get plannerId(): PlannerId {
return this.args.plannerId
}
get plannerName(): Name {
return this.args.plannerName
}
isWithin30DaysOfTheWeddingDate = (today: MyDate): boolean =>
this.weddingDate === undefined ? false : today.daysUntil(this.weddingDate) 30
toDomainModel = (): Proposal =>
new Proposal({ ...this.args })
}
クエリモデルのポイントとしては、画面の描画の都合に合わせたデータ構造にして良いということです。基本的には階層構造にする必要はなく、フラットに持たせるようにします。
画面上で挙式日から30日前になると表示が切り替わるロジックがある場合などは、30日前かどうかを判定するメソッドを isWithin30DaysOfTheWeddingDate
のようにして持たせても良いでしょう。
また toDomainModel
メソッドを持たせることでドメインモデルに変換できるようにします。クエリモデルからドメインモデル(コマンドモデル)の参照はOKだが、逆はNGという関係性にします。
クエリモデルはライフサイクルを持たないので、状態を変更するようなメソッドは定義してはいけません。編集モードに切り替える場合などは toDomainModel を呼び出して、ドメインモデルに変換し、ドメインモデルのメソッドを通じてライフサイクルを管理します。
この変更に伴い、ドメインモデル側もplannerIdを追加しておきます
type ProposalArgs = {
...
plannerId: PlannerId
}
export class Proposal extends Model'Proposal', ProposalId, Proposal, ProposalArgs> {
...
get plannerId(): PlannerId {
return this.args.plannerId
}
changePlannerId = (plannerId: PlannerId): Proposal => {
return new Proposal({ ...this.args, plannerId })
}
}
QueryRepository (interface)
この ProposalQueryModel を取得するためのクエリリポジトリーを定義します。先述の通り、クエリモデルとドメインモデルは別物なので、リポジトリーも別物になります。ドメインリポジトリと同様、依存関係逆転の法則を利用するので、インターフェースだけの定義になります。
interface IProposalQueryRepository {
find(id: ProposalId): PromiseProposalQueryModel | undefined>
}
QueryRepository (infrastructure)
export class ProposalGraphQLQueryRepository extends BaseGraphQLRepository implements IProposalQueryRepository {
async find(id: ProposalId): PromiseProposalQueryModel | undefined> {
const variables: ProposalGetQueryVariables = { id: id.value }
const response = await super.queryOneProposalGetQuery>(ProposalGetDocument, variables)
const data = response.data
if (data === undefined || data === null) return undefined
return new ProposalQueryModel(ProposalGraphQLQueryRepository.toQueryModel(data.proposal))
}
static toQueryModel(dto: ProposalFragment): ProposalQueryModel {
return new ProposalQueryModel({
id: new ProposalId(dto.id),
name: Name.withValidate(dto.name),
contractDate: dto.contractDate ? MyDate.withValidate(dto.contractDate) : undefined,
weddingDate: dto.weddingDate ? MyDate.withValidate(dto.weddingDate) : undefined,
status: ProposalStatus.withValidate(dto.proposalType),
plannerId: new PlannerId(dto.plannerId)
})
}
}
export const proposalGraphQLQueryRepository = new ProposalGraphQLQueryRepository()
QueryUsecase
基本的にな作りは Command Usecaseと同じです。登録更新時と違う点は戻り値の型がドメインモデルではなくクエリモデルになっていることくらいです。引数の型はプリミティブなTypeScriptの型とします。
type Args = {
proposalId: string
}
export class GetProposalUsecase extends BaseUsecaseArgs, ProposalQueryModel | undefined> {
constructor(private readonly proposalQueryRepository: IProposalQueryRepository) {
super()
}
async run({ proposalId }: Args): PromiseProposalQueryModel | undefined> {
return await this.proposalQueryRepository.find(new ProposalId(proposalId))
}
}
export const getProposalUsecase = new GetProposalUsecase(proposalGraphQLQueryRepository)
※ BaseUsecase は react-query
でクエリキャッシュの制御を行う際に使う処理を共通化しているもので、次回の後編で紹介します。
今回のサンプルコードのディレクトリは以下のようになります
.
├── App.css
├── App.tsx
├── domain
│ ├── models
│ │ └── proposal
│ │ ├── index.ts
│ │ └── status.ts
│ ├── repositories
│ │ └── proposalRepository.ts
│ ├── services
│ └── valueObjects
│ └── myDate.ts
├── infrastructure
│ ├── factories
│ └── repositories
│ └── graphqlRepositories
│ ├── commands
│ │ └── proposalGraphQLRepository
│ │ ├── index.ts
│ │ ├── proposalCreate.graphql
│ │ └── proposalUpdate.graphql
│ └── queries
│ └── proposalGraphQLQueryRepository
│ ├── index.ts
│ └── proposalGet.graphql
├── main.tsx
├── ui
│ ├── pages
│ ├── root
│ └── routes
└── usecases
├── commands
│ └── proposal
│ ├── createProposalUsecase.ts
│ ├── updateProposalStatusUsecase.ts
│ └── updateProposalUsecase.ts
└── queries
└── proposal
├── queryModels
│ └── proposalQueryModel.ts
├── queryRepositories
│ └── proposalQLQueryRepository.ts
└── getProposalUsecase.ts
今回の記事では、株式会社TAIANのフロントエンド開発(React)で採用している Yagyu.js について、戦術的DDDの内容を中心に紹介しました。フロントエンドエンジニアとしては ui ディレクトリ配下の実装が気になるかもしれませんが、後編をお待ちください。
Yagyu.js はビジネスロジックを ui から切り離すことに重きを置いています。 React に関するコードは ui 配下に、API通信(RESTful APIやGraphQL API)に関する実装は infrastructure 配下に集中させ、ビジネスロジックをピュアな TypeScript で書くことで、
- 特定のライブラリの依存を最小限に抑えることができる
- あちこちに utils が宣言されてビジネスロジックが飛散するのが防げる
- ビジネスロジックに対するユニットテストが書きやすくなる
- ファイルごとに役割が細分化され明確になるので、どこに何を書くか分かりやすくなる
- メンバーのコード品質が揃うことでコードレビューの負荷も下がる
- AIエージェントとの相性も良いので、ある程度自動でコード生成できる
- バックエンドエンジニアも ui ディレクトリ以外は実装できる
といったメリットがあります。
極端な話、 Yagyu.js は React 以外のフレームワークでも対応できます。複雑なドメインロジック(ビジネスロジック)を UI から切り離しておくことができれば、進化のスピードが速いフロントエンド開発においても柔軟に対応していくことができるでしょう。
TAIANでは、このような開発・技術・思想に向き合い、未来をつくる仲間を一人でも多く探しています。少しでも興味を持っていただいた方は弊社の紹介ページをご覧ください。
Views: 3