Todoアプリの構築
このチュートリアルでは、MBC CQRS Serverlessを使用して完全なTodoアプリケーションを構築する方法を説明します。CQRSパターン、イベントハンドリング、段階的な機能追加を学びます。
このチュートリアルは、段階的なステップで構成されたサンプルコードに従っています。
構築するもの
以下の機能を持つ完全に機能するTodoアプリケーション:
- TodoのCRUD操作
- コマンド/クエリ分離によるCQRSパターン
- RDSへのイベント駆動データ同期
- オプション:Todoのシーケンス番号
- オプション:非同期タスク処理
前提条件
- クイックスタートチュートリアルを完了していること
- NestJSの基本的な理解
- ローカル開発用にDockerが実行中であること
サンプルの実行
各ステップには完全に動作するサンプルがあります。サンプルを実行するには:
# ステップディレクトリに移動
cd step-02-create # または他のステップ
# 依存関係のインストール
npm install
# ターミナル1:Dockerサービスを起動
npm run offline:docker
# ターミナル2:データベースマイグレーションを実行
npm run migrate
# ターミナル3:serverless offlineサーバーを起動
npm run offline:sls
Part 1:基本的なCQRS実装(step-02-create)
ステップ1:ヘルパー関数の作成
まず、パーティションキーとソートキーを管理するヘルパー関数を作成します(src/helpers/id.ts):
import { KEY_SEPARATOR } from '@mbc-cqrs-serverless/core'
import { ulid } from 'ulid'
export const TODO_PK_PREFIX = 'TODO'
export function generateTodoPk(tenantCode: string): string {
return `${TODO_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`
}
export function generateTodoSk(): string {
return ulid() // ULID provides time-ordered unique identifiers(ULIDは時間順の一意識別子を提供)
}
export function parsePk(pk: string): { type: string; tenantCode: string } {
if (pk.split(KEY_SEPARATOR).length !== 2) {
throw new Error('Invalid PK')
}
const [type, tenantCode] = pk.split(KEY_SEPARATOR)
return { type, tenantCode }
}
ステップ2:DTOの定義
Todo属性DTOを作成(dto/todo-attributes.dto.ts):
import { ApiProperty } from '@nestjs/swagger'
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator'
// TodoStatus enum (will be synced with Prisma in step-03)(TodoStatusのenum、step-03でPrismaと同期)
export enum TodoStatus {
PENDING = 'PENDING',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELED = 'CANCELED',
}
export class TodoAttributes {
@IsOptional()
@IsString()
description?: string
@IsOptional()
@ApiProperty({ enum: TodoStatus })
@IsEnum(TodoStatus)
status?: TodoStatus
@IsOptional()
@IsDateString()
dueDate?: string
}
入力DTOを作成(dto/create-todo.dto.ts):
import { Type } from 'class-transformer'
import { IsOptional, IsString, ValidateNested } from 'class-validator'
import { TodoAttributes } from './todo-attributes.dto'
export class CreateTodoDto {
@IsString()
name: string // The name field is required by CommandEntity(nameフィールドはCommandEntityで必須)
@Type(() => TodoAttributes)
@ValidateNested()
@IsOptional()
attributes?: TodoAttributes
constructor(partial: Partial<CreateTodoDto>) {
Object.assign(this, partial)
}
}
ス テップ3:エンティティの定義
コマンドエンティティを作成(entity/todo-command.entity.ts):
import { CommandEntity } from '@mbc-cqrs-serverless/core'
import { TodoAttributes } from '../dto/todo-attributes.dto'
export class TodoCommandEntity extends CommandEntity {
attributes: TodoAttributes
constructor(partial: Partial<TodoCommandEntity>) {
super()
Object.assign(this, partial)
}
}
コマンドDTOを作成(dto/todo-command.dto.ts):
import { CommandDto } from '@mbc-cqrs-serverless/core'
import { Type } from 'class-transformer'
import { IsOptional, ValidateNested } from 'class-validator'
import { TodoAttributes } from './todo-attributes.dto'
export class TodoCommandDto extends CommandDto {
@Type(() => TodoAttributes)
@ValidateNested()
@IsOptional()
attributes?: TodoAttributes
constructor(partial: Partial<TodoCommandDto>) {
super()
Object.assign(this, partial)
}
}
データエンティティを作成(entity/todo-data.entity.ts):
import { DataEntity } from '@mbc-cqrs-serverless/core'
import { TodoAttributes } from '../dto/todo-attributes.dto'
export class TodoDataEntity extends DataEntity {
attributes: TodoAttributes
constructor(partial: Partial<TodoDataEntity>) {
super(partial)
Object.assign(this, partial)
}
}
ステップ4:サービスの実装
Todoサービスを作成(todo.service.ts):
import {
CommandService,
generateId,
getUserContext,
IInvoke,
VERSION_FIRST,
} from '@mbc-cqrs-serverless/core'
import { Injectable, Logger } from '@nestjs/common'
import { generateTodoPk, generateTodoSk, TODO_PK_PREFIX } from 'src/helpers'
import { CreateTodoDto } from './dto/create-todo.dto'
import { TodoCommandDto } from './dto/todo-command.dto'
import { TodoDataEntity } from './entity/todo-data.entity'
@Injectable()
export class TodoService {
private readonly logger = new Logger(TodoService.name)
constructor(private readonly commandService: CommandService) {}
async create(
createDto: CreateTodoDto,
opts: { invokeContext: IInvoke },
): Promise<TodoDataEntity> {
// Get tenant code from user context (JWT token)(ユーザーコンテキストからテナントコードを取得)
const { tenantCode } = getUserContext(opts.invokeContext)
// Generate partition key and sort key(パーティションキーとソートキーを生成)
const pk = generateTodoPk(tenantCode)
const sk = generateTodoSk()
// Create command DTO(コマンドDTOを作成)
const todo = new TodoCommandDto({
pk,
sk,
id: generateId(pk, sk),
tenantCode,
code: sk,
type: TODO_PK_PREFIX,
version: VERSION_FIRST, // Version for optimistic locking(楽観的ロック用バージョン)
name: createDto.name,
attributes: createDto.attributes,
})
this.logger.debug('Creating todo:', todo)
// Publish command to DynamoDB(DynamoDBにコマンドを発行)
const item = await this.commandService.publishAsync(todo, opts)
return new TodoDataEntity(item as TodoDataEntity)
}
}
ステップ5:コントローラーの作成
コントローラーを作成(todo.controller.ts):
import { IInvoke, INVOKE_CONTEXT } from '@mbc-cqrs-serverless/core'
import { Body, Controller, Logger, Post } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import { CreateTodoDto } from './dto/create-todo.dto'
import { TodoDataEntity } from './entity/todo-data.entity'
import { TodoService } from './todo.service'
@Controller('api/todo')
@ApiTags('todo')
export class TodoController {
private readonly logger = new Logger(TodoController.name)
constructor(private readonly todoService: TodoService) {}
@Post('/')
async create(
@INVOKE_CONTEXT() invokeContext: IInvoke,
@Body() createDto: CreateTodoDto,
): Promise<TodoDataEntity> {
this.logger.debug('createDto:', createDto)
return this.todoService.create(createDto, { invokeContext })
}
}
ステップ6:モジュールの作成
モジュールを作成(todo.module.ts):
import { CommandModule } from '@mbc-cqrs-serverless/core'
import { Module } from '@nestjs/common'
import { TodoController } from './todo.controller'
import { TodoService } from './todo.service'
@Module({
imports: [
CommandModule.register({
tableName: 'todo',
// Data sync handlers will be added in step-03-rds-sync(データ同期ハンドラーはstep-03-rds-syncで追加)
// dataSyncHandlers: [TodoDataSyncRdsHandler],
}),
],
controllers: [TodoController],
providers: [TodoService],
})
export class TodoModule {}
Part 2:RDSデータ同期(step-03-rds-sync)
DynamoDBからRDSへの自動データ同期を実装します。