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)
// New entities (VERSION_FIRST) always publish successfully and return a non-null result (新規エンティティ(VERSION_FIRST)は常に正常にパブリッシュされ、非nullの結果を返す)
return new TodoDataEntity(item as TodoDataEntity)
}
}
publishAsync の null 戻り値(v1.2.0以降)
v1.2.0以降、publishAsync()とpublishPartialUpdateAsync()はコマンドが変更を生じない場合(no-op)にnullを返します。VERSION_FIRST(0)で作成された新規エンティティは常に変更があるためnullを返しません。ペイロードが変更されない可能性のある更新操作では、使用前に戻り値のnullチェックを行ってください。詳細はv1.2.0マイグレーションガイドを参照してください。