初めてDDDを使ってみて悩んだところ
研修でDDDを使ったサービスを作ってみることになったが、DDDを使うのが初めてなので同じような状況の人向けに悩んだところをメモしておこうと思う。
DDDとは
DDD(Domain-Driven Design)とはドメイン駆動設計と呼ばれる設計方法の一種で、複雑なビジネスの要件をソフトウェアで上手く扱うためのアプローチとなっている。(DDDの詳しい説明などは以下を参照)
DDDはドメイン(業界領域)の複雑さにフォーカスを当て、ドメインに精通しているドメインエキスパートと呼ぶ人の協力を得てシステム開発を行ってい行く。また、DDDではクリーンアーキテクチャ、ヘキサゴナルアーキテクチャなどのアーキテクチャと共に用いられることが多い。(今回作っているサービスではクリーンアーキテクチャを採用しているつもりだが、他のアーキテクチャとの違いが正直良く分かっていない)
サービスの概要
ざっくりと説明すると、インスタグラムっぽい画像投稿サイトを作っている。
構成は、
- フロントエンド:Next.js
- バックエンド:Express
- DB:sqlite
となっていて、バックエンドにDDDとクリーンアーキテクチャを採用している。
フロントエンドは今回手を抜いていて、以下のサイトの通りの実装を進めたものをそのまま使っている。サイト内ではバックエンドにDjangoを使っていたので、それをExpressに移植しているような感じ。
出来るのはこんなやつ。
(https://zenn.dev/hathle/books/next-drf-image-post-book/viewer/00_first より引用)
悩んだところ
ドメインモデル図で「フォロー関係」をどう表現するか
DDDの勉強をすると、まず初めに「ユースケース図、ドメインモデル図を書きましょう!そしてそれをもとに実装していきましょう!」といろんなサイトでおすすめされる。このサービスでも最初にユースケース図を書き、そのユースケースの中で特定の範囲(アカウント関連の処理)のドメインモデル図を書いていた。
ユースケース図(一部)
この中の「アカウントを作成する」とか「ログインする」、「他のユーザをフォローする」みたいなアカウント関連の処理に絞ってドメインモデル図を作成していた。とりあえず簡単に作れたのは、下のような感じで「アカウント」が「プロフィール」を持っている形だった。
ドメインモデル図(作りかけ)
ここまではすごく単純で、アカウント集約でもアカウントに一つ紐づいているプロフィールを持ってくれば良いだけだった。これでユースケースが満たせるか考えてみると、
- 満たせる
- ログイン・ログアウトする
- アカウントを作成する
- 自身のプロフィールを編集する
- 満たせない
- 他のユーザをフォローする
このドメインモデル図だとユーザ同士のフォロー関係が書かれていないので「他のユーザをフォローする」のユースケースを満たすことが出来ない。ただ、フォロー関係を表現する際に、”フォロー・フォロワーも結局はアカウントだよな。そうするとアカウント同士の結びつき的なものを書くのか?”と考えていた。(ただ図が分かりずらくなりそうでなんか気持ちが悪かった)
そこでDDDやクリーンアーキテクチャの理解が深い同期に相談してみたところ、 "ドメインモデル図を考えるときは、画面に表示される要素で考えてみるといい" というアドバイスをもらえた。今回の場合だと、フォロー関係というのは画面では「フォロー」「フォロワー」と表示されるはずで、そうなると、ドメインモデル図も「フォロー」「フォロワー」モデルをそれぞれ作ると良さそう。
そして、集約単位で考え「アカウント集約」に「フォロー」「フォロワー」を入れてしまうと、不要なところでもフォロー、フォロワー情報が取得されてしまうので、「フォロー集約」「フォロワー集約」とすることにした。
ドメインモデル図(修正後)
また、こうすることで実装も「フォロー」「フォロワー」をそれぞれ別のクラスにすることができ、コードの可読性も上がっていくと思う。例えば、下のようなアカウント・フォロー・フォロワーを全てAccountクラスで表現するコードだと、変数名でしかその内容を判断できなくなってしまう。
const account: Account = new Account(...)
const follows: Account[] = account.getFollows()
const followers: Account[] = account.getFollowers()
これが、Follow
クラス、Follower
クラスを作ることでFollow
クラスのみの制約をかけることなどもできる。
アカウントIDをDBの連番(Auto increment)で振りずらい
ドメインモデル図を書いた後に実装に移っていったが、アプリケーション(ユースケース)層に差し掛かったところで悩みポイントがあった。それが、「アカウントIDをDBの連番(Auto increment)で振りずらい」ということだった(「振れない」ではなく、あくまで「振りずらい」という感じ)。
今回のサービスのバックエンドでは以下のようなディレクトリ構成になっており、アプリケーション層(application)では基本的にドメイン層(domain)のみに依存している形になっている。そして、ドメイン層は他の何にも依存しない形になっていて、DBやフレームワークのことは少なくともアプリケーション層では見えないはず。
.
├── app.ts
├── application
├── domain
├── infrastructure
└── interface
では、実際にアプリケーション層で「アカウントを作成する」というユースケースを書いてみる。
import { injectable, inject } from 'inversify';
import { Account } from '../../domain/model/Account';
import { IAccountRepository } from '../../domain/repository/IAccountRepository';
import { CreateAccountDTO } from '../dot/CreateAccountDTO';
@injectable()
export class AccountService {
constructor(
@inject('IAccountRepository') private accountRepository: IAccountRepository
) {}
async createAccount(dto: CreateAccountDTO): Promise<Account> {
const email = dto.email
const name = dto.name
const password = dto.passowrd
const account = new Account(null, email, name, password); // IDがまだ分からない
await this.accountRepository.save(account);
return account;
}
}
上のコードだとnew Account(...)
の部分でidにNULLを代入していることが分かると思う。これはつまり、AccountクラスがidにNULLを許容していることを意味しており、一時的にでも正しくないインスタンスが作られて、使われることになってしまう。
NULLを代入する理由としては、「DBがどんなIDを振るのか分からない」からであり、DB登録前では一度DBにIDを確認しに行く必要が出てきてしまう。そうすると単純にパフォーマンスの低下を引き起こすだけでなく、DBインスタンスが複数存在する場合に整合性を取る工夫も必要となる。また、Accountのインスタンスを作るためのアカウントIDがDBに依存している状態はDDDの方針と反しているのではないかとも考えている(これは勝手に自分で思っているだけ)。
この場合の解決策として選んだのが「IDはアプリケーション層でUUIDを生成する」というアプローチで、アプリケーション層に置く実装が多くなってしまうデメリットはあるが、AccountクラスのidにNULLを許容しなくて良くなりDBにIDを聞きに行く必要もなくなる。
(ふたを開けてみれば単純な問題だったが、実際に実装してみるまでこういう細かいところにも気が付かなかったので"手を動かすのはやっぱり大事だな"と思った。)
ただ、あくまでDDDを考えるなら振りずらいというだけで、DBの連番でIDを振る実装はできなくはないし、明確なデメリットもない気がする。
一旦おわり
まだサービスを作っている途中なので、また悩んだことがあったらここに更新していく予定
Discussion