Skip to main content

Entity Definition Patterns

This guide explains how to define entities, DTOs, and attributes in MBC CQRS Serverless applications. Proper entity definition ensures type safety, clear separation of read and write operations, and maintainable code.

When to Use This Guide

Use this guide when you need to:

  • Create a new domain entity (Product, Order, User, etc.)
  • Define input validation for API endpoints
  • Structure data for DynamoDB storage and RDS synchronization
  • Implement pagination for list queries

Problems This Pattern Solves

ProblemSolution
No type safety for entity attributesDefine TypeScript interfaces for attributes
Same entity used for reads and writes causes confusionSeparate DataEntity (read) and CommandEntity (write)
Inconsistent validation across endpointsUse DTOs with class-validator decorators
Missing audit fields (createdAt, updatedAt)Base classes include standard audit fields

Entity Types Overview

The framework provides three base entity classes:

ClassPurposeUsage
DataEntityRead operationsQuery results from DynamoDB/RDS
CommandEntityWrite operationsCommands sent to DynamoDB
DataListEntityPaginated listsList responses with metadata

Data Entity

Use Case: Return Data from API Queries

Scenario: Your API needs to return product information to the frontend.

Problem: Raw DynamoDB items lack type safety and may contain version suffixes in keys.

Solution: Use DataEntity to wrap query results with typed attributes and computed properties.

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);
}
}

The DataEntity base class includes:

PropertyTypeRequiredDescription
pkstringYesPartition key. Format: {tenantCode}#{entityType}
skstringYesSort key. Format: {entityType}#{entityId}
idstringYesUnique entity identifier
codestringYesBusiness code
namestringYesDisplay name
versionnumberYesVersion number for optimistic locking
tenantCodestringYesTenant code for multi-tenant isolation
typestringYesEntity type identifier
cpkstringNoCommand partition key - references source command record
cskstringNoCommand sort key with version - references exact command version
seqnumberNoSequence number
ttlnumberNoTime-to-live in seconds for DynamoDB TTL
isDeletedbooleanNoSoft delete flag
sourcestringNoEvent source identifier (e.g., 'POST /api/master', 'SQS')
requestIdstringNoUnique request ID for tracing and idempotency
createdAtDateNoTimestamp when the entity was created
createdBystringNoUser ID who created the entity
createdIpstringNoIP address of the creator
updatedAtDateNoTimestamp when the entity was last updated
updatedBystringNoUser ID who last updated the entity
updatedIpstringNoIP address of the last updater
attributesanyNoCustom attributes object for domain-specific data

The key getter returns a DetailKey object with pk and sk for DynamoDB operations.

Command Entity

Use Case: Create or Update Data via Commands

Scenario: User submits a form to create a new product or update an existing one.

Problem: Need to structure data for DynamoDB command publishing with proper keys and version.

Solution: Use CommandEntity to structure write operations with required fields for CQRS command processing.

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);
}
}

The CommandEntity base class includes:

PropertyTypeRequiredDescription
pkstringYesPartition key. Format: {tenantCode}#{entityType}
skstringYesSort key. Format: {entityType}#{entityId}@{version}
idstringYesUnique entity identifier
codestringYesBusiness code
namestringYesDisplay name
versionnumberYesVersion number for optimistic locking
tenantCodestringYesTenant code for multi-tenant isolation
typestringYesEntity type identifier
statusstringNoProcessing status (e.g., 'PENDING', 'COMPLETED', 'FAILED')
seqnumberNoSequence number
ttlnumberNoTime-to-live in seconds for DynamoDB TTL
isDeletedbooleanNoSoft delete flag
sourcestringNoEvent source identifier (e.g., 'POST /api/master', 'SQS')
requestIdstringNoUnique request ID for tracing and idempotency
createdAtDateNoTimestamp when the command was created
createdBystringNoUser ID who created the command
createdIpstringNoIP address of the creator
updatedAtDateNoTimestamp when the command was last updated
updatedBystringNoUser ID who last updated the command
updatedIpstringNoIP address of the last updater
attributesanyNoCustom attributes object for domain-specific data

The key getter returns a DetailKey object with pk and sk for DynamoDB operations.

CommandEntity vs DataEntity

The main differences between CommandEntity and DataEntity are:

AspectCommandEntityDataEntity
TableCommand (write) tableData (read) table
Sort KeyIncludes version suffix (@{version})No version suffix
statusYes (processing status)No
cpk/cskNoYes (references source command)

Data List Entity

Use Case: Return Paginated Lists

Scenario: Frontend requests a list of products with pagination.

Problem: Need to return both the items and total count for pagination UI.

Solution: Use DataListEntity to wrap list results with total count and pagination cursor.

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);
}
}

The DataListEntity base class includes:

// Inherited from DataListEntity
{
total: number; // Total count
lastSk?: string; // Last sort key for pagination
}

Command DTO

Use Case: Prepare Data for Command Publishing

Scenario: Service layer needs to create a command from validated input.

Solution: Use CommandDto to transform input data into the structure required by 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 Features

The base CommandDto class includes:

  • Swagger decorators (@ApiProperty, @ApiPropertyOptional) for API documentation
  • Validation decorators from class-validator (@IsString, @IsNumber, @IsOptional, etc.)
  • Properties: pk, sk, id, code, name, version, tenantCode (optional), type, isDeleted, seq, ttl, attributes

Note: tenantCode is marked as optional (@IsOptional()) in the base class, allowing the framework to extract it from the invoke context if not provided.

Attributes DTO

Use Case: Define Business Data Structure

Scenario: Your entity has business-specific fields like price, status, and shipping information.

Solution: Define TypeScript interfaces that describe the structure of your domain data.

// 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 DTOs

Use Case: Validate API Input

Scenario: API receives JSON from frontend and needs to validate before processing.

Problem: Invalid data (empty strings, negative prices) could corrupt your data store.

Solution: Use class-validator decorators to define validation rules that run automatically.

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 DTOs

Use Case: Query Parameters for List and Detail Endpoints

Scenario: Frontend sends query parameters for filtering, pagination, and detail lookups.

Solution: Define DTOs that validate query parameters and provide default values.

import { IsString, IsOptional, IsNumber, 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;
}

Complete Domain Example

Use Case: Full E-Commerce Order Domain

Scenario: Building an order management system with orders, items, shipping, and payment.

This example shows how all entity patterns work together in a real domain:

Directory Structure

src/order/
├── order.module.ts
├── order.service.ts
├── order.controller.ts
├── entity/
│ ├── order-data.entity.ts
│ ├── order-command.entity.ts
│ └── order-list.entity.ts
├── dto/
│ ├── order-command.dto.ts
│ ├── order-attributes.dto.ts
│ ├── create-order.dto.ts
│ ├── update-order.dto.ts
│ ├── detail.dto.ts
│ └── search-order.dto.ts
├── handler/
│ └── order-rds.handler.ts
└── constant/
└── order.enum.ts

Enums

// constant/order.enum.ts
export enum OrderStatus {
DRAFT = "DRAFT",
PENDING = "PENDING",
CONFIRMED = "CONFIRMED",
PROCESSING = "PROCESSING",
SHIPPED = "SHIPPED",
DELIVERED = "DELIVERED",
CANCELLED = "CANCELLED",
REFUNDED = "REFUNDED",
}

export enum PaymentMethod {
CREDIT_CARD = "CREDIT_CARD",
DEBIT_CARD = "DEBIT_CARD",
BANK_TRANSFER = "BANK_TRANSFER",
DIGITAL_WALLET = "DIGITAL_WALLET",
CASH_ON_DELIVERY = "CASH_ON_DELIVERY",
}

export enum PaymentStatus {
PENDING = "PENDING",
AUTHORIZED = "AUTHORIZED",
CAPTURED = "CAPTURED",
FAILED = "FAILED",
REFUNDED = "REFUNDED",
}

Attributes DTO

// dto/order-attributes.dto.ts
import { OrderStatus, PaymentMethod, PaymentStatus } from "../constant/order.enum";

export interface OrderItem {
productId: string;
productCode: string;
productName: string;
quantity: number;
unitPrice: number;
discount: number;
subtotal: number;
}

export interface ShippingInfo {
recipientName: string;
phoneNumber: string;
address: string;
city: string;
state: string;
postalCode: string;
country: string;
instructions?: string;
}

export interface PaymentInfo {
method: PaymentMethod;
status: PaymentStatus;
transactionId?: string;
authorizedAt?: string;
capturedAt?: string;
}

export interface OrderAttributes {
customerId: string;
customerEmail: string;
status: OrderStatus;
items: OrderItem[];
shipping: ShippingInfo;
payment: PaymentInfo;
subtotal: number;
shippingFee: number;
tax: number;
discount: number;
totalAmount: number;
currency: string;
notes?: string;
orderedAt: string;
confirmedAt?: string;
shippedAt?: string;
deliveredAt?: string;
}

Data Entity

// entity/order-data.entity.ts
import { DataEntity } from "@mbc-cqrs-serverless/core";
import { OrderAttributes } from "../dto/order-attributes.dto";

export class OrderDataEntity extends DataEntity {
attributes: OrderAttributes;

constructor(partial: Partial<OrderDataEntity>) {
super(partial);
Object.assign(this, partial);
}

// Computed properties
get status(): string {
return this.attributes?.status;
}

get totalAmount(): number {
return this.attributes?.totalAmount ?? 0;
}

get itemCount(): number {
return this.attributes?.items?.length ?? 0;
}
}

Command Entity

// entity/order-command.entity.ts
import { CommandEntity } from "@mbc-cqrs-serverless/core";
import { OrderAttributes } from "../dto/order-attributes.dto";

export class OrderCommandEntity extends CommandEntity {
attributes: OrderAttributes;

constructor(partial: Partial<OrderCommandEntity>) {
super();
Object.assign(this, partial);
}
}

List Entity

// entity/order-list.entity.ts
import { DataListEntity } from "@mbc-cqrs-serverless/core";
import { OrderDataEntity } from "./order-data.entity";

export class OrderListEntity extends DataListEntity {
items: OrderDataEntity[];

constructor(partial: Partial<OrderListEntity>) {
super(partial);
Object.assign(this, partial);
}
}

Command DTO

// dto/order-command.dto.ts
import { CommandDto } from "@mbc-cqrs-serverless/core";
import { OrderAttributes } from "./order-attributes.dto";

export class OrderCommandDto extends CommandDto {
attributes: OrderAttributes;

constructor(partial: Partial<OrderCommandDto>) {
super();
Object.assign(this, partial);
}
}

Create DTO

// dto/create-order.dto.ts
import {
IsString,
IsEmail,
IsArray,
ValidateNested,
IsNumber,
Min,
IsOptional,
} from "class-validator";
import { Type } from "class-transformer";

class CreateOrderItemDto {
@IsString()
productId: string;

@IsNumber()
@Min(1)
quantity: number;
}

class CreateShippingDto {
@IsString()
recipientName: string;

@IsString()
phoneNumber: string;

@IsString()
address: string;

@IsString()
city: string;

@IsString()
state: string;

@IsString()
postalCode: string;

@IsString()
country: string;

@IsString()
@IsOptional()
instructions?: string;
}

export class CreateOrderDto {
@IsString()
customerId: string;

@IsEmail()
customerEmail: string;

@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateOrderItemDto)
items: CreateOrderItemDto[];

@ValidateNested()
@Type(() => CreateShippingDto)
shipping: CreateShippingDto;

@IsString()
@IsOptional()
notes?: string;
}

Update DTO

// dto/update-order.dto.ts
import { IsString, IsEnum, IsOptional, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
import { OrderStatus, PaymentStatus } from "../constant/order.enum";

class UpdateShippingDto {
@IsString()
@IsOptional()
recipientName?: string;

@IsString()
@IsOptional()
phoneNumber?: string;

@IsString()
@IsOptional()
address?: string;

@IsString()
@IsOptional()
instructions?: string;
}

export class UpdateOrderDto {
@IsEnum(OrderStatus)
@IsOptional()
status?: OrderStatus;

@ValidateNested()
@Type(() => UpdateShippingDto)
@IsOptional()
shipping?: UpdateShippingDto;

@IsString()
@IsOptional()
notes?: string;
}

export class UpdatePaymentDto {
@IsEnum(PaymentStatus)
status: PaymentStatus;

@IsString()
@IsOptional()
transactionId?: string;
}

Search DTO

// dto/search-order.dto.ts
import { IsString, IsEnum, IsOptional, IsNumber, Min, Max, IsDateString } from "class-validator";
import { Type } from "class-transformer";
import { OrderStatus } from "../constant/order.enum";

export class SearchOrderDto {
@IsString()
tenantCode: string;

@IsString()
@IsOptional()
customerId?: string;

@IsEnum(OrderStatus)
@IsOptional()
status?: OrderStatus;

@IsDateString()
@IsOptional()
orderedFrom?: string;

@IsDateString()
@IsOptional()
orderedTo?: string;

@IsNumber()
@IsOptional()
@Type(() => Number)
minAmount?: number;

@IsNumber()
@IsOptional()
@Type(() => Number)
maxAmount?: number;

@IsNumber()
@IsOptional()
@Type(() => Number)
@Min(1)
page?: number = 1;

@IsNumber()
@IsOptional()
@Type(() => Number)
@Min(1)
@Max(100)
limit?: number = 20;

@IsString()
@IsOptional()
sortBy?: "orderedAt" | "totalAmount" | "status" = "orderedAt";

@IsString()
@IsOptional()
sortOrder?: "asc" | "desc" = "desc";
}

Best Practices

1. Separate Read and Write Entities

Use DataEntity for reads and CommandEntity for writes:

// Read operations return DataEntity
async findOne(key: DetailDto): Promise<OrderDataEntity>

// Write operations return DataEntity (after command is processed)
async create(dto: CreateOrderDto): Promise<OrderDataEntity>

2. Use Typed Attributes

Always define interfaces for attributes:

interface ProductAttributes {
description: string;
price: number;
// ...
}

// Not this:
attributes: Record<string, any> // Avoid

3. Add Computed Properties

Add getters for commonly accessed nested data:

export class OrderDataEntity extends DataEntity {
attributes: OrderAttributes;

get totalAmount(): number {
return this.attributes?.totalAmount ?? 0;
}

get isPaid(): boolean {
return this.attributes?.payment?.status === PaymentStatus.CAPTURED;
}
}

4. Validate Input DTOs

Use class-validator for input validation:

import { IsString, IsNumber, Min, IsEmail } from "class-validator";

export class CreateOrderDto {
@IsEmail()
customerEmail: string;

@IsNumber()
@Min(0)
totalAmount: number;
}

5. Use Enums for Status Fields

Define enums for status and type fields:

enum OrderStatus {
PENDING = "PENDING",
CONFIRMED = "CONFIRMED",
// ...
}

// In DTO
@IsEnum(OrderStatus)
status: OrderStatus;