エンティティ定義パターン
このガイドでは、MBC CQRS Serverlessアプリケーションでエンティティ、DTO、および属性を定義する方法を説明します。適切なエンティティ定義により、型安全性、読み取りと書き込み操作の明確な分離、保守可能なコードが保証されます。
このガイドを使用するタイミング
以下が必要な場合にこのガイドを使用してください:
- 新しいドメインエンティティ(Product、Order、Userなど)を作成する
- APIエンドポイントの入力バリデーションを定義する
- DynamoDBストレージとRDS同期用のデータ構造を設計する
- リストクエリのページネーションを実装する
このパターンが解決する問題
| 問題 | 解決策 |
|---|---|
| エンティティ属性に型安全性がない | 属性にTypeScriptインターフェースを定義する |
| 読み取りと書き込みに同じエンティティを使用すると混乱を招く | DataEntity(読み取り)とCommandEntity(書き込み)を分離する |
| エンドポイント間でバリデーションが不整合 | class-validatorデコレーター付きDTOを使用する |
| 監査フィールド(createdAt、updatedAt)が不足 | 基底クラスに標準監査フィールドを含める |
エンティティタイプの概要
フレームワークは3つの基本エンティティクラスを提供します:
| クラス | 目的 | 用途 |
|---|---|---|
DataEntity | 読み取り操作 | DynamoDB/RDSからのクエリ結果 |
CommandEntity | 書き込み操作 | DynamoDBに送信されるコマンド |
DataListEntity | ページネーションリスト | メタデータ付きのリストレスポンス |
Data Entity
ユースケース: APIクエリからデータを返す
シナリオ: APIがフロントエンドに商品情報を返す必要がある。
問題: 生のDynamoDBアイテムは型安全性がなく、キーにバージョンサフィックスが含まれる場合がある。
解決策: DataEntityを使用して、型付き属性と算出プロパティでクエリ結果をラップする。
import { DataEntity } from "@mbc-cqrs-serverless/core";
export interface ProductAttributes {
description: string;
price: number;
category: string;
inStock: boolean;
tags?: string[];
}
export class ProductDataEntity extends DataEntity {
attributes: ProductAttributes;
constructor(partial: Partial<ProductDataEntity>) {
super(partial);
Object.assign(this, partial);
}
}
DataEntity基底クラスに含まれるもの:
| プロパティ | 型 | 必須 | 説明 |
|---|---|---|---|
pk | string | はい | パーティションキー。形式: {entityType}#{tenantCode} |
sk | string | はい | ソートキー。形式: {entityType}#{entityId} — バージョンサフィックスなし(CommandEntityとの違い) |
id | string | はい | エンティティの一意識別子 |
code | string | はい | ビジネスコード |
name | string | はい | 表示名 |
version | number | はい | 楽観的ロック用のバージョン番号 |
tenantCode | string | はい | マルチテナント分離用のテナントコード |
type | string | はい | エンティティタイプ識別子 |
cpk | string | いいえ | コマンドパーティションキー — このデータアイテムを作成・更新したコマンドのPKを記録(監査証跡) |
csk | string | いいえ | コマンドソートキー — 元コマンドの正確なSK(@versionを含む)を記録(監査証跡) |
seq | number | いいえ | シーケンス番号 |
ttl | number | いいえ | DynamoDB TTL用の有効期限(秒) |
isDeleted | boolean | いいえ | 論理削除フラグ |
source | string | いいえ | イベントソース識別子(例: 'POST /api/master', 'SQS') |
requestId | string | いいえ | トレースと冪等性のための一意のリクエストID |
createdAt | Date | いいえ | エンティティが作成されたタイムスタンプ |
createdBy | string | いいえ | エンティティを作成したユーザーID |
createdIp | string | いいえ | 作成者のIPアドレス |
updatedAt | Date | いいえ | エンティティが最後に更新されたタイムスタンプ |
updatedBy | string | いいえ | エンティティを最後に更新したユーザーID |
updatedIp | string | いいえ | 最終更新者のIPアドレス |
attributes | any | いいえ | ドメイン固有データ用のカスタム属性オブジェクト |
keyゲッターはDynamoDB操作用のpkとskを持つDetailKeyオブジェクトを返します。
Command Entity
ユースケース: コマンドによるデータの作成・更新
シナリオ: ユーザーがフォームを送信して新しい商品を作成または既存の商品を更新する。
問題: 適切なキーとバージョンでDynamoDBコマンド発行用のデータ構造が必要。
解決策: CommandEntityを使用して、CQRSコマンド処理に必要なフィールドを持つ書き込み操作を構造化する。
import { CommandEntity } from "@mbc-cqrs-serverless/core";
export interface ProductAttributes {
description: string;
price: number;
category: string;
inStock: boolean;
tags?: string[];
}
export class ProductCommandEntity extends CommandEntity {
attributes: ProductAttributes;
constructor(partial: Partial<ProductCommandEntity>) {
super();
Object.assign(this, partial);
}
}
CommandEntity基底クラスに含まれるもの:
| プロパティ | 型 | 必須 | 説明 |
|---|---|---|---|
pk | string | はい | パーティションキー。形式: {entityType}#{tenantCode} |
sk | string | はい | ソートキー。形式: {entityType}#{entityId}@{version} |
id | string | はい | エンティティの一意識別子 |
code | string | はい | ビジネスコード |
name | string | はい | 表示名 |
version | number | はい | 楽観的ロック用のバージョン番号 |
tenantCode | string | はい | マルチテナント分離用のテナントコード |
type | string | はい | エンティティタイプ識別子 |
status | string | いいえ | 処理ステータス(例: 'PENDING', 'COMPLETED', 'FAILED') |
seq | number | いいえ | シーケンス番号 |
ttl | number | いいえ | DynamoDB TTL用の有効期限(秒) |
isDeleted | boolean | いいえ | 論理削除フラグ |
source | string | いいえ | イベントソース識別子(例: 'POST /api/master', 'SQS') |
requestId | string | いいえ | トレースと冪等性のための一意のリクエストID |
createdAt | Date | いいえ | コマンドが作成されたタイムスタンプ |
createdBy | string | いいえ | コマンドを作成したユーザーID |
createdIp | string | いいえ | 作成者のIPアドレス |
updatedAt | Date | いいえ | コマンドが最後に更新されたタイムスタンプ |
updatedBy | string | いいえ | コマンドを最後に更新したユーザーID |
updatedIp | string | いいえ | 最終更新者のIPアドレス |
attributes | any | いいえ | ドメイン固有データ用のカスタム属性オブジェクト |
keyゲッターはDynamoDB操作用のpkとskを持つDetailKeyオブジェクトを返します。
CommandEntityとDataEntityの主な違いは以下の通りです:
| 観点 | CommandEntity | DataEntity |
|---|---|---|
| テーブル | コマンド(書き込み)テーブル | データ(読み取り)テーブル |
| ソートキー | バージョンサフィックス(@{version})を含む | バージョンサフィックスなし |
status | あり(処理ステータス) | いいえ |
cpk/csk | いいえ | あり(ソースコマンドを参照) |
Data List Entity
ユースケース: ページネーション付きリストを返す
シナリオ: フロントエンドがページネーション付きの商品リストを要求する。
問題: ページネーションUI用にアイテムと総数の両方を返す必要がある。
解決策: DataListEntityを使用して、総数とページネーションカーソル付きでリスト結果をラップする。
import { DataListEntity } from "@mbc-cqrs-serverless/core";
import { ProductDataEntity } from "./product-data.entity";
export class ProductListEntity extends DataListEntity {
items: ProductDataEntity[];
constructor(partial: Partial<ProductListEntity>) {
super(partial);
Object.assign(this, partial);
}
}
DataListEntity基底クラスに含まれるもの:
// Inherited from DataListEntity (DataListEntityから継承)
{
total?: number; // 合計件数(オプション — listItemsByPkでは設定されない)
lastSk?: string; // ページネーション用の最終ソートキー
}
DataService.listItemsByPk()はtotalを設定しません — itemsとlastSkのみ設定されます。totalを取得するには別途countItemsByPk()を実行してください。明示的に設定しない限り、UIにtotalを表示しないでください。
すべてのリスト・検索サービスメソッドの戻り値型にDataListEntityを使用してください。コントローラーから常にこの型を返し、生の配列を返さないようにすることで、フロントエンドは一貫した{ items, total, lastSk }形式のレスポンスを受け取れます。
Command DTO
ユースケース: コマンド発行用データを準備する
シナリオ: サービス層がバリデーション済み入力からコマンドを作成する必要がある。
解決策: CommandDtoを使用して、入力データをCommandServiceが必要とする構造に変換する。
import { CommandDto } from "@mbc-cqrs-serverless/core";
export interface ProductAttributes {
description: string;
price: number;
category: string;
inStock: boolean;
tags?: string[];
}
export class ProductCommandDto extends CommandDto {
attributes: ProductAttributes;
constructor(partial: Partial<ProductCommandDto>) {
super();
Object.assign(this, partial);
}
}
基底クラス CommandDto に含まれるもの:
- APIドキュメント用のSwaggerデコレータ(
@ApiProperty、@ApiPropertyOptional) class-validatorのバリデーションデコレータ(@IsString、@IsNumber、@IsOptionalなど)- プロパティ:
pk、sk、id、code、name、version、tenantCode(オプション)、type、isDeleted、seq、ttl、attributes
注: tenantCode は基底クラスでオプション(@IsOptional())としてマークされており、提供されない場合はフレームワークがinvokeコンテキストから抽出できます。
Attributes DTO
ユースケース: ビジネスデータ構造を定義する
シナリオ: エンティティに価格、ステータス、配送情報などのビジネス固有のフィールドがある。
解決策: ドメインデータの構造を記述するTypeScriptインターフェースを定義する。
// Simple attributes (シンプルな属性)
export interface ProductAttributes {
description: string;
price: number;
category: string;
inStock: boolean;
}
// Complex attributes with nested objects (ネストされたオブジェクトを含む複雑な属性)
export interface OrderAttributes {
customerId: string;
status: OrderStatus;
items: OrderItem[];
shipping: {
address: string;
city: string;
postalCode: string;
country: string;
};
payment: {
method: PaymentMethod;
transactionId?: string;
paidAt?: string;
};
totalAmount: number;
currency: string;
}
interface OrderItem {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
}
enum OrderStatus {
PENDING = "PENDING",
CONFIRMED = "CONFIRMED",
SHIPPED = "SHIPPED",
DELIVERED = "DELIVERED",
CANCELLED = "CANCELLED",
}
enum PaymentMethod {
CREDIT_CARD = "CREDIT_CARD",
BANK_TRANSFER = "BANK_TRANSFER",
CASH_ON_DELIVERY = "CASH_ON_DELIVERY",
}
Create/Update DTO
ユースケース: API入力をバリデートする
シナリオ: APIがフロントエンドからJSONを受信し、処理前にバリデーションが必要。
問題: 無効なデータ(空文字列、負の価格)がデータストアを破損する可能性がある。
解決策: class-validatorデコレーターを使用して、自動実行されるバリデーションルールを定義する。
import { IsString, IsNumber, IsBoolean, IsOptional, Min } from "class-validator";
export class CreateProductDto {
@IsString()
name: string;
@IsString()
description: string;
@IsNumber()
@Min(0)
price: number;
@IsString()
category: string;
@IsBoolean()
@IsOptional()
inStock?: boolean;
}
export class UpdateProductDto {
@IsString()
@IsOptional()
name?: string;
@IsOptional()
attributes?: Partial<ProductAttributes>;
}
Detail/Search DTO
ユースケース: リスト・詳細エンドポイントのクエリパラメータ
シナリオ: フロントエンドがフィルタリング、ページネーション、詳細検索 用のクエリパラメータを送信する。
解決策: クエリパラメータをバリデートし、デフォルト値を提供するDTOを定義する。
import { IsBoolean, IsNumber, IsOptional, IsString, Min, Max } from "class-validator";
import { Type } from "class-transformer";
// For single item lookup (単一アイテム検索用)
export class DetailDto {
@IsString()
pk: string;
@IsString()
sk: string;
}
// For list queries (リストクエリ用)
export class SearchProductDto {
@IsString()
tenantCode: string;
@IsString()
@IsOptional()
category?: string;
@IsBoolean()
@IsOptional()
inStock?: boolean;
@IsString()
@IsOptional()
search?: string;
@IsNumber()
@IsOptional()
@Type(() => Number)
@Min(1)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
@Min(1)
@Max(100)
limit?: number = 20;
}