3行で言うと
- ORMみたいな、レイヤーをまるっと抽象化してくれるライブラリを導入するなら
- インターフェースをいちいち書いてコアビジネスルールがそれに依存しないようにするのって
- 関心の分離はできるけど大変だからちょっと考え直しましょうよ
その時の状況
- 自社開発、数人規模、エンジニアは自分だけ
- 使用技術: TypeScript, Express, Next.js, AWS, Terraform, Docker(今じゃもういちいち書かないか)
- 自分は歴1年と3ヶ月(1年はRailsやってた)
ストーリー
私はアーキテクチャ大好きマンなので、この数ヶ月かなり研究を重ねていました。
その道中で、関心の分離を盲信する時期が訪れます。
その時の私は、DI(依存性注入)のためのインターフェースを大量生産していました。
そのため機能追加をするたびに多くのインターフェースを調整する必要があり、だんだん私は疲弊していきました。
ここで気づくのです。
「インターフェースが保守性を下げてる!?」
本末転倒とはこのことです。この時点ですでにベータ版リリース予定1ヶ月前。。。 コードが増えたせいで必須要件の半分も完成していませんでした(そもそも機能が多すぎたという反省もある)。
私はDIPを神聖視するのをやめて、妥協点を探り始めたのです。
具体例
今回の話とは関係ないのですが、Web APIでClean Architectureをやろうとすると、Presenterの扱いに困るのでControllerがUse caseの戻り値・エラーを使ってPresenterまで担うのがいいと思ってます。
本題に戻りますが、このアーキテクチャの問題点は、
RepositoryとUse caseを疎結合にするようなRepository Interfaceを保守することが難しい点です。
...ややこしいですね。
記事の最初にあった言葉をちょっと変えて言うと、
- Prismaみたいな、Repositoryレイヤーをまるっと抽象化してくれるORMを導入するなら
- Repositoryインターフェースをいちいち書いてUse caseがORMに依存しないようにするのって
- 関心の分離はできるけど大変だからちょっと考え直しましょうよ
というわけです。伝わらなかったらごめんなさい。
以下の具体例では、Prismaを使用しているAPIアプリケーションで、ユースケースを
- Prismaに依存しないパターンA
- Prismaに依存するパターンB
の2パターンで比較します。
パターンA: Repositoryを切り出したパターン
以下はUser
オブジェクトです。
export type User = {
id: string;
name: string;
}
以下はUser
オブジェクトの永続化を担うRepository Interfaceです。
export interface IUserRepository {
create(input: IUserRepositoryCreateInput): Promise<User>
}
export type IUserRepositoryCreateInput = {
id: string;
name: string;
}
以下はIUserRepository
を使用するUse caseです。
IUserRepository
の実装はインスタンス化する時にDIします。
import { User } from '#/app/schemas/User';
import type { IUserRepository, IUserRepositoryCreateInput } from '#/app/interfaces/repository/User.repo';
export type CreateUserInput = IUserRepositoryCreateInput;
export class CreateUser {
constructor(private readonly userRepository: IUserRepository) {}
async execute(input: CreateUserInput) {
const user = this.userRepository.create(input);
// Do something
}
}
以下はIUserRepository
のPrismaによる実装です。
import { User } from '#/app/schemas/User';
import type { IUserRepository } from '#/app/interfaces/repository/User.repo';
import { PrismaClient } from '@prisma/client';
export class PrismaUserRepository implements IUserRepository {
constructor(private readonly db: PrismaClient) {}
async create(input: IUserRepositoryCreateInput): Promise<User> {
const user = this.db.create({
data: input,
});
return user;
}
}
これを見て、保守性を保つならこれくらいは書くだろ、と思われたでしょうか。
ではこれをUse caseがPrismaに依存してもいいようにしたらどうなるでしょうか?
パターンB: Repositoryを切り出さないパターン
import { Prisma, PrismaClient } from '@prisma/client';
export type CreateUserInput = Prisma.UserUncheckedCreateInput;
export class CreateUser {
constructor(private readonly db: PrismaClient) {}
async execute(input: CreateUserInput) {
const user = this.db.create({
data: input,
});
// Do something
}
}
え、これだけ!?
Prismaに依存することは避けられないとしても、一目でビジネスルールを理解できますよね?
処理がもう少し複雑になったら多少手続き的になることは避けられませんが、クエリを作っている部分に関してはよほど悪い書き方をしない限り簡単に理解できます。
また、Prismaが提供するMockを使えば独立したテストは可能なので、テスタビリティという観点でも十分といえます。
数字で比較しても、当然ながらPrismaに頼って(依存して)作った方が圧倒的に複雑性が小さくて済むことがわかります。
ファイル数 | 行数 | 文字数 | import数 | export数 | |
---|---|---|---|---|---|
パターンA | 4 | 39 | 1073 | 6 | 6 |
パターンB | 1 | 14 | 327 | 2 | 2 |
ライブラリにUse caseが依存してもいいかどうかの判断基準は大体次のあたりだと思っています。
- ライブラリがメンテナンスされている -> 依存しても比較的問題ない
- 開発するソフトウェアの使用が短期的 -> 依存しても比較的問題ない
- 他のライブラリに乗り換える可能性が低い -> 依存しても比較的問題ない
- ライブラリにレイヤーを丸ごと任せられる -> 依存しても比較的問題ない (ビジネスルールがすぐに理解できるから)
まとめ
最初の1年間RailsでMVCを使っていた私にとって依存性注入という概念は非常に革命的だったが故にドツボにハマってしまったのだと思います。営利行為である以上、技術を手段として使う意識をもっと持たなければと思いました。
とはいえ今回のような答えのない問いに落とし所を見つける作業は応用が効きそうですね(マーケティングや営業などの他業種に比べれば大した難しさではないのかもしれませんが)。
誤解を恐れずにこの記事を一言でまとめるなら、
ORMとDIPは相性悪い。
以上です。ご精読ありがとうございました🙇