簡単なタスク管理アプリケーションをレイヤードアーキテクチャを意識して NestJS で組んでみました。
その記録や思ったことなどを残したいと思います。
すごい間違っていたらすみません。
ざっくりとしたアプリケーションの要件メモ
非常に小さなアプリケーションなため、雑にざっくりとした要件を箇条書きしちゃいます。
- タスク管理アプリケーションである
- ウェブサーバーアプリケーションである
- ユーザーは自分1人だけ
- タスクは以下の仕様を含む
- タスク名と完了ステータスを持つ
- タスク名は重複してはならない
- タスク名は1文字以上、20文字以内
- アプリケーションは以下の機能を提供する
- タスクの作成
- タスクの全件取得
- タスクの更新
- タスクの削除
ざっくりとした実装方針メモ
- レイヤードアーキテクチャをベースとする
- タスクに関するドメインしか無いため、集約、パッケージ、モジュールなどについてはそこまで深く考えない
- 例えば今回 task という名前のディレクトリは作りませんでした
- ドメイン層やアプリケーション層ではなるべくフレームワークに依存しない
ただし、以下についてはフレームワークの仕組みを利用する
(参考: ドメイン駆動設計 サンプルコード&FAQ > 8.5.6 ライブラリに依存してよいのはどの層まで?)- DI
- トランザクション
- タスク識別子は連番
- プレゼンテーション層でドメインのふるまいを実行するのは禁止
実装してみる
cli で雛形を作ったら実装していきます。
(作ったものは以下になります)
https://github.com/minericefield/trying-nestjs-ddd
ドメイン層
タスク名値オブジェクト
最初に、タスク名値オブジェクトを作ってみます。
export class TaskName {
private _value: string;
get value(): string {
return this._value;
}
constructor(value: string) {
if (value.length === 0)
throw new Exception('Task name should not be empty.');
if (value.length > 20)
throw new Exception('Task name should not be longer than 20 characters.');
this._value = value;
}
}
値のカプセル化と、コンストラクターでのバリデーションをしてるだけのシンプルな値オブジェクトです。
タスクエンティティ
次にタスクエンティティです。
export class Task {
private _id: number;
private _name: TaskName;
private _done: boolean;
get id(): number {
return this._id;
}
get name(): TaskName {
return this._name;
}
get done(): boolean {
return this._done;
}
constructor(id: number, name: TaskName, done = false) {
this._id = id;
this._name = name;
this._done = done;
}
updateName(name: TaskName): void {
this._name = name;
}
updateDone(done: boolean): void {
this._done = done;
}
}
コンストラクターで、 id, タスク名, 完了ステータス を受け取って初期化しています。
コンストラクターに渡すタスク名は string型 と、 TaskName型 ではどちらが良いのか
これについては ドメイン駆動設計 サンプルコード&FAQ > 3.2.4 生成メソッドには値オブジェクトを渡してもよい? で以下のようなことが言及されています。
- いずれの場合も間違ってはないが、値オブジェクト型の方が何が渡されるか明確になり、可読性が高まる
- 重要なのは、値オブジェクトのバリデーションを、責務の観点・再利用性の観点を考慮して値オブジェクト側に実装すること
今回すでに値オブジェクト側でバリデーションを実装しているため、どちらでも大丈夫ですが、やはりこちらの方が可読性が高いため、引数で TaskName を受け取るようにしました。
ドメインオブジェクト内で表現するべきではない仕様を実装する
タスク名重複チェックドメインサービス
今回、 タスク名は重複してはならない
という仕様があります。
ドメインオブジェクトにこちらのふるまいを実装してしまうと、自分自身が自分自身に重複を問い合わせるという不自然なふるまいになってしまいます。
また、最も高レベルな概念であるドメインオブジェクトが低レベルなレポジトリへ依存することも避けたいです。
こちらをドメインサービスとして実装していきます。
※ この時点で既にレポジトリのインターフェースの定義に着手し始めていますが、後でまとめて説明します。
@Injectable()
export class DuplicateTaskChecker {
constructor(private readonly taskRepository: ITaskRepository) {}
async handle(task: Task): Promise<boolean> {
const foundedTask = await this.taskRepository.findOneByName(task.name);
return !!foundedTask;
}
}
タスク名での検索結果をもとに、重複チェック結果を返しています。
新規タスク作成ファクトリー
タスクエンティティは、IDをコンストラクターで受け取りますが、 タスク識別子は連番
という仕様を実現する必要があります。
IDを知るためにはレポジトリに問い合わせる必要があるため、専用の新規タスク作成ファクトリーを実装します。
ただし、ファクトリーを実装したところでその存在に開発者が気付いてくれない可能性があることを考慮して、そのインターフェースをタスクエンティティと同じディレクトリーに配置します(変かもしれません)。
抽象を参照して実体をDIする予定は今回特にありません。
export interface INewTaskCreator {
handle(taskName: TaskName): Promise<Task>;
}
@Injectable()
export class NewTaskCreator implements INewTaskCreator {
constructor(private readonly taskRepository: ITaskRepository) {}
async handle(taskName: TaskName): Promise<Task> {
const id = await this.taskRepository.getNextId();
return new Task(id, taskName);
}
}
ここで、 getNextId
すなわち次の連番IDを取得するメソッドを使用していますが。
レポジトリの責務に違反している可能性があります。
(レポジトリの責務)
- データの永続化
- データの再構築
- 集約の範囲の表現 (ドメインオブジェクトだけで集約を表現するのには限界があり、レポジトリのインターフェースで集約、そしてデータ永続化時の規約を表現する)
こちらの方法は、ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 > 9.2.2 レポジトリに採番用メソッドを用意する で紹介されていながらも、
採番処理にまで手を伸ばすのは少し責務を広げ過ぎているように感じるため推奨していません
と言われています。
getLastIdTask
なるものを用意して、最後のIDのタスクを丸ごと取得する方が良かったかもしれません。
(それか、実践ではuuid系のライブラリのを使うのが良さそうです)
レポジトリインターフェース
既に登場してしまいましたが作成したレポジトリのインターフェースは以下です。
export abstract class ITaskRepository {
abstract getAll(): Promise<Task[]>; // 全件取得
abstract save(task: Task): Promise<void>; // 保存
abstract updateOne(task: Task): Promise<void>; // 更新
abstract deleteOne(id: number): Promise<void>; // 削除
abstract findOneById(id: number): Promise<Task | null>; // idで取得
abstract findOneByName(taskName: TaskName): Promise<Task | null>; // 名前で取得
abstract getNextId(): Promise<number>; // 次のID取得(一旦特別に許可)
}
レポジトリは依存性を逆転させる必要があるため、抽象を参照させ実体をDIします。
その際、NestJSではインターフェースより抽象クラスの方が相性がいいので(詳細は割愛します)、抽象クラスになっています。
概念としてはインターフェースとして統一して表現したかったので ITaskRepository
という命名になっています。
これでドメイン層は一旦完成です。
アプリケーション層
アプリケーション層を実装していきます。
今回実現したいユースケースのうち、 タスクの作成
と タスクの全件取得
を説明します。
なお、ユースケース1つにつき1クラスで実装しています。
全てのユースケースを1つのクラスにまとめるべきか、それとも分けるべきかについては絶対的なルールは無いようで、今回の規模であれば、1クラスにまとめても問題はなさそうです。
また、共通するエンドポイントは1つのコントローラーでまとめるため、ユースケースを分けてしまうとコントローラーの凝集度は下がってしまいます。
ただし、アプリケーション層は最も肥大化しやすそうなので、こちらを1つずつに分けるのが吉かもしれません。
タスク作成ユースケース
@Injectable()
export class CreateTaskUseCase {
constructor(
private readonly duplicateTaskChecker: DuplicateTaskChecker,
private readonly newTaskCreator: NewTaskCreator,
private readonly taskRepository: ITaskRepository,
) {}
async handle(createTaskCommand: CreateTaskCommand): Promise<void> {
const task = await this.newTaskCreator.handle(
new TaskName(createTaskCommand.name),
);
const doesTaskExist = await this.duplicateTaskChecker.handle(task);
if (doesTaskExist) throw new Exception('Same task already exists.');
await this.taskRepository.save(task);
}
}
- タスク新規作成ファクトリーでタスク作成
- 重複チェック(重複してたらエラーを投げる)
- タスク保存
の流れで、特筆するところは特になさそうです。
タスク全件取得ユースケース
@Injectable()
export class GetAllTasksUseCase {
constructor(private readonly taskRepository: ITaskRepository) {}
async handle(): Promise<Task[]> {
return this.taskRepository.getAll();
}
}
戻り値としてタスクエンティティの配列をそのまま返しています。
ドメインオブジェクトをそのまま返すのも、専用のDTO型に詰め替えて返すのも、どちらも間違いでは無いようですが、
その振る舞いが流出することを防ぐためにDTOを返す場合の方が多いようです。
今回個人的に、 タスクを全件取得したいという要件、そしてそのタスクはドメインとして定義されているよね
と単純に考えることを好み、このようにしてます。
なおDTOの実装ルールは、API仕様書やプレゼンテーション層を意識せずに、「ただ振る舞いを無くして展開する」程度にしてアプリケーション層の概念をぼやけさせないことが重要なのかな...とか思ったりしました。
また、 (例えば仮にページング情報とかであれど) なるべくアプリケーション層で取得したい概念は全て、 ドメインモデル → ドメイン層 に落とし込んだ方がユースケースがはっきり見えそうだと感じました。
次に、インフラストラクチャ層やプレゼンテーション層ですが、こちらでは特に NestJS というフレームワークが活躍します。
インフラストラクチャ層
レポジトリを作成します。
NestJS では以下のようにインターフェースと実体をマッピングしDIすることができます。
{
provide: ITaskRepository, // レポジトリインターフェース
useClass: TaskTypeormRepository, // typeormレポジトリ
}
こちらを利用して 依存性の逆転 と、 インメモリレポジトリ ⇆ typeormレポジトリ の切り替え を実装しています。
インメモリレポジトリ
開発中のモックやテスト容易性を提供するインメモリ(オンメモリ?)レポジトリを作成します。
@Injectable()
export class TaskInMemoryRepository implements ITaskRepository {
// Mock.
private tasks = [
{
id: 1,
name: 'Install mysql.',
done: true,
},
{
id: 2,
name: 'Create database.',
done: false,
},
];
async getAll(): Promise<Task[]> {
return this.tasks.map(
(taskData) =>
new Task(taskData.id, new TaskName(taskData.name), taskData.done),
);
}
async save(task: Task): Promise<void> {
this.tasks.push({
id: task.id,
name: task.name.value,
done: task.done,
});
}
async updateOne(task: Task): Promise<void> {
this.tasks = this.tasks.map((_task) => {
if (_task.id === task.id) {
return { id: task.id, name: task.name.value, done: task.done };
}
return _task;
});
}
// ~ 以下省略 ~
}
特筆する点は特になく、ただローカルの tasks を参照してドメイン層で定義した要求を実現しているだけになります。
(ちなみに NestJS の @Injectable()
はデフォルトでシングルトンになり、 tasks の状態は保持されます)
typeorm レポジトリ
typeorm(今回使ったORM) レポジトリも作成します。
こちらも特に特筆する点はありません。
@Injectable()
export class TaskTypeormRepository implements ITaskRepository {
constructor(
@InjectRepository(TaskTypeormEntity)
private readonly taskTypeormEntityRepository: Repository<TaskTypeormEntity>,
) {}
async getAll(): Promise<Task[]> {
const taskDatas = await this.taskTypeormEntityRepository.find({});
return taskDatas.map(
(taskData) =>
new Task(taskData.id, new TaskName(taskData.name), taskData.done),
);
}
async save(task: Task): Promise<void> {
await this.taskTypeormEntityRepository.save({
id: task.id,
name: task.name.value,
done: task.done,
});
}
async updateOne(task: Task): Promise<void> {
const taskData = await this.taskTypeormEntityRepository.findOne(task.id);
taskData.name = task.name.value;
taskData.done = task.done;
await this.taskTypeormEntityRepository.save(taskData);
}
// ~ 以下省略 ~
}
レポジトリを提供するモジュール
使用したいレポジトリ(in-memory
または mysql-typeorm
)を引数で受け取って適切なレポジトリを返すダイナミックモジュールを作成します。
(DIコンテナ側では一旦環境変数でレポジトリを切り替えるようにしてみました)
@Module({})
export class RepositoryModule {
static register(repositoryType: RepositoryType): DynamicModule {
let repositoryModule;
switch (repositoryType) {
case 'in-memory':
repositoryModule = InMemoryRepositoryModule;
break;
case 'mysql-typeorm':
repositoryModule = MysqlTypeormModule;
break;
default:
throw new Exception('Please provide a proper "REPOSITORY_TYPE"');
}
return {
module: repositoryModule,
};
}
}
レポジトリが増えたら個別のレポジトリごとに in-memory
または mysql-typeorm
を選べるようにすると良さそうです。
プレゼンテーション層
NestJS では通常コントローラーがそのままプレゼンテーション層に該当することが多いかと思われます。
タスクコントローラー
@Controller('tasks')
@UseFilters(DefaultExceptionPresenter, UnexpectedExceptionPresenter)
export class TaskController {
constructor(
private readonly createTaskUseCase: CreateTaskUseCase,
private readonly deleteTaskUseCase: DeleteTaskUseCase,
private readonly getAllTasksUseCase: GetAllTasksUseCase,
private readonly updateTaskUseCase: UpdateTaskUseCase,
) {}
@Get()
async getAll(): Promise<GetAllTasksResponseDto> {
const tasks = await this.getAllTasksUseCase.handle();
return new GetAllTasksResponseDto(tasks);
}
@Post()
async createOne(
@Body() createTaskRequestDto: CreateTaskRequestDto,
): Promise<CreateTaskResponseDto> {
await this.createTaskUseCase.handle(
new CreateTaskCommand(createTaskRequestDto.name),
);
return { statusCode: HttpStatus.OK };
}
@Put('/:id')
async updateOne(
@Param('id') id: string,
@Body() updateTaskRequestDto: UpdateTaskRequestDto,
): Promise<UpdateTaskResponseDto> {
await this.updateTaskUseCase.handle(
new UpdateTaskCommand(
Number(id),
updateTaskRequestDto.name,
updateTaskRequestDto.done,
),
);
return { statusCode: HttpStatus.OK };
}
@Delete('/:id')
async deleteOne(@Param('id') id: string): Promise<DeleteTaskResponseDto> {
await this.deleteTaskUseCase.handle(new DeleteTaskCommand(Number(id)));
return { statusCode: HttpStatus.OK };
}
}
各エンドポイントで対応するユースケースを実行しています。
こちらの層ではエラーハンドリングの取りまとめを行なっています。
ドメイン駆動設計 サンプルコード&FAQ > 8.1.2 投げられた例外はどう処理する? より以下引用。
プレゼンテーション層に共通の例外ハンドラーを定義し、そこで例外をキャッチしてエ
ラーレスポンス用のオブジェクトに詰め替えるのがよいでしょう。 (...中略...) この実装方法はフレーム ワークに依存します。
あらかじめ定義した例外型とその他すべての例外をそれぞれキャッチしエラーレスポンスを返す
@Catch(Exception)
export class DefaultExceptionPresenter implements ExceptionFilter {
catch(exception: Exception, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
response.status(exception.statusCode).json({ ...exception });
}
}
@Catch()
export class UnexpectedExceptionPresenter implements ExceptionFilter {
catch(exception: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
response
.status(statusCode)
.json({ statusCode, message: 'Unexpected error ocurred.', ...exception });
}
}
こちらはエラーを適当に整形してレスポンスを返しているだけの例外フィルターです。
コントローラー側 では @UseFilters(DefaultExceptionPresenter, UnexpectedExceptionPresenter)
でこれらを仕込んでいます。
デコレーターで宣言的に書けるところが NestJS とても気持ち良いです。
これで全ての層がそろいました。
サーバー実行
あとは依存性を解決してサーバーを実行するだけです。
@Module({
imports: [RepositoryModule.register(process.env.REPOSITORY_TYPE)],
controllers: [TaskController],
providers: [
// Entity factory
NewTaskCreator,
// Domain service
DuplicateTaskChecker,
// Application service
CreateTaskUseCase,
DeleteTaskUseCase,
GetAllTasksUseCase,
UpdateTaskUseCase,
],
})
export class AppModule {}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();
一応テストも
テストについては、試しに軽く書いてみた程度になります。
テスト用環境変数に REPOSITORY_TYPE=in-memory
を仕込み、インメモリレポジトリで実行してみましたが、簡単にe2eテストが書けました。
describe('TaskController (e2e)', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/tasks (GET)', () => {
return request(app.getHttpServer())
.get('/tasks')
.expect({
statusCode: 200,
tasks: [
{
id: 1,
name: 'Install mysql.',
done: true,
},
{
id: 2,
name: 'Create database.',
done: false,
},
],
});
});
it('/tasks (POST) duplicate error', () => {
return request(app.getHttpServer())
.post('/tasks')
.send({ name: 'Install mysql.' })
.expect({
statusCode: 500,
message: 'Same task already exists.',
});
});
});
jest.mock
や jest.spyOn
なしに素早く網羅的な検証ができそうです。
(もちろんテストケースによってこれらをむしろ使った方が良い場合もありますが)