# MBC CQRS Serverless Framework - Full Documentation > Production-ready CQRS and Event Sourcing framework for building serverless applications on AWS with NestJS, DynamoDB, and other AWS services. This file contains the full documentation content following the llmstxt.org standard. --- # Getting Started ## CLI URL: https://mbc-cqrs-serverless.mbc-net.com/docs/cli # CLI The mbc-cqrs-serverless CLI helps you quickly scaffold new projects and generate boilerplate code for modules, services, and entities. It follows the framework's conventions to ensure consistency. ## When to Use the CLI {#when-to-use} Use the CLI when you need to: - Create a new MBC CQRS Serverless project from scratch - Add a new domain module (product, order, user) - Generate controller, service, entity, and DTO files with correct structure - Start the local development server ## Problems the CLI Solves {#problems-solved} | Problem | Solution | |---------|----------| | Setting up project structure manually is error-prone | `mbc new` creates complete project skeleton | | Remembering correct file names and imports | `mbc generate` creates consistent boilerplate | | Forgetting to register modules correctly | Generated code follows NestJS conventions | ## Installation {#installation} To install the CLI globally: ```bash npm install -g @mbc-cqrs-serverless/cli ``` ## Available Commands {#available-commands} To get a list of the available CLI commands, run the following command: ```bash mbc -h ``` The output should look like this: ```bash Usage: mbc [options] Options: -v, --version Output the current version. -h, --help Output usage information. Commands: new|n [name] Generate a new CQRS application using the MBC CQRS serverless framework start|s Start application with serverless framework ui-common|ui [options] Add mbc-cqrs-ui-common components to your project. generate|g [options] [name] Generate a MBC-cqrs-serverless element. Schematics available on the collection: ┌────────────┬───────┬──────────────────────┐ │ name │ alias │ description │ │ module │ mo │ Create a module. │ │ controller │ co │ Create a controller. │ │ service │ se │ Create a service. │ │ entity │ en │ Create an entity. │ │ dto │ dto │ Create a DTO. │ └────────────┴───────┴──────────────────────┘ install-skills|skills [options] Install Claude Code skills for MBC CQRS Serverless development help [command] display help for command ``` ## new Command {#new-command} ### Use Case: Start a New Backend Project Scenario: You're starting a new microservice or API backend and need the complete project structure with all dependencies. ```bash mbc new [projectName[@version]] ``` ### Examples Create a new project in the current directory: ```bash mbc new ``` Create a project with a specific name: ```bash mbc new my-cqrs-app ``` Create a project with a specific version: ```bash mbc new my-cqrs-app@0.1.45 ``` ## generate Command {#generate-command} ### Use Case: Add a New Domain to Existing Project Scenario: Your project needs a new Order feature with API endpoints, business logic, and database entities. Solution: Generate module, controller, service, entity, and DTO in sequence. ```bash mbc generate [name] # or mbc g [name] ``` ### Available Schematics | Name | Alias | Description | |------|-------|-------------| | module | mo | Create a module | | controller | co | Create a controller | | service | se | Create a service | | entity | en | Create an entity | | dto | dto | Create a DTO | ### Options | Option | Description | |--------|-------------| | `-d, --dry-run` | Report actions that would be taken without writing out results | | `--mode ` | Specify the mode of operation: sync or async (default: async) | | `--schema` | Enable schema generation (default: true) | | `--no-schema` | Disable schema generation | ### Examples Generate a new module: ```bash mbc generate module order # or mbc g mo order ``` Generate a controller: ```bash mbc generate controller order # or mbc g co order ``` Generate a service: ```bash mbc generate service order # or mbc g se order ``` Generate an entity: ```bash mbc generate entity order # or mbc g en order ``` Generate a DTO: ```bash mbc generate dto order # or mbc g dto order ``` Dry run (preview without creating files): ```bash mbc g mo order --dry-run ``` ## start Command {#start-command} ```bash mbc start # or mbc s ``` :::warning Not Yet Implemented The `mbc start` command is currently a placeholder and does not start a local development server yet. To run your application locally, use the npm scripts included in generated projects instead: ```bash npm run build # Build the application in watch mode npm run offline:docker # Start local Docker services (DynamoDB, Cognito, etc.) npm run offline:sls # Start the serverless offline server ``` See [Installation](/docs/installation) for the full local development setup. ::: ## ui-common Command {#ui-common-command} ### Use Case: Add Pre-built UI Components to Frontend Scenario: You're building a frontend and want to use the standard MBC CQRS UI component library. ```bash mbc ui-common [options] # or mbc ui [options] ``` This command integrates the MBC CQRS UI Common library into your project, providing pre-built UI components and utilities. ### Options | Option | Description | |--------|-------------| | `-p, --pathDir ` | (Required) The destination path for common-ui components | | `-b, --branch ` | The branch name to clone from (default: main) | | `--auth ` | The authentication method: SSH or HTTPS - Token (default: SSH) | | `--token ` | The token for HTTPS authentication, format: tokenId:tokenPassword | | `-c, --component ` | Component to install: all, appsync, or component (default: all) | | `--alias` | Currently has no effect — the `@ms/*` path alias is always added to tsconfig.json regardless of this flag | ### Examples Install all components using SSH authentication: ```bash mbc ui-common -p src/common-ui ``` Install only UI components (excluding appsync): ```bash mbc ui -p src/common-ui -c component ``` Install from a specific branch: ```bash mbc ui -p src/common-ui -b develop ``` Install using HTTPS with token authentication: ```bash mbc ui -p src/common-ui --auth "HTTPS - Token" --token "user:password" ``` ## install-skills Command {#install-skills} ### Use Case: Install Claude Code Skills Scenario: You want to use Claude Code skills for MBC CQRS Serverless development assistance. ```bash mbc install-skills [options] # or mbc skills [options] ``` This command installs Claude Code skills that provide guided assistance for code generation, code review, migration, and debugging. ### Options | Option | Description | |--------|-------------| | `-p, --project` | Install to project directory (`.claude/skills/`) instead of personal (`~/.claude/skills/`) | | `-f, --force` | Overwrite existing skills | | `-l, --list` | List available skills without installing | | `-c, --check` | Check if updates are available without installing | ### Examples Install to personal skills directory (available in all projects): ```bash mbc install-skills ``` Install to project directory (shared with team via git): ```bash mbc install-skills --project # or mbc skills -p ``` List available skills: ```bash mbc install-skills --list ``` Force overwrite existing skills: ```bash mbc install-skills --force ``` Check for updates: ```bash mbc install-skills --check ``` ### Upgrading Skills Skills do not auto-update. To upgrade to the latest version: ```bash # Update CLI to latest version npm update -g @mbc-cqrs-serverless/cli # Check if updates are available mbc install-skills --check # Force reinstall to get latest version mbc install-skills --force ``` ### Available Skills | Skill | Description | |-------|-------------| | `/mbc-generate` | Generate boilerplate code (modules, services, controllers, DTOs, handlers) | | `/mbc-review` | Review code for best practices and anti-patterns (22 patterns) | | `/mbc-migrate` | Guide version migrations and breaking changes | | `/mbc-debug` | Debug and troubleshoot common issues | :::info Version Note The `install-skills` command was added in [version 1.0.24](/docs/changelog#v1024). ::: ## Troubleshooting {#troubleshooting} ### Version not found ```bash mbc new myapp@999.999.999 # Error: Version not found ``` Solution: Use a valid version number. Check available versions in npm registry. ### Directory not empty ```bash mbc new my-project # Error: Directory not empty ``` Solution: Use a new directory or remove existing files before creating a project. ### Permission denied If you encounter permission errors during global installation: ```bash sudo npm install -g @mbc-cqrs-serverless/cli # or use npm prefix npm config set prefix ~/.npm-global npm install -g @mbc-cqrs-serverless/cli ``` ## Related Documentation - [Getting Started](/docs/getting-started) - Introduction and first steps - [Installation](/docs/installation) - Install CLI and setup - [MCP Server](/docs/mcp-server) - AI development tool integration - [AI Prompts Library](/docs/ai-prompts) - Optimized prompts for generating modules and services - [Project Structure](/docs/project-structure) - Generated project layout --- ## Introduction URL: https://mbc-cqrs-serverless.mbc-net.com/docs/getting-started # Introduction Welcome to the MBC CQRS Serverless framework documentation! ## What is MBC CQRS Serverless framework? {#what-is-mbc-cqrs} This framework provides core functionalities for implementing the Command Query Responsibility Segregation (CQRS) pattern within AWS serverless architectures, powered by the incredible NestJS framework. It simplifies the development of highly scalable and decoupled systems that can handle complex business logic and high-volume data processing. ## Main features {#main-features} | features | Description | | ---------------------------- | ---------------------------- | | CQRS framework for AWS serverless | - Structured approach for separating commands and queries.
- Integration with AWS services like Cognito, API Gateway, Lambda, DynamoDB, SNS, and SQS, Step Functions, RDS ⚡ | | Event-driven architecture | - Leverages Event Sourcing and messaging for asynchronous communication
- Enables loose coupling and independent scaling of components | | Command and query handlers | - Provides abstractions for handling commands and queries
- Facilitates business logic implementation and data persistence | | Asynchronous communication | - Supports event publishing and message passing for inter-component communication | | Data consistency and integrity | - Ensures data consistency through Event Sourcing and optimistic locking
- Enforces data integrity with validation and constraints | | Experience a harmonious symphony of CQRS and NestJS | - **Modular structure**: Organize CQRS components with NestJS's elegant modularity.
- **Dependency injection**: Simplify dependency management and embrace loose coupling with NestJS's DI system
- **TypeScript support**: Write type-safe, crystal-clear code with built-in TypeScript
- **Testing and error handling**: Build confidence with comprehensive testing and robust error handling, courtesy of NestJS
- **Ecosystem compatibility**: Tap into the vast NestJS universe of modules and libraries to expand possibilities | | Local Development | - **Embrace agility**: Experience rapid iteration and experimentation in a local environment, without the need for constant cloud deployment.
- **Debugging bliss**: Debug with ease using your favorite tools and techniques, gaining deeper insights into your application's behavior.
- **Cost-effective exploration**: Explore and refine your CQRS implementation locally, without incurring AWS costs during development. | ## How to Use These Docs {#how-to-use-docs} On the left side of the screen, you'll find the docs navbar. The pages of the docs are organized sequentially, you can follow them step-by-step when building your application. However, you can read them in any order or skip to the pages that apply to your case. On the right side of the screen, you'll see a table of contents that makes it easier to navigate between sections of a page. If you need to quickly find a page, you can use the search bar at the top, or the search shortcut (Ctrl+K or Cmd+K). To get started, check out the [Installation](/docs/installation) guide. ## Recommended Learning Path {#recommended-learning-path} If you're new to the framework, follow this sequence: | Step | Page | What you'll learn | |----------|----------|----------------------| | 1 | [Installation](/docs/installation) | Set up your local development environment | | 2 | [Quickstart Tutorial](/docs/quickstart-tutorial) | Build a working API in 15 minutes | | 3 | [Project Structure](/docs/project-structure) | Understand the generated file layout | | 4 | [Architecture](/docs/architecture) | CQRS and Event Sourcing concepts | | 5 | [Backend Development](/docs/backend-development) | Implement features with the CQRS pattern | | 6 | [Authentication](/docs/authentication) | Role-based access control and group-based authorization | | 7 | Examples | [E-commerce](/docs/ecommerce-example) · [SaaS](/docs/saas-example) | ## Related Documentation - [Installation](/docs/installation) - System requirements and setup instructions - [Quickstart Tutorial](/docs/quickstart-tutorial) - Build your first API in 15 minutes - [Project Structure](/docs/project-structure) - Understanding the generated project layout - [Architecture](/docs/architecture) - CQRS and Event Sourcing concepts - [Building Your Application](/docs/build-your-application) - Application development guides and patterns - [Glossary](/docs/glossary) - Framework terminology and key concepts - [Changelog](/docs/changelog) - Release history and version notes - [AI Integration](/docs/ai-integration) - AI development tool support with llms.txt and MCP - [Examples](/docs/recipes) - Practical implementation examples and use cases - [API Reference](/docs/api-reference) - Complete module API reference --- ## Installation URL: https://mbc-cqrs-serverless.mbc-net.com/docs/installation # Installation System Requirements: - [Node.js](https://nodejs.org/en/download/package-manager) (20.x or later) - [JQ cli](https://jqlang.github.io/jq/download/) - [AWS cli](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) - [Docker](https://docs.docker.com/engine/install/) - Windows, macOS and Linux are supported. ## Automatic Installation {#automatic-installation} To get started, scaffold the project with the [mbc-cqrs-serverless CLI](/docs/cli). Run the following commands. This will create a new project directory, and populate the directory with the initial core mbc-cqrs-serverless files and supporting modules, creating a conventional base structure for your project. ```bash npm i -g @mbc-cqrs-serverless/cli mbc new project-name ``` If you're new to mbc-cqrs-serverless, see the [project structure](/docs/project-structure) docs for an overview of all the possible files and folders in your application. ## Run the Development Server {#run-dev-server} 1. Run `npm run build` to build the project in watch mode. 2. Open another terminal session and run `npm run offline:docker` to start Docker services (DynamoDB Local, MySQL, LocalStack). 3. Wait ~30 seconds for MySQL to fully start, then open another terminal and run `npm run migrate` to migrate RDS and DynamoDB tables. 4. Finally, run `npm run offline:sls` to start serverless offline mode. :::info AWS Credentials for Local Development Local development uses LocalStack to emulate AWS services. You do not need real AWS credentials — set dummy values in your `.env` file: ```bash AWS_ACCESS_KEY_ID=local AWS_SECRET_ACCESS_KEY=local ``` ::: After the server runs successfully, you can see: ```bash DEBUG[serverless-offline-sns][adapter]: successfully subscribed queue "http://localhost:9324/101010101010/notification-queue" to topic: "arn:aws:sns:ap-northeast-1:101010101010:MySnsTopic" Offline Lambda Server listening on http://localhost:4000 serverless-offline-aws-eventbridge :: Plugin ready serverless-offline-aws-eventbridge :: Mock server running at port: 4010 Starting Offline SQS at stage dev (ap-northeast-1) Starting Offline Dynamodb Streams at stage dev (ap-northeast-1) Starting Offline at stage dev (ap-northeast-1) Offline [http for lambda] listening on http://localhost:3002 Function names exposed for local invocation by aws-sdk: * main: serverless-example-dev-main Configuring JWT Authorization: ANY /{proxy+} ┌────────────────────────────────────────────────────────────────────────┐ │ │ │ ANY | http://localhost:3000/api/public │ │ POST | http://localhost:3000/2015-03-31/functions/main/invocations │ │ ANY | http://localhost:3000/swagger-ui/{proxy*} │ │ POST | http://localhost:3000/2015-03-31/functions/main/invocations │ │ ANY | http://localhost:3000/{proxy*} │ │ POST | http://localhost:3000/2015-03-31/functions/main/invocations │ │ │ └────────────────────────────────────────────────────────────────────────┘ Server ready: http://localhost:3000 🚀 ``` You can also use several endpoints: - API gateway: http://localhost:3000 - Swagger UI: http://localhost:3000/swagger-ui - Offline Lambda Server: http://localhost:4000 - HTTP for lambda: http://localhost:3002 - Step Functions: http://localhost:8083 - DynamoDB: http://localhost:8000 - DynamoDB admin: http://localhost:8001 - SNS: http://localhost:4002 - SQS: http://localhost:9324 - SQS admin: http://localhost:9325 - Localstack: http://localhost:4566 - AppSync: http://localhost:4001 - Cognito: http://localhost:9229 - EventBridge: http://localhost:4010 - Simple Email Service: http://localhost:8005 - Run `npx prisma studio` to open studio web: http://localhost:5000 :::tip Verify Your Setup Open the [Swagger UI](http://localhost:3000/swagger-ui) in your browser to confirm the API server is running. You should see the interactive API documentation with all available endpoints. ::: ## Configuring Local Service Ports {#configuring-local-ports} :::info Version Note Local port configuration feature was added in [version 1.0.26](/docs/changelog#v1026). ::: If you have port conflicts with other services (e.g., another MySQL instance, another application using port 3000), you can configure the local service ports via environment variables in your `.env` file. ### Available Port Variables | Variable | Default | Service | |-------------|-------------|-------------| | `LOCAL_HTTP_PORT` | `3000` | API Gateway (Serverless Offline) | | `LOCAL_LAMBDA_PORT` | `3002` | Lambda HTTP endpoint | | `LOCAL_DYNAMODB_PORT` | `8000` | DynamoDB Local | | `LOCAL_RDS_PORT` | `3306` | MySQL (RDS) | | `LOCAL_S3_PORT` | `4566` | LocalStack (S3) | | `LOCAL_SNS_PORT` | `4002` | SNS | | `LOCAL_SQS_PORT` | `9324` | SQS (ElasticMQ) | | `LOCAL_SQS_UI_PORT` | `9325` | SQS Admin UI | | `LOCAL_SFN_PORT` | `8083` | Step Functions Local | | `LOCAL_COGNITO_PORT` | `9229` | Cognito Local | | `LOCAL_APPSYNC_PORT` | `4001` | AppSync Simulator | | `LOCAL_EVENTBRIDGE_PORT` | `4010` | EventBridge | | `LOCAL_SES_PORT` | `8005` | Simple Email Service | | `LOCAL_DDB_ADMIN_PORT` | `8001` | DynamoDB Admin UI | ### Example: Changing Ports To change the API Gateway port from 3000 to 3010 and MySQL port from 3306 to 3307, add the following to your `.env` file: ```bash # Change API Gateway port to 3010 LOCAL_HTTP_PORT=3010 # Change MySQL port to 3307 LOCAL_RDS_PORT=3307 # Change DynamoDB port to 9000 LOCAL_DYNAMODB_PORT=9000 ``` After changing the ports, restart all services: 1. Stop all running services (Docker and Serverless Offline) 2. Run `npm run offline:docker` to restart Docker services 3. Run `npm run offline:sls` to restart Serverless Offline :::tip The port configuration is automatically applied to all related services including Docker Compose, Serverless Offline, and the DynamoDB stream trigger script. You only need to set the environment variables once in your `.env` file. ::: :::note In the local environment, if you have trouble with the `npm run migrate` command or cannot log in with local Cognito, you will need to add more permissions to files and folders using the command below: ```bash sudo chmod -R 777 ./infra-local/cognito-local sudo chmod -R 777 ./infra-local/cognito-local/db/clients.json sudo chmod -R 777 ./infra-local sudo chmod -R 777 ./infra-local/docker-data/ sudo chmod -R 777 ./infra-local/docker-data/dynamodb-local ``` ::: ## Next Steps {#next-steps} Your local environment is ready. Here's the recommended path forward: 1. **[Quickstart Tutorial](/docs/quickstart-tutorial)** — Build your first API endpoint in 15 minutes 2. **[Project Structure](/docs/project-structure)** — Understand what each generated file and folder does 3. **[Architecture](/docs/architecture)** — Learn the CQRS and Event Sourcing concepts behind the framework 4. **[Backend Development](/docs/backend-development)** — Start implementing real features ## Related Documentation - [Getting Started](/docs/getting-started) - Introduction to MBC CQRS Serverless - [Project Structure](/docs/project-structure) - Understanding the generated project layout - [Configuring](/docs/configuring) - Configure modules for your application - [CLI](/docs/cli) - CLI commands for scaffolding - [Glossary](/docs/glossary) - Framework terminology and concepts - [Building Your Application](/docs/build-your-application) - Application development guides after setup - [CodePipeline CI/CD](/docs/codepipeline-cicd) - Automated deployment with AWS CodePipeline --- ## Project structure URL: https://mbc-cqrs-serverless.mbc-net.com/docs/project-structure # Project structure ## MBC CQRS Serverless Project Structure {#project-structure} This page provides an overview of the project structure of an mbc-cqrs-serverless application. It covers top-level files and folders, configuration files. ### Top-level folders Top-level folders are used to organize your application's code, infrastructure for local development, data migration, and testing. | | | | ----------- | --------------------------- | | infra | AWS CDK infrastructure code for cloud deployment | | infra-local | Infrastructure runs in a local environment | | prisma | Configuration for your Prisma ORM and DynamoDB table | | src | Application source folder | | test | Configuration for e2e Jest testing and manual API tests | ### Top-level files Top-level files are used to configure your application, manage dependencies, and define environment variables. | | | | ------------------- | ------------------------------ | | .env | Environment variables | | .env.local | Local environment variables | | .eslintrc.js | Configuration file for ESLint | | .gitignore | Specifies files and directories that Git should ignore | | .prettierrc | Configure Prettier's code formatting rules | | jest.config.json | Configuration for Jest testing | | nest-cli.json | NestJS plugins configuration | | package-lock.json | Lockfile that holds information on the dependencies installed | | package.json | Project dependencies and scripts | | README.md | Information about a project, including its description, installation instructions, and usage guidelines | | tsconfig.build.json | Configuration for TypeScript compiler options | | tsconfig.json | Configuration file for TypeScript | ## Application module conventions {#module-conventions} The following file conventions are used to define a new module in the src folder. | | folder | | | -------------------- | ------ | ------------------------------- | | dto | folder | Define the DTO (Data Transfer Object) schema. A DTO is an object that defines how the data will be sent over the network. | | entity | folder | Define the business object. | | handler | folder | Define the data sync handler classes. | | [name].service.ts | file | Define business logic. | | [name].controller.ts | file | Define a controller. | | [name].module.ts | file | Organizes code relevant for a specific feature, keeping code organized and establishing clear boundaries. | ## Related Documentation - [Getting Started](/docs/getting-started) - Introduction and prerequisites - [Modules](/docs/modules) - Module configuration and usage - [Configuring](/docs/configuring) - Configuration options - [CLI](/docs/cli) - Generate project with CLI - [Absolute Imports and Module Path Aliases](/docs/absolute_imports_and_module_path_aliases) - TypeScript path alias configuration --- # Tutorials ## Build a Todo App URL: https://mbc-cqrs-serverless.mbc-net.com/docs/build-todo-app # Build a Todo App This tutorial guides you through building a complete Todo application using MBC CQRS Serverless. You'll learn CQRS patterns, event handling, and progressive feature additions. This tutorial follows the [sample code](https://github.com/mbc-net/mbc-cqrs-serverless-samples) which is organized into progressive steps. ## What You'll Build {#what-youll-build} A fully functional Todo application with: - CRUD operations for todos - CQRS pattern with Command/Query separation - Event-driven data synchronization to RDS - Optional: Sequence numbers for todos - Optional: Async task processing ## Prerequisites {#prerequisites} - Completed the [Quickstart Tutorial](/docs/quickstart-tutorial) - Basic understanding of NestJS - Docker running for local development ## Running the Samples {#running-samples} Each step has a complete working sample. To run any sample: ```bash # Navigate to the step directory cd step-02-create # or any other step # Install dependencies npm install # Terminal 1: Start Docker services npm run offline:docker # Terminal 2: Run database migrations npm run migrate # Terminal 3: Start the serverless offline server npm run offline:sls ``` ## Part 1: Basic CQRS Implementation (step-02-create) {#part1-create} ### Step 1: Create Helper Functions First, create helper functions for managing partition keys and sort keys (`src/helpers/id.ts`): ```typescript 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 } 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 } } ``` ### Step 2: Define DTOs Create the todo attributes DTO (`dto/todo-attributes.dto.ts`): ```typescript import { ApiProperty } from '@nestjs/swagger' import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator' // TodoStatus enum (will be synced with Prisma in step-03) 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 } ``` Create the input DTO (`dto/create-todo.dto.ts`): ```typescript 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 @Type(() => TodoAttributes) @ValidateNested() @IsOptional() attributes?: TodoAttributes constructor(partial: Partial) { Object.assign(this, partial) } } ``` ### Step 3: Define Entities Create the command entity (`entity/todo-command.entity.ts`): ```typescript import { CommandEntity } from '@mbc-cqrs-serverless/core' import { TodoAttributes } from '../dto/todo-attributes.dto' export class TodoCommandEntity extends CommandEntity { attributes: TodoAttributes constructor(partial: Partial) { super() Object.assign(this, partial) } } ``` Create the command DTO (`dto/todo-command.dto.ts`): ```typescript 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) { super() Object.assign(this, partial) } } ``` Create the data entity (`entity/todo-data.entity.ts`): ```typescript import { DataEntity } from '@mbc-cqrs-serverless/core' import { TodoAttributes } from '../dto/todo-attributes.dto' export class TodoDataEntity extends DataEntity { attributes: TodoAttributes constructor(partial: Partial) { super(partial) Object.assign(this, partial) } } ``` ### Step 4: Implement the Service Create the todo service (`todo.service.ts`): ```typescript 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 { // 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 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 const item = await this.commandService.publishAsync(todo, opts) // New entities (VERSION_FIRST) always publish successfully and return a non-null result return new TodoDataEntity(item as TodoDataEntity) } } ``` :::info publishAsync null return (v1.2.0+) Since v1.2.0, `publishAsync()` and `publishPartialUpdateAsync()` return `null` when the command produces no changes (no-op). New entities created with `VERSION_FIRST` (0) are always dirty and never return `null`. For update operations where the payload may be unchanged, null-check the result before using it. See the [v1.2.0 migration guide](/docs/migration/v1.2.0) for details. ::: ### Step 5: Create the Controller Create the controller (`todo.controller.ts`): ```typescript import { getUserContext, IInvoke, INVOKE_CONTEXT } from '@mbc-cqrs-serverless/core' import { Body, Controller, Get, Logger, Post, Query } 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 { this.logger.debug('createDto:', createDto) return this.todoService.create(createDto, { invokeContext }) } } ``` ### Step 6: Create the Module Create the module (`todo.module.ts`): ```typescript 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 // dataSyncHandlers: [TodoDataSyncRdsHandler], }), ], controllers: [TodoController], providers: [TodoService], }) export class TodoModule {} ``` ## Part 2: RDS Data Synchronization (step-03-rds-sync) {#part2-rds-sync} Implement automatic data synchronization from DynamoDB to RDS. ### Update Prisma Schema Add TodoStatus enum and Todo model to `prisma/schema.prisma`: ```prisma // Todo status enum enum TodoStatus { PENDING IN_PROGRESS COMPLETED CANCELED } // Todo model for RDS (PostgreSQL) - synchronized from DynamoDB model Todo { id String @id // Unique ID (generated from pk#sk) cpk String // Command partition key csk String // Command sort key (with version) pk String // Data partition key: TODO#tenantCode sk String // Data sort key: ULID tenantCode String @map("tenant_code") // Tenant code for multi-tenancy seq Int @default(0) // Sequence number (for ordering) code String // Record code (same as sk) name String // Todo name/title version Int // Version for optimistic locking isDeleted Boolean @default(false) @map("is_deleted") // Soft delete flag createdBy String @default("") @map("created_by") // Created by user createdIp String @default("") @map("created_ip") // Created from IP createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) updatedBy String @default("") @map("updated_by") // Updated by user updatedIp String @default("") @map("updated_ip") // Updated from IP updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(0) // Todo-specific attributes description String? @default("") @map("description") // Description status TodoStatus @default(PENDING) @map("status") // Status dueDate DateTime? @map("due_date") // Due date // Indexes for efficient queries @@unique([cpk, csk]) // Command table unique constraint @@unique([pk, sk]) // Data table unique constraint @@unique([tenantCode, code]) // Tenant + code unique constraint @@index([tenantCode, name]) // Search by tenant and name @@map("todos") // Table name in database } ``` ### Create Data Sync Handler Create the RDS sync handler (`handler/todo-rds.handler.ts`): ```typescript import { CommandModel, IDataSyncHandler, removeSortKeyVersion, } from '@mbc-cqrs-serverless/core' import { Injectable, Logger } from '@nestjs/common' import { PrismaService } from 'src/prisma' import { TodoAttributes } from '../dto/todo-attributes.dto' @Injectable() export class TodoDataSyncRdsHandler implements IDataSyncHandler { private readonly logger = new Logger(TodoDataSyncRdsHandler.name) constructor(private readonly prismaService: PrismaService) {} // Called when data is created or updated in DynamoDB async up(cmd: CommandModel): Promise { this.logger.debug('Syncing to RDS:', cmd) // Remove version suffix from sort key for the data table const sk = removeSortKeyVersion(cmd.sk) const attrs = cmd.attributes as TodoAttributes await this.prismaService.todo.upsert({ where: { id: cmd.id }, // Update existing record update: { csk: cmd.sk, name: cmd.name, version: cmd.version, seq: cmd.seq, isDeleted: cmd.isDeleted || false, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, updatedIp: cmd.updatedIp, description: attrs?.description, status: attrs?.status, dueDate: attrs?.dueDate, }, // Create new record create: { id: cmd.id, cpk: cmd.pk, csk: cmd.sk, pk: cmd.pk, sk, code: sk, name: cmd.name, version: cmd.version, tenantCode: cmd.tenantCode, seq: cmd.seq, createdAt: cmd.createdAt, createdBy: cmd.createdBy, createdIp: cmd.createdIp, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, updatedIp: cmd.updatedIp, description: attrs?.description, status: attrs?.status, dueDate: attrs?.dueDate, }, }) } // Called when data needs to be rolled back async down(cmd: CommandModel): Promise { this.logger.debug('Rollback requested:', cmd) // Implement rollback logic if needed } } ``` ### Register Handler in Module Update `todo.module.ts`: ```typescript import { CommandModule } from '@mbc-cqrs-serverless/core' import { Module } from '@nestjs/common' import { TodoDataSyncRdsHandler } from './handler/todo-rds.handler' import { TodoController } from './todo.controller' import { TodoService } from './todo.service' @Module({ imports: [ CommandModule.register({ tableName: 'todo', // Register RDS sync handler to synchronize DynamoDB data to PostgreSQL dataSyncHandlers: [TodoDataSyncRdsHandler], }), ], controllers: [TodoController], providers: [TodoService], }) export class TodoModule {} ``` ## Part 3: Read Operations (step-04-read) {#part3-read} Add methods to retrieve single items from DynamoDB. ### Update Service Add `findOne` method to `todo.service.ts`: ```typescript import { DataService } from '@mbc-cqrs-serverless/core' import { Injectable, NotFoundException } from '@nestjs/common' @Injectable() export class TodoService { constructor( private readonly commandService: CommandService, private readonly dataService: DataService, // Inject DataService ) {} // ... create method ... async findOne(pk: string, sk: string): Promise { this.logger.debug(`Finding todo: pk=${pk}, sk=${sk}`) const item = await this.dataService.getItem({ pk, sk }) if (!item) { throw new NotFoundException(`Todo not found: pk=${pk}, sk=${sk}`) } return new TodoDataEntity(item as TodoDataEntity) } } ``` ### Update Controller ```typescript @Get(':pk/:sk') async findOne( @Param('pk') pk: string, @Param('sk') sk: string, ): Promise { this.logger.debug(`findOne: pk=${pk}, sk=${sk}`) return this.todoService.findOne(pk, sk) } ``` ## Part 4: Search Operations (step-05-search) {#part4-search} Implement search using RDS for efficient queries. ### Create Search DTO ```typescript import { ApiPropertyOptional } from '@nestjs/swagger' import { TodoStatus } from '@prisma/client' // Import from Prisma generated types import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator' import { Transform, Type } from 'class-transformer' export class SearchTodoDto { @IsOptional() @IsString() @ApiPropertyOptional({ description: 'Search by name (partial match)' }) name?: string @IsOptional() @IsEnum(TodoStatus) @ApiPropertyOptional({ enum: TodoStatus, description: 'Filter by status' }) status?: TodoStatus @IsOptional() @Type(() => Number) @IsInt() @Min(1) @ApiPropertyOptional({ description: 'Page number (1-based)', default: 1 }) page?: number = 1 @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) @ApiPropertyOptional({ description: 'Items per page', default: 10 }) limit?: number = 10 @IsOptional() @IsString() @ApiPropertyOptional({ description: 'Sort field', default: 'createdAt', enum: ['name', 'status', 'createdAt', 'updatedAt'], }) sortBy?: string = 'createdAt' @IsOptional() @IsString() @Transform(({ value }) => value?.toUpperCase()) @ApiPropertyOptional({ description: 'Sort order', default: 'DESC', enum: ['ASC', 'DESC'], }) sortOrder?: 'ASC' | 'DESC' = 'DESC' } export class SearchTodoResultDto { data: T[] total: number page: number limit: number totalPages: number constructor(data: T[], total: number, page: number, limit: number) { this.data = data this.total = total this.page = page this.limit = limit this.totalPages = Math.ceil(total / limit) } } ``` ### Update Service ```typescript import { Prisma } from '@prisma/client' async findAll( tenantCode: string, searchDto: SearchTodoDto, ): Promise> { this.logger.debug(`Searching todos for tenant: ${tenantCode}`, searchDto) const { name, status, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'DESC' } = searchDto // Build where clause dynamically const where: Prisma.TodoWhereInput = { tenantCode, isDeleted: false, } // Add name filter (partial match) if (name) { where.name = { contains: name } } // Add status filter (exact match) if (status) { where.status = status } // Build orderBy clause const orderBy: Prisma.TodoOrderByWithRelationInput = { [sortBy]: sortOrder.toLowerCase(), } // Calculate skip for pagination const skip = (page - 1) * limit // Execute query with pagination const [data, total] = await Promise.all([ this.prismaService.todo.findMany({ where, orderBy, skip, take: limit, }), this.prismaService.todo.count({ where }), ]) // Map Prisma results to TodoDataEntity const todos = data.map((item) => new TodoDataEntity({ ...item, type: TODO_PK_PREFIX, attributes: { description: item.description, status: item.status, dueDate: item.dueDate?.toISOString(), }, } as unknown as TodoDataEntity)) return new SearchTodoResultDto(todos, total, page, limit) } ``` ### Update Controller ```typescript @Get('/') async findAll( @INVOKE_CONTEXT() invokeContext: IInvoke, @Query() searchDto: SearchTodoDto, ): Promise> { const { tenantCode } = getUserContext(invokeContext) this.logger.debug(`findAll: tenantCode=${tenantCode}`, searchDto) return this.todoService.findAll(tenantCode, searchDto) } ``` ## Part 5: Update and Delete (step-06-update-delete) {#part5-update-delete} ### Update DTO ```typescript import { ApiPropertyOptional } from '@nestjs/swagger' import { IsInt, IsOptional, IsString, Min } from 'class-validator' import { Type } from 'class-transformer' import { TodoAttributes } from './todo-attributes.dto' export class UpdateTodoDto { @IsOptional() @IsString() @ApiPropertyOptional({ description: 'Todo name/title' }) name?: string @IsOptional() @ApiPropertyOptional({ description: 'Todo attributes (description, status, dueDate)' }) attributes?: TodoAttributes @Type(() => Number) @IsInt() @Min(1) @ApiPropertyOptional({ description: 'Version for optimistic locking' }) version: number // Required for optimistic locking } ``` ### Update Service ```typescript import { CommandPartialInputModel } from '@mbc-cqrs-serverless/core' async update( pk: string, sk: string, updateDto: UpdateTodoDto, opts: { invokeContext: IInvoke }, ): Promise { this.logger.debug(`Updating todo: pk=${pk}, sk=${sk}`, updateDto) // First, verify the item exists const currentItem = await this.dataService.getItem({ pk, sk }) if (!currentItem) { throw new NotFoundException(`Todo not found: pk=${pk}, sk=${sk}`) } // Build the partial update object const partialUpdate: CommandPartialInputModel = { pk, sk, version: updateDto.version, // Required for optimistic locking ...(updateDto.name !== undefined && { name: updateDto.name }), ...(updateDto.attributes !== undefined && { attributes: updateDto.attributes }), } // Publish partial update command const item = await this.commandService.publishPartialUpdateAsync(partialUpdate, opts) // null means no fields changed — treat as NotFoundException since caller verified it exists if (!item) throw new NotFoundException(`Todo not found or no changes: pk=${pk}, sk=${sk}`) return new TodoDataEntity(item as TodoDataEntity) } async remove( pk: string, sk: string, version: number, opts: { invokeContext: IInvoke }, ): Promise { this.logger.debug(`Removing todo: pk=${pk}, sk=${sk}, version=${version}`) // First, verify the item exists const currentItem = await this.dataService.getItem({ pk, sk }) if (!currentItem) { throw new NotFoundException(`Todo not found: pk=${pk}, sk=${sk}`) } // Soft delete by setting isDeleted flag const item = await this.commandService.publishPartialUpdateAsync( { pk, sk, version, isDeleted: true, }, opts, ) // null means already deleted (isDeleted was already true) if (!item) throw new NotFoundException(`Todo not found or already deleted: pk=${pk}, sk=${sk}`) return new TodoDataEntity(item as TodoDataEntity) } ``` ### Update Controller ```typescript @Patch(':pk/:sk') async update( @INVOKE_CONTEXT() invokeContext: IInvoke, @Param('pk') pk: string, @Param('sk') sk: string, @Body() updateDto: UpdateTodoDto, ): Promise { this.logger.debug(`update: pk=${pk}, sk=${sk}`, updateDto) return this.todoService.update(pk, sk, updateDto, { invokeContext }) } @Delete(':pk/:sk') async remove( @INVOKE_CONTEXT() invokeContext: IInvoke, @Param('pk') pk: string, @Param('sk') sk: string, @Query('version') version: number, ): Promise { this.logger.debug(`remove: pk=${pk}, sk=${sk}, version=${version}`) return this.todoService.remove(pk, sk, version, { invokeContext }) } ``` ## Part 6: Sequence Numbers (step-07-sequence) {#part6-sequence} Add auto-incrementing todo numbers. ### Install Sequence Module ```bash npm install @mbc-cqrs-serverless/sequence ``` ### Update Module ```typescript import { SequencesModule } from '@mbc-cqrs-serverless/sequence' @Module({ imports: [ CommandModule.register({ tableName: 'todo', dataSyncHandlers: [TodoDataSyncRdsHandler], }), SequencesModule, // Add SequencesModule ], // ... }) export class TodoModule {} ``` ### Update Service ```typescript import { Injectable } from '@nestjs/common' import { SequencesService } from '@mbc-cqrs-serverless/sequence' @Injectable() export class TodoService { constructor( private readonly commandService: CommandService, private readonly dataService: DataService, private readonly prismaService: PrismaService, private readonly sequencesService: SequencesService, // Inject SequencesService ) {} async create( createDto: CreateTodoDto, opts: { invokeContext: IInvoke }, ): Promise { const { tenantCode } = getUserContext(opts.invokeContext) // Generate sequential number const seqItem = await this.sequencesService.generateSequenceItem( { tenantCode, typeCode: TODO_PK_PREFIX, }, opts, ) this.logger.debug(`Generated sequence number: ${seqItem.formattedNo} for tenant: ${tenantCode}`) const pk = generateTodoPk(tenantCode) const sk = generateTodoSk() // SK is still ULID const todo = new TodoCommandDto({ pk, sk, id: generateId(pk, sk), tenantCode, code: sk, type: TODO_PK_PREFIX, version: VERSION_FIRST, seq: seqItem.no, // Store sequence number in seq field name: createDto.name, attributes: createDto.attributes, }) this.logger.debug('Creating todo with sequence:', todo) const item = await this.commandService.publishAsync(todo, opts) // New entities (VERSION_FIRST) always publish successfully and return a non-null result return new TodoDataEntity(item as TodoDataEntity) } } ``` ## Part 7: Async Task Processing (complete/with-task) {#part7-async-tasks} Process long-running todo operations asynchronously. ### Install Task Module ```bash npm install @mbc-cqrs-serverless/task ``` ### Create Task Event ```typescript // src/todo/handler/todo-task.event.ts import { TaskQueueEvent } from '@mbc-cqrs-serverless/task' export class TodoTaskEvent extends TaskQueueEvent {} ``` ### Create Task Handler ```typescript // src/todo/handler/todo-task.event.handler.ts import { EventHandler, IEventHandler } from '@mbc-cqrs-serverless/core' import { Logger } from '@nestjs/common' import { TodoTaskEvent } from './todo-task.event' @EventHandler(TodoTaskEvent) export class TodoTaskEventHandler implements IEventHandler { private readonly logger = new Logger(TodoTaskEventHandler.name) async execute(event: TodoTaskEvent): Promise { this.logger.debug('Processing todo task:', event) // Implement your async task processing here // e.g., send notification, sync to external system return { processed: true } } } ``` ### Create Task Queue Event Factory ```typescript // src/my-task/task-queue-event-factory.ts import { ITaskQueueEventFactory, TaskQueueEvent, } from '@mbc-cqrs-serverless/task' import { TodoTaskEvent } from '../todo/handler/todo-task.event' export class TaskQueueEventFactory implements ITaskQueueEventFactory { async transformTask(event: TaskQueueEvent): Promise { return [new TodoTaskEvent().fromSqsRecord(event)] } } ``` ### Create Task Module ```typescript // src/my-task/my-task.module.ts import { TaskModule } from '@mbc-cqrs-serverless/task' import { Module } from '@nestjs/common' import { TaskQueueEventFactory } from './task-queue-event-factory' @Module({ imports: [ TaskModule.register({ taskQueueEventFactory: TaskQueueEventFactory, }), ], exports: [TaskModule], }) export class MyTaskModule {} ``` ## Testing Your Application {#testing} ### Run Locally ```bash # Terminal 1: Start Docker services npm run offline:docker # Terminal 2: Run database migrations npm run migrate # Terminal 3: Start serverless offline npm run offline:sls ``` ### Test API Endpoints ```bash # Create a todo curl -X POST http://localhost:3000/api/todo \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{"name": "My First Todo", "attributes": {"description": "Testing CQRS", "status": "PENDING"}}' # List todos curl "http://localhost:3000/api/todo?page=1&limit=10" \ -H "Authorization: Bearer " # Get a todo (Note: # in pk must be URL-encoded as %23) curl "http://localhost:3000/api/todo/TODO%23MBC/" \ -H "Authorization: Bearer " # Update a todo curl -X PATCH "http://localhost:3000/api/todo/TODO%23MBC/" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{"name": "Updated Todo", "version": 1}' # Delete a todo curl -X DELETE "http://localhost:3000/api/todo/TODO%23MBC/?version=1" \ -H "Authorization: Bearer " ``` ### Unit Tests Create unit tests for the service (`todo.service.spec.ts`): ```typescript import { Test, TestingModule } from '@nestjs/testing' import { NotFoundException } from '@nestjs/common' import { CommandService, DataService } from '@mbc-cqrs-serverless/core' import { TodoService } from './todo.service' import { PrismaService } from '../prisma/prisma.service' // Mock getUserContext jest.mock('@mbc-cqrs-serverless/core', () => ({ ...jest.requireActual('@mbc-cqrs-serverless/core'), getUserContext: jest.fn().mockReturnValue({ tenantCode: 'test', userId: 'user-123', }), })) describe('TodoService', () => { let service: TodoService let commandService: jest.Mocked let dataService: jest.Mocked beforeEach(async () => { const mockCommandService = { publishAsync: jest.fn(), publishPartialUpdateAsync: jest.fn(), } const mockDataService = { getItem: jest.fn(), } const mockPrismaService = { todo: { findMany: jest.fn(), count: jest.fn(), }, } const module: TestingModule = await Test.createTestingModule({ providers: [ TodoService, { provide: CommandService, useValue: mockCommandService }, { provide: DataService, useValue: mockDataService }, { provide: PrismaService, useValue: mockPrismaService }, ], }).compile() service = module.get(TodoService) commandService = module.get(CommandService) dataService = module.get(DataService) }) describe('findOne', () => { it('should return a todo when found', async () => { const mockTodo = { pk: 'TODO#test', sk: '01HXY', name: 'Test' } dataService.getItem.mockResolvedValue(mockTodo as any) const result = await service.findOne('TODO#test', '01HXY') expect(dataService.getItem).toHaveBeenCalledWith({ pk: 'TODO#test', sk: '01HXY', }) expect(result.name).toBe('Test') }) it('should throw NotFoundException when not found', async () => { dataService.getItem.mockResolvedValue(null) await expect(service.findOne('TODO#test', 'nonexistent')) .rejects.toThrow(NotFoundException) }) }) }) ``` Run unit tests: ```bash npm test ``` ### E2E Tests Create E2E tests (`test/todo.e2e-spec.ts`): ```typescript import { Test, TestingModule } from '@nestjs/testing' import { INestApplication, ValidationPipe } from '@nestjs/common' import request from 'supertest' import { TodoController } from '../src/todo/todo.controller' import { TodoService } from '../src/todo/todo.service' // Mock getUserContext jest.mock('@mbc-cqrs-serverless/core', () => ({ ...jest.requireActual('@mbc-cqrs-serverless/core'), getUserContext: jest.fn().mockReturnValue({ tenantCode: 'test', userId: 'user-123', }), INVOKE_CONTEXT: () => () => {}, // Decorator stub })) describe('TodoController (e2e)', () => { let app: INestApplication const mockTodoData = { pk: 'TODO#test', sk: '01HXY', name: 'Test Todo', version: 1, } beforeAll(async () => { const mockTodoService = { create: jest.fn().mockResolvedValue(mockTodoData), findOne: jest.fn().mockResolvedValue(mockTodoData), findAll: jest.fn().mockResolvedValue({ data: [mockTodoData], total: 1 }), update: jest.fn().mockResolvedValue(mockTodoData), remove: jest.fn().mockResolvedValue({ ...mockTodoData, isDeleted: true }), } const moduleFixture: TestingModule = await Test.createTestingModule({ controllers: [TodoController], providers: [{ provide: TodoService, useValue: mockTodoService }], }).compile() app = moduleFixture.createNestApplication() app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })) await app.init() }) afterAll(async () => { await app.close() }) it('POST /api/todo - should create a todo', () => { return request(app.getHttpServer()) .post('/api/todo') .send({ name: 'New Todo', attributes: { status: 'PENDING' } }) .expect(201) }) it('GET /api/todo/:pk/:sk - should return a todo', () => { return request(app.getHttpServer()) .get('/api/todo/TODO%23TEST/01HXY') .expect(200) }) it('PATCH /api/todo/:pk/:sk - should update a todo', () => { return request(app.getHttpServer()) .patch('/api/todo/TODO%23TEST/01HXY') .send({ name: 'Updated', version: 1 }) .expect(200) }) }) ``` Configure Jest for E2E tests (`test/jest-e2e.json`): ```json { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": ["ts-jest", { "tsconfig": "/../tsconfig.json" }] }, "moduleNameMapper": { "^src/(.*)$": "/../src/$1" } } ``` Run E2E tests: ```bash npm run test:e2e ``` ## Sample Code Repository {#sample-repo} The complete source code for each step is available at: - [step-01-setup](https://github.com/mbc-net/mbc-cqrs-serverless-samples/tree/main/step-01-setup) - Environment setup - [step-02-create](https://github.com/mbc-net/mbc-cqrs-serverless-samples/tree/main/step-02-create) - Create operation - [step-03-rds-sync](https://github.com/mbc-net/mbc-cqrs-serverless-samples/tree/main/step-03-rds-sync) - RDS synchronization - [step-04-read](https://github.com/mbc-net/mbc-cqrs-serverless-samples/tree/main/step-04-read) - Read operation - [step-05-search](https://github.com/mbc-net/mbc-cqrs-serverless-samples/tree/main/step-05-search) - Search operation - [step-06-update-delete](https://github.com/mbc-net/mbc-cqrs-serverless-samples/tree/main/step-06-update-delete) - Update and delete - [step-07-sequence](https://github.com/mbc-net/mbc-cqrs-serverless-samples/tree/main/step-07-sequence) - Sequence numbers - [complete/basic](https://github.com/mbc-net/mbc-cqrs-serverless-samples/tree/main/complete/basic) - Full basic implementation - [complete/with-task](https://github.com/mbc-net/mbc-cqrs-serverless-samples/tree/main/complete/with-task) - With async task processing ## Related Documentation - [Quickstart Tutorial](/docs/quickstart-tutorial) - Shorter introduction - [Command Service](/docs/command-service) - CommandService API reference - [Service Patterns](/docs/service-patterns) - Advanced CRUD patterns - [Testing](/docs/testing) - Write tests for your application --- ## Quickstart Tutorial URL: https://mbc-cqrs-serverless.mbc-net.com/docs/quickstart-tutorial # Quickstart Tutorial This tutorial will guide you through creating your first MBC CQRS Serverless application. By the end, you'll have a working API running locally. ## Prerequisites {#prerequisites} Before you begin, ensure you have the following installed: - Node.js 20.x or later - Docker and Docker Compose - AWS CLI (real credentials are not required for local development — set `AWS_ACCESS_KEY_ID=local` and `AWS_SECRET_ACCESS_KEY=local` in your `.env` file) - Git ## Step 1: Create a New Project {#step1-create} Use the MBC CQRS CLI to scaffold a new project: ```bash npx @mbc-cqrs-serverless/cli new my-app cd my-app ``` The CLI will create a project with the following structure: ```text my-app/ ├── src/ │ ├── main.ts │ ├── main.module.ts │ └── ... ├── infra-local/ │ ├── docker-compose.yml │ └── serverless.yml ├── prisma/ │ └── schema.prisma ├── package.json └── ... ``` ## Step 2: Install Dependencies {#step2-install} ```bash npm install ``` ## Step 3: Configure Environment {#step3-configure} Copy the example environment file and set dummy AWS credentials for local development: ```bash cp .env.example .env ``` Open `.env` and ensure these values are set (LocalStack emulates AWS — no real credentials needed): ```bash AWS_ACCESS_KEY_ID=local AWS_SECRET_ACCESS_KEY=local ``` Build the TypeScript application (required before starting the server): ```bash npm run build ``` ## Step 4: Start Local Infrastructure {#step4-infrastructure} Start the local development environment using Docker Compose: ```bash npm run offline:docker ``` This starts the following services: - DynamoDB Local (port 8000) - MySQL (port 3306) - LocalStack for AWS services ## Step 5: Initialize the Database {#step5-database} Wait ~30 seconds for MySQL to fully start, then run Prisma migrations to set up your database schema: ```bash npm run migrate ``` ## Step 6: Start the Development Server {#step6-server} In a new terminal, start the Serverless Offline server: ```bash npm run offline:sls ``` Your API is now running at `http://localhost:3000`. ## Step 7: Test Your API {#step7-test} Open the Swagger UI in your browser to explore and test your API: ```text http://localhost:3000/swagger-ui/ ``` You should see the Swagger UI with all available endpoints listed. ## Creating Your First Endpoint {#first-endpoint} Let's create a simple "Hello World" endpoint. ### Create a Controller Create a new file `src/hello/hello.controller.ts`: ```typescript import { Controller, Get } from '@nestjs/common'; @Controller('hello') export class HelloController { @Get() getHello(): string { return 'Hello, MBC CQRS Serverless!'; } } ``` ### Create a Module Create a new file `src/hello/hello.module.ts`: ```typescript import { Module } from '@nestjs/common'; import { HelloController } from './hello.controller'; @Module({ controllers: [HelloController], }) export class HelloModule {} ``` ### Register the Module Add the HelloModule to your main module in `src/main.module.ts`: ```typescript import { Module } from '@nestjs/common'; import { HelloModule } from './hello/hello.module'; @Module({ imports: [ // ... existing imports HelloModule, ], }) export class MainModule {} ``` ### Test the New Endpoint Restart the server and test your new endpoint: ```bash curl http://localhost:3000/hello ``` You should see: `Hello, MBC CQRS Serverless!` ## Next Steps {#next-steps} Congratulations! You've created your first MBC CQRS Serverless application. Here's what to explore next: - [Build a Todo App](/docs/build-todo-app) - Learn CQRS patterns by building a complete application - [Core Concepts](/docs/architecture) - Understand the CQRS and Event Sourcing architecture - [Deployment Guide](/docs/deployment-guide) - Deploy your application to AWS ## Common Commands {#common-commands} | Command | Description | |-------------|-----------------| | `npm run offline:docker` | Start local Docker services | | `npm run offline:sls` | Start Serverless Offline | | `npm run migrate` | Run database migrations | | `npm run build` | Build the application | | `npm run test` | Run unit tests | | `npm run test:e2e` | Run end-to-end tests | ## Troubleshooting {#troubleshooting} ### Docker services won't start Ensure Docker is running and you have sufficient resources allocated. Try: ```bash docker-compose -f infra-local/docker-compose.yml down docker-compose -f infra-local/docker-compose.yml up -d ``` ### Database connection errors Wait a few seconds for MySQL to fully start, then run migrations again: ```bash npm run migrate ``` ### Port conflicts If ports 3000, 3306, or 8000 are in use, configure custom ports via environment variables in your `.env` file: ```bash LOCAL_HTTP_PORT=3010 LOCAL_RDS_PORT=3307 LOCAL_DYNAMODB_PORT=9000 ``` See [Configuring Local Service Ports](/docs/installation#configuring-local-ports) for all available port variables. ## Related Documentation - [Build a Todo App](/docs/build-todo-app) - More complete CQRS pattern example - [Backend Development](/docs/backend-development) - Deep dive into backend patterns - [Service Patterns](/docs/service-patterns) - Complete CRUD service patterns - [Command Service](/docs/command-service) - CommandService API reference - [Glossary](/docs/glossary) - Framework terminology reference - [Changelog](/docs/changelog) - Release history and what changed between versions - [Building Your Application](/docs/build-your-application) - Application development guides and patterns - [API Reference](/docs/api-reference) - Module API documentation - [Examples](/docs/recipes) - More examples and implementation patterns --- # Core Concepts ## CQRS Pattern Flow URL: https://mbc-cqrs-serverless.mbc-net.com/docs/architecture/cqrs-flow # CQRS Pattern Flow This document explains how the CQRS (Command Query Responsibility Segregation) pattern is implemented in MBC CQRS Serverless. ## CQRS Overview ```mermaid flowchart TB Client[Client Application] subgraph Write CommandAPI[Command API] CommandHandler[Command Handler] CommandService[Command Service] EventStore[(Event Store)] EventPublisher[Event Publisher] end subgraph Read QueryAPI[Query API] QueryHandler[Query Handler] DataService[Data Service] ReadStore[(Read Store)] end subgraph Events EventHandler[Event Handler] Projector[Projector] end Client --> CommandAPI Client --> QueryAPI CommandAPI --> CommandHandler CommandHandler --> CommandService CommandService --> EventStore CommandService --> EventPublisher QueryAPI --> QueryHandler QueryHandler --> DataService DataService --> ReadStore EventPublisher --> EventHandler EventHandler --> Projector Projector --> ReadStore ``` ## Command Flow - Write Path The flow of write operations. ```mermaid sequenceDiagram Client->>Gateway: POST request Gateway->>Controller: DTO Controller->>Handler: Command Handler->>Handler: Validate Handler->>Service: publish Service->>DynamoDB: PutItem (COMMAND table) DynamoDB-->>Service: OK Service-->>Handler: CommandModel | null Note over DynamoDB,SNS: Async: DynamoDB Streams → DataSyncHandler → DATA table → SNS Handler-->>Controller: Result Controller-->>Gateway: 201 Gateway-->>Client: Response ``` ### Command Flow Steps 1. **Request Received**: Client sends POST/PUT/DELETE request 2. **DTO Validation**: Controller validates input using class-validator 3. **Command Dispatch**: Controller creates and dispatches command 4. **Business Logic**: Command handler executes business rules 5. **Persistence**: Command service persists to DynamoDB with optimistic locking 6. **Event Publishing**: Domain events are published to SNS asynchronously via DynamoDB Streams and DataSyncHandler 7. **Response**: Success response returned to client ## Query Flow - Read Path The flow of read operations. ```mermaid sequenceDiagram Client->>Gateway: GET request Gateway->>Controller: Request Controller->>Handler: Query Handler->>DataService: getItem DataService->>Database: Query Database-->>DataService: Item DataService-->>Handler: Entity Handler-->>Controller: Result Controller-->>Gateway: 200 Gateway-->>Client: Response ``` ### Query Flow Steps 1. **Request Received**: Client sends GET request 2. **Query Dispatch**: Controller creates and dispatches query 3. **Data Retrieval**: Query handler calls data service 4. **Database Query**: Data service queries DynamoDB or RDS 5. **Response**: Data returned to client ## Read-Your-Writes Consistency {#read-your-writes} ### The Eventual Consistency Challenge Because `publishAsync` writes to the command table and then returns immediately — before the SNS-triggered projector has updated the read store — there is a short window where a subsequent read will return stale data: ```text publishAsync() │ ▼ CommandTable ──► SNS ──► Lambda ──► ReadStore │ ▲ │ ~async window~ │ └──── publishAsync returns ──── │ │ Client reads here ───────────────────────┘ ← may return OLD data ``` This is expected behaviour in an eventually-consistent system, but can cause confusing UX when a user creates/updates a record and immediately navigates to the list — only to see the previous state. ### Read-Your-Writes (RYW) Solution {#ryw-solution} MBC CQRS Serverless v1.2.0 introduced a session-based **Read-Your-Writes** layer that bridges this async window for the user who just issued the write: ```text publishAsync() │ ├──► CommandTable ──► SNS ──► Lambda ──► ReadStore │ └──► SessionTable ← small TTL-bounded entry │ ▼ Repository.getItem / listItemsByPk / listItems │ ├── fetch from ReadStore (DataService) └── fetch pending commands from SessionTable │ └── merge → return consistent result ``` When `RYW_SESSION_TTL_MINUTES` is set, after each `publishAsync` / `publishPartialUpdateAsync` call the `SessionService` writes a short-lived entry to a dedicated session table. The `Repository` class (which wraps `DataService`) automatically reads those entries and merges any pending commands into the query result — so the caller sees their own write immediately, even before the projector has run. ### RYW Concepts at a Glance | Concept | Description | |-------------|-----------------| | Session entry | Written by `SessionService.put()` after every successful `publishAsync`; expires after `RYW_SESSION_TTL_MINUTES` minutes | | Repository | Drop-in replacement for `DataService` that transparently applies the RYW merge | | Merge strategy | Pending `create` commands are prepended; pending `delete` commands are filtered out; `update` / `partial-update` are applied on top of the read-store item | | Fallback | When `RYW_SESSION_TTL_MINUTES` is unset or set to a non-positive value, `SessionService.put()` is a no-op and `Repository` behaves identically to `DataService` | :::info Version Note (v1.2.0) Read-Your-Writes support (`SessionService`, `Repository`) was added in [v1.2.0](/docs/changelog#v120). Enabling it requires setting `RYW_SESSION_TTL_MINUTES` and provisioning a session DynamoDB table. See the [Read-Your-Writes implementation guide](/docs/command-service#read-your-writes) for setup steps and full API reference. ::: ## Key Components ### Command Handler ```typescript @Injectable() export class ResourceService { constructor(private readonly commandService: CommandService) {} async create( dto: CreateResourceDto, invokeContext: IInvoke, ): Promise { // 1. Validate business rules // 2. Build the command input (pk, sk, attributes, ...) // 3. Persist and publish event return this.commandService.publishAsync(input, { invokeContext }); } } ``` ### Query Handler ```typescript @Injectable() export class ResourceQueryService { constructor(private readonly dataService: DataService) {} async findOne(pk: string, sk: string): Promise { return this.dataService.getItem({ pk, sk }); } } ``` ## Benefits of CQRS Adopting the CQRS pattern provides these benefits: - **Scalability**: Read and write can be scaled independently - **Optimization**: Optimize each side for its specific purpose - **Flexibility**: Use different data models for reads and writes - **Performance**: Denormalize read models for fast queries - **Auditability**: Complete event history for audit trails ## Related Documentation - [System Overview](/docs/architecture/system-overview) - AWS infrastructure components - [Event Sourcing](/docs/architecture/event-sourcing) - Event storage and replay - [Command Service](/docs/command-service) - Implement CQRS commands - [Data Service](/docs/data-service) - Implement CQRS queries --- ## Event Sourcing Pattern URL: https://mbc-cqrs-serverless.mbc-net.com/docs/architecture/event-sourcing # Event Sourcing Pattern This document explains the Event Sourcing implementation in MBC CQRS Serverless. ## Event Sourcing Overview ```mermaid flowchart TB subgraph EventStore ES[(DynamoDB)] end subgraph EventFlow Command[Command] Aggregate[Aggregate] Event1[Event 1] Event2[Event 2] Event3[Event 3] end subgraph Projections P1[Read Model A] P2[Read Model B] P3[Notification Service] end Command --> Aggregate Aggregate --> Event1 Aggregate --> Event2 Aggregate --> Event3 Event1 --> ES Event2 --> ES Event3 --> ES ES --> P1 ES --> P2 ES --> P3 ``` ## Event Lifecycle ```mermaid sequenceDiagram Cmd->>Agg: Execute Command Agg->>Agg: Validate Business Rules Agg->>Agg: Apply State Change Agg->>ES: Store Event (COMMAND table) Note over ES,SQS: Async: DynamoDB Streams → DataSyncHandler → DATA table ES->>SNS: Publish Event (via DataSyncHandler) SNS->>SQS: Fan-out SQS->>EH: Trigger Handler EH->>RM: Update Projection ``` ## DynamoDB Event Store Schema ### Key Structure The DynamoDB key structure for event storage: - **PK (Partition Key)**: `{TENANT}#{ENTITY_TYPE}` (Example: `TENANT001#ORDER`) - **SK (Sort Key)**: `{ENTITY_TYPE}#{ID}@{version}` (Example: `ORDER#20240101-001@1`) ### Event Record Example ```json { "pk": "TENANT001#ORDER", "sk": "ORDER#20240101-001@1", "version": 3, "type": "OrderCreated", "data": { "orderId": "20240101-001", "customerId": "CUST-001", "items": [], "totalAmount": 15000 }, "createdAt": "2024-01-01T10:00:00Z", "createdBy": "user-123" } ``` ## Optimistic Locking Explains the optimistic locking mechanism for ensuring data consistency during concurrent updates. ```mermaid sequenceDiagram C1->>DB: Read v1 C2->>DB: Read v1 C1->>DB: Update v1 to v2 DB-->>C1: Success C2->>DB: Update v1 to v2 DB-->>C2: ConditionalCheckFailed C2->>DB: Retry Read v2 C2->>DB: Update v2 to v3 DB-->>C2: Success ``` ### Version Control Implementation ```typescript // Command Service automatically handles versioning await this.commandService.publishAsync(entity, { invokeContext: context, }); // DynamoDB ConditionExpression ensures optimistic locking // ConditionExpression: 'attribute_not_exists(pk) AND attribute_not_exists(sk)' // sk includes the version number, preventing duplicate command versions ``` ## Event Processing Pipeline ```mermaid flowchart LR subgraph EventSource ES[Event Store] end subgraph MessageBroker SNS[SNS Topic] SQS1[SQS Queue 1] SQS2[SQS Queue 2] SQS3[SQS Queue 3] end subgraph EventHandlers EH1[Projection Handler] EH2[Notification Handler] EH3[Integration Handler] end subgraph Outputs RM[(Read Model)] Email[Email Service] ExtAPI[External API] end ES --> SNS SNS --> SQS1 SNS --> SQS2 SNS --> SQS3 SQS1 --> EH1 SQS2 --> EH2 SQS3 --> EH3 EH1 --> RM EH2 --> Email EH3 --> ExtAPI ``` ## Event Handler Implementation ```typescript import { EventHandler, IEventHandler } from '@mbc-cqrs-serverless/core'; @EventHandler(OrderCreatedEvent) export class OrderCreatedHandler implements IEventHandler { constructor( private readonly notificationService: NotificationService, private readonly readModelService: ReadModelService, ) {} async execute(event: OrderCreatedEvent): Promise { // Update read model await this.readModelService.updateOrderSummary(event); // Send notification await this.notificationService.sendOrderConfirmation(event); } } ``` ## Benefits of Event Sourcing Adopting Event Sourcing provides these benefits: - **Complete Audit Trail**: All state changes are recorded as events - **Time Travel**: Reconstruct state at any point in time - **Event Replay**: Replay events to rebuild projections - **Debugging**: Trace exact sequence of operations - **Analytics**: Rich event data for business intelligence - **Integration**: Events can trigger external system updates ## Best Practices Best practices for effective Event Sourcing: 1. **Immutable Events**: Never modify stored events 2. **Idempotent Handlers**: Handle duplicate event delivery gracefully 3. **Event Versioning**: Plan for event schema evolution 4. **Correlation IDs**: Track related events across services 5. **Dead Letter Queues**: Handle failed event processing ## Related Documentation - [CQRS Flow](/docs/architecture/cqrs-flow) - Command and query separation - [Command Service](/docs/command-service) - Publishing events with commands - [DynamoDB](/docs/dynamodb) - Event store implementation - [Version Conflict Guide](/docs/version-conflict-guide) - Optimistic locking with events --- ## System Architecture Overview URL: https://mbc-cqrs-serverless.mbc-net.com/docs/architecture/system-overview # System Architecture Overview This document provides an overview of the MBC CQRS Serverless framework architecture. ## AWS Infrastructure ```mermaid flowchart TB subgraph Clients WebApp[Web Application] MobileApp[Mobile Application] External[External Systems] end subgraph AWS subgraph API APIGW[API Gateway] AppSync[AppSync] WSGateway[WebSocket API] end subgraph Auth Cognito[Amazon Cognito] end subgraph Compute Lambda[AWS Lambda] end subgraph Storage DynamoDB[(DynamoDB)] RDS[(RDS Aurora)] S3[(S3)] end subgraph Messaging SNS[SNS] SQS[SQS] end subgraph Orchestration StepFunctions[Step Functions] end subgraph Notifications SES[SES] end end WebApp --> APIGW WebApp --> AppSync WebApp --> WSGateway MobileApp --> APIGW MobileApp --> AppSync External --> APIGW APIGW --> Cognito AppSync --> Cognito WSGateway --> Cognito APIGW --> Lambda AppSync --> Lambda WSGateway --> Lambda Lambda --> DynamoDB Lambda --> RDS Lambda --> S3 Lambda --> SNS Lambda --> SES Lambda --> StepFunctions SNS --> SQS SQS --> Lambda StepFunctions --> Lambda ``` ## Component Description ### API Layer The entry point for receiving client requests. - **API Gateway**: REST API endpoints for CRUD operations - **AppSync**: GraphQL API for flexible queries and subscriptions - **WebSocket API**: Real-time bidirectional communication ### Authentication - **Amazon Cognito**: User authentication, JWT tokens, and user pools ### Compute - **AWS Lambda**: Serverless execution of NestJS applications ### Data Storage - **DynamoDB**: Primary event store for CQRS data persistence - **RDS Aurora**: Optional relational data for complex queries - **S3**: File and document storage ### Messaging - **SNS**: Event fan-out and topic-based publishing - **SQS**: Reliable message queuing and async processing ### Orchestration - **Step Functions**: Long-running workflows and saga patterns ### Notifications - **SES**: Transactional email delivery ## Data Flow How requests flow through the system. 1. **Client Request**: Client sends request via API Gateway, AppSync, or WebSocket 2. **Authentication**: Cognito validates JWT tokens 3. **Command Execution**: Lambda processes command and persists to DynamoDB 4. **Event Publishing**: DynamoDB Streams triggers DataSyncHandler, which syncs to the DATA table and publishes events to SNS 5. **Event Processing**: SQS queues trigger Lambda handlers for async processing 6. **Read Model Update**: Projections update RDS for complex queries ## Multi-Tenant Architecture ```mermaid flowchart LR subgraph TenantIsolation Request[Incoming Request] Auth[Authentication] TenantResolver[Tenant Resolver] subgraph DataPartition T1[Tenant A Data] T2[Tenant B Data] T3[Tenant C Data] end end Request --> Auth Auth --> TenantResolver TenantResolver --> T1 TenantResolver --> T2 TenantResolver --> T3 ``` Tenant isolation is achieved through: - **Partition Key Prefix**: Each tenant's data is prefixed with the tenant code - **Request Context**: Tenant information is extracted from JWT tokens - **Query Filtering**: All queries are automatically scoped to the tenant ## Related Documentation - [CQRS Flow](/docs/architecture/cqrs-flow) - How commands and queries flow - [CDK Infrastructure](/docs/architecture/cdk-infrastructure) - AWS infrastructure code - [Multi-Tenant Patterns](/docs/multi-tenant-patterns) - Tenant isolation details - [Getting Started](/docs/getting-started) - Start building --- ## Architecture URL: https://mbc-cqrs-serverless.mbc-net.com/docs/architecture # Architecture This section provides a comprehensive overview of the MBC CQRS Serverless framework architecture. ## Overview {#overview} The framework is built on AWS serverless services and implements the CQRS pattern with Event Sourcing for scalable, event-driven applications. ## Architecture Sections {#architecture-sections} - [System Overview](/docs/architecture/system-overview) - AWS infrastructure components and their interactions. - [CQRS Pattern Flow](/docs/architecture/cqrs-flow) - How commands and queries are separated and processed. - [Event Sourcing](/docs/architecture/event-sourcing) - Event storage, replay, and projection mechanisms. - [Step Functions](/docs/architecture/step-functions) - Workflow orchestration for async task processing. - [CDK Infrastructure](/docs/architecture/cdk-infrastructure) - AWS CDK infrastructure provisioning and configuration. ## Key Concepts {#key-concepts} ### CQRS Separating read and write operations for optimized data handling. ### Event Sourcing Storing all changes as a sequence of events. ### Serverless Leveraging AWS Lambda, DynamoDB, and other managed services. ## Related Documentation - [Getting Started](/docs/getting-started) - Introduction to the framework - [Installation](/docs/installation) - Set up your local environment - [Backend Development](/docs/backend-development) - Implement features using these patterns - [Key Patterns](/docs/key-patterns) - PK/SK design in DynamoDB - [Glossary](/docs/glossary) - Framework terminology reference --- ## Entity Definition Patterns URL: https://mbc-cqrs-serverless.mbc-net.com/docs/entity-patterns # 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 {#when-to-use} 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 {#problems-solved} | Problem | Solution | |---------|----------| | No type safety for entity attributes | Define TypeScript interfaces for attributes | | Same entity used for reads and writes causes confusion | Separate DataEntity (read) and CommandEntity (write) | | Inconsistent validation across endpoints | Use DTOs with class-validator decorators | | Missing audit fields (createdAt, updatedAt) | Base classes include standard audit fields | ## Entity Types Overview {#entity-types-overview} The framework provides three base entity classes: | Class | Purpose | Usage | |-----------|-------------|-----------| | `DataEntity` | Read operations | Query results from DynamoDB/RDS | | `CommandEntity` | Write operations | Commands sent to DynamoDB | | `DataListEntity` | Paginated lists | List responses with metadata | ## Data Entity {#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. ```ts 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) { super(partial); Object.assign(this, partial); } } ``` The `DataEntity` base class includes: | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `pk` | `string` | Yes | Partition key. Format: `{entityType}#{tenantCode}` | | `sk` | `string` | Yes | Sort key. Format: `{entityType}#{entityId}` — **no version suffix** (unlike CommandEntity) | | `id` | `string` | Yes | Unique entity identifier | | `code` | `string` | Yes | Business code | | `name` | `string` | Yes | Display name | | `version` | `number` | Yes | Version number for optimistic locking | | `tenantCode` | `string` | Yes | Tenant code for multi-tenant isolation | | `type` | `string` | Yes | Entity type identifier | | `cpk` | `string` | No | Command partition key — records the PK of the command that created/updated this data item (audit trail) | | `csk` | `string` | No | Command sort key — records the exact SK (including `@version`) of the source command (audit trail) | | `seq` | `number` | No | Sequence number | | `ttl` | `number` | No | Time-to-live in seconds for DynamoDB TTL | | `isDeleted` | `boolean` | No | Soft delete flag | | `source` | `string` | No | Event source identifier (e.g., 'POST /api/master', 'SQS') | | `requestId` | `string` | No | Unique request ID for tracing and idempotency | | `createdAt` | `Date` | No | Timestamp when the entity was created | | `createdBy` | `string` | No | User ID who created the entity | | `createdIp` | `string` | No | IP address of the creator | | `updatedAt` | `Date` | No | Timestamp when the entity was last updated | | `updatedBy` | `string` | No | User ID who last updated the entity | | `updatedIp` | `string` | No | IP address of the last updater | | `attributes` | `any` | No | Custom attributes object for domain-specific data | The `key` getter returns a `DetailKey` object with `pk` and `sk` for DynamoDB operations. ## Command Entity {#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. ```ts 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) { super(); Object.assign(this, partial); } } ``` The `CommandEntity` base class includes: | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `pk` | `string` | Yes | Partition key. Format: `{entityType}#{tenantCode}` | | `sk` | `string` | Yes | Sort key. Format: `{entityType}#{entityId}@{version}` | | `id` | `string` | Yes | Unique entity identifier | | `code` | `string` | Yes | Business code | | `name` | `string` | Yes | Display name | | `version` | `number` | Yes | Version number for optimistic locking | | `tenantCode` | `string` | Yes | Tenant code for multi-tenant isolation | | `type` | `string` | Yes | Entity type identifier | | `status` | `string` | No | Processing status (e.g., 'PENDING', 'COMPLETED', 'FAILED') | | `seq` | `number` | No | Sequence number | | `ttl` | `number` | No | Time-to-live in seconds for DynamoDB TTL | | `isDeleted` | `boolean` | No | Soft delete flag | | `source` | `string` | No | Event source identifier (e.g., 'POST /api/master', 'SQS') | | `requestId` | `string` | No | Unique request ID for tracing and idempotency | | `createdAt` | `Date` | No | Timestamp when the command was created | | `createdBy` | `string` | No | User ID who created the command | | `createdIp` | `string` | No | IP address of the creator | | `updatedAt` | `Date` | No | Timestamp when the command was last updated | | `updatedBy` | `string` | No | User ID who last updated the command | | `updatedIp` | `string` | No | IP address of the last updater | | `attributes` | `any` | No | Custom attributes object for domain-specific data | The `key` getter returns a `DetailKey` object with `pk` and `sk` for DynamoDB operations. :::info CommandEntity vs DataEntity The main differences between `CommandEntity` and `DataEntity` are: | Aspect | `CommandEntity` | `DataEntity` | |------------|-----------------|--------------| | Table | Command (write) table | Data (read) table | | Sort Key | Includes version suffix (`@{version}`) | No version suffix | | `status` | Yes (processing status) | No | | `cpk`/`csk` | No | Yes (references source command) | ::: ## Data List Entity {#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. ```ts import { DataListEntity } from "@mbc-cqrs-serverless/core"; import { ProductDataEntity } from "./product-data.entity"; export class ProductListEntity extends DataListEntity { items: ProductDataEntity[]; constructor(partial: Partial) { super(partial); Object.assign(this, partial); } } ``` The `DataListEntity` base class includes: ```ts // Inherited from DataListEntity { total?: number; // Total count (optional — not populated by listItemsByPk) lastSk?: string; // Last sort key for pagination } ``` :::warning total field is not auto-populated `DataService.listItemsByPk()` does **not** set `total` — it only sets `items` and `lastSk`. To populate `total`, run a separate `countItemsByPk()` call. Avoid displaying `total` in the UI unless you explicitly populate it. ::: Use `DataListEntity` as the return type for all list/search service methods. Always return it from controllers — never return raw arrays — so the frontend receives a consistent `{ items, total, lastSk }` envelope. ## Command DTO {#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. ```ts 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) { super(); Object.assign(this, partial); } } ``` :::info 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 {#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. ```ts // 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 {#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. ```ts 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; } ``` ## Detail/Search DTOs {#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. ```ts 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; } ``` ## Complete Domain Example {#complete-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 ```text 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 ```ts // 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 ```ts // 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 ```ts // 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) { 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 ```ts // 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) { super(); Object.assign(this, partial); } } ``` ### List Entity ```ts // 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) { super(partial); Object.assign(this, partial); } } ``` ### Command DTO ```ts // 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) { super(); Object.assign(this, partial); } } ``` ### Create DTO ```ts // 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 ```ts // 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 ```ts // 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 {#best-practices} ### 1. Separate Read and Write Entities Use `DataEntity` for reads and `CommandEntity` for writes: ```ts // Read operations return DataEntity async findOne(key: DetailDto): Promise // Write operations return DataEntity (after command is processed) async create(dto: CreateOrderDto): Promise ``` ### 2. Use Typed Attributes Always define interfaces for attributes: ```ts interface ProductAttributes { description: string; price: number; // ... } // Not this: attributes: Record // Avoid ``` ### 3. Add Computed Properties Add getters for commonly accessed nested data: ```ts 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: ```ts 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: ```ts enum OrderStatus { PENDING = "PENDING", CONFIRMED = "CONFIRMED", // ... } // In DTO @IsEnum(OrderStatus) status: OrderStatus; ``` ## Related Documentation - [DynamoDB](/docs/dynamodb) - Table structure where entities are stored - [Key Patterns](/docs/key-patterns) - PK/SK key design - [Data Service](/docs/data-service) - Query entities with DataService - [Serialization Helpers](/docs/serialization) - Convert between DynamoDB and external structures - [Interfaces](/docs/interfaces) - DataEntity and CommandEntity interfaces - [Service Patterns](/docs/service-patterns) - Service layer with entities --- ## Key Design Patterns URL: https://mbc-cqrs-serverless.mbc-net.com/docs/key-patterns # Key Design Patterns This guide explains how to design partition keys (PK) and sort keys (SK) for your entities in DynamoDB. Proper key design is critical for performance, scalability, and query efficiency. ## When to Use This Guide {#when-to-use} Use this guide when you need to: - Design keys for a new entity type - Model parent-child relationships (Order → OrderItems) - Support multi-tenant data isolation - Enable efficient query patterns (list by tenant, filter by date) - Handle versioning for optimistic locking :::tip Related Documentation - [Entity Definition Patterns](/docs/entity-patterns) - How to define entities that use these key patterns - [Multi-Tenant Patterns](/docs/multi-tenant-patterns) - Tenant isolation and cross-tenant operations - [Backend Development Guide](/docs/backend-development) - Complete module implementation patterns ::: ## Problems This Pattern Solves {#problems-solved} | Problem | Solution | |---------|----------| | Querying all items for a tenant is slow | Include tenant code in PK for partition-level isolation | | Can't list child items without knowing all keys | Use shared PK with different SK prefixes | | IDs are not sortable by creation time | Use ULID which is both unique and time-sortable | | Version conflicts in concurrent updates | Version suffix in SK enables optimistic locking | ## Pattern Selection Guide {#pattern-selection} Use this decision tree to choose the right key pattern for your use case: ### Decision Matrix | Requirement | Recommended Pattern | PK Structure | SK Structure | |-----------------|------------------------|------------------|------------------| | Simple CRUD with tenant isolation | [Simple Entity](#pattern-1-simple-entity) | `ENTITY#tenantCode` | `ulid()` | | Parent with multiple children | [Hierarchical](#pattern-2-hierarchical-entity) | `PARENT#tenantCode` | `TYPE#parentId[#childId]` | | Multiple entity variants | [Composite SK](#pattern-3-user-with-multiple-auth-providers) | `ENTITY#tenantCode` | `variant#identifier` | | Cross-tenant shared data | [Common Tenant](#pattern-4-multi-tenant-association) | `ENTITY#common` | `tenantCode#identifier` | | Categorized configurations | [Master Data](#pattern-5-master-data-with-categories) | `MASTER#tenantCode` | `TYPE#category#code` | | Time-based queries | [Time-Series](#pattern-6-time-series-data) | `LOG#tenantCode#YYYY-MM` | `timestamp#eventId` | ### Decision Tree ```text Start: What type of data are you storing? │ ├─ Standalone entity (Product, Customer) │ └─ Use Simple Entity Pattern │ ├─ Parent-child relationship (Order → Items) │ └─ Do children need independent access? │ ├─ Yes → Use separate PK with reference │ └─ No → Use Hierarchical Pattern (shared PK) │ ├─ Configuration/Master data │ └─ Use Master Data Pattern │ ├─ Time-based events (logs, audit) │ └─ Use Time-Series Pattern │ └─ User/Entity with multiple variants └─ Use Composite SK Pattern ``` ## Key Structure Overview {#key-structure-overview} The framework uses a consistent key structure: ```text PK = PREFIX#TENANT_CODE SK = IDENTIFIER[@VERSION] ID = PK#SK (without version) ``` The `KEY_SEPARATOR` constant (`#`) is used to separate key components. ### Framework Constants {#framework-constants} The framework provides these constants in `@mbc-cqrs-serverless/core`: | Constant | Value | Description | |--------------|-----------|-----------------| | `KEY_SEPARATOR` | `#` | Separates key components (PK segments, SK segments, ID) | | `VER_SEPARATOR` | `@` | Separates sort key from version number | | `VERSION_FIRST` | `0` | Initial version for new entities | | `VERSION_LATEST` | `-1` | Indicates query for latest version | | `TENANT_COMMON` | `common` | Tenant code for shared/cross-tenant data (deprecated — use DEFAULT_COMMON_TENANT_CODES) | | `DEFAULT_COMMON_TENANT_CODES` | `['common']` | Common tenant codes list, configurable via COMMON_TENANT_CODES env var | | `DEFAULT_TENANT_CODE` | `single` | Default tenant for single-tenant mode | :::tip Consistent Tenant Code Format The `@mbc-cqrs-serverless/master` and `@mbc-cqrs-serverless/tenant` packages use `SettingTypeEnum.TENANT_COMMON = 'common'` (lowercase), which is consistent with the tenant code normalization in `getUserContext()`. This ensures that data saved by `createCommonTenantSetting()` or `createCommonTenant()` methods can be correctly queried. ::: ### Built-in Key Generators {#built-in-generators} The framework provides these pre-built key generators: ```ts import { masterPk, seqPk, ttlSk } from "@mbc-cqrs-serverless/core"; // Master data partition key masterPk("tenant001"); // "MASTER#tenant001" masterPk(); // "MASTER#single" (default tenant) // Sequence partition key seqPk("tenant001"); // "SEQ#tenant001" // TTL sort key for table-level TTL settings ttlSk("product"); // "TTL#product" ``` ## Basic Key Generation {#basic-key-generation} Import utilities from the core package: ```ts import { generateId, getTenantCode, KEY_SEPARATOR, VER_SEPARATOR, removeSortKeyVersion, addSortKeyVersion, getSortKeyVersion, VERSION_FIRST, VERSION_LATEST, TENANT_COMMON, } from "@mbc-cqrs-serverless/core"; import { ulid } from "ulid"; ``` ### Generating Keys ```ts const PRODUCT_PK_PREFIX = "PRODUCT"; // Generate PK const pk = `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`; // Result: "PRODUCT#tenant001" // Generate SK (using ULID for uniqueness and sortability) const sk = ulid(); // Result: "01HX7MBJK3V9WQBZ7XNDK5ZT2M" // Generate ID (combination of PK and SK) const id = generateId(pk, sk); // Result: "PRODUCT#tenant001#01HX7MBJK3V9WQBZ7XNDK5ZT2M" ``` ### Version Handling ```ts // Add version to SK const skWithVersion = addSortKeyVersion(sk, 3); // Result: "01HX7MBJK3V9WQBZ7XNDK5ZT2M@3" // Remove version from SK const baseSk = removeSortKeyVersion(skWithVersion); // Result: "01HX7MBJK3V9WQBZ7XNDK5ZT2M" // Get version number from SK const version = getSortKeyVersion(skWithVersion); // Result: 3 // Get version from SK without version suffix const latestVersion = getSortKeyVersion(sk); // Result: -1 (VERSION_LATEST) ``` ### Tenant Code Extraction ```ts import { getTenantCode } from "@mbc-cqrs-serverless/core"; // Extract tenant code from PK const tenantCode = getTenantCode("PRODUCT#tenant001"); // Result: "tenant001" // Returns undefined if no separator found const noTenant = getTenantCode("PRODUCT"); // Result: undefined ``` ## Common Key Patterns {#common-patterns} ### Pattern 1: Simple Entity {#pattern-1-simple-entity} #### Use Case: Product Catalog Scenario: Store products that belong to a tenant with unique IDs. When to use: Standalone entities without parent-child relationships. ```ts // Key Structure PK: PRODUCT# SK: // Example PK: PRODUCT#tenant001 SK: 01HX7MBJK3V9WQBZ7XNDK5ZT2M ID: PRODUCT#tenant001#01HX7MBJK3V9WQBZ7XNDK5ZT2M ``` ```ts const pk = `PRODUCT${KEY_SEPARATOR}${tenantCode}`; const sk = ulid(); const id = generateId(pk, sk); ``` ### Pattern 2: Hierarchical Entity {#pattern-2-hierarchical-entity} #### Use Case: Order with Line Items Scenario: An order contains multiple items. Need to query all items for an order efficiently. Solution: Share PK between parent and children, use SK prefix to distinguish item types. ```ts // Order Key Structure PK: ORDER# SK: ORDER# // Order Item Key Structure (same PK, different SK prefix) PK: ORDER# SK: ORDER_ITEM## // Example Order: PK: ORDER#tenant001 SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M Order Items: PK: ORDER#tenant001 SK: ORDER_ITEM#01HX7MBJK3V9WQBZ7XNDK5ZT2M#001 SK: ORDER_ITEM#01HX7MBJK3V9WQBZ7XNDK5ZT2M#002 ``` ```ts const ORDER_SK_PREFIX = "ORDER"; const ORDER_ITEM_SK_PREFIX = "ORDER_ITEM"; // Create order const orderPk = `ORDER${KEY_SEPARATOR}${tenantCode}`; const orderId = ulid(); const orderSk = `${ORDER_SK_PREFIX}${KEY_SEPARATOR}${orderId}`; // Create order item const itemSk = `${ORDER_ITEM_SK_PREFIX}${KEY_SEPARATOR}${orderId}${KEY_SEPARATOR}${itemId}`; ``` ### Pattern 3: User with Multiple Auth Providers {#pattern-3-user-with-multiple-auth-providers} #### Use Case: Unified User Identity Scenario: Users can sign in with local password, SSO, or OAuth. Need to link all auth methods to one user. Solution: Same PK for all user records, SK prefix indicates authentication provider. ```ts // Key Structure PK: USER# SK: # // Examples PK: USER#common SK: local#user123 // Local authentication SK: sso#abc123def456 // SSO provider SK: oauth#google789 // OAuth provider SK: temp#session456 // Temporary session SK: profile#user123 // User profile data ``` ```ts type AuthProvider = "local" | "sso" | "oauth" | "temp" | "profile"; function generateUserSk(provider: AuthProvider, userId: string): string { return `${provider}${KEY_SEPARATOR}${userId}`; } const pk = `USER${KEY_SEPARATOR}common`; const sk = generateUserSk("sso", cognitoSubId); ``` ### Pattern 4: Multi-Tenant Association {#pattern-4-multi-tenant-association} #### Use Case: User Belongs to Multiple Organizations Scenario: In a SaaS application, one user can belong to multiple tenants/organizations. Solution: Use a common tenant with SK that combines tenant and user codes. ```ts // Key Structure PK: USER_TENANT# SK: # // Example PK: USER_TENANT#common SK: tenant001#user123 SK: tenant002#user123 // Same user in different tenant ``` ```ts const pk = `USER_TENANT${KEY_SEPARATOR}common`; const sk = `${tenantCode}${KEY_SEPARATOR}${userCode}`; ``` ### Pattern 5: Master Data with Categories {#pattern-5-master-data-with-categories} #### Use Case: Application Settings and Configuration Scenario: Store email templates, product categories, and application settings. Solution: Use type prefix (SETTING, DATA) in SK to organize different configuration types. ```ts // Key Structure PK: MASTER#common SK: ## // Types: SETTING, DATA, COPY // Master data is shared across all tenants under the common partition // Examples PK: MASTER#common SK: SETTING#notification#email_template SK: DATA#product_category#electronics SK: DATA#product_category#clothing SK: COPY#backup#2024-01-01 ``` ```ts const SETTING_PREFIX = "SETTING"; const DATA_PREFIX = "DATA"; function generateMasterSk(type: string, category: string, code: string): string { return `${type}${KEY_SEPARATOR}${category}${KEY_SEPARATOR}${code}`; } const pk = `MASTER${KEY_SEPARATOR}common`; const sk = generateMasterSk(DATA_PREFIX, "product_category", "electronics"); ``` ### Pattern 6: Time-Series Data {#pattern-6-time-series-data} #### Use Case: Activity Logs and Audit Trail Scenario: Store time-stamped events that need to be queried by date range. Solution: Include date in PK for time-based partitioning, timestamp in SK for sorting. ```ts // Key Structure PK: LOG## SK: # // Example PK: LOG#tenant001#2024-01 SK: 2024-01-15T10:30:00Z#evt001 SK: 2024-01-15T10:31:00Z#evt002 ``` ```ts function generateLogKeys(tenantCode: string, timestamp: Date, eventId: string) { const yearMonth = timestamp.toISOString().slice(0, 7); // "2024-01" const pk = `LOG${KEY_SEPARATOR}${tenantCode}${KEY_SEPARATOR}${yearMonth}`; const sk = `${timestamp.toISOString()}${KEY_SEPARATOR}${eventId}`; return { pk, sk }; } ``` ## Key Helper Functions {#key-helper-functions} Create a helpers file for consistent key generation: ```ts // helpers/key.ts import { KEY_SEPARATOR, generateId } from "@mbc-cqrs-serverless/core"; import { ulid } from "ulid"; // Entity prefixes export const PRODUCT_PK_PREFIX = "PRODUCT"; export const ORDER_PK_PREFIX = "ORDER"; export const ORDER_SK_PREFIX = "ORDER"; export const ORDER_ITEM_SK_PREFIX = "ORDER_ITEM"; export const USER_PK_PREFIX = "USER"; export const MASTER_PK_PREFIX = "MASTER"; export const NOTIFICATION_PK_PREFIX = "NOTIFICATION"; // Key generators export function generateProductPk(tenantCode: string): string { return `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`; } export function generateOrderPk(tenantCode: string): string { return `${ORDER_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`; } export function generateOrderSk(orderId?: string): string { const id = orderId ?? ulid(); return `${ORDER_SK_PREFIX}${KEY_SEPARATOR}${id}`; } export function generateOrderItemSk(orderId: string, itemId: string): string { return `${ORDER_ITEM_SK_PREFIX}${KEY_SEPARATOR}${orderId}${KEY_SEPARATOR}${itemId}`; } // Key parsers export function parseOrderSk(sk: string): { prefix: string; orderId: string } { const parts = sk.split(KEY_SEPARATOR); return { prefix: parts[0], orderId: parts[1], }; } export function parseOrderItemSk(sk: string): { prefix: string; orderId: string; itemId: string; } { const parts = sk.split(KEY_SEPARATOR); return { prefix: parts[0], orderId: parts[1], itemId: parts[2], }; } // ID generator with entity type export function generateEntityId( prefix: string, tenantCode: string, sk?: string, ): { pk: string; sk: string; id: string } { const pk = `${prefix}${KEY_SEPARATOR}${tenantCode}`; const finalSk = sk ?? ulid(); const id = generateId(pk, finalSk); return { pk, sk: finalSk, id }; } ``` ## Version Management {#version-management} The framework uses versioning for optimistic locking: ```ts // DynamoDB Tables // Command table: Stores all versions with @version suffix // Data table: Stores latest version only (no version suffix) // Version in SK (Command table) SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M@1 // Version 1 SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M@2 // Version 2 SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M@3 // Version 3 // Data table SK (no version suffix) SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M ``` ```ts import { VERSION_FIRST, VERSION_LATEST, addSortKeyVersion, removeSortKeyVersion, } from "@mbc-cqrs-serverless/core"; // Creating first version const command = new OrderCommandDto({ pk, sk, version: VERSION_FIRST, // 0 // ... }); // Reading specific version from history const skWithVersion = addSortKeyVersion(baseSk, 2); const historicalItem = await historyService.getItem({ pk, sk: skWithVersion }); // In Data Sync Handler - always remove version for RDS storage async up(cmd: CommandModel): Promise { const sk = removeSortKeyVersion(cmd.sk); // Store sk without version in RDS } ``` ## Query Patterns {#query-patterns} ### Query by PK ```ts // Get all products for a tenant const items = await dataService.listItemsByPk( `PRODUCT${KEY_SEPARATOR}${tenantCode}`, ); ``` ### Query with SK Prefix ```ts // Get all order items for a specific order const items = await dataService.listItemsByPk( `ORDER${KEY_SEPARATOR}${tenantCode}`, { sk: { skExpression: 'begins_with(sk, :skPrefix)', skAttributeValues: { ':skPrefix': `ORDER_ITEM${KEY_SEPARATOR}${orderId}` }, }, }, ); ``` ### Query with SK Range ```ts // Get orders within a date range (if using timestamp in SK) const items = await dataService.listItemsByPk( `ORDER${KEY_SEPARATOR}${tenantCode}`, { sk: { skExpression: 'sk BETWEEN :start AND :end', skAttributeValues: { ':start': startDate, ':end': endDate }, }, }, ); ``` ## Best Practices {#best-practices} ### 1. Use Consistent Prefixes Define prefixes as constants: ```ts // Good export const PRODUCT_PK_PREFIX = "PRODUCT"; const pk = `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`; // Avoid const pk = `PRODUCT#${tenantCode}`; // Magic string ``` ### 2. Use ULID for Sortable IDs ULID provides both uniqueness and time-based sorting: ```ts import { ulid } from "ulid"; const sk = ulid(); // Result: "01HX7MBJK3V9WQBZ7XNDK5ZT2M" // Sortable by creation time ``` ### 3. Design for Query Patterns Structure keys based on how you'll query data: ```ts // If you need to query all items for an order: PK: ORDER#tenant001 SK: ORDER_ITEM#orderId#itemId // Query by SK prefix // If you need to query items by product across orders: // Consider a GSI or separate table ``` ### 4. Keep PK Cardinality Manageable Avoid too many unique PKs to prevent hot partitions: ```ts // Good - bounded by tenants PK: PRODUCT#tenant001 // Avoid - unbounded by users PK: USER_ACTIVITY#user123 // Could create millions of partitions ``` ### 5. Include Tenant in PK Always include tenant code in PK for multi-tenant isolation: ```ts // Good PK: PRODUCT#tenant001 // Avoid PK: PRODUCT // No tenant isolation SK: tenant001#productId // Tenant in SK is less efficient ``` :::danger Tenant Code Normalization - Breaking Change The `getUserContext()` function normalizes `tenantCode` to lowercase. This affects partition key generation: ```ts // User's Cognito has uppercase tenant custom:tenant = "MY_TENANT" // getUserContext() returns lowercase tenantCode = "my_tenant" // Generated PK uses lowercase PK: PRODUCT#my_tenant ``` **Impact on existing data:** If your existing data was saved with uppercase tenant codes in PK (e.g., `PRODUCT#MY_TENANT`), queries using normalized tenant codes will NOT find that data. **Migration required:** See [Tenant Code Normalization Migration](/docs/data-migration-patterns#tenant-code-normalization-migration) for migration strategies, or the [v1.1.0 Migration Guide](/docs/migration/v1.1.0) for complete upgrade instructions. ::: ### 6. Use Common Tenant for Shared Data Use a common tenant code for data shared across tenants: ```ts // User data (shared across tenants) PK: USER#common SK: sso#userId // User-tenant association PK: USER_TENANT#common SK: tenant001#userId ``` ## Anti-Patterns to Avoid {#anti-patterns} ### 1. Embedding Too Much in Keys ```ts // Avoid - too complex SK: ORDER#2024-01-15#electronics#high-priority#01HX7M... // Better - use attributes for filtering SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M attributes: { date: "2024-01-15", category: "electronics", priority: "high" } ``` ### 2. Using Mutable Data in Keys ```ts // Avoid - status changes SK: ORDER#pending#01HX7M... // What happens when status changes? // Better - use attributes SK: ORDER#01HX7MBJK3V9WQBZ7XNDK5ZT2M attributes: { status: "pending" } ``` ### 3. Inconsistent Separators ```ts // Avoid - mixed separators SK: ORDER-01HX7M_item:001 // Better - consistent separator SK: ORDER#01HX7M#ITEM#001 ``` ### 4. Version Suffix in Data Operations ```ts // Avoid - including version in data table SK await dataService.getItem({ pk: "PRODUCT#tenant001", sk: "01HX7MBJK3V9WQBZ7XNDK5ZT2M@3" // Version should not be here }); // Better - always use removeSortKeyVersion const cleanSk = removeSortKeyVersion(skWithVersion); await dataService.getItem({ pk, sk: cleanSk }); ``` ## API Reference {#api-reference} ### Key Functions | Function | Signature | Description | |--------------|---------------|-----------------| | `generateId` | `(pk: string, sk: string) => string` | Combines PK and SK into ID, removes version from SK | | `getTenantCode` | `(pk: string) => string \| undefined` | Extracts tenant code from PK | | `addSortKeyVersion` | `(sk: string, version: number) => string` | Adds version suffix to SK | | `removeSortKeyVersion` | `(sk: string) => string` | Removes version suffix from SK | | `getSortKeyVersion` | `(sk: string) => number` | Gets version number from SK (returns -1 if no version) | | `masterPk` | `(tenantCode?: string) => string` | Generates MASTER#tenantCode PK | | `seqPk` | `(tenantCode?: string) => string` | Generates SEQ#tenantCode PK | | `ttlSk` | `(tableName: string) => string` | Generates TTL#tableName SK | ### Constants | Constant | Value | Usage | |--------------|-----------|-----------| | `KEY_SEPARATOR` | `#` | Use for joining key components | | `VER_SEPARATOR` | `@` | Used internally for version suffix | | `VERSION_FIRST` | `0` | Use when creating new entities | | `VERSION_LATEST` | `-1` | Returned when SK has no version | | `TENANT_COMMON` | `common` | Use for cross-tenant shared data | | `DEFAULT_TENANT_CODE` | `single` | Default for single-tenant mode | ## Related Documentation - [DynamoDB](/docs/dynamodb) - Table architecture and attribute schema for these keys - [Entity Patterns](/docs/entity-patterns) - Define entities that use these key patterns - [Data Service](/docs/data-service) - Query data using designed keys - [Multi-Tenant Patterns](/docs/multi-tenant-patterns) - Tenant isolation and cross-tenant operations - [Sequence](/docs/sequence) - SequencesModule for auto-incrementing sort keys - [Version Conflict Guide](/docs/version-conflict-guide) - Concurrency control using version keys - [Helpers](/docs/helpers) - Key helper functions reference --- # Backend Development ## Backend Development Guide URL: https://mbc-cqrs-serverless.mbc-net.com/docs/backend-development # Backend Development Guide This guide provides comprehensive patterns and best practices for building backend applications with MBC CQRS Serverless framework. Examples are generalized from production projects. ## Module Structure {#module-structure} ### Standard Module Layout Every domain module follows this consistent structure: ```text src/[domain]/ ├── dto/ │ ├── [domain]-command.dto.ts # Command input validation │ ├── [domain]-attributes.dto.ts # Domain-specific attributes │ └── [domain]-search.dto.ts # Search parameters ├── entity/ │ ├── [domain]-command.entity.ts # Command entity │ ├── [domain]-data.entity.ts # Data entity │ └── [domain]-data-list.entity.ts # List wrapper ├── handler/ │ └── [domain]-rds.handler.ts # RDS sync handler ├── [domain].service.ts # Business logic ├── [domain].controller.ts # HTTP handlers └── [domain].module.ts # Module definition ``` ### Module Registration Register your module with CommandModule to enable CQRS features: ```typescript // product.module.ts import { Module } from '@nestjs/common'; import { CommandModule } from '@mbc-cqrs-serverless/core'; import { ProductService } from './product.service'; import { ProductController } from './product.controller'; import { ProductDataSyncRdsHandler } from './handler/product-rds.handler'; @Module({ imports: [ CommandModule.register({ tableName: 'product', dataSyncHandlers: [ProductDataSyncRdsHandler], }), ], controllers: [ProductController], providers: [ProductService], exports: [ProductService], }) export class ProductModule {} ``` ## Entity Design {#entity-design} ### Command Entity Command entities represent write operations and include version tracking: ```typescript // entity/product-command.entity.ts import { CommandEntity } from '@mbc-cqrs-serverless/core'; import { ProductAttributes } from '../dto/product-attributes.dto'; export class ProductCommandEntity extends CommandEntity { attributes: ProductAttributes; } ``` ### Data Entity Data entities represent the read model after processing: ```typescript // entity/product-data.entity.ts import { DataEntity } from '@mbc-cqrs-serverless/core'; import { ProductAttributes } from '../dto/product-attributes.dto'; export class ProductDataEntity extends DataEntity { attributes: ProductAttributes; } // entity/product-data-list.entity.ts import { DataListEntity } from '@mbc-cqrs-serverless/core'; import { ProductDataEntity } from './product-data.entity'; export class ProductDataListEntity extends DataListEntity { items: ProductDataEntity[]; } ``` ### Attributes DTO Define domain-specific attributes with validation: ```typescript // dto/product-attributes.dto.ts import { IsString, IsNumber, IsOptional, ValidateNested, Type } from 'class-validator'; export class ProductAttributes { @IsString() @IsOptional() category?: string; @IsNumber() @IsOptional() price?: number; @IsString() @IsOptional() description?: string; @Type(() => ProductSpecification) @ValidateNested() @IsOptional() specification?: ProductSpecification; } export class ProductSpecification { @IsString() @IsOptional() weight?: string; @IsString() @IsOptional() dimensions?: string; } ``` ## Controller Pattern {#controller-pattern} ### Standard Controller Controllers should be thin, delegating business logic to services: ```typescript // product.controller.ts import { Controller, Get, Post, Put, Body, Param, Query, } from '@nestjs/common'; import { INVOKE_CONTEXT, IInvoke, SearchDto } from '@mbc-cqrs-serverless/core'; import { ProductService } from './product.service'; import { ProductCommandDto } from './dto/product-command.dto'; import { ProductDataEntity, ProductDataListEntity } from './entity'; @Controller('api/product') export class ProductController { constructor(private readonly productService: ProductService) {} /** * Create or update a product */ @Post('/') async publishCommand( @INVOKE_CONTEXT() invokeContext: IInvoke, @Body() cmdDto: ProductCommandDto, ): Promise { return this.productService.publishCommand(cmdDto, invokeContext); } /** * Bulk create/update products */ @Post('/bulk') async publishBulkCommands( @INVOKE_CONTEXT() invokeContext: IInvoke, @Body() cmdDtos: ProductCommandDto[], ): Promise { return this.productService.publishBulkCommands(cmdDtos, invokeContext); } /** * Get product by PK and SK */ @Get('data/:pk/:sk') async getData( @Param('pk') pk: string, @Param('sk') sk: string, ): Promise { return this.productService.getData(pk, sk); } /** * List products by PK */ @Get('data/:pk') async listDataByPk( @Param('pk') pk: string, @Query() searchDto: SearchDto, ): Promise { return this.productService.listDataByPk(pk, searchDto); } /** * Search products */ @Get('data') async searchData( @Query() searchDto: SearchDto, ): Promise { return this.productService.searchData(searchDto); } /** * Resync all data to RDS */ @Put('resync-data/:pk') async resyncData(@Param('pk') pk: string): Promise { return this.productService.resyncData(pk); } } ``` ## Service Implementation {#service-implementation} ### Basic Service Pattern Services contain business logic and orchestrate data operations. Here is a minimal example: ```typescript // product.service.ts import { Injectable, Logger } from '@nestjs/common'; import { CommandService, DataService, IInvoke, KEY_SEPARATOR, generateId, getUserContext, VERSION_FIRST, } from '@mbc-cqrs-serverless/core'; import { ulid } from 'ulid'; import { PrismaService } from '../prisma/prisma.service'; import { ProductCommandDto } from './dto/product-command.dto'; import { ProductDataEntity } from './entity'; const PRODUCT_PK_PREFIX = 'PRODUCT'; @Injectable() export class ProductService { private readonly logger = new Logger(ProductService.name); constructor( private readonly commandService: CommandService, private readonly dataService: DataService, private readonly prismaService: PrismaService, ) {} /** * Create a new product */ async create( createDto: { name: string; description?: string }, opts: { invokeContext: IInvoke }, ): Promise { const { tenantCode } = getUserContext(opts.invokeContext); const pk = `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`; const sk = ulid(); const command = new ProductCommandDto({ pk, sk, id: generateId(pk, sk), tenantCode, code: sk, type: 'PRODUCT', name: createDto.name, version: VERSION_FIRST, attributes: { description: createDto.description }, }); const item = await this.commandService.publishAsync(command, { invokeContext: opts.invokeContext, }); // publishAsync returns null when command is a no-op (no changes detected) if (!item) return null; return new ProductDataEntity(item); } /** * Get product by key */ async findOne(pk: string, sk: string): Promise { const item = await this.dataService.getItem({ pk, sk }); if (!item) return undefined; return new ProductDataEntity(item); } } ``` :::tip For Complete Service Patterns For comprehensive CRUD operations, batch processing, optimistic locking, and more advanced patterns, see [Service Patterns](/docs/service-patterns). ::: :::info Read-Your-Writes Consistency `DataService.getItem()` reads from DynamoDB, which may return stale data immediately after a write (eventual consistency). If users need to see their own writes instantly, use the [Read-Your-Writes pattern with Repository](/docs/command-service#read-your-writes) instead. ::: ## Data Sync Handler {#data-sync-handler} ### RDS Sync Handler Sync data from DynamoDB to RDS for complex queries: ```typescript // handler/product-rds.handler.ts import { Injectable, Logger } from '@nestjs/common'; import { IDataSyncHandler, CommandModel, removeSortKeyVersion, } from '@mbc-cqrs-serverless/core'; import { PrismaService } from '../../prisma/prisma.service'; import { ProductAttributes } from '../dto/product-attributes.dto'; @Injectable() export class ProductDataSyncRdsHandler implements IDataSyncHandler { private readonly logger = new Logger(ProductDataSyncRdsHandler.name); constructor(private readonly prismaService: PrismaService) {} /** * Sync command to RDS (upsert) */ async up(cmd: CommandModel): Promise { // Remove version suffix from SK const sk = removeSortKeyVersion(cmd.sk); const attrs = cmd.attributes as ProductAttributes; try { await this.prismaService.product.upsert({ where: { id: cmd.id }, update: { pk: cmd.pk, sk: sk, code: cmd.code, name: cmd.name, version: cmd.version, tenantCode: cmd.tenantCode, isDeleted: cmd.isDeleted ?? false, // Map attributes to columns category: attrs?.category, price: attrs?.price, description: attrs?.description, specification: attrs?.specification, // Audit fields createdAt: cmd.createdAt, createdBy: cmd.createdBy ?? '', createdIp: cmd.createdIp ?? '', updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy ?? '', updatedIp: cmd.updatedIp ?? '', }, create: { id: cmd.id, cpk: cmd.pk, csk: cmd.sk, pk: cmd.pk, sk: sk, code: cmd.code, name: cmd.name, version: cmd.version, tenantCode: cmd.tenantCode, isDeleted: cmd.isDeleted ?? false, category: attrs?.category, price: attrs?.price, description: attrs?.description, specification: attrs?.specification, createdAt: cmd.createdAt, createdBy: cmd.createdBy ?? '', createdIp: cmd.createdIp ?? '', updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy ?? '', updatedIp: cmd.updatedIp ?? '', }, }); this.logger.debug(`Synced product ${cmd.id} to RDS`); } catch (error) { this.logger.error(`Failed to sync product ${cmd.id}:`, error); throw error; } } /** * Handle rollback or delete */ async down(cmd: CommandModel): Promise { // Soft delete implementation await this.prismaService.product.update({ where: { id: cmd.id }, data: { isDeleted: true }, }); } } ``` ## Prisma Schema {#prisma-schema} ### Standard Model Definition Define your Prisma model with CQRS fields: ```prisma // prisma/schema.prisma model Product { // CQRS composite keys id String @id // PK#SK without version cpk String // Command PK csk String // Command SK with version pk String // Data PK sk String // Data SK without version // Domain fields code String name String // Domain-specific category String? price Decimal? description String? specification Json? // Multi-tenant tenantCode String // Audit fields version Int isDeleted Boolean @default(false) createdBy String @default("") createdIp String @default("") createdAt DateTime updatedBy String @default("") updatedIp String @default("") updatedAt DateTime // Indexes @@unique([cpk, csk]) @@unique([pk, sk]) @@unique([tenantCode, code]) @@index([tenantCode, name]) @@index([category]) } ``` ## Best Practices {#best-practices} ### 1. Source Tracking Always track the source of operations for debugging: ```typescript const opts = { source: getCommandSource( basename(__dirname), // Module name this.constructor.name, // Class name 'methodName', // Method name ), invokeContext, }; ``` ### 2. Batch Processing Process large datasets in batches to avoid timeouts. See [Service Patterns - Batch Operations](/docs/service-patterns#batch-operations) for detailed examples. ### 3. Error Handling Implement proper error handling with logging: ```typescript import { Injectable, Logger } from '@nestjs/common'; import { IDataSyncHandler, CommandModel, SnsService } from '@mbc-cqrs-serverless/core'; @Injectable() export class MyDataSyncHandler implements IDataSyncHandler { private readonly logger = new Logger(MyDataSyncHandler.name); private readonly alarmTopicArn = process.env.ALARM_TOPIC_ARN || ''; constructor( private readonly snsService: SnsService, // Inject for alarm notifications ) {} async up(cmd: CommandModel): Promise { try { await this.processItem(cmd); // Your application-specific processing } catch (error) { this.logger.error(`Failed to process item ${cmd.sk}:`, error); // Send alarm for critical errors if (this.isCriticalError(error)) { await this.snsService.publish({ topicArn: this.alarmTopicArn, subject: 'Processing Error', message: JSON.stringify({ sk: cmd.sk, error: error.message }), }); } throw error; } } private isCriticalError(error: unknown): boolean { // Return true for errors that require immediate alerting return error instanceof Error && !error.message.includes('not found'); } } ``` ### 4. Data Consistency Use dirty checking to avoid unnecessary syncs: ```typescript import { CommandService, CommandModel, CommandInputModel } from '@mbc-cqrs-serverless/core'; constructor(private readonly commandService: CommandService) {} async syncToRds(existingData: CommandModel, newData: CommandInputModel): Promise { if (this.commandService.isNotCommandDirty(existingData, newData)) { this.logger.debug('Data unchanged, skipping sync'); return; } // Perform your actual sync: upsert via Prisma or publish a downstream command await this.prismaService.entity.upsert({ where: { sk: newData.sk }, create: { ...newData.attributes, sk: newData.sk, tenantCode: newData.tenantCode }, update: { ...newData.attributes }, }); } ``` ### 5. Pagination Always support pagination for list operations: ```typescript async searchWithPagination( searchDto: SearchDto, ): Promise { const { page = 1, pageSize = 20 } = searchDto; const [total, items] = await Promise.all([ this.prismaService.entity.count({ where }), this.prismaService.entity.findMany({ where, take: pageSize, skip: pageSize * (page - 1), orderBy: [{ createdAt: 'desc' }], }), ]); return new DataListEntity({ total, items }); } ``` ## Related Documentation - [Service Patterns](/docs/service-patterns) - Advanced service implementation patterns - [Command Service](/docs/command-service) - Publishing commands and Read-Your-Writes consistency - [Data Service](/docs/data-service) - Querying data from the read model - [Data Sync Handler Examples](/docs/data-sync-handler-examples) - Comprehensive sync handler examples - [Key Patterns](/docs/key-patterns) - PK/SK design patterns - [Anti-Patterns](/docs/anti-patterns) - Common mistakes and how to avoid them - [Authentication](/docs/authentication) - Role-based access control with `@Auth` and `@Roles` - [Controllers](/docs/controllers) - NestJS controller patterns with MBC decorators - [Helpers](/docs/helpers) - getUserContext and key helper functions - [Prisma](/docs/prisma) - ORM setup for RDS data sync - [Multi-Tenant Patterns](/docs/multi-tenant-patterns) - Multi-tenant implementation - [Import/Export Patterns](/docs/import-export-patterns) - Batch data processing - [Examples](/docs/recipes) - Practical implementation examples - [Versioning Rules](/docs/version-rules) - Optimistic locking and version conflict handling - [Absolute Imports](/docs/absolute_imports_and_module_path_aliases) - TypeScript path aliases with `@/` prefix --- ## Building your application URL: https://mbc-cqrs-serverless.mbc-net.com/docs/build-your-application # Building your application MBC CQRS Serverless provides the core functionalities to create backend applications. These guides explain how to use these features and how to customize your application's behavior. ```mdx-code-block import DocCardList from '@theme/DocCardList'; ``` ## Related Documentation - [Getting Started](/docs/getting-started) - Initial setup - [Backend Development](/docs/backend-development) - Backend development guide - [Service Patterns](/docs/service-patterns) - Service implementation patterns --- ## Controllers URL: https://mbc-cqrs-serverless.mbc-net.com/docs/controllers # Controllers Controllers are responsible for handling incoming **requests** and returning **responses** to the client. Defining a controller in the MBC Serverless Framework is the same as in NestJS, so please refer to this section using the [provided link](https://docs.nestjs.com/controllers). :::note To get the invoke context, you can provide the following argument in the controller function. ```ts @INVOKE_CONTEXT() invokeContext: IInvoke, ``` ::: In the following example we'll use the `@Controller()` decorator, which is required to define a basic controller; `@Auth(ROLE_SYSTEM_ADMIN)` decorator, which is specified for authorization purpose; and `@ApiTags('cat')` to attach a controller to a specific tag. ```ts import { Controller, Post, Body } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ROLE_SYSTEM_ADMIN, INVOKE_CONTEXT, IInvoke, Auth } from '@mbc-cqrs-serverless/core'; import { CatService } from './cat.service'; import { CreateCatDto } from './create-cat.dto'; @Auth(ROLE_SYSTEM_ADMIN) @Controller("api/cat") @ApiTags("cat") export class CatController { constructor(private readonly catService: CatService) {} @Post() async create( @INVOKE_CONTEXT() invokeContext: IInvoke, @Body() createCatDto: CreateCatDto ) { return this.catService.create(createCatDto, { invokeContext }); } } ``` ## Decorators {#decorators} The framework provides several decorators to simplify common patterns in your controllers. ### `@INVOKE_CONTEXT()` Parameter decorator that extracts the invocation context from the request. This provides access to Lambda context, JWT claims, and request metadata. ```ts import { INVOKE_CONTEXT, IInvoke } from '@mbc-cqrs-serverless/core'; @Get(':id') async getItem( @INVOKE_CONTEXT() invokeContext: IInvoke, @Param('id') id: string ) { return this.service.getItem(id, { invokeContext }); } ``` ### `@Auth(...roles)` Method/class decorator that applies authentication and role-based access control. Combines RolesGuard with Swagger documentation (ApiBearerAuth, ApiUnauthorizedResponse). ```ts import { Auth, ROLE_SYSTEM_ADMIN } from '@mbc-cqrs-serverless/core'; @Controller('admin') @Auth(ROLE_SYSTEM_ADMIN) // Class-level: applies to all methods export class AdminController { @Post() @Auth(ROLE_SYSTEM_ADMIN) // Method-level: override class decorator async create() {} } ``` ### `@HeaderTenant()` Method/class decorator that adds tenant code header requirement to Swagger documentation. Use this when your endpoint requires a tenant code header. ```ts import { HeaderTenant } from '@mbc-cqrs-serverless/core'; @Controller('items') @HeaderTenant() // Adds x-tenant-code header to Swagger docs export class ItemController { @Get() async list() { // Tenant code available via invokeContext } } ``` The decorator adds the following header to Swagger: - **Header name**: `x-tenant-code` - **Required**: `true` - **Description**: Current working tenant code ### `@SwaggerResponse(ApiResponse, options?)` Method decorator factory that applies standardized error response documentation. Use with Swagger response decorators to document error responses. ```ts import { Controller, Get, Param } from '@nestjs/common'; import { ApiBadRequestResponse, ApiNotFoundResponse } from '@nestjs/swagger'; import { SwaggerResponse } from '@mbc-cqrs-serverless/core'; @Controller('items') export class ItemController { @Get(':id') @SwaggerResponse(ApiNotFoundResponse, { description: 'Item not found' }) @SwaggerResponse(ApiBadRequestResponse, { description: 'Invalid ID format' }) async getItem(@Param('id') id: string) {} } ``` This decorator automatically includes the standard HttpExceptionResponse schema with status and message fields. ### `@Roles(...roles)` Method/class decorator that specifies which roles can access an endpoint. Used internally by `@Auth()` but can be used directly with custom guards. :::info Version Note Since [v1.3.1](/docs/changelog#v131), `RolesGuard` checks both direct roles (from `custom:roles`) and group-derived roles (from `custom:groups`, when a `@GroupRoleResolver()` is configured). Existing `@Roles()` calls benefit automatically — no code changes required. See [Group-Based Roles](/docs/authentication#group-based-roles). ::: ```ts import { Roles } from '@mbc-cqrs-serverless/core'; import { Controller, Get, UseGuards } from '@nestjs/common'; import { CustomGuard } from './guards/custom.guard'; @Controller('custom') export class CustomController { @Get() @Roles('admin', 'manager') @UseGuards(CustomGuard) async protectedEndpoint() {} } ``` :::tip Other Framework Decorators The following decorators are used on provider classes rather than controllers — see their respective guides: - `@DataSyncHandler(tableName)` — marks a class as a DynamoDB Stream sync handler → [Data Sync Handler Examples](/docs/data-sync-handler-examples) - `@EventHandler(EventClass)` — marks a class as a domain event handler → [Tasks](/docs/tasks) - `@EventFactory()` — marks a class as an event factory → [Tasks](/docs/tasks) - `@GroupRoleResolver()` — registers a group-to-role mapping class → [Authentication — Group-Based Roles](/docs/authentication#group-based-roles) - `@NotificationTransport(name)` — registers a custom notification transport → [Notification Module](/docs/notification-module) ::: ## Related Documentation - [Authentication](/docs/authentication) - Cognito authentication and JWT setup - [Modules](/docs/modules) - Module configuration for controllers - [Backend Development](/docs/backend-development) - Complete backend development guide - [Interfaces](/docs/interfaces) - IInvoke and IInvokeContext interfaces - [Service Patterns](/docs/service-patterns) - Service layer with CQRS patterns - [Form Handling Patterns](/docs/form-handling-patterns) - DTO validation and form data processing - [Serialization](/docs/serialization) - Date and class-transformer serialization --- ## Data Sync Handler Examples URL: https://mbc-cqrs-serverless.mbc-net.com/docs/data-sync-handler-examples # Data Sync Handler Examples This guide explains how to implement Data Sync Handlers that automatically synchronize data from DynamoDB (command source) to RDS (query database). This is the core mechanism that enables the CQRS read model. ## When to Use This Guide {#when-to-use} Use this guide when you need to: - Sync entity data from DynamoDB to MySQL/PostgreSQL for complex queries - Transform nested JSON attributes into relational columns - Handle different record types within the same DynamoDB table - Process parent-child relationships (Order, OrderItem) separately ## Problems This Pattern Solves {#problems-solved} | Problem | Solution | |---------|----------| | DynamoDB cannot do JOINs or complex filters | Sync data to RDS for SQL queries | | Version suffix in SK causes duplicate records | Use removeSortKeyVersion() before upserting | | Different record types need different RDS tables | Filter by SK prefix in handler | | JSON attributes need to be searchable columns | Map attributes to individual RDS columns | ## Basic Structure {#basic-structure} All Data Sync Handlers follow this basic structure: ```ts import { CommandModel, IDataSyncHandler, removeSortKeyVersion } from "@mbc-cqrs-serverless/core"; import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "src/prisma"; @Injectable() export class EntityDataSyncRdsHandler implements IDataSyncHandler { private readonly logger = new Logger(EntityDataSyncRdsHandler.name); constructor(private readonly prismaService: PrismaService) {} async up(cmd: CommandModel): Promise { // Sync data to RDS } async down(cmd: CommandModel): Promise { // Optional: Handle rollback (usually just logs) this.logger.debug(cmd); } } ``` ## Example 1: Simple Entity Sync {#example-simple-sync} ### Use Case: Sync Products to Enable Search and Filtering Scenario: Products stored in DynamoDB need to be searchable by category, price range, and text. Solution: Sync to RDS and map attributes to indexed columns for efficient queries. ```ts import { CommandModel, IDataSyncHandler, removeSortKeyVersion } from "@mbc-cqrs-serverless/core"; import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "src/prisma"; interface ProductAttributes { name: string; description: string; price: number; category: string; inStock: boolean; } @Injectable() export class ProductDataSyncRdsHandler implements IDataSyncHandler { private readonly logger = new Logger(ProductDataSyncRdsHandler.name); constructor(private readonly prismaService: PrismaService) {} async up(cmd: CommandModel): Promise { // Remove version suffix from sort key (e.g., "PROD001@1" -> "PROD001") const sk = removeSortKeyVersion(cmd.sk); const attrs = cmd.attributes as ProductAttributes; await this.prismaService.product.upsert({ where: { id: cmd.id }, update: { pk: cmd.pk, sk: sk, name: cmd.name, code: cmd.code, version: cmd.version, tenantCode: cmd.tenantCode, // Map attributes to columns description: attrs.description, price: attrs.price, category: attrs.category, inStock: attrs.inStock, // Audit fields isDeleted: cmd.isDeleted ?? false, createdAt: cmd.createdAt, createdBy: cmd.createdBy, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, }, create: { id: cmd.id, pk: cmd.pk, sk: sk, // Also store original keys with version for reference cpk: cmd.pk, csk: cmd.sk, name: cmd.name, code: cmd.code, version: cmd.version, tenantCode: cmd.tenantCode, description: attrs.description, price: attrs.price, category: attrs.category, inStock: attrs.inStock, isDeleted: cmd.isDeleted ?? false, createdAt: cmd.createdAt, createdBy: cmd.createdBy, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, }, }); } async down(cmd: CommandModel): Promise { this.logger.debug(cmd); } } ``` ## Example 2: Conditional Processing with SK Prefix {#example-sk-prefix} ### Use Case: Order and OrderItem in Same DynamoDB Table Scenario: Orders and their items share the same PK but have different SK prefixes. Each needs to go to a different RDS table. Solution: Check SK prefix to route to appropriate sync logic. ```ts import { CommandModel, IDataSyncHandler, KEY_SEPARATOR, removeSortKeyVersion } from "@mbc-cqrs-serverless/core"; import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "src/prisma"; const ORDER_SK_PREFIX = "ORDER"; const ORDER_ITEM_SK_PREFIX = "ORDER_ITEM"; interface OrderAttributes { customerId: string; status: string; totalAmount: number; orderDate: string; } interface OrderItemAttributes { orderId: string; productId: string; quantity: number; unitPrice: number; } @Injectable() export class OrderDataSyncRdsHandler implements IDataSyncHandler { private readonly logger = new Logger(OrderDataSyncRdsHandler.name); constructor(private readonly prismaService: PrismaService) {} async up(cmd: CommandModel): Promise { const sk = removeSortKeyVersion(cmd.sk); // Process only ORDER records, skip ORDER_ITEM if (sk.startsWith(ORDER_SK_PREFIX) && !sk.startsWith(ORDER_ITEM_SK_PREFIX)) { await this.syncOrder(cmd, sk); } else if (sk.startsWith(ORDER_ITEM_SK_PREFIX)) { await this.syncOrderItem(cmd, sk); } // Skip other record types } private async syncOrder(cmd: CommandModel, sk: string): Promise { const attrs = cmd.attributes as OrderAttributes; await this.prismaService.order.upsert({ where: { id: cmd.id }, update: { pk: cmd.pk, sk: sk, code: cmd.code, version: cmd.version, customerId: attrs.customerId, status: attrs.status, totalAmount: attrs.totalAmount, orderDate: new Date(attrs.orderDate), isDeleted: cmd.isDeleted ?? false, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, }, create: { id: cmd.id, pk: cmd.pk, sk: sk, cpk: cmd.pk, csk: cmd.sk, code: cmd.code, version: cmd.version, tenantCode: cmd.tenantCode, customerId: attrs.customerId, status: attrs.status, totalAmount: attrs.totalAmount, orderDate: new Date(attrs.orderDate), isDeleted: cmd.isDeleted ?? false, createdAt: cmd.createdAt, createdBy: cmd.createdBy, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, }, }); } private async syncOrderItem(cmd: CommandModel, sk: string): Promise { const attrs = cmd.attributes as OrderItemAttributes; await this.prismaService.orderItem.upsert({ where: { id: cmd.id }, update: { pk: cmd.pk, sk: sk, orderId: attrs.orderId, productId: attrs.productId, quantity: attrs.quantity, unitPrice: attrs.unitPrice, isDeleted: cmd.isDeleted ?? false, updatedAt: cmd.updatedAt, }, create: { id: cmd.id, pk: cmd.pk, sk: sk, cpk: cmd.pk, csk: cmd.sk, tenantCode: cmd.tenantCode, orderId: attrs.orderId, productId: attrs.productId, quantity: attrs.quantity, unitPrice: attrs.unitPrice, isDeleted: cmd.isDeleted ?? false, createdAt: cmd.createdAt, updatedAt: cmd.updatedAt, }, }); } async down(cmd: CommandModel): Promise { this.logger.debug(cmd); } } ``` ## Example 3: Complex Attribute Transformation {#example-attribute-transform} ### Use Case: Notifications with Different Content Types Scenario: Notification entity has different content structures based on type (Alert, Info, Promotion). Solution: Extract and flatten type-specific fields into common RDS columns. ```ts import { CommandModel, IDataSyncHandler, removeSortKeyVersion } from "@mbc-cqrs-serverless/core"; import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "src/prisma"; enum NotificationType { ALERT = "ALERT", INFO = "INFO", PROMOTION = "PROMOTION", } interface AlertContent { title: string; message: string; severity: string; } interface InfoContent { headline: string; body: string; } interface PromotionContent { campaignName: string; discount: number; validUntil: string; } interface NotificationAttributes { type: NotificationType; alertContent?: AlertContent; infoContent?: InfoContent; promotionContent?: PromotionContent; targetUsers: string[]; tags: string[]; schedule: { startDate: string; endDate: string; }; } @Injectable() export class NotificationDataSyncRdsHandler implements IDataSyncHandler { private readonly logger = new Logger(NotificationDataSyncRdsHandler.name); constructor(private readonly prismaService: PrismaService) {} async up(cmd: CommandModel): Promise { const sk = removeSortKeyVersion(cmd.sk); const attrs = cmd.attributes as NotificationAttributes; // Extract title based on notification type const title = this.getTitle(attrs); const body = this.getBody(attrs); await this.prismaService.notification.upsert({ where: { id: cmd.id }, update: { pk: cmd.pk, sk: sk, code: cmd.code, version: cmd.version, type: attrs.type, title: title, body: body, // Convert arrays to comma-separated strings for RDS targetUsers: attrs.targetUsers?.join(",") ?? null, tags: attrs.tags?.join(",") ?? null, // Handle dates startDate: attrs.schedule?.startDate ? new Date(attrs.schedule.startDate) : null, endDate: attrs.schedule?.endDate ? new Date(attrs.schedule.endDate) : null, isDeleted: cmd.isDeleted ?? false, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, }, create: { id: cmd.id, pk: cmd.pk, sk: sk, cpk: cmd.pk, csk: cmd.sk, code: cmd.code, version: cmd.version, tenantCode: cmd.tenantCode, type: attrs.type, title: title, body: body, targetUsers: attrs.targetUsers?.join(",") ?? null, tags: attrs.tags?.join(",") ?? null, startDate: attrs.schedule?.startDate ? new Date(attrs.schedule.startDate) : null, endDate: attrs.schedule?.endDate ? new Date(attrs.schedule.endDate) : null, isDeleted: cmd.isDeleted ?? false, createdAt: cmd.createdAt, createdBy: cmd.createdBy, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, }, }); } /** * Extract title based on notification type */ private getTitle(attrs: NotificationAttributes): string | null { switch (attrs.type) { case NotificationType.ALERT: return attrs.alertContent?.title ?? null; case NotificationType.INFO: return attrs.infoContent?.headline ?? null; case NotificationType.PROMOTION: return attrs.promotionContent?.campaignName ?? null; default: return null; } } /** * Extract body/message based on notification type */ private getBody(attrs: NotificationAttributes): string | null { switch (attrs.type) { case NotificationType.ALERT: return attrs.alertContent?.message ?? null; case NotificationType.INFO: return attrs.infoContent?.body ?? null; case NotificationType.PROMOTION: return `${attrs.promotionContent?.discount}% off until ${attrs.promotionContent?.validUntil}`; default: return null; } } async down(cmd: CommandModel): Promise { this.logger.debug(cmd); } } ``` ## Example 4: PK Prefix Filtering {#example-pk-filtering} ### Use Case: User Records in Shared Table Scenario: Multiple entity types share a DynamoDB table. Handler should only process USER records. Solution: Check PK prefix and skip non-matching records early. ```ts import { CommandModel, IDataSyncHandler, KEY_SEPARATOR, removeSortKeyVersion } from "@mbc-cqrs-serverless/core"; import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "src/prisma"; const USER_PK_PREFIX = "USER"; interface UserAttributes { email: string; userId: string; displayName: string; role: string; lastLoginAt?: string; } @Injectable() export class UserDataSyncRdsHandler implements IDataSyncHandler { private readonly logger = new Logger(UserDataSyncRdsHandler.name); constructor(private readonly prismaService: PrismaService) {} async up(cmd: CommandModel): Promise { // Only process USER records if (!cmd.pk.startsWith(USER_PK_PREFIX + KEY_SEPARATOR)) { return; } // Skip temporary or profile records if (cmd.sk.startsWith("temp") || cmd.sk.startsWith("profile")) { return; } const sk = removeSortKeyVersion(cmd.sk); const attrs = cmd.attributes as UserAttributes; await this.prismaService.user.upsert({ where: { id: cmd.id }, update: { pk: cmd.pk, sk: sk, code: cmd.code, version: cmd.version, email: attrs.email, userId: attrs.userId, displayName: attrs.displayName, role: attrs.role, lastLoginAt: attrs.lastLoginAt ? new Date(attrs.lastLoginAt) : null, isDeleted: cmd.isDeleted ?? false, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, }, create: { id: cmd.id, pk: cmd.pk, sk: sk, cpk: cmd.pk, csk: cmd.sk, code: cmd.code, version: cmd.version, tenantCode: cmd.tenantCode, type: cmd.type, email: attrs.email, userId: attrs.userId, displayName: attrs.displayName, role: attrs.role, lastLoginAt: attrs.lastLoginAt ? new Date(attrs.lastLoginAt) : null, isDeleted: cmd.isDeleted ?? false, createdAt: cmd.createdAt, createdBy: cmd.createdBy, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, }, }); } async down(cmd: CommandModel): Promise { this.logger.debug(cmd); } } ``` ## Example 5: Parsing SK for Derived Data {#example-sk-parsing} ### Use Case: Master Data with Category Information in SK Scenario: SK contains structured data like "SETTING#category#code" that should be stored as separate columns. Solution: Parse SK to extract type, category, and code for querying. ```ts import { CommandModel, IDataSyncHandler, KEY_SEPARATOR, removeSortKeyVersion } from "@mbc-cqrs-serverless/core"; import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "src/prisma"; const SETTING_SK_PREFIX = "SETTING"; const DATA_SK_PREFIX = "DATA"; interface MasterAttributes { value: any; displayOrder: number; isActive: boolean; } @Injectable() export class MasterDataSyncRdsHandler implements IDataSyncHandler { private readonly logger = new Logger(MasterDataSyncRdsHandler.name); constructor(private readonly prismaService: PrismaService) {} async up(cmd: CommandModel): Promise { const sk = removeSortKeyVersion(cmd.sk); const attrs = cmd.attributes as MasterAttributes; // Parse SK to extract type and code // SK format: "SETTING#category#code" or "DATA#category#code" const skParts = sk.split(KEY_SEPARATOR); let masterType: string; let masterCategory: string; let masterCode: string; if (sk.startsWith(SETTING_SK_PREFIX)) { masterType = "SETTING"; masterCategory = skParts[1] ?? ""; masterCode = skParts[2] ?? ""; } else if (sk.startsWith(DATA_SK_PREFIX)) { masterType = "DATA"; masterCategory = skParts[1] ?? ""; masterCode = skParts[2] ?? ""; } else { // Skip unknown types return; } await this.prismaService.master.upsert({ where: { id: cmd.id }, update: { pk: cmd.pk, sk: sk, code: cmd.code, version: cmd.version, masterType: masterType, masterCategory: masterCategory, masterCode: masterCode, name: cmd.name, value: JSON.stringify(attrs.value), displayOrder: attrs.displayOrder, isActive: attrs.isActive, isDeleted: cmd.isDeleted ?? false, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, }, create: { id: cmd.id, pk: cmd.pk, sk: sk, cpk: cmd.pk, csk: cmd.sk, code: cmd.code, version: cmd.version, tenantCode: cmd.tenantCode, masterType: masterType, masterCategory: masterCategory, masterCode: masterCode, name: cmd.name, value: JSON.stringify(attrs.value), displayOrder: attrs.displayOrder, isActive: attrs.isActive, isDeleted: cmd.isDeleted ?? false, createdAt: cmd.createdAt, createdBy: cmd.createdBy, updatedAt: cmd.updatedAt, updatedBy: cmd.updatedBy, }, }); } async down(cmd: CommandModel): Promise { this.logger.debug(cmd); } } ``` ## Registering Multiple Handlers {#multiple-handlers} You can register multiple handlers for the same table to handle different record types: ```ts import { CommandModule } from "@mbc-cqrs-serverless/core"; import { Module } from "@nestjs/common"; import { OrderDataSyncRdsHandler } from "./handler/order-rds.handler"; import { OrderItemDataSyncRdsHandler } from "./handler/order-item-rds.handler"; import { OrderHistoryDataSyncRdsHandler } from "./handler/order-history-rds.handler"; import { OrderController } from "./order.controller"; import { OrderService } from "./order.service"; @Module({ imports: [ CommandModule.register({ tableName: "order", dataSyncHandlers: [ OrderDataSyncRdsHandler, OrderItemDataSyncRdsHandler, OrderHistoryDataSyncRdsHandler, ], }), ], controllers: [OrderController], providers: [OrderService], }) export class OrderModule {} ``` ## Best Practices {#best-practices} ### 1. Always Remove Version from SK Use `removeSortKeyVersion()` to get a consistent SK for RDS storage: ```ts const sk = removeSortKeyVersion(cmd.sk); // "ORDER001@3" -> "ORDER001" ``` ### 2. Handle undefined isDeleted Always provide a default value for `isDeleted`: ```ts isDeleted: cmd.isDeleted ?? false, ``` ### 3. Store Both Original and Cleaned Keys Store original keys (cpk, csk) in create operations for reference: ```ts create: { pk: cmd.pk, // Cleaned PK sk: sk, // Cleaned SK (without version) cpk: cmd.pk, // Original PK (same as pk for most cases) csk: cmd.sk, // Original SK (with version) // ... } ``` ### 4. Type Your Attributes Define interfaces for attributes to ensure type safety: ```ts interface ProductAttributes { name: string; price: number; // ... } const attrs = cmd.attributes as ProductAttributes; ``` ### 5. Handle Null/Undefined Gracefully Use nullish coalescing and optional chaining: ```ts tags: attrs.tags?.join(",") ?? null, startDate: attrs.schedule?.startDate ? new Date(attrs.schedule.startDate) : null, ``` ## Related Documentation - [Event Handling Patterns](/docs/event-handling-patterns) - Event handler patterns - [Backend Development](/docs/backend-development) - Core backend patterns - [Database Selection Guide](/docs/database-selection-guide) - DynamoDB vs RDS - [Prisma](/docs/prisma) - RDS with Prisma ORM --- ## Event Handling Patterns URL: https://mbc-cqrs-serverless.mbc-net.com/docs/event-handling-patterns # Event Handling Patterns This guide covers patterns for implementing event-driven architectures using various AWS event sources including S3, Step Functions, SQS, and DynamoDB streams. ## When to Use This Guide {#when-to-use} Use this guide when you need to: - Process file uploads from S3 - Orchestrate workflows with Step Functions - Handle asynchronous messages from SQS - React to data changes via DynamoDB streams - Implement error handling and retry logic - Send notifications and alarms ## Event Architecture Overview {#event-architecture} ```text ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ S3 │────>│ │ │ Event │ │ Events │ │ │────>│ Handler 1 │ └─────────────┘ │ │ └─────────────┘ │ │ ┌─────────────┐ │ Event │ ┌─────────────┐ │ Step │────>│ Factory │────>│ Event │ │ Functions │ │ │ │ Handler 2 │ └─────────────┘ │ │ └─────────────┘ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ SQS │────>│ │────>│ Event │ │ Events │ │ │ │ Handler 3 │ └─────────────┘ └─────────────┘ └─────────────┘ ``` ## Event Factory {#event-factory} ### Custom Event Factory The Event Factory routes incoming events to appropriate handlers: ```typescript // event-factory.ts import { Injectable } from '@nestjs/common'; import { EventFactory, DefaultEventFactory, IEvent, } from '@mbc-cqrs-serverless/core'; import { S3Event } from 'aws-lambda'; import { StepFunctionsEvent, SQSEvent, DynamoDBStreamEvent } from './types'; // Import event classes import { CsvImportEvent } from './csv-import/event/csv-import.event'; import { FileProcessEvent } from './file/event/file-process.event'; import { OrderCreatedEvent } from './order/event/order-created.event'; import { SendNotificationEvent } from './notification/event/send-notification.event'; @EventFactory() @Injectable() export class CustomEventFactory extends DefaultEventFactory { /** * Transform S3 events to domain events */ async transformS3(event: S3Event): Promise { const events: IEvent[] = []; for (const record of event.Records) { const bucket = record.s3.bucket.name; const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' ')); // Route based on S3 key pattern if (key.startsWith('imports/csv/')) { events.push(new CsvImportEvent({ bucket, key, eventType: record.eventName, size: record.s3.object.size, })); } else if (key.startsWith('uploads/')) { events.push(new FileProcessEvent({ bucket, key, eventType: record.eventName, })); } } return events; } /** * Transform Step Functions events to domain events */ async transformStepFunction(event: StepFunctionsEvent): Promise { const { type, payload, taskToken } = event; switch (type) { case 'CSV_IMPORT': return [new CsvImportEvent({ ...payload, taskToken, })]; case 'ORDER_PROCESS': return [new OrderCreatedEvent({ ...payload, taskToken, })]; default: console.warn(`Unknown Step Function event type: ${type}`); return []; } } /** * Transform SQS events to domain events */ async transformSqs(event: SQSEvent): Promise { const events: IEvent[] = []; for (const record of event.Records) { const body = JSON.parse(record.body); switch (body.type) { case 'SEND_NOTIFICATION': events.push(new SendNotificationEvent(body)); break; // Add more event types as needed } } return events; } /** * Transform DynamoDB stream events to domain events */ async transformDynamodbStream(event: DynamoDBStreamEvent): Promise { const events: IEvent[] = []; for (const record of event.Records) { if (record.eventName === 'INSERT' || record.eventName === 'MODIFY') { const newImage = record.dynamodb?.NewImage; if (newImage) { // Route based on entity type const pk = newImage.pk?.S || ''; if (pk.startsWith('ORDER#')) { events.push(new OrderCreatedEvent({ pk, sk: newImage.sk?.S, data: newImage, })); } } } } return events; } } ``` ### Register Event Factory ```typescript // main.module.ts import { Module } from '@nestjs/common'; import { CustomEventFactory } from './event-factory'; @Module({ providers: [CustomEventFactory], // ... other configuration }) export class MainModule {} ``` ## Event Handler Patterns {#event-handler-patterns} ### Basic Event Handler ```typescript // order/event/order-created.event.ts export class OrderCreatedEvent { pk: string; sk: string; orderId: string; tenantCode: string; taskToken?: string; constructor(data: Partial) { Object.assign(this, data); } } // order/event/order-created.handler.ts import { Injectable, Logger } from '@nestjs/common'; import { EventHandler, IEventHandler } from '@mbc-cqrs-serverless/core'; import { OrderCreatedEvent } from './order-created.event'; import { NotificationService } from '../../notification/notification.service'; import { InventoryService } from '../../inventory/inventory.service'; interface OrderProcessingResult { success: boolean; orderId: string; } @EventHandler(OrderCreatedEvent) @Injectable() export class OrderCreatedHandler implements IEventHandler { private readonly logger = new Logger(OrderCreatedHandler.name); constructor( private readonly notificationService: NotificationService, private readonly inventoryService: InventoryService, ) {} /** * Handle order created event */ async execute(event: OrderCreatedEvent): Promise { this.logger.log(`Processing order: ${event.orderId}`); try { // Process order-related tasks await Promise.all([ this.updateInventory(event), this.sendNotification(event), this.triggerWorkflow(event), ]); return { success: true, orderId: event.orderId, }; } catch (error) { this.logger.error(`Failed to process order ${event.orderId}:`, error); throw error; } } private async updateInventory(event: OrderCreatedEvent): Promise { await this.inventoryService.reserveItems(event.orderId); } private async sendNotification(event: OrderCreatedEvent): Promise { await this.notificationService.sendOrderConfirmation(event.orderId); } private async triggerWorkflow(event: OrderCreatedEvent): Promise { // Trigger additional workflows if needed } } ``` ### Step Function Event Handler Handle events from Step Functions with task token support: ```typescript // import/event/import-process.event.handler.ts import { Injectable, Logger } from '@nestjs/common'; import { EventHandler, IEventHandler, StepFunctionService, SnsService, SnsEvent, } from '@mbc-cqrs-serverless/core'; import { ConfigService } from '@nestjs/config'; import { ImportProcessEvent } from './import-process.event'; import { ImportService } from '../import.service'; // Define SNS event for alarm notifications class AlarmSnsEvent implements SnsEvent { action: string; importId: string; bucket: string; key: string; errorMessage: string; timestamp: string; } @EventHandler(ImportProcessEvent) @Injectable() export class ImportProcessEventHandler implements IEventHandler { private readonly logger = new Logger(ImportProcessEventHandler.name); private readonly alarmTopicArn: string; constructor( private readonly importService: ImportService, private readonly sfnService: StepFunctionService, private readonly snsService: SnsService, private readonly configService: ConfigService, ) { this.alarmTopicArn = this.configService.get('SNS_ALARM_TOPIC_ARN'); } /** * Process import with Step Function callback */ async execute(event: ImportProcessEvent): Promise<{ success: boolean; importId: string }> { this.logger.log(`Processing import: ${event.importId}`); try { // Process the import const result = await this.importService.processImport(event); // Resume Step Functions execution on success if (event.taskToken) { await this.sfnService.resumeExecution(event.taskToken, result); } return { success: true, importId: event.importId }; } catch (error) { this.logger.error(`Import failed: ${event.importId}`, error); // Send alarm notification await this.sendAlarm(event, error as Error); throw error; } } private async sendAlarm(event: ImportProcessEvent, error: Error): Promise { const alarmEvent: AlarmSnsEvent = { action: 'IMPORT_ERROR', importId: event.importId, bucket: event.bucket, key: event.key, errorMessage: error.message, timestamp: new Date().toISOString(), }; await this.snsService.publish(alarmEvent, this.alarmTopicArn); } } ``` ### S3 Event Handler Process file uploads from S3: ```typescript // file/event/file-upload.event.ts export class FileUploadEvent { bucket: string; key: string; size: number; eventType: string; constructor(data: Partial) { Object.assign(this, data); } } // file/event/file-upload.handler.ts import { Injectable, Logger } from '@nestjs/common'; import { EventHandler, IEventHandler, S3Service } from '@mbc-cqrs-serverless/core'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { FileUploadEvent } from './file-upload.event'; import { FileProcessService } from '../file-process.service'; interface FileProcessingResult { status: 'processed' | 'skipped'; fileType?: string; reason?: string; } @EventHandler(FileUploadEvent) @Injectable() export class FileUploadHandler implements IEventHandler { private readonly logger = new Logger(FileUploadHandler.name); constructor( private readonly s3Service: S3Service, private readonly fileProcessService: FileProcessService, ) {} /** * Process uploaded file */ async execute(event: FileUploadEvent): Promise { this.logger.log(`Processing file: ${event.key}`); // Get file content from S3 const command = new GetObjectCommand({ Bucket: event.bucket, Key: event.key, }); const response = await this.s3Service.client.send(command); // Determine file type and process accordingly const fileExtension = event.key.split('.').pop()?.toLowerCase(); switch (fileExtension) { case 'csv': await this.fileProcessService.processCsv(response.Body, event); return { status: 'processed', fileType: 'csv' }; case 'xlsx': case 'xls': await this.fileProcessService.processExcel(response.Body, event); return { status: 'processed', fileType: fileExtension }; case 'pdf': await this.fileProcessService.processPdf(response.Body, event); return { status: 'processed', fileType: 'pdf' }; case 'jpg': case 'jpeg': case 'png': await this.fileProcessService.processImage(response.Body, event); return { status: 'processed', fileType: fileExtension }; default: this.logger.warn(`Unsupported file type: ${fileExtension}`); return { status: 'skipped', reason: 'Unsupported file type' }; } } } ``` ### SQS Event Handler Process asynchronous messages from SQS: ```typescript // notification/event/send-notification.event.ts export class SendNotificationEvent { type: 'EMAIL' | 'SMS' | 'PUSH'; recipient: string; subject: string; body: string; templateId?: string; templateData?: Record; constructor(data: Partial) { Object.assign(this, data); } } // notification/event/send-notification.handler.ts import { Injectable, Logger } from '@nestjs/common'; import { EventHandler, IEventHandler, EmailService, } from '@mbc-cqrs-serverless/core'; import { SendNotificationEvent } from './send-notification.event'; interface NotificationResult { status: 'sent' | 'skipped'; type: 'EMAIL' | 'SMS' | 'PUSH'; reason?: string; } @EventHandler(SendNotificationEvent) @Injectable() export class SendNotificationHandler implements IEventHandler { private readonly logger = new Logger(SendNotificationHandler.name); constructor(private readonly emailService: EmailService) {} /** * Send notification based on type */ async execute(event: SendNotificationEvent): Promise { this.logger.log(`Sending ${event.type} notification to ${event.recipient}`); switch (event.type) { case 'EMAIL': return this.sendEmail(event); case 'SMS': return this.sendSms(event); case 'PUSH': return this.sendPush(event); default: throw new Error(`Unknown notification type: ${event.type}`); } } private async sendEmail(event: SendNotificationEvent): Promise { let body = event.body; // Render template if provided if (event.templateId && event.templateData) { body = await this.renderTemplate(event.templateId, event.templateData); } await this.emailService.sendEmail({ toAddrs: [event.recipient], subject: event.subject, body, }); return { status: 'sent', type: 'EMAIL' }; } private async sendSms(event: SendNotificationEvent): Promise { // Implement SMS sending logic this.logger.log('SMS sending not implemented'); return { status: 'skipped', type: 'SMS', reason: 'Not implemented' }; } private async sendPush(event: SendNotificationEvent): Promise { // Implement push notification logic this.logger.log('Push notification not implemented'); return { status: 'skipped', type: 'PUSH', reason: 'Not implemented' }; } private async renderTemplate( templateId: string, data: Record, ): Promise { // Template rendering logic return `Template ${templateId} rendered with data`; } } ``` ## DynamoDB Stream Handler {#dynamodb-stream-handler} ### Data Change Event Handler React to data changes in DynamoDB: ```typescript // sync/event/data-change.event.ts export class DataChangeEvent { pk: string; sk: string; eventType: 'INSERT' | 'MODIFY' | 'REMOVE'; oldImage?: Record; newImage?: Record; constructor(data: Partial) { Object.assign(this, data); } } // sync/event/data-change.handler.ts import { Injectable, Logger } from '@nestjs/common'; import { EventHandler, IEventHandler } from '@mbc-cqrs-serverless/core'; import { DataChangeEvent } from './data-change.event'; import { ExternalSyncService } from '../external-sync.service'; import { CacheService } from '../../cache/cache.service'; interface DataSyncResult { synced: boolean; type?: string; } @EventHandler(DataChangeEvent) @Injectable() export class DataChangeHandler implements IEventHandler { private readonly logger = new Logger(DataChangeHandler.name); constructor( private readonly externalSyncService: ExternalSyncService, private readonly cacheService: CacheService, ) {} /** * Handle data changes from DynamoDB stream */ async execute(event: DataChangeEvent): Promise { this.logger.log( `Data change: ${event.eventType} on ${event.pk}/${event.sk}`, ); // Invalidate cache await this.cacheService.invalidate(event.pk, event.sk); // Sync to external systems based on entity type const entityType = event.pk.split('#')[0]; switch (entityType) { case 'PRODUCT': return this.syncProduct(event); case 'ORDER': return this.syncOrder(event); case 'USER': return this.syncUser(event); default: this.logger.debug(`No external sync for entity type: ${entityType}`); return { synced: false }; } } private async syncProduct(event: DataChangeEvent): Promise { if (event.eventType === 'REMOVE') { await this.externalSyncService.deleteProduct(event.pk, event.sk); } else { await this.externalSyncService.upsertProduct(event.newImage); } return { synced: true, type: 'PRODUCT' }; } private async syncOrder(event: DataChangeEvent): Promise { // Sync order to external ERP system await this.externalSyncService.syncOrder(event.newImage); return { synced: true, type: 'ORDER' }; } private async syncUser(event: DataChangeEvent): Promise { // Sync user to external identity provider await this.externalSyncService.syncUser(event.newImage); return { synced: true, type: 'USER' }; } } ``` ## Error Handling and Retry {#error-handling-retry} The following patterns show how you can implement error handling and retry logic in your application. These are example implementations that you need to create in your own project. ### Retry Pattern Example :::info Example Implementation The following retry decorator is not provided by the framework. You need to implement it yourself if you need retry functionality within Lambda execution. ::: ```typescript // common/event/retry.decorator.ts import { Logger } from '@nestjs/common'; export interface RetryOptions { maxRetries: number; backoffMs: number; backoffMultiplier: number; } const DEFAULT_RETRY_OPTIONS: RetryOptions = { maxRetries: 3, backoffMs: 1000, backoffMultiplier: 2, }; /** * Example retry decorator for event handlers */ export function WithRetry(options: Partial = {}) { const retryOptions = { ...DEFAULT_RETRY_OPTIONS, ...options }; return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor, ) { const originalMethod = descriptor.value; const logger = new Logger(`${target.constructor.name}.${propertyKey}`); descriptor.value = async function (...args: any[]) { let lastError: Error; let delay = retryOptions.backoffMs; for (let attempt = 1; attempt <= retryOptions.maxRetries; attempt++) { try { return await originalMethod.apply(this, args); } catch (error) { lastError = error; logger.warn( `Attempt ${attempt}/${retryOptions.maxRetries} failed: ${error.message}`, ); if (attempt < retryOptions.maxRetries) { await new Promise(resolve => setTimeout(resolve, delay)); delay *= retryOptions.backoffMultiplier; } } } logger.error(`All ${retryOptions.maxRetries} attempts failed`); throw lastError!; }; return descriptor; }; } ``` ### AWS Native Retry Options For production use, consider using AWS-native retry mechanisms: - **SQS Retry**: Configure `maxReceiveCount` on the SQS queue to automatically retry failed messages - **Lambda Retry**: Configure retry settings on the Lambda function for asynchronous invocations - **Step Functions Retry**: Use the `Retry` field in your state machine definition ## Best Practices {#best-practices} ### 1. Idempotent Event Handlers ```typescript interface IdempotentResult { skipped?: boolean; processed?: boolean; } // Always check if event was already processed async execute(event: OrderEvent): Promise { const existing = await this.prismaService.processedEvent.findUnique({ where: { eventId: event.eventId }, }); if (existing) { this.logger.log(`Event ${event.eventId} already processed, skipping`); return { skipped: true }; } // Process event const result = await this.processEvent(event); // Mark as processed await this.prismaService.processedEvent.create({ data: { eventId: event.eventId, processedAt: new Date() }, }); return { processed: true, ...result }; } ``` ### 2. Structured Logging ```typescript // Use structured logging for better observability this.logger.log({ message: 'Processing event', eventType: event.constructor.name, eventId: event.id, tenantCode: event.tenantCode, correlationId: event.correlationId, }); ``` ### 3. Timeout Handling ```typescript interface TimeoutResult { success: boolean; data?: Record; } // Implement timeout for long-running operations async execute(event: LongRunningEvent): Promise { const timeout = 25000; // 25 seconds (Lambda default is 30s) const result = await Promise.race([ this.processEvent(event), new Promise((_, reject) => setTimeout(() => reject(new Error('Operation timeout')), timeout), ), ]); return result; } ``` ### 4. Graceful Degradation ```typescript interface BatchProcessingResult { processed: number; failed: number; } // Continue processing even if some operations fail async execute(event: BatchEvent): Promise { const results: unknown[] = []; const errors: Array<{ item: unknown; error: string }> = []; for (const item of event.items) { try { results.push(await this.processItem(item)); } catch (error) { errors.push({ item, error: (error as Error).message }); // Continue with next item } } if (errors.length > 0) { this.logger.warn(`${errors.length} items failed`, { errors }); } return { processed: results.length, failed: errors.length }; } ``` ## Data Sync Handler {#data-sync-handler} The data sync event is a particularly significant custom event because it is one of the most commonly registered events within the application. Handlers for this event play a crucial role in ensuring data consistency and synchronization between different databases. ### IDataSyncHandler Interface By convention, you create a class that implements `IDataSyncHandler` and then override the up and down methods: ```typescript import { CommandModel, IDataSyncHandler, removeSortKeyVersion } from "@mbc-cqrs-serverless/core"; import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "src/prisma"; @Injectable() export class ProductDataSyncRdsHandler implements IDataSyncHandler { private readonly logger = new Logger(ProductDataSyncRdsHandler.name); constructor(private readonly prismaService: PrismaService) {} /** * Sync data from DynamoDB to RDS on create/update */ async up(cmd: CommandModel): Promise { this.logger.debug('Syncing to RDS:', cmd.pk, cmd.sk); const { pk, id, code, name, tenantCode, attributes } = cmd; const sk = removeSortKeyVersion(cmd.sk); // Strip @version suffix from command sk await this.prismaService.product.upsert({ where: { id }, create: { id, pk, sk, code, name, tenantCode, ...attributes, createdAt: new Date(), updatedAt: new Date(), }, update: { name, ...attributes, updatedAt: new Date(), }, }); } /** * Handle delete/rollback operations */ async down(cmd: CommandModel): Promise { this.logger.debug('Removing from RDS:', cmd.pk, cmd.sk); await this.prismaService.product.delete({ where: { id: cmd.id }, }).catch(() => { // Ignore if already deleted }); } } ``` ### Register Data Sync Handler Register your handler to `CommandModule`: ```typescript import { Module } from '@nestjs/common'; import { CommandModule } from '@mbc-cqrs-serverless/core'; import { ProductDataSyncRdsHandler } from './handler/product-rds.handler'; @Module({ imports: [ CommandModule.register({ tableName: 'product', dataSyncHandlers: [ProductDataSyncRdsHandler], }), ], // ... }) export class ProductModule {} ``` ### Multiple Sync Handlers You can register multiple handlers for different sync targets: ```typescript CommandModule.register({ tableName: 'order', dataSyncHandlers: [ OrderRdsSyncHandler, // Sync to RDS for queries OrderElasticSyncHandler, // Sync to Elasticsearch for search OrderAnalyticsSyncHandler, // Sync to analytics warehouse ], }), ``` ## Creating Custom Events {#creating-custom-events} To create a custom event, implement the `IEvent` interface from `@mbc-cqrs-serverless/core`. Depending on the event source, you should typically implement a second interface from the `aws-lambda` library, such as `SNSEventRecord`, `SQSRecord`, `DynamoDBRecord`, `EventBridgeEvent`, `S3EventRecord`, etc. ### Custom S3 Event Example ```typescript // custom-s3-import.event.ts import { IEvent } from "@mbc-cqrs-serverless/core"; import { S3EventRecord } from "aws-lambda"; export class CustomS3ImportEvent implements IEvent, Partial { source: string; bucket: string; key: string; size: number; eventType: string; static fromS3Record(record: S3EventRecord): CustomS3ImportEvent { const event = new CustomS3ImportEvent(); event.source = record.eventSource; event.bucket = record.s3.bucket.name; event.key = record.s3.object.key; event.size = record.s3.object.size; event.eventType = record.eventName; return event; } } ``` ### Event Factory Transform Methods The Event Factory supports transforming events from various AWS sources: ```typescript // Available transform methods in DefaultEventFactory transformSqs(event: SQSEvent): Promise; transformSns(event: SNSEvent): Promise; transformDynamodbStream(event: DynamoDBStreamEvent): Promise; transformEventBridge(event: EventBridgeEvent): Promise; transformStepFunction(event: StepFunctionsEvent): Promise; transformS3(event: S3Event): Promise; ``` ## Related Documentation - [Backend Development Guide](/docs/backend-development) - Core patterns - [Step Functions](/docs/architecture/step-functions) - Workflow orchestration - [Data Sync Handler Examples](/docs/data-sync-handler-examples) - Comprehensive sync examples - [Queue Module](/docs/queue) - SQS and SNS messaging services - [Notification Module](/docs/notification-module) - Event-triggered notifications and email --- ## Import/Export Patterns URL: https://mbc-cqrs-serverless.mbc-net.com/docs/import-export-patterns # Import/Export Patterns This guide covers patterns for handling data import and export operations, including CSV processing, Excel file handling, and batch data operations with Step Functions. ## When to Use This Guide {#when-to-use} Use this guide when you need to: - Import bulk data from CSV or Excel files - Export data to various formats - Process large datasets with Step Functions - Implement file upload with S3 presigned URLs - Transform data between external and internal formats ## Import Architecture Overview {#import-architecture} ```text ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Client │────>│ S3 │────>│Step Function│────>│ Lambda │ │ (Upload) │ │ (Storage) │ │(Orchestrate)│ │ (Process) │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ ┌───────────────────────────┘ ▼ ┌─────────────┐ ┌─────────────┐ │ DynamoDB │<────│ Import │ │ (Command) │ │ Handler │ └─────────────┘ └─────────────┘ ``` ## File Upload Pattern {#file-upload-pattern} ### Storage Service Generate presigned URLs for secure file uploads: ```typescript // storage/storage.service.ts import { Injectable } from '@nestjs/common'; import { S3Service } from '@mbc-cqrs-serverless/core'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; @Injectable() export class StorageService { constructor(private readonly s3Service: S3Service) {} /** * Generate upload URL for file import */ async genUploadUrl( filename: string, contentType = 'text/csv', ): Promise<{ bucket: string; key: string; url: string }> { const bucket = this.s3Service.privateBucket; const timestamp = Date.now(); const key = `imports/${timestamp}/${filename}`; const command = new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType, ACL: 'private', }); const url = await getSignedUrl(this.s3Service.client, command, { expiresIn: 3600, // 1 hour }); return { bucket, key, url }; } /** * Generate download URL for file export */ async genDownloadUrl( key: string, filename?: string, ): Promise<{ url: string }> { const command = new GetObjectCommand({ Bucket: this.s3Service.privateBucket, Key: key, ResponseContentDisposition: filename ? `attachment; filename="${filename}"` : undefined, }); const url = await getSignedUrl(this.s3Service.client, command, { expiresIn: 3600, }); return { url }; } } ``` ### Storage Controller ```typescript // storage/storage.controller.ts import { Controller, Post, Get, Body, Query } from '@nestjs/common'; import { StorageService } from './storage.service'; @Controller('api/storage') export class StorageController { constructor(private readonly storageService: StorageService) {} /** * Get presigned URL for upload */ @Post('upload-url') async getUploadUrl( @Body() dto: { filename: string; contentType?: string }, ) { return this.storageService.genUploadUrl(dto.filename, dto.contentType); } /** * Get presigned URL for download */ @Get('download-url') async getDownloadUrl( @Query('key') key: string, @Query('filename') filename?: string, ) { return this.storageService.genDownloadUrl(key, filename); } } ``` ## CSV Import Pattern {#csv-import-pattern} ### CSV Import Controller ```typescript // csv-import/csv-import.controller.ts import { Controller, Post, Body } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { StepFunctionService, INVOKE_CONTEXT, IInvoke } from '@mbc-cqrs-serverless/core'; export class CsvImportDto { bucket: string; key: string; type: string; // Import type identifier } @Controller('api/csv-import') export class CsvImportController { private readonly importArn: string; constructor( private readonly configService: ConfigService, private readonly sfnService: StepFunctionService, ) { this.importArn = this.configService.get('SFN_CSV_IMPORT_ARN'); } /** * Start CSV import via Step Functions */ @Post('/') async startImport( @INVOKE_CONTEXT() invokeContext: IInvoke, @Body() dto: CsvImportDto, ) { const executionName = `${dto.type}-${Date.now()}`; return this.sfnService.startExecution( this.importArn, { ...dto, invokeContext, }, executionName, ); } } ``` ### CSV Parser Service ```typescript // csv-import/csv-parser.service.ts import { Injectable, Logger } from '@nestjs/common'; import { S3Service } from '@mbc-cqrs-serverless/core'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import * as csvParser from 'csv-parser'; import { Readable } from 'stream'; export interface ParsedRow { rowNumber: number; data: Record; errors: string[]; } @Injectable() export class CsvParserService { private readonly logger = new Logger(CsvParserService.name); constructor(private readonly s3Service: S3Service) {} /** * Parse CSV file from S3 */ async parseFromS3( bucket: string, key: string, options?: { encoding?: string; delimiter?: string }, ): Promise { const command = new GetObjectCommand({ Bucket: bucket, Key: key }); const response = await this.s3Service.client.send(command); const stream = response.Body as Readable; const results: ParsedRow[] = []; let rowNumber = 0; return new Promise((resolve, reject) => { stream .pipe(csvParser({ separator: options?.delimiter || ',', skipLines: 0, })) .on('data', (data) => { rowNumber++; results.push({ rowNumber, data, errors: [], }); }) .on('end', () => { this.logger.log(`Parsed ${results.length} rows from ${key}`); resolve(results); }) .on('error', (error) => { this.logger.error(`Failed to parse CSV: ${error.message}`); reject(error); }); }); } /** * Validate parsed rows */ validateRows( rows: ParsedRow[], requiredFields: string[], ): ParsedRow[] { return rows.map(row => { const errors: string[] = []; for (const field of requiredFields) { if (!row.data[field] || row.data[field].trim() === '') { errors.push(`Missing required field: ${field}`); } } return { ...row, errors }; }); } } ``` ### Import Event Handler ```typescript // csv-import/event/csv-import.event.handler.ts import { Injectable, Logger } from '@nestjs/common'; import { EventHandler, IEventHandler, SnsService } from '@mbc-cqrs-serverless/core'; import { ConfigService } from '@nestjs/config'; import { CsvParserService } from '../csv-parser.service'; import { ProductService } from '../../product/product.service'; export class CsvImportEvent { bucket: string; key: string; type: string; invokeContext: any; } @EventHandler(CsvImportEvent) @Injectable() export class CsvImportEventHandler implements IEventHandler { private readonly logger = new Logger(CsvImportEventHandler.name); private readonly alarmTopicArn: string; constructor( private readonly csvParser: CsvParserService, private readonly productService: ProductService, private readonly snsService: SnsService, private readonly configService: ConfigService, ) { this.alarmTopicArn = this.configService.get('SNS_ALARM_TOPIC_ARN'); } /** * Process CSV import event */ async execute(event: CsvImportEvent): Promise { this.logger.log(`Processing import: ${event.key}`); try { // Parse CSV const rows = await this.csvParser.parseFromS3(event.bucket, event.key); // Validate const validatedRows = this.csvParser.validateRows(rows, [ 'code', 'name', 'price', ]); // Filter valid rows const validRows = validatedRows.filter(r => r.errors.length === 0); const invalidRows = validatedRows.filter(r => r.errors.length > 0); if (invalidRows.length > 0) { this.logger.warn(`${invalidRows.length} rows have validation errors`); } // Process in batches const batchSize = 30; let processedCount = 0; for (let i = 0; i < validRows.length; i += batchSize) { const batch = validRows.slice(i, i + batchSize); await Promise.all( batch.map(row => this.processRow(row.data, event.invokeContext)), ); processedCount += batch.length; this.logger.log(`Processed ${processedCount}/${validRows.length} rows`); } return { success: true, totalRows: rows.length, processedRows: processedCount, errorRows: invalidRows.length, }; } catch (error) { await this.sendAlarm(event, error); throw error; } } private async processRow(data: Record, invokeContext: any) { await this.productService.publishCommand({ code: data.code, name: data.name, attributes: { price: parseFloat(data.price), category: data.category, description: data.description, }, }, invokeContext); } private async sendAlarm(event: CsvImportEvent, error: Error) { await this.snsService.publish({ action: 'CSV_IMPORT_ERROR', key: event.key, error: error.message, timestamp: new Date().toISOString(), }, this.alarmTopicArn); } } ``` ## Excel Import Pattern {#excel-import-pattern} ### Excel Helper Functions ```typescript // helpers/excel.ts import { Workbook, Worksheet, Cell } from 'exceljs'; /** * Get cell value handling formulas and rich text */ export function getCellValue(row: any, column: string): string | undefined { const cell = row.getCell(column); if (!cell || cell.value === null || cell.value === undefined) { return undefined; } // Handle formula result if (typeof cell.value === 'object' && 'result' in cell.value) { return String(cell.value.result); } // Handle rich text if (typeof cell.value === 'object' && 'richText' in cell.value) { return cell.value.richText.map((r: any) => r.text).join(''); } return String(cell.value); } /** * Get numeric cell value */ export function getCellNumber(row: any, column: string): number | undefined { const value = getCellValue(row, column); if (!value) return undefined; const num = parseFloat(value.replace(/,/g, '')); return isNaN(num) ? undefined : num; } /** * Get date cell value */ export function getCellDate(row: any, column: string): Date | undefined { const cell = row.getCell(column); if (cell.value instanceof Date) { return cell.value; } const value = getCellValue(row, column); if (!value) return undefined; const date = new Date(value); return isNaN(date.getTime()) ? undefined : date; } /** * Find header row by matching column headers */ export function findHeaderRow( worksheet: Worksheet, expectedHeaders: string[], maxRows = 20, ): number { for (let rowNum = 1; rowNum <= maxRows; rowNum++) { const row = worksheet.getRow(rowNum); const values = row.values as any[]; const matches = expectedHeaders.filter(header => values.some(v => v && String(v).includes(header)), ); if (matches.length >= expectedHeaders.length * 0.8) { return rowNum; } } throw new Error('Header row not found'); } ``` ### Excel Import Service ```typescript // excel-import/excel-import.service.ts import { Injectable, Logger } from '@nestjs/common'; import { S3Service } from '@mbc-cqrs-serverless/core'; import { GetObjectCommand } from '@aws-sdk/client-s3'; import { Workbook } from 'exceljs'; import { getCellValue, getCellNumber, findHeaderRow } from '../helpers/excel'; export interface ExcelImportResult { success: boolean; sheetName: string; totalRows: number; processedRows: number; errors: Array<{ row: number; message: string }>; } @Injectable() export class ExcelImportService { private readonly logger = new Logger(ExcelImportService.name); constructor(private readonly s3Service: S3Service) {} /** * Load workbook from S3 */ async loadWorkbook(bucket: string, key: string): Promise { const command = new GetObjectCommand({ Bucket: bucket, Key: key }); const response = await this.s3Service.client.send(command); const chunks: Buffer[] = []; for await (const chunk of response.Body as any) { chunks.push(chunk); } const buffer = Buffer.concat(chunks); const workbook = new Workbook(); if (key.endsWith('.xlsx') || key.endsWith('.xlsm')) { await workbook.xlsx.load(buffer); } else if (key.endsWith('.xls')) { // For .xls files, use different parser throw new Error('XLS format not supported. Please use XLSX.'); } return workbook; } /** * Process worksheet with row processor */ async processWorksheet( worksheet: any, config: { headerRow?: number; expectedHeaders?: string[]; startRow?: number; processor: (row: any, rowNumber: number) => Promise; }, ): Promise { const errors: Array<{ row: number; message: string }> = []; const data: T[] = []; // Find or use specified header row const headerRow = config.headerRow || ( config.expectedHeaders ? findHeaderRow(worksheet, config.expectedHeaders) : 1 ); const startRow = config.startRow || headerRow + 1; let processedRows = 0; let totalRows = 0; worksheet.eachRow((row: any, rowNumber: number) => { if (rowNumber < startRow) return; totalRows++; }); for (let rowNum = startRow; rowNum <= worksheet.rowCount; rowNum++) { const row = worksheet.getRow(rowNum); // Skip empty rows if (this.isEmptyRow(row)) continue; try { const result = await config.processor(row, rowNum); if (result !== null) { data.push(result); processedRows++; } } catch (error) { errors.push({ row: rowNum, message: error.message, }); } } return { success: errors.length === 0, sheetName: worksheet.name, totalRows, processedRows, errors, data, }; } private isEmptyRow(row: any): boolean { const values = row.values as any[]; return !values || values.every(v => v === null || v === undefined || v === ''); } } ``` ### Import Strategy Pattern ```typescript // import/base-import.strategy.ts import { BadRequestException } from '@nestjs/common'; import { validate, ValidationError } from 'class-validator'; import { IInvoke } from '@mbc-cqrs-serverless/core'; /** * Base interface for import strategies * @typeParam TInput - The input type, must be an object * @typeParam TAttributesDto - The output DTO type, must be an object */ export interface IImportStrategy { /** * Transform raw input to command DTO */ transform(input: TInput): Promise; /** * Validate transformed DTO */ validate(data: TAttributesDto): Promise; } /** * Base import strategy with common functionality * @typeParam TInput - The input type, must be an object * @typeParam TAttributesDto - The output DTO type, must be an object */ export abstract class BaseImportStrategy implements IImportStrategy { /** * Transform raw input to command DTO (default: return as-is) */ async transform(input: TInput): Promise { return input as unknown as TAttributesDto; } /** * Validate transformed DTO using class-validator */ async validate(data: TAttributesDto): Promise { // Uses class-validator for validation const errors = await validate(data as object); if (errors.length > 0) { throw new BadRequestException({ statusCode: 400, message: this.flattenValidationErrors(errors), error: 'Bad Request', }); } } /** * Flatten validation errors to a simple format */ private flattenValidationErrors( errors: ValidationError[], parentPath = '', ): string[] { const messages: string[] = []; for (const error of errors) { const currentPath = parentPath ? `${parentPath}.${error.property}` : error.property; if (error.children && error.children.length > 0) { messages.push( ...this.flattenValidationErrors(error.children, currentPath), ); } else if (error.constraints) { const firstConstraint = Object.values(error.constraints)[0]; const message = firstConstraint.replace(error.property, currentPath); messages.push(message); } } return messages; } } ``` ### Concrete Import Strategy ```typescript // product/import/product-import.strategy.ts import { Injectable } from '@nestjs/common'; import { KEY_SEPARATOR, generateId } from '@mbc-cqrs-serverless/core'; import { BaseImportStrategy } from '@mbc-cqrs-serverless/import'; import { ulid } from 'ulid'; import { ProductCommandDto } from '../dto/product-command.dto'; const PRODUCT_PK_PREFIX = 'PRODUCT'; export interface ProductImportInput { code: string; name: string; category?: string; price?: string; description?: string; tenantCode: string; // Passed from import context } @Injectable() export class ProductImportStrategy extends BaseImportStrategy { /** * Transform import data to command DTO */ async transform(input: ProductImportInput): Promise { const { tenantCode } = input; const pk = `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`; const sk = ulid(); const id = generateId(pk, sk); return new ProductCommandDto({ pk, sk, id, tenantCode, code: input.code?.trim(), name: input.name?.trim(), type: 'PRODUCT', attributes: { category: input.category?.trim(), price: input.price ? parseFloat(input.price.replace(/,/g, '')) : undefined, description: input.description?.trim(), }, }); } /** * Validate import input */ async validate(input: ProductImportInput): Promise { if (!input.code) { throw new Error('Product code is required'); } if (!input.name) { throw new Error('Product name is required'); } if (input.price && isNaN(parseFloat(input.price.replace(/,/g, '')))) { throw new Error('Invalid price format'); } } } ``` ## Export Pattern {#export-pattern} :::info Note The export patterns shown below are example implementations for your application. Unlike the import module (`@mbc-cqrs-serverless/import`), there is no dedicated export package in the framework. You can implement these patterns directly in your application code. ::: ### Export Service ```typescript // export/export.service.ts import { Injectable, Logger } from '@nestjs/common'; import { S3Service } from '@mbc-cqrs-serverless/core'; import { PutObjectCommand } from '@aws-sdk/client-s3'; import { Workbook } from 'exceljs'; @Injectable() export class ExportService { private readonly logger = new Logger(ExportService.name); constructor(private readonly s3Service: S3Service) {} /** * Export data to CSV and upload to S3 */ async exportToCsv( data: Record[], headers: { key: string; label: string }[], filename: string, ): Promise<{ bucket: string; key: string }> { // Build CSV content const headerRow = headers.map(h => h.label).join(','); const dataRows = data.map(row => headers.map(h => this.escapeCsvValue(row[h.key])).join(','), ); const csvContent = [headerRow, ...dataRows].join('\n'); // Upload to S3 const bucket = this.s3Service.privateBucket; const key = `exports/${Date.now()}/${filename}`; await this.s3Service.client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: csvContent, ContentType: 'text/csv; charset=utf-8', })); this.logger.log(`Exported ${data.length} rows to ${key}`); return { bucket, key }; } /** * Export data to Excel and upload to S3 */ async exportToExcel( data: Record[], headers: { key: string; label: string; width?: number }[], filename: string, sheetName = 'Data', ): Promise<{ bucket: string; key: string }> { const workbook = new Workbook(); const worksheet = workbook.addWorksheet(sheetName); // Set columns worksheet.columns = headers.map(h => ({ header: h.label, key: h.key, width: h.width || 15, })); // Style header row worksheet.getRow(1).font = { bold: true }; worksheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' }, }; // Add data rows data.forEach(row => { const rowData: Record = {}; headers.forEach(h => { rowData[h.key] = row[h.key]; }); worksheet.addRow(rowData); }); // Generate buffer const buffer = await workbook.xlsx.writeBuffer(); // Upload to S3 const bucket = this.s3Service.privateBucket; const key = `exports/${Date.now()}/${filename}`; await this.s3Service.client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: buffer as Buffer, ContentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', })); this.logger.log(`Exported ${data.length} rows to ${key}`); return { bucket, key }; } private escapeCsvValue(value: any): string { if (value === null || value === undefined) return ''; const str = String(value); if (str.includes(',') || str.includes('"') || str.includes('\n')) { return `"${str.replace(/"/g, '""')}"`; } return str; } } ``` ## Step Function Integration {#step-function-integration} ### Import Orchestration ```typescript // Import workflow with Step Functions // serverless.yml /* stepFunctions: stateMachines: csvImport: name: ${self:custom.prefix}-csv-import definition: StartAt: ParseFile States: ParseFile: Type: Task Resource: !GetAtt ParseFileLambda.Arn Next: ProcessBatches Catch: - ErrorEquals: ["States.ALL"] Next: HandleError ProcessBatches: Type: Map ItemsPath: $.batches MaxConcurrency: 5 Iterator: StartAt: ProcessBatch States: ProcessBatch: Type: Task Resource: !GetAtt ProcessBatchLambda.Arn End: true Next: Finalize Finalize: Type: Task Resource: !GetAtt FinalizeLambda.Arn End: true HandleError: Type: Task Resource: !GetAtt HandleErrorLambda.Arn End: true */ ``` ## Best Practices {#best-practices} ### 1. Batch Processing Always process large files in batches: ```typescript const BATCH_SIZE = 30; async processBatches( items: T[], processor: (item: T) => Promise, ): Promise { for (let i = 0; i < items.length; i += BATCH_SIZE) { const batch = items.slice(i, i + BATCH_SIZE); await Promise.all(batch.map(processor)); } } ``` ### 2. Error Handling Collect and report errors without stopping processing: ```typescript const errors: Array<{ row: number; error: string }> = []; for (const [index, row] of rows.entries()) { try { await processRow(row); } catch (error) { errors.push({ row: index + 1, error: error.message }); // Continue processing } } if (errors.length > 0) { await this.reportErrors(errors); } ``` ### 3. Validation Before Processing Validate all data before starting import: ```typescript // First pass: validate const validationErrors = await this.validateAll(rows); if (validationErrors.length > 0) { return { success: false, errors: validationErrors }; } // Second pass: process await this.processAll(rows); ``` ### 4. Progress Reporting Report progress for long-running imports: ```typescript const total = rows.length; let processed = 0; for (const batch of batches) { await processBatch(batch); processed += batch.length; // Report progress every 100 rows if (processed % 100 === 0) { this.logger.log(`Progress: ${processed}/${total} (${Math.round(processed/total*100)}%)`); } } ``` ## ImportModule API Reference {#importmodule-api} The `@mbc-cqrs-serverless/import` package provides a comprehensive framework for managing data import tasks. ### Installation ```bash npm install @mbc-cqrs-serverless/import ``` ### ProcessingMode Enum {#processingmode-enum} The `ProcessingMode` enum defines how import jobs are executed: ```typescript export enum ProcessingMode { DIRECT = 'DIRECT', // Direct processing without Step Functions STEP_FUNCTION = 'STEP_FUNCTION', // Processing orchestrated by Step Functions } ``` | Mode | Description | Use Case | |----------|-----------------|--------------| | `DIRECT` | Import is processed directly without Step Functions orchestration | Small imports, simple data | | `STEP_FUNCTION` | Import is orchestrated by Step Functions for reliability | Large imports, complex workflows, ZIP imports | ### CreateCsvImportDto {#createcsvimportdto} The `CreateCsvImportDto` is used to start a CSV import job: ```typescript import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator' import { ProcessingMode } from '@mbc-cqrs-serverless/import' export class CreateCsvImportDto { @IsString() @IsOptional() sourceId?: string // Optional source identifier @IsNotEmpty() @IsEnum(ProcessingMode) processingMode: ProcessingMode // How the import should be processed @IsString() @IsNotEmpty() bucket: string // S3 bucket containing the CSV file @IsString() @IsNotEmpty() key: string // S3 key (path) to the CSV file @IsString() @IsNotEmpty() tableName: string // Target table name for import profile matching @IsString() @IsNotEmpty() tenantCode: string // Tenant code for multi-tenancy } ``` | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `sourceId` | `string` | No | Optional identifier for the import source | | `processingMode` | `ProcessingMode` | Yes | DIRECT or STEP_FUNCTION mode | | `bucket` | `string` | Yes | S3 bucket containing the CSV file | | `key` | `string` | Yes | S3 key (path) to the CSV file | | `tableName` | `string` | Yes | Target table name, used to match import profile | | `tenantCode` | `string` | Yes | Tenant code for multi-tenancy | ### CreateZipImportDto {#createzipimportdto} The `CreateZipImportDto` is used to start a ZIP import job that contains multiple CSV files: ```typescript import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator' export class CreateZipImportDto { @IsString() @IsNotEmpty() bucket: string // S3 bucket containing the ZIP file @IsString() @IsNotEmpty() key: string // S3 key (path) to the ZIP file @IsString() @IsNotEmpty() tenantCode: string // Tenant code for multi-tenancy // High priority: sortedFileKeys // If not provided, it will use the default sorting logic @IsArray() @IsOptional() sortedFileKeys?: string[] // Optional ordered list of file keys to process // High priority: tableName // If not provided, it will be extracted from the filename @IsString() @IsOptional() tableName?: string // Optional table name override } ``` | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `bucket` | `string` | Yes | S3 bucket containing the ZIP file | | `key` | `string` | Yes | S3 key (path) to the ZIP file | | `tenantCode` | `string` | Yes | Tenant code for multi-tenancy | | `sortedFileKeys` | `string[]` | No | Ordered list of file keys to process. If not provided, default sorting is used | | `tableName` | `string` | No | Table name override. If not provided, extracted from filename (format: yyyymmddhhMMss-\{tableName\}.csv) | ### Core Concepts The module operates on a two-phase architecture: 1. **Import Phase** (`IImportStrategy`): Transform raw data (from JSON or CSV) into a standardized DTO and validate it. 2. **Process Phase** (`IProcessStrategy`): Compare validated DTO with existing data and map it to a command payload for creation or update. ### Implementing Import Strategy The import strategy handles initial transformation and validation: ```typescript import { BadRequestException, Injectable } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { BaseImportStrategy, IImportStrategy } from '@mbc-cqrs-serverless/import'; import { PolicyCommandDto } from '../dto/policy-command.dto'; @Injectable() export class PolicyImportStrategy extends BaseImportStrategy, PolicyCommandDto> implements IImportStrategy, PolicyCommandDto> { async transform(input: Record): Promise { const attrSource = input.attributes && typeof input.attributes === 'object' ? input.attributes : input; const mappedObject = { pk: input.pk, sk: input.sk, attributes: { policyType: attrSource.policyType, applyDate: new Date(attrSource.applyDate).toISOString(), }, }; return plainToInstance(PolicyCommandDto, mappedObject); } } ``` ### ComparisonStatus Enum {#comparisonstatus-enum} The `ComparisonStatus` enum defines the result of comparing imported data with existing data: ```typescript export enum ComparisonStatus { EQUAL = 'EQUAL', // Data exists and is identical - no action needed NOT_EXIST = 'NOT_EXIST', // Data does not exist - create new record CHANGED = 'CHANGED', // Data exists but differs - update existing record } ``` | Value | Description | Action | |-----------|-----------------|------------| | `EQUAL` | Imported data matches existing data | Skip (no operation) | | `NOT_EXIST` | No existing data found | Create new record | | `CHANGED` | Existing data differs from imported data | Update existing record | ### ComparisonResult Interface {#comparisonresult-interface} The `ComparisonResult` interface wraps the comparison status with optional existing data. The generic type `TEntity` must extend `DataModel`: ```typescript import { DataModel } from '@mbc-cqrs-serverless/core' export interface ComparisonResult { status: ComparisonStatus; // The result of the comparison /** * If the status is 'CHANGED', this property holds the existing entity data * retrieved from the database. It is undefined otherwise. */ existingData?: TEntity; } ``` | Property | Type | Description | |--------------|----------|-----------------| | `status` | `ComparisonStatus` | The comparison result status | | `existingData` | `TEntity \| undefined` | The existing entity data, present when status is CHANGED | ### IProcessStrategy Interface {#iprocessstrategy-interface} The `IProcessStrategy` interface defines the contract for processing validated import data. Note that the generic type `TEntity` must extend `DataModel`: ```typescript import { CommandInputModel, CommandPartialInputModel, CommandService, DataModel, } from '@mbc-cqrs-serverless/core'; export interface IProcessStrategy { /** * Compare the validated DTO with existing data */ compare( importAttributes: TAttributesDto, tenantCode: string, ): Promise>; /** * Map the DTO to a command payload based on comparison status * Note: status excludes EQUAL since no mapping is needed for identical data * @returns CommandInputModel for create, CommandPartialInputModel for update */ map( status: Exclude, importAttributes: TAttributesDto, tenantCode: string, existingData?: TEntity, ): Promise; /** * Get the command service for publishing commands */ getCommandService(): CommandService; } ``` ### BaseProcessStrategy Abstract Class {#baseprocessstrategy-class} The `BaseProcessStrategy` abstract class provides a base implementation that subclasses must extend. The generic type `TEntity` must extend `DataModel`: ```typescript import { DataModel } from '@mbc-cqrs-serverless/core'; export abstract class BaseProcessStrategy implements IProcessStrategy { /** * Abstract method - must be implemented to compare data */ abstract compare( transformedData: TTransformedDto, tenantCode: string, ): Promise>; /** * Abstract method - must be implemented to map data to command payload * Note: status excludes EQUAL since no mapping is needed for identical data */ abstract map( status: Exclude, transformedData: TTransformedDto, tenantCode: string, existingData?: TEntity, ): Promise; /** * Abstract method - must be implemented to return the command service */ abstract getCommandService(): CommandService; } ``` :::info Note All three methods (`compare()`, `map()`, `getCommandService()`) in `BaseProcessStrategy` are abstract and must be implemented by subclasses. ::: ### Implementing Process Strategy The process strategy contains core business logic for comparing and mapping data: ```typescript import { Injectable } from '@nestjs/common'; import { CommandInputModel, CommandPartialInputModel, CommandService, DataService, } from '@mbc-cqrs-serverless/core'; import { BaseProcessStrategy, ComparisonResult, ComparisonStatus, IProcessStrategy, } from '@mbc-cqrs-serverless/import'; import { PolicyCommandDto } from '../dto/policy-command.dto'; import { PolicyDataEntity } from '../entity/policy-data.entity'; @Injectable() export class PolicyProcessStrategy extends BaseProcessStrategy implements IProcessStrategy { constructor( private readonly commandService: CommandService, private readonly dataService: DataService, ) { super(); } getCommandService(): CommandService { return this.commandService; } async compare( dto: PolicyCommandDto, tenantCode: string, ): Promise> { const existing = await this.dataService.getItem({ pk: dto.pk, sk: dto.sk }); if (!existing) return { status: ComparisonStatus.NOT_EXIST }; return { status: ComparisonStatus.CHANGED, existingData: existing as PolicyDataEntity }; } async map( status: Exclude, dto: PolicyCommandDto, tenantCode: string, existingData?: PolicyDataEntity, ): Promise { if (status === ComparisonStatus.NOT_EXIST) { // Return CommandInputModel for creating new records return { ...dto, version: 0 } as CommandInputModel; } // status === ComparisonStatus.CHANGED // Return CommandPartialInputModel for updating existing records return { pk: dto.pk, sk: dto.sk, attributes: dto.attributes, version: existingData.version, } as CommandPartialInputModel; } } ``` ### Module Configuration Register the ImportModule with your profiles: ```typescript import { Module } from '@nestjs/common'; import { ImportModule } from '@mbc-cqrs-serverless/import'; import { PolicyModule } from './policy/policy.module'; import { PolicyImportStrategy } from './policy/strategies/policy.import-strategy'; import { PolicyProcessStrategy } from './policy/strategies/policy.process-strategy'; @Module({ imports: [ PolicyModule, ImportModule.register({ enableController: true, imports: [PolicyModule], profiles: [ { tableName: 'policy', importStrategy: PolicyImportStrategy, processStrategy: PolicyProcessStrategy, }, ], }), ], }) export class AppModule {} ``` ### Custom Event Factory for Imports Configure the event factory to handle import events: ```typescript import { EventFactory, IEvent, StepFunctionsEvent, } from '@mbc-cqrs-serverless/core'; import { CsvImportSfnEvent, DEFAULT_IMPORT_ACTION_QUEUE, ImportEvent, ImportQueueEvent, } from '@mbc-cqrs-serverless/import'; import { EventFactoryAddedTask } from '@mbc-cqrs-serverless/task'; import { DynamoDBStreamEvent, SQSEvent } from 'aws-lambda'; @EventFactory() export class CustomEventFactory extends EventFactoryAddedTask { async transformDynamodbStream(event: DynamoDBStreamEvent): Promise { const curEvents = await super.transformDynamodbStream(event); const importEvents = event.Records.map((record) => { if ( record.eventSourceARN.endsWith('import_tmp') || record.eventSourceARN.includes('import_tmp/stream/') ) { if (record.eventName === 'INSERT') { return new ImportEvent().fromDynamoDBRecord(record); } } return undefined; }).filter((event) => !!event); return [...curEvents, ...importEvents]; } async transformSqs(event: SQSEvent): Promise { const curEvents = await super.transformSqs(event); const importEvents = event.Records.map((record) => { if (record.eventSourceARN.endsWith(DEFAULT_IMPORT_ACTION_QUEUE)) { return new ImportQueueEvent().fromSqsRecord(record); } return undefined; }).filter((event) => !!event); return [...importEvents, ...curEvents]; } async transformStepFunction(event: StepFunctionsEvent): Promise { if (event.context.StateMachine.Name.includes('import-csv')) { return [new CsvImportSfnEvent(event)]; } return super.transformStepFunction(event); } } ``` ### ImportStatusHandler API {#importstatushandler-api} The `ImportStatusHandler` is an internal event handler that manages Step Functions callbacks for import jobs. When using Step Functions orchestration (ZIP imports or STEP_FUNCTION mode CSV imports), this handler ensures proper communication with the state machine. #### Behavior | Import Status | Action | Step Functions Command | |-------------------|------------|---------------------------| | `COMPLETED` | Send success callback | `SendTaskSuccessCommand` | | `FAILED` | Send failure callback | `SendTaskFailureCommand` | | Other statuses | Ignored | None | #### Methods | Method | Description | |------------|-----------------| | `sendTaskSuccess(taskToken, output)` | Sends success signal to Step Functions with the import result | | `sendTaskFailure(taskToken, error, cause)` | Sends failure signal to Step Functions with error details | #### Step Functions Integration When an import job is created as part of a Step Functions workflow (e.g., ZIP import), a `taskToken` is stored in the job's attributes. The `ImportStatusHandler` listens for status change notifications and: 1. Retrieves the import job from DynamoDB 2. Checks if a `taskToken` exists in the job's attributes 3. Sends the appropriate callback based on the final status: - `COMPLETED` → `SendTaskSuccessCommand` with result data - `FAILED` → `SendTaskFailureCommand` with error details This ensures Step Functions workflows properly handle both success and failure cases without hanging indefinitely. :::info Version Note The `sendTaskFailure()` method was added in [version 1.0.18](/docs/changelog#v1018) to fix an issue where Step Functions would wait indefinitely when import jobs failed. See also [Import Module Errors](/docs/error-catalog#import-module-errors) for troubleshooting. ::: ### ImportQueueEventHandler Error Handling {#import-error-handling} The `ImportQueueEventHandler` processes individual import records from the SQS queue. When an error occurs during processing (e.g., `ConditionalCheckFailedException`), the handler properly updates the parent job status. #### Error Flow (v1.0.19+) ```text Child Job Error Occurs │ ▼ Mark Child Job as FAILED │ ▼ Update Parent Job Counters (incrementParentJobCounters) │ ▼ Check if All Children Complete │ ┌────┴────┐ │ Yes │ No ▼ ▼ Update Master Wait for Job Status more children │ ┌────┴────┐ │ Has │ All │ Failures│ Succeeded ▼ ▼ FAILED COMPLETED │ ▼ ImportStatusHandler Triggered │ ▼ SendTaskFailure/SendTaskSuccess ``` #### Key Methods | Method | Description | |------------|-----------------| | `handleImport(event)` | Orchestrates single import record processing with error handling | | `executeStrategy(...)` | Executes compare, map, and save lifecycle for a strategy | #### Error Handling Behavior When a child import job fails: 1. Child job status is set to `FAILED` with error details 2. Parent job counters are atomically updated (`failedRows` incremented) 3. When all children complete, master job status is set based on results: - If `failedRows > 0` → Master status = `FAILED` - If all succeeded → Master status = `COMPLETED` 4. Lambda does NOT crash - error is handled gracefully :::warning Common Errors `ConditionalCheckFailedException`: This occurs when attempting to import data that already exists with conflicting version. The import job will be marked as FAILED and the parent job will properly aggregate this failure. ::: :::info Version Note Prior to v1.0.19, errors in child jobs would crash the Lambda and leave the master job in `PROCESSING` status indefinitely. The fixes in [version 1.0.19](/docs/changelog#v1019) ensure proper error propagation and status updates. ::: ### CsvImportSfnEventHandler {#csvimportsfneventhandler} The `CsvImportSfnEventHandler` handles Step Functions CSV import workflow states. It manages the `csv_loader` and `finalize_parent_job` states in the import state machine. #### Key Methods | Method | Description | |------------|-----------------| | `handleStepState(event)` | Routes events to appropriate handlers based on state name (`csv_loader` or `finalize_parent_job`) | | `loadCsv(input)` | Processes the csv_loader state, creates child jobs for CSV rows | | `finalizeParentJob(event)` | Finalizes the parent job after all children complete, sets final status | | `countCsvRows(input)` | Counts total data rows in a CSV file from S3 (excluding header) | #### V2 Batch Processing Architecture (v1.1.5+) {#v2-batch-processing} :::info Version Note The v2 batch processing architecture was introduced in [version 1.1.5](/docs/changelog#v115) to dramatically improve throughput for large-scale imports (e.g. 300,000+ rows). ::: In v1.1.5, the CSV import pipeline was redesigned to eliminate Hot Partition bottlenecks: | Aspect | v1 (≤ 1.1.4) | v2 (1.1.5+) | |------------|------------------|-----------------| | Batch size | `MaxInputBytesPerBatch: 10` | `MaxItemsPerBatch: 100` | | Row processing | Write to `import_tmp` → SQS → Step Functions per row | Direct `CommandService` publish inside Lambda batch | | Job finalization | Atomic counter update per row via `CommandFinishedHandler` | Single `UpdateItem` in `finalize_parent_job` state | | Progress tracking | Real-time per-row | Aggregated at completion | ##### V2 Breaking Changes {#v2-batch-processing-breaking-changes} :::danger Breaking Change (v1.1.5) The following changes require infrastructure updates when upgrading to v1.1.5: 1. **No real-time progress tracking**: `processedRows`, `succeededRows`, `failedRows` counters are updated only once when the Step Functions execution completes. The job stays in `PROCESSING` until it transitions directly to `COMPLETED` or `FAILED`. 2. **`import_tmp` table bypassed**: Individual CSV rows are no longer written to the `import_tmp` DynamoDB table. 3. **State machine update required**: The `import-csv` state machine must include a `finalize_parent_job` state and `resultPath: '$.processingResults'`. Update CDK (`infra-stack.ts`) and `serverless.yml` together with this package. ::: ##### Configuring Publish Mode {#import-publish-mode} v1.1.5 introduces `ImportPublishMode` to control how commands are published per entity: ```typescript // Register import module with per-entity publish mode ImportModule.register({ profiles: [ { tableName: 'building', importStrategy: BuildingImportStrategy, processStrategy: BuildingProcessStrategy, publishMode: ImportPublishMode.ASYNC, // Default: non-blocking (recommended for large imports) }, { tableName: 'room', importStrategy: RoomImportStrategy, processStrategy: RoomProcessStrategy, publishMode: ImportPublishMode.SYNC, // Blocking: use only for small batches }, ], }), ``` :::warning `ImportPublishMode.SYNC` executes `publishSync` sequentially per row inside the Lambda batch. With `MaxItemsPerBatch: 100`, this can exceed the Lambda 15-minute timeout for slow operations. Prefer `ASYNC` for large imports. ::: #### Total Row Counting (v1.1.3+) {#csv-total-row-counting} In v1.1.3 and v1.1.4, `finalizeParentJob` counted total rows by reading the CSV from S3 using `countCsvRows()`. In v1.1.5+, total row counts are derived from batch summaries aggregated natively by Step Functions — no S3 re-read is needed. :::info Version Note In v1.1.3, the total row counting was changed from `MapResult.length` to `countCsvRows()` to avoid the AWS Step Functions 256KB state data limit. The Distributed Map `resultPath` is now set to `DISCARD`, preventing child execution results from being aggregated. See [version 1.1.3](/docs/changelog#v113). ::: #### Status Determination (v1.0.20+) When finalizing the parent job, the handler correctly determines the final status: ```typescript // Correct behavior (v1.0.20+) const status = failedRows > 0 ? ImportJobStatus.FAILED // Any child failed → FAILED : ImportJobStatus.COMPLETED // All children succeeded → COMPLETED ``` :::warning Known Issue (Fixed in v1.0.20) Prior to v1.0.20, a bug in the ternary operator caused the status to always be `COMPLETED`: ```typescript // Bug (pre-v1.0.20): always returned COMPLETED const status = failedRows > 0 ? ImportJobStatus.COMPLETED // Wrong! : ImportJobStatus.COMPLETED ``` This caused Step Functions to report SUCCESS even when child import jobs failed. See [version 1.0.20](/docs/changelog#v1020) for details. ::: ### ZipImportSfnEventHandler {#zipimportsfneventhandler} The `ZipImportSfnEventHandler` handles Step Functions ZIP import workflow states. It orchestrates the processing of multiple CSV files extracted from a ZIP archive. #### Workflow States | State | Description | |-----------|-----------------| | `trigger_single_csv_and_wait` | Triggers a single CSV import job for each file in the ZIP | | `finalize_zip_job` | Aggregates results from all CSV imports and finalizes the master job | #### Key Methods | Method | Description | |------------|-----------------| | `triggerSingleCsvJob(event)` | Creates a CSV import job with STEP_FUNCTION mode, passing the taskToken for callback | | `finalizeZipMasterJob(event)` | Aggregates results from all processed CSV files and updates the ZIP master job status | :::warning Known Issue The `finalizeZipMasterJob` method currently always sets the master job status to `COMPLETED`, regardless of whether any child CSV import jobs failed. This means ZIP import workflows will report success even when individual CSV files failed to import correctly. To work around this, check the `failedRows` count in the result object to determine if any errors occurred during processing. ::: #### File Naming Convention When processing CSV files from a ZIP archive, the handler extracts the table name from the filename: ```text Format: yyyymmddhhMMss-\{tableName\}.csv Example: 20240115120000-products.csv → extracts tableName = "products" ``` If `tableName` is provided in the `CreateZipImportDto`, it overrides the extracted name. #### Processing Flow ```text ZIP File Uploaded to S3 │ ▼ Step Functions Triggered │ ▼ Unzip and List CSV Files │ ▼ Map State: For Each CSV File ┌────┴────┐ │ │ ▼ ▼ trigger_single_csv_and_wait │ │ ▼ ▼ CSV Import Job Created CSV Import Job Created (with taskToken) (with taskToken) │ │ ▼ ▼ Wait for Completion Wait for Completion │ │ └────┬────┘ │ ▼ finalize_zip_job │ ▼ Aggregate Results & Update Master Job ``` #### ZipImportSfnEvent Structure ```typescript export class ZipImportSfnEvent implements IEvent { source: string // Execution ID from Step Functions context: StepFunctionsContext // Step Functions context with state info input: string | any[] // S3 key or array of results taskToken: string // Token for callback to Step Functions } ``` The `context.Execution.Input` contains: - `masterJobKey`: Primary key of the ZIP master job in DynamoDB - `parameters`: Original import parameters (bucket, tenantCode, tableName) --- ## Related Documentation - [Backend Development Guide](/docs/backend-development) - Core backend patterns - [Service Patterns](/docs/service-patterns) - Service implementation - [Import Module](/docs/import) - ImportModule API reference and strategy interfaces - [Data Migration Patterns](/docs/data-migration-patterns) - Migrating data between schema versions - [Step Functions](/docs/architecture/step-functions) - Workflow orchestration - [Directory](/docs/directory) - S3 file and folder management with presigned URLs - [Data Sync Handler Examples](/docs/data-sync-handler-examples) - Sync handler patterns --- ## Modules URL: https://mbc-cqrs-serverless.mbc-net.com/docs/modules # Modules ## Overview {#overview} A module is a class annotated with a `@Module()` decorator. The `@Module()` decorator provides metadata that organizes the application structure. Modules encapsulate related functionality and follow the NestJS module pattern. ```mermaid graph TB subgraph "Your Application" A["AppModule"] A --> B["CatModule"] A --> C["OrderModule"] A --> D["UserModule"] end subgraph "Framework Modules" E["CommandModule"] F["SequencesModule"] G["TenantModule"] end B --> E C --> E C --> F D --> G ``` ## Module Structure {#module-structure} A typical module in MBC CQRS Serverless includes: ```typescript import { Module } from '@nestjs/common'; import { CommandModule } from '@mbc-cqrs-serverless/core'; import { CatController } from './cat.controller'; import { CatService } from './cat.service'; import { CatDataSyncRdsHandler } from './handler/cat-rds.handler'; @Module({ imports: [ CommandModule.register({ tableName: 'cat', dataSyncHandlers: [CatDataSyncRdsHandler], }), ], controllers: [CatController], providers: [CatService], exports: [CatService], }) export class CatModule {} ``` ## Module Components {#module-components} | Component | Description | |-----------|-------------| | `imports` | List of imported modules that export providers used in this module | | `controllers` | Controllers that handle HTTP requests | | `providers` | Services and other providers available for injection | | `exports` | Providers that should be available in modules that import this module | ## Framework Modules {#framework-modules} MBC CQRS Serverless provides several ready-to-use modules: ### Core Modules | Module | Package | Purpose | |--------|---------|---------| | `CommandModule` | `@mbc-cqrs-serverless/core` | CQRS command handling and data sync | | `SequencesModule` | `@mbc-cqrs-serverless/sequence` | Sequential ID generation | | `TenantModule` | `@mbc-cqrs-serverless/tenant` | Multi-tenant management | ### Feature Modules | Module | Package | Purpose | |--------|---------|---------| | `TaskModule` | `@mbc-cqrs-serverless/task` | Async task execution with Step Functions | | `MasterModule` | `@mbc-cqrs-serverless/master` | Master data and settings management | | `ImportModule` | `@mbc-cqrs-serverless/import` | CSV/API data import | | `DirectoryStorageModule` | `@mbc-cqrs-serverless/directory` | File and folder management with S3 | | `SurveyTemplateModule` | `@mbc-cqrs-serverless/survey-template` | Survey template management | ### Support Modules | Module | Package | Purpose | |--------|---------|---------| | `QueueModule` | `@mbc-cqrs-serverless/core` | SNS publish and SQS send/receive messaging | | `NotificationModule` | `@mbc-cqrs-serverless/core` | Email via SES and real-time WebSocket notifications via AppSync | | `SettingModule` | `@mbc-cqrs-serverless/ui-setting` | User interface settings storage | ## Dynamic Module Registration {#dynamic-registration} Most framework modules are dynamic modules that accept configuration: ### CommandModule ```typescript CommandModule.register({ tableName: 'cat', dataSyncHandlers: [CatDataSyncRdsHandler], skipError: false, disableDefaultHandler: false, }) ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `tableName` | `string` | Required | DynamoDB table name (without postfix) | | `dataSyncHandlers` | `Type[]` | `[]` | Data sync handler classes | | `skipError` | `boolean` | `false` | Reserved for future use (not yet implemented) | | `disableDefaultHandler` | `boolean` | `false` | Disable default DynamoDB data sync handler | ### SequencesModule ```typescript SequencesModule.register({ enableController: true, // Enable built-in sequence REST endpoints }) ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `enableController` | `boolean` | `false` | Enable the built-in sequence generation endpoints | ### TenantModule The TenantModule provides multi-tenant management. It can expose REST endpoints for creating and updating tenants and their group configurations. ```typescript import { TenantModule } from '@mbc-cqrs-serverless/tenant'; TenantModule.register({ enableController: true, // Enable built-in tenant REST endpoints dataSyncHandlers: [TenantRdsSyncHandler], // Optional: Data sync handlers for RDS synchronization }) ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `enableController` | `boolean` | `false` | Enable the built-in TenantController endpoints | | `dataSyncHandlers` | `Type[]` | `[]` | Data sync handler classes | See [Tenant](/docs/tenant) for the full API reference. ### MasterModule ```typescript MasterModule.register({ enableController: true, prismaService: PrismaService, }) ``` :::warning MasterModule Configuration Note When `enableController: true`, the `prismaService` parameter is **required**. You must provide your application's PrismaService class. The framework will throw an error if `prismaService` is not provided when controllers are enabled. ::: ### TaskModule TaskModule handles asynchronous task execution using AWS Step Functions. It requires a custom event factory that implements `ITaskQueueEventFactory`. ```typescript import { TaskModule } from '@mbc-cqrs-serverless/task'; import { MyTaskQueueEventFactory } from './my-task-queue-event.factory'; TaskModule.register({ taskQueueEventFactory: MyTaskQueueEventFactory, enableController: true, }) ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `taskQueueEventFactory` | `Type` | Required | Factory class for transforming task queue events | | `enableController` | `boolean` | `false` | Enable built-in task REST endpoints | The `taskQueueEventFactory` must implement the `ITaskQueueEventFactory` interface. Both methods are optional - implement only what you need: ```typescript import { ITaskQueueEventFactory, TaskQueueEvent, StepFunctionTaskEvent } from '@mbc-cqrs-serverless/task'; import { IEvent } from '@mbc-cqrs-serverless/core'; import { MyTaskEvent } from './my-task.event'; import { MyStepFunctionTaskEvent } from './my-sfn-task.event'; export class MyTaskQueueEventFactory implements ITaskQueueEventFactory { // Optional: Transform SQS task queue events into domain events async transformTask(event: TaskQueueEvent): Promise { // Create domain-specific events from task queue events return [new MyTaskEvent().fromSqsRecord(event)]; } // Optional: Transform Step Function task events into domain events async transformStepFunctionTask(event: StepFunctionTaskEvent): Promise { // Check taskKey.sk to determine which event type to create if (event.taskKey.sk.startsWith('MY_TASK')) { return [new MyStepFunctionTaskEvent(event)]; } return []; } } ``` ### ImportModule The ImportModule provides CSV and API data import functionality. It requires defining import profiles that specify how data should be imported and processed for each entity type. ```typescript import { ImportModule } from '@mbc-cqrs-serverless/import'; import { PolicyImportStrategy } from './strategies/policy-import.strategy'; import { PolicyProcessStrategy } from './strategies/policy-process.strategy'; import { PolicyModule } from './policy.module'; @Module({ imports: [ ImportModule.register({ profiles: [ { tableName: 'policy', importStrategy: PolicyImportStrategy, processStrategy: PolicyProcessStrategy, }, ], imports: [PolicyModule], // Modules that export providers needed by strategies enableController: true, }), ], }) export class AppModule {} ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `profiles` | `ImportEntityProfile[]` | Required | Array of import profiles for each entity type | | `imports` | `ModuleMetadata['imports']` | `[]` | Modules that export providers needed by strategy classes | | `enableController` | `boolean` | `false` | Enable built-in `/imports`, `/imports/csv`, and `/imports/zip` endpoints | Each `ImportEntityProfile` requires: | Property | Type | Description | |----------|------|-------------| | `tableName` | `string` | Unique identifier for the data type (e.g., 'policy', 'user') | | `importStrategy` | `Type` | Class implementing import logic (transform & validate) | | `processStrategy` | `Type` | Class implementing business processing logic (compare & map) | ### DirectoryStorageModule The DirectoryStorageModule provides S3-backed file and folder management with permissions, version history, and presigned URL generation. ```typescript import { DirectoryStorageModule } from '@mbc-cqrs-serverless/directory'; import { PrismaService } from './prisma.service'; DirectoryStorageModule.register({ enableController: true, prismaService: PrismaService, dataSyncHandlers: [], }) ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `enableController` | `boolean` | `false` | Enable built-in directory REST endpoints | | `prismaService` | `Type` | Required when enableController is true | Application PrismaService class | | `dataSyncHandlers` | `Type[]` | `[]` | Data sync handler classes | ### SurveyTemplateModule The SurveyTemplateModule provides survey template management with support for multiple question types including text, radio, checkbox, and rating questions. ```typescript import { SurveyTemplateModule } from '@mbc-cqrs-serverless/survey-template'; import { PrismaService } from './prisma.service'; SurveyTemplateModule.register({ enableController: true, prismaService: PrismaService, }) ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `enableController` | `boolean` | `false` | Enable built-in survey template REST endpoints | | `prismaService` | `Type` | Required when enableController is true | Application PrismaService class | ### SettingModule The SettingModule manages user interface settings. It can optionally expose REST endpoints for managing settings. ```typescript import { SettingModule } from '@mbc-cqrs-serverless/ui-setting'; @Module({ imports: [ SettingModule.register({ enableSettingController: true, enableDataController: true, }), ], }) export class AppModule {} ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `enableSettingController` | `boolean` | `false` | Enable the setting controller for UI settings management | | `enableDataController` | `boolean` | `false` | Enable the data setting controller for data-related settings | ### NotificationModule (Static) The NotificationModule is a static (not dynamic) module that provides email notifications via SES and real-time updates via AppSync. It is automatically registered as a global module, so you only need to import it once in your AppModule. ```typescript import { NotificationModule } from '@mbc-cqrs-serverless/core'; @Module({ imports: [ NotificationModule, // No configuration needed - static module ], }) export class AppModule {} ``` This module exports: - `EmailService` - Send emails via Amazon SES - `AppSyncService` - Send real-time notifications via AppSync (GraphQL Subscriptions) - `AppSyncEventsService` - Send real-time notifications via AppSync Events API (opt-in, added in v1.3.0) → [Notification Module](/docs/notification-module#appsync-events-service) ## Creating Custom Modules {#custom-modules} ### Step 1: Create Module File ```typescript // src/order/order.module.ts import { Module } from '@nestjs/common'; import { CommandModule } from '@mbc-cqrs-serverless/core'; import { SequencesModule } from '@mbc-cqrs-serverless/sequence'; import { OrderController } from './order.controller'; import { OrderService } from './order.service'; import { OrderDataSyncHandler } from './handlers/order-data-sync.handler'; @Module({ imports: [ CommandModule.register({ tableName: 'order', dataSyncHandlers: [OrderDataSyncHandler], }), SequencesModule.register({ enableController: false, }), ], controllers: [OrderController], providers: [OrderService], exports: [OrderService], }) export class OrderModule {} ``` ### Step 2: Register in AppModule ```typescript // src/app.module.ts import { Module } from '@nestjs/common'; import { OrderModule } from './order/order.module'; @Module({ imports: [OrderModule], }) export class AppModule {} ``` ## Best Practices {#best-practices} 1. **One module per entity**: Create a dedicated module for each business entity 2. **Export services, not controllers**: Only export providers that other modules need 3. **Use forRoot for global modules**: Register global configuration once in AppModule 4. **Keep modules focused**: Each module should have a single responsibility ## Related Documentation - [NestJS Modules](https://docs.nestjs.com/modules): Official NestJS module documentation - [CommandService](/docs/command-service): Detailed CommandModule configuration - [API Reference](/docs/api-reference): Full module API documentation - [Queue Module](/docs/queue): QueueModule — SNS and SQS messaging - [Tenant Module](/docs/tenant): TenantModule — multi-tenant management - [Sequence](/docs/sequence): SequencesModule — sequential ID generation - [Tasks](/docs/tasks): TaskModule — async Step Functions execution - [Master](/docs/master): MasterModule — master data and settings - [Import Module](/docs/import): ImportModule — CSV data import - [Event Handling Patterns](/docs/event-handling-patterns): Creating data sync handlers - [Notification Module](/docs/notification-module): Real-time notifications and email via AppSync and SES - [Directory](/docs/directory): File and folder management with DirectoryStorageModule - [Survey Template](/docs/survey-template): Survey template management with SurveyTemplateModule - [UI Setting](/docs/ui-setting): SettingModule for UI configuration storage --- ## Multi-Tenant Patterns URL: https://mbc-cqrs-serverless.mbc-net.com/docs/multi-tenant-patterns # Multi-Tenant Patterns This guide covers patterns for implementing multi-tenant applications with proper data isolation, shared resources, and cross-tenant operations. ## When to Use This Guide {#when-to-use} Use this guide when you need to: - Isolate data between tenants (customers/organizations) - Share common data across all tenants - Allow users to belong to multiple tenants - Implement tenant-specific configuration - Sync data between tenants ## Multi-Tenant Architecture {#multi-tenant-architecture} ```text ┌─────────────────────────────────────────────────────────────────┐ │ Application │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Tenant A │ │ Tenant B │ │ Common │ │ │ │ PK: X#A │ │ PK: X#B │ │ PK: X#common│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ DynamoDB Table │ │ │ │ PK: ENTITY#tenantCode | SK: identifier │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ## Tenant Context {#tenant-context} :::info Tenant Code Normalization All tenant codes returned by `getUserContext()` are normalized to lowercase. This means `TenantA`, `TENANTA`, and `tenanta` are all treated as `tenanta`. When defining tenant codes in Cognito custom claims or HTTP headers, the case doesn't matter - internally they are always lowercase for consistent matching. ::: ### Extracting Tenant Context Create a helper to extract tenant information from invoke context: ```typescript // helpers/context.ts import { IInvoke, getUserContext, getAuthorizerClaims } from '@mbc-cqrs-serverless/core'; export interface CustomUserContext { tenantCode: string; userCode: string; userId: string; email?: string; role?: string; } /** * Get custom user context from invoke context */ export function getCustomUserContext(invokeContext: IInvoke): CustomUserContext { const userContext = getUserContext(invokeContext); const claims = getAuthorizerClaims(invokeContext); return { tenantCode: userContext.tenantCode || DEFAULT_TENANT_CODE, userCode: claims['custom:userCode'] || userContext.userId || '', userId: userContext.userId || '', email: claims.email, role: claims['custom:role'], }; } /** * Tenant code for shared/common data across all tenants * Use this for master data, settings, and resources shared by all tenants */ export const TENANT_COMMON = 'common'; /** * Default tenant code when no tenant is specified * Used in single-tenant mode or when tenant context is not available */ export const DEFAULT_TENANT_CODE = 'single'; ``` :::info Consistent Tenant Code in Master and Tenant Modules The `@mbc-cqrs-serverless/master` and `@mbc-cqrs-serverless/tenant` packages use `SettingTypeEnum.TENANT_COMMON = 'common'` (lowercase), consistent with `getUserContext()` normalization. When using built-in methods (e.g., `createCommonTenantSetting`, `createCommonTenant`), data is stored under the `'common'` tenant code, which can be queried using the normalized lowercase tenant code. ```typescript // In @mbc-cqrs-serverless/master and @mbc-cqrs-serverless/tenant export enum SettingTypeEnum { TENANT_COMMON = 'common', // Common tenant code (lowercase) } ``` ::: ```typescript /** * Check if user has access to tenant */ export function hasTenantAccess( userContext: CustomUserContext, targetTenantCode: string, ): boolean { // System admin can access all tenants if (userContext.role === 'SYSTEM_ADMIN') { return true; } // User can only access their own tenant return userContext.tenantCode === targetTenantCode; } ``` ### Tenant Guard Implement a guard to enforce tenant access: ```typescript // guards/tenant.guard.ts import { Injectable, CanActivate, ExecutionContext, ForbiddenException, } from '@nestjs/common'; import { getCustomUserContext, hasTenantAccess } from '../helpers/context'; @Injectable() export class TenantGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const invokeContext = request.invokeContext; const userContext = getCustomUserContext(invokeContext); // Get target tenant from path or body const targetTenant = request.params.tenantCode || request.body?.tenantCode || this.extractTenantFromPk(request.body?.pk); if (!targetTenant) { return true; // No tenant specified, will use user's tenant } if (!hasTenantAccess(userContext, targetTenant)) { throw new ForbiddenException( `Access denied to tenant: ${targetTenant}`, ); } return true; } private extractTenantFromPk(pk: string | undefined): string | undefined { if (!pk) return undefined; const parts = pk.split('#'); return parts.length >= 2 ? parts[1] : undefined; } } ``` ## Data Isolation Patterns {#data-isolation} ### Pattern 1: Tenant in Partition Key Include tenant code in the partition key for complete isolation: ```typescript // Standard tenant isolation pattern const PRODUCT_PK_PREFIX = 'PRODUCT'; function generateProductPk(tenantCode: string): string { return `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`; } // Example keys: // PK: PRODUCT#tenant-a // SK: 01HX7MBJK3V9WQBZ7XNDK5ZT2M // Query all products for a tenant async function listProductsByTenant(tenantCode: string) { const pk = generateProductPk(tenantCode); return dataService.listItemsByPk(pk); } ``` ### Pattern 2: Common Tenant for Shared Data Use a common tenant code for data shared across all tenants: ```typescript // Shared data pattern for master data and configurations const COMMON_TENANT = 'common'; // System-wide settings const settingsPk = `SETTINGS${KEY_SEPARATOR}${COMMON_TENANT}`; // User data (users can belong to multiple tenants) const userPk = `USER${KEY_SEPARATOR}${COMMON_TENANT}`; // Example: Get system-wide email templates async function getEmailTemplates() { return dataService.listItemsByPk(`TEMPLATE${KEY_SEPARATOR}${COMMON_TENANT}`); } ``` ### Pattern 3: User-Tenant Association Handle users belonging to multiple tenants: ```typescript // user/dto/user-tenant.dto.ts export interface UserTenantAssociation { pk: string; // USER_TENANT#common sk: string; // {tenantCode}#{userCode} tenantCode: string; // Owner tenant (COMMON_TENANT) attributes: { userCode: string; tenantCode: string; // Associated tenant code role: string; // Role within this tenant isDefault: boolean; // Default tenant for user }; } // user/user.service.ts import { Injectable, ForbiddenException } from '@nestjs/common'; import { CommandService, DataService, IInvoke, KEY_SEPARATOR, generateId, VERSION_FIRST } from '@mbc-cqrs-serverless/core'; import { AuthService } from '../auth/auth.service'; import { UserTenantAssociation } from './dto/user-tenant.dto'; const COMMON_TENANT = 'common'; @Injectable() export class UserService { constructor( private readonly commandService: CommandService, private readonly dataService: DataService, private readonly authService: AuthService, ) {} /** * Get all tenants a user belongs to */ async getUserTenants(userCode: string): Promise { const pk = `USER_TENANT${KEY_SEPARATOR}${COMMON_TENANT}`; // List all items under the PK, then filter by user code const result = await this.dataService.listItemsByPk(pk); return result.items.filter(item => item.sk.endsWith(`${KEY_SEPARATOR}${userCode}`), ); } /** * Add user to tenant */ async addUserToTenant( userCode: string, tenantCode: string, role: string, invokeContext: IInvoke, ): Promise { const pk = `USER_TENANT${KEY_SEPARATOR}${COMMON_TENANT}`; const sk = `${tenantCode}${KEY_SEPARATOR}${userCode}`; await this.commandService.publishSync({ pk, sk, id: generateId(pk, sk), tenantCode: COMMON_TENANT, code: `${tenantCode}-${userCode}`, name: `User ${userCode} in ${tenantCode}`, type: 'USER_TENANT', version: VERSION_FIRST, attributes: { userCode, tenantCode, role, isDefault: false, }, }, { invokeContext }); } /** * Switch user's active tenant */ async switchTenant( userCode: string, newTenantCode: string, invokeContext: IInvoke, ): Promise<{ token: string }> { // Verify user belongs to tenant const associations = await this.getUserTenants(userCode); const association = associations.find(a => a.attributes.tenantCode === newTenantCode, ); if (!association) { throw new ForbiddenException( `User does not belong to tenant: ${newTenantCode}`, ); } // Generate new token with updated tenant context return this.authService.generateToken({ userCode, tenantCode: newTenantCode, role: association.attributes.role, }); } } ``` ## Cross-Tenant Operations {#cross-tenant-operations} ### Pattern 1: Data Sync Between Tenants Sync data from one tenant to another (e.g., master data distribution): ```typescript // sync/tenant-sync.service.ts import { Injectable, Logger } from '@nestjs/common'; import { CommandService, DataService, IInvoke, KEY_SEPARATOR, generateId, VERSION_FIRST } from '@mbc-cqrs-serverless/core'; @Injectable() export class TenantSyncService { private readonly logger = new Logger(TenantSyncService.name); constructor( private readonly commandService: CommandService, private readonly dataService: DataService, ) {} /** * Sync master data from source to target tenants */ async syncMasterData( sourceTenantCode: string, targetTenantCodes: string[], entityType: string, invokeContext: IInvoke, ): Promise<{ synced: number; errors: string[] }> { const sourcePk = `${entityType}${KEY_SEPARATOR}${sourceTenantCode}`; const sourceData = await this.dataService.listItemsByPk(sourcePk); let synced = 0; const errors: string[] = []; for (const targetTenant of targetTenantCodes) { for (const item of sourceData.items) { try { await this.syncItem(item, targetTenant, invokeContext); synced++; } catch (error) { errors.push(`Failed to sync ${item.id} to ${targetTenant}: ${error.message}`); } } } this.logger.log(`Synced ${synced} items to ${targetTenantCodes.length} tenants`); return { synced, errors }; } private async syncItem( sourceItem: any, targetTenantCode: string, invokeContext: IInvoke, ): Promise { // Create new keys for target tenant const pkParts = sourceItem.pk.split(KEY_SEPARATOR); const entityType = pkParts[0]; const targetPk = `${entityType}${KEY_SEPARATOR}${targetTenantCode}`; const targetId = generateId(targetPk, sourceItem.sk); await this.commandService.publishSync({ pk: targetPk, sk: sourceItem.sk, id: targetId, tenantCode: targetTenantCode, code: sourceItem.code, name: sourceItem.name, type: sourceItem.type, version: VERSION_FIRST, attributes: { ...sourceItem.attributes, // Mark as synced from source syncedFrom: sourceItem.id, }, }, { invokeContext }); } } ``` ### Pattern 2: Cross-Tenant Reporting Aggregate data across tenants for reporting: ```typescript // reporting/cross-tenant-report.service.ts import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma'; interface SystemMetrics { totalProducts: number; productsByTenant: Array<{ tenantCode: string; count: number }>; recentOrdersCount: number; } interface TenantMetrics { tenantCode: string; products: number; orders: number; users: number; } @Injectable() export class CrossTenantReportService { constructor(private readonly prismaService: PrismaService) {} /** * Get aggregated metrics across all tenants */ async getSystemMetrics(): Promise { const [totalProducts, productsByTenant, recentOrders] = await Promise.all([ // Total count across all tenants this.prismaService.product.count({ where: { isDeleted: false }, }), // Count by tenant this.prismaService.product.groupBy({ by: ['tenantCode'], _count: { id: true }, where: { isDeleted: false }, }), // Recent orders across all tenants (admin only) this.prismaService.order.findMany({ where: { isDeleted: false }, orderBy: { createdAt: 'desc' }, take: 100, }), ]); return { totalProducts, productsByTenant: productsByTenant.map(t => ({ tenantCode: t.tenantCode, count: t._count.id, })), recentOrdersCount: recentOrders.length, }; } /** * Get tenant-specific metrics */ async getTenantMetrics(tenantCode: string): Promise { const [products, orders, users] = await Promise.all([ this.prismaService.product.count({ where: { tenantCode, isDeleted: false }, }), this.prismaService.order.count({ where: { tenantCode, isDeleted: false }, }), this.prismaService.user.count({ where: { tenantCode, isDeleted: false }, }), ]); return { tenantCode, products, orders, users }; } } ``` ## Tenant Configuration {#tenant-configuration} ### Tenant Settings Pattern ```typescript // tenant/tenant-settings.service.ts import { Injectable } from '@nestjs/common'; import { CommandService, DataService, IInvoke, KEY_SEPARATOR, generateId, VERSION_FIRST } from '@mbc-cqrs-serverless/core'; @Injectable() export class TenantSettingsService { private readonly settingsCache = new Map(); constructor( private readonly dataService: DataService, private readonly commandService: CommandService, ) {} /** * Get tenant settings with caching */ async getSettings(tenantCode: string): Promise { // Check cache first if (this.settingsCache.has(tenantCode)) { return this.settingsCache.get(tenantCode)!; } const pk = `SETTINGS${KEY_SEPARATOR}${tenantCode}`; const sk = 'config'; try { const settings = await this.dataService.getItem({ pk, sk }); this.settingsCache.set(tenantCode, settings.attributes); return settings.attributes; } catch (error) { // Return default settings if not found return this.getDefaultSettings(); } } /** * Update tenant settings */ async updateSettings( tenantCode: string, settings: Partial, invokeContext: IInvoke, ): Promise { const currentSettings = await this.getSettings(tenantCode); const mergedSettings = { ...currentSettings, ...settings }; const pk = `SETTINGS${KEY_SEPARATOR}${tenantCode}`; const sk = 'config'; await this.commandService.publishSync({ pk, sk, id: generateId(pk, sk), tenantCode, code: 'config', name: 'Tenant Configuration', type: 'SETTINGS', version: VERSION_FIRST, attributes: mergedSettings, }, { invokeContext }); // Invalidate cache this.settingsCache.delete(tenantCode); return mergedSettings; } private getDefaultSettings(): TenantSettings { return { timezone: 'Asia/Tokyo', locale: 'ja', dateFormat: 'YYYY-MM-DD', currency: 'JPY', features: { exportEnabled: true, importEnabled: true, apiEnabled: true, }, }; } } export interface TenantSettings { timezone: string; locale: string; dateFormat: string; currency: string; features: { exportEnabled: boolean; importEnabled: boolean; apiEnabled: boolean; }; } ``` ## Prisma Multi-Tenant Schema {#prisma-multi-tenant} ### RDS Schema for Multi-Tenant ```prisma // prisma/schema.prisma // Base fields for all entities model Product { id String @id pk String sk String tenantCode String // Tenant isolation field code String name String attributes Json? version Int isDeleted Boolean @default(false) createdAt DateTime createdBy String @default("") updatedAt DateTime updatedBy String @default("") // Unique constraint includes tenant @@unique([tenantCode, code]) @@unique([pk, sk]) // Index for tenant queries @@index([tenantCode]) @@index([tenantCode, name]) @@index([tenantCode, createdAt]) } // User-Tenant association model UserTenant { id String @id pk String sk String userCode String tenantCode String role String isDefault Boolean @default(false) createdAt DateTime updatedAt DateTime @@unique([userCode, tenantCode]) @@index([userCode]) @@index([tenantCode]) } // Tenant settings model TenantSettings { id String @id tenantCode String @unique settings Json createdAt DateTime updatedAt DateTime } ``` ## Best Practices {#best-practices} ### 1. Always Include Tenant in Queries ```typescript // Good: Tenant-scoped query const products = await prismaService.product.findMany({ where: { tenantCode, isDeleted: false, }, }); // Bad: Missing tenant scope (data leak risk) const products = await prismaService.product.findMany({ where: { isDeleted: false }, }); ``` ### 2. Validate Tenant Access ```typescript // Always verify tenant access before operations async updateProduct( productId: string, updateDto: UpdateProductDto, invokeContext: IInvoke, ): Promise { const { tenantCode } = getCustomUserContext(invokeContext); // Verify product belongs to user's tenant const existing = await this.prismaService.product.findUnique({ where: { id: productId }, }); if (existing?.tenantCode !== tenantCode) { throw new ForbiddenException('Access denied'); } // Proceed with update return this.publishCommand(updateDto, invokeContext); } ``` ### 3. Tenant-Aware Logging ```typescript // Include tenant in all logs for debugging this.logger.log({ message: 'Processing order', tenantCode, orderId, userId: userContext.userId, }); ``` ### 4. Separate System and Tenant Operations ```typescript // Use separate endpoints for system-wide vs tenant operations @Controller('api/admin/tenants') @UseGuards(SystemAdminGuard) export class TenantAdminController { // System admin operations across tenants } @Controller('api/products') @UseGuards(TenantGuard) export class ProductController { // Tenant-scoped operations } ``` ## TenantModule API Reference {#tenant-module-api} The `@mbc-cqrs-serverless/tenant` module provides ready-to-use tenant management functionality. ### Installation ```bash npm install @mbc-cqrs-serverless/tenant ``` ### Module Registration ```typescript import { TenantModule } from '@mbc-cqrs-serverless/tenant'; @Module({ imports: [ TenantModule.register({ enableController: true, dataSyncHandlers: [TenantRdsSyncHandler], // Optional: sync to external systems }), ], }) export class AppModule {} ``` ### Module Options | Option | Type | Description | |--------|------|-------------| | `enableController` | `boolean` | Enable REST endpoints for tenant CRUD operations | | `dataSyncHandlers` | `Type[]` | Optional handlers to sync tenant data to external systems (e.g., RDS) | ### TenantService Methods #### `getTenant(key: DetailKey): Promise` Retrieves tenant details based on the given key. ```typescript const tenant = await tenantService.getTenant({ pk: 'TENANT#mbc', sk: 'MASTER', }); ``` #### `createCommonTenant(dto, context): Promise` Creates a common tenant that is shared across the entire system. ```typescript const tenant = await tenantService.createCommonTenant({ name: 'Common', }, { invokeContext }); ``` #### `createTenant(dto, context): Promise` Creates a tenant for an individual entity. ```typescript const tenant = await tenantService.createTenant({ name: 'MBC tenant', code: 'mbc', }, { invokeContext }); ``` #### `updateTenant(key, dto, context): Promise` Updates an existing tenant's details. ```typescript const tenant = await tenantService.updateTenant( { pk: 'TENANT#mbc', sk: 'MASTER' }, { name: 'Updated MBC tenant' }, { invokeContext }, ); ``` #### `deleteTenant(key, context): Promise` Deletes a tenant based on the provided key. ```typescript await tenantService.deleteTenant( { pk: 'TENANT#mbc', sk: 'MASTER' }, { invokeContext }, ); ``` #### `addTenantGroup(dto, context): Promise` Adds a group to a specific tenant. ```typescript await tenantService.addTenantGroup({ tenantCode: 'abc', groupId: '19', role: 'company', }, { invokeContext }); ``` #### `createTenantGroup(tenantGroupCode, dto, context): Promise` Creates a tenant within a specific tenant group. ```typescript await tenantService.createTenantGroup( 'group-001', { code: 'mbc', name: 'MBC Tenant', description: 'Tenant in group-001', }, { invokeContext }, ); ``` #### `customizeSettingGroups(dto, context): Promise` Customizes the settings of groups associated with a tenant. ```typescript await tenantService.customizeSettingGroups({ tenantCode: 'mbc', settingGroups: ['19', '20'], role: 'company', }, { invokeContext }); ``` ## Related Documentation - [Backend Development Guide](/docs/backend-development) - Core patterns - [Key Patterns](/docs/key-patterns) - PK/SK design for multi-tenant - [Authentication](/docs/authentication) - User authentication - [Authentication — Group-Based Roles](/docs/authentication#group-based-roles) - Group-based role authorization using `IGroupRoleResolver` (v1.3.1+) - [Tenant Module](/docs/tenant) - TenantModule configuration and interfaces --- ## Service Implementation Patterns URL: https://mbc-cqrs-serverless.mbc-net.com/docs/service-patterns # Service Implementation Patterns This guide explains how to implement service classes that handle CRUD operations in MBC CQRS Serverless. Services are the core of your business logic, coordinating between controllers, commands, and data access. ## When to Use This Guide {#when-to-use} Use this guide when you need to: - Build a service layer for a new domain entity - Implement create, read, update, delete (CRUD) operations - Handle multi-tenant data isolation - Use optimistic locking for concurrent updates - Implement batch operations for bulk data processing ## Problems This Pattern Solves {#problems-solved} | Problem | Solution | |---------|----------| | Direct database access bypasses CQRS pattern | Use CommandService for writes, DataService for reads | | No audit trail for data changes | Pass invokeContext to capture user and timestamp | | Concurrent updates overwrite each other | Use version field for optimistic locking | | Slow responses due to synchronous processing | Use publishAsync for non-blocking command publishing | ## Basic Service Structure {#basic-structure} A typical service uses both `CommandService` for write operations and `DataService` for read operations: ```ts import { CommandService, DataService, generateId, getUserContext, IInvoke, VERSION_FIRST, KEY_SEPARATOR, } from "@mbc-cqrs-serverless/core"; import { Injectable } from "@nestjs/common"; import { ulid } from "ulid"; import { PrismaService } from "src/prisma"; import { ProductCommandDto } from "./dto/product-command.dto"; import { ProductDataEntity } from "./entity/product-data.entity"; import { CreateProductDto } from "./dto/create-product.dto"; import { UpdateProductDto } from "./dto/update-product.dto"; const PRODUCT_PK_PREFIX = "PRODUCT"; @Injectable() export class ProductService { constructor( private readonly commandService: CommandService, private readonly dataService: DataService, private readonly prismaService: PrismaService, ) {} // CRUD methods will be implemented below } ``` ## Create Operation {#create-operation} ### Use Case: Create a New Product Scenario: User submits a form to add a new product to the catalog. Flow: Controller receives CreateProductDto → Service generates keys → Command published to DynamoDB → Data synced to RDS. ```ts async create( createDto: CreateProductDto, opts: { invokeContext: IInvoke }, ): Promise { // Get tenant context from the invoke context const { tenantCode } = getUserContext(opts.invokeContext); // Generate PK and SK const pk = `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`; const sk = ulid(); // Use ULID for sortable unique ID const id = generateId(pk, sk); // Create command DTO const command = new ProductCommandDto({ pk, sk, id, tenantCode, code: sk, type: "PRODUCT", name: createDto.name, version: VERSION_FIRST, attributes: { description: createDto.description, price: createDto.price, category: createDto.category, inStock: createDto.inStock ?? true, }, }); // Publish command (async - returns immediately) const item = await this.commandService.publishAsync(command, { invokeContext: opts.invokeContext, }); // publishAsync returns null when command is a no-op (no changes detected) if (!item) return null; return new ProductDataEntity(item); } ``` ## Read Operations {#read-operations} ### Find One by Key #### Use Case: Get Product Detail Page Scenario: User navigates to a product detail page and needs the full product data. When to use: Single-item lookup where you have the pk and sk. ```ts async findOne( detailDto: { pk: string; sk: string }, ): Promise { const item = await this.dataService.getItem(detailDto); if (!item) return undefined; return new ProductDataEntity(item); } ``` ### Find All with Pagination (from RDS) #### Use Case: Product List with Filtering Scenario: Display a paginated product list that users can filter by category or search. Why RDS: DynamoDB is not optimized for complex queries. Use Prisma/RDS for filtering and full-text search. ```ts async findAll( searchDto: { tenantCode: string; category?: string; inStock?: boolean; page?: number; limit?: number; }, ): Promise<{ items: ProductDataEntity[]; total: number }> { const page = searchDto.page ?? 1; const limit = searchDto.limit ?? 20; const skip = (page - 1) * limit; // Build where clause const where: any = { tenantCode: searchDto.tenantCode, isDeleted: false, }; if (searchDto.category) { where.category = searchDto.category; } if (searchDto.inStock !== undefined) { where.inStock = searchDto.inStock; } // Execute parallel queries for count and data const [total, items] = await Promise.all([ this.prismaService.product.count({ where }), this.prismaService.product.findMany({ where, take: limit, skip, orderBy: { createdAt: "desc" }, }), ]); return { total, items: items.map((item) => new ProductDataEntity(item)), }; } ``` ## Update Operation {#update-operation} ### Use Case: Edit Product Details Scenario: User updates product name or price through an edit form. Important: Include the version field to enable optimistic locking and prevent concurrent update conflicts. ```ts import { CommandPartialInputModel, CommandService, DataService, IInvoke, } from "@mbc-cqrs-serverless/core"; import { NotFoundException } from "@nestjs/common"; async update( detailDto: { pk: string; sk: string }, updateDto: UpdateProductDto, opts: { invokeContext: IInvoke }, ): Promise { // First, get the existing item const existing = await this.dataService.getItem(detailDto); if (!existing) { throw new NotFoundException("Product not found"); } // Merge existing attributes with updates const updatedAttributes = { ...existing.attributes, ...updateDto.attributes, }; // Create partial update command const command: CommandPartialInputModel = { pk: existing.pk, sk: existing.sk, version: existing.version, // Required for optimistic locking name: updateDto.name ?? existing.name, attributes: updatedAttributes, }; // Publish partial update const item = await this.commandService.publishPartialUpdateAsync(command, { invokeContext: opts.invokeContext, }); if (!item) return null; return new ProductDataEntity(item); } ``` :::info Version Parameter Behavior The `version` field in `publishPartialUpdateAsync()` controls how the existing item is retrieved: - **`version > 0`**: Uses the specified version number. The command will fail if the version doesn't match (optimistic locking). - **`version <= 0`** (e.g., `VERSION_LATEST = -1`): Automatically retrieves the latest version using `getLatestItem()`. Use `existing.version` (as shown above) for strict optimistic locking. Use `VERSION_LATEST` (-1) when you want to always update the latest version regardless of what version you have cached. ::: ## Delete Operation (Soft Delete) {#delete-operation} ### Use Case: Remove Product from Catalog Scenario: Admin removes a discontinued product. Why Soft Delete: Data is marked as deleted (isDeleted=true) rather than physically removed, preserving audit history. ```ts import { CommandPartialInputModel, CommandService, DataService, IInvoke, } from "@mbc-cqrs-serverless/core"; import { NotFoundException } from "@nestjs/common"; async remove( detailDto: { pk: string; sk: string }, opts: { invokeContext: IInvoke }, ): Promise { // Get existing item const existing = await this.dataService.getItem(detailDto); if (!existing) { throw new NotFoundException("Product not found"); } // Create soft delete command const command: CommandPartialInputModel = { pk: existing.pk, sk: existing.sk, version: existing.version, isDeleted: true, }; const item = await this.commandService.publishPartialUpdateAsync(command, { invokeContext: opts.invokeContext, }); if (!item) return null; return new ProductDataEntity(item); } ``` ## Complete Service Example {#complete-example} Here is a complete service implementation: ```ts import { CommandPartialInputModel, CommandService, DataService, generateId, getUserContext, VERSION_FIRST, KEY_SEPARATOR, IInvoke, } from "@mbc-cqrs-serverless/core"; import { Injectable, NotFoundException } from "@nestjs/common"; import { ulid } from "ulid"; import { PrismaService } from "src/prisma"; import { ProductCommandDto } from "./dto/product-command.dto"; import { ProductDataEntity } from "./entity/product-data.entity"; import { ProductListEntity } from "./entity/product-list.entity"; import { CreateProductDto } from "./dto/create-product.dto"; import { UpdateProductDto } from "./dto/update-product.dto"; import { SearchProductDto } from "./dto/search-product.dto"; import { DetailDto } from "./dto/detail.dto"; const PRODUCT_PK_PREFIX = "PRODUCT"; @Injectable() export class ProductService { constructor( private readonly commandService: CommandService, private readonly dataService: DataService, private readonly prismaService: PrismaService, ) {} /** * Create a new product */ async create( createDto: CreateProductDto, opts: { invokeContext: IInvoke }, ): Promise { const { tenantCode } = getUserContext(opts.invokeContext); const pk = `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`; const sk = ulid(); const id = generateId(pk, sk); const command = new ProductCommandDto({ pk, sk, id, tenantCode, code: sk, type: "PRODUCT", name: createDto.name, version: VERSION_FIRST, attributes: { description: createDto.description, price: createDto.price, category: createDto.category, inStock: createDto.inStock ?? true, }, }); const item = await this.commandService.publishAsync(command, { invokeContext: opts.invokeContext, }); // publishAsync returns null when the command is not dirty (no-op) return item ? new ProductDataEntity(item) : null; } /** * Find all products with filtering and pagination */ async findAll(searchDto: SearchProductDto): Promise { const page = searchDto.page ?? 1; const limit = searchDto.limit ?? 20; const skip = (page - 1) * limit; const where: any = { tenantCode: searchDto.tenantCode, isDeleted: false, }; if (searchDto.category) { where.category = searchDto.category; } if (searchDto.inStock !== undefined) { where.inStock = searchDto.inStock; } if (searchDto.search) { where.OR = [ { name: { contains: searchDto.search}}, { description: { contains: searchDto.search}}, ]; } const [total, items] = await Promise.all([ this.prismaService.product.count({ where }), this.prismaService.product.findMany({ where, take: limit, skip, orderBy: { createdAt: "desc" }, }), ]); return new ProductListEntity({ total, items: items.map((item) => new ProductDataEntity(item)), }); } /** * Find one product by key */ async findOne(detailDto: DetailDto): Promise { const item = await this.dataService.getItem(detailDto); if (!item) { throw new NotFoundException("Product not found"); } return new ProductDataEntity(item); } /** * Update a product */ async update( detailDto: DetailDto, updateDto: UpdateProductDto, opts: { invokeContext: IInvoke }, ): Promise { const existing = await this.dataService.getItem(detailDto); if (!existing) { throw new NotFoundException("Product not found"); } const command: CommandPartialInputModel = { pk: existing.pk, sk: existing.sk, version: existing.version, name: updateDto.name ?? existing.name, attributes: { ...existing.attributes, ...updateDto.attributes, }, }; const item = await this.commandService.publishPartialUpdateAsync(command, { invokeContext: opts.invokeContext, }); // publishPartialUpdateAsync returns null when command is a no-op (no changes detected) if (!item) return null; return new ProductDataEntity(item); } /** * Soft delete a product */ async remove( detailDto: DetailDto, opts: { invokeContext: IInvoke }, ): Promise { const existing = await this.dataService.getItem(detailDto); if (!existing) { throw new NotFoundException("Product not found"); } const command: CommandPartialInputModel = { pk: existing.pk, sk: existing.sk, version: existing.version, isDeleted: true, }; const item = await this.commandService.publishPartialUpdateAsync(command, { invokeContext: opts.invokeContext, }); // publishPartialUpdateAsync returns null when command is a no-op (no changes detected) if (!item) return null; return new ProductDataEntity(item); } } ``` ## Batch Operations {#batch-operations} ### Use Case: Import Multiple Products Scenario: Admin uploads a CSV file containing multiple products to import. Solution: Process items in parallel using Promise.all for better performance. ```ts async createBatch( items: CreateProductDto[], opts: { invokeContext: IInvoke }, ): Promise { const { tenantCode } = getUserContext(opts.invokeContext); const pk = `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`; // Create all commands const commands = items.map((item) => { const sk = ulid(); return new ProductCommandDto({ pk, sk, id: generateId(pk, sk), tenantCode, code: sk, type: "PRODUCT", name: item.name, version: VERSION_FIRST, attributes: { description: item.description, price: item.price, category: item.category, inStock: item.inStock ?? true, }, }); }); // Publish all commands in parallel const results = await Promise.all( commands.map((command) => this.commandService.publishAsync(command, { invokeContext: opts.invokeContext, }), ), ); // Filter out null results (no-op commands) before mapping to entities return results.filter(Boolean).map((item) => new ProductDataEntity(item!)); } ``` ## Chunked Batch Operations {#chunked-batch-operations} ### Use Case: Large Data Migration Scenario: Migrating thousands of records from a legacy system. Problem: Processing all at once may cause Lambda timeout or memory issues. Solution: Process in chunks of 100 items to stay within Lambda limits. ```ts async createLargeBatch( items: CreateProductDto[], opts: { invokeContext: IInvoke }, ): Promise { const { tenantCode } = getUserContext(opts.invokeContext); const pk = `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${tenantCode}`; const chunkSize = 100; const results: ProductDataEntity[] = []; for (let i = 0; i < items.length; i += chunkSize) { const chunk = items.slice(i, i + chunkSize); const commands = chunk.map((item) => { const sk = ulid(); return new ProductCommandDto({ pk, sk, id: generateId(pk, sk), tenantCode, code: sk, type: "PRODUCT", name: item.name, version: VERSION_FIRST, attributes: item, }); }); const chunkResults = await Promise.all( commands.map((command) => this.commandService.publishAsync(command, { invokeContext: opts.invokeContext, }), ), ); results.push(...chunkResults.filter(Boolean).map((item) => new ProductDataEntity(item!))); } return results; } ``` ## Copy Operation {#copy-operation} ### Use Case: Clone Product to Different Tenant Scenario: Multi-tenant SaaS where a template product needs to be copied to a new tenant. Solution: Read source entity and create new entity with different tenant's keys. ```ts import { CommandService, DataService, generateId, IInvoke, KEY_SEPARATOR, VERSION_FIRST, } from "@mbc-cqrs-serverless/core"; import { NotFoundException } from "@nestjs/common"; import { ulid } from "ulid"; async copy( sourceKey: { pk: string; sk: string }, targetTenantCode: string, opts: { invokeContext: IInvoke }, ): Promise { // Get source item const source = await this.dataService.getItem(sourceKey); if (!source) { throw new NotFoundException("Source product not found"); } // Create new keys for target tenant const pk = `${PRODUCT_PK_PREFIX}${KEY_SEPARATOR}${targetTenantCode}`; const sk = ulid(); const id = generateId(pk, sk); // Create command with source data const command = new ProductCommandDto({ pk, sk, id, tenantCode: targetTenantCode, code: sk, type: source.type, name: source.name, version: VERSION_FIRST, attributes: source.attributes, }); const item = await this.commandService.publishAsync(command, { invokeContext: opts.invokeContext, }); // New items with VERSION_FIRST always publish successfully — result is never null return new ProductDataEntity(item!); } ``` ## Using History Service {#history-service} ### Use Case: View Previous Versions of a Document Scenario: Audit requirement to show what a document looked like at a specific version. Solution: Use HistoryService to retrieve a specific version from the history table. ```ts import { addSortKeyVersion, CommandService, DataService, HistoryService, } from "@mbc-cqrs-serverless/core"; import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma"; import { ProductDataEntity } from "./entity/product-data.entity"; @Injectable() export class ProductService { constructor( private readonly commandService: CommandService, private readonly dataService: DataService, private readonly historyService: HistoryService, private readonly prismaService: PrismaService, ) {} async findByVersion( detailDto: { pk: string; sk: string }, version: number, ): Promise { // Add version to SK const skWithVersion = addSortKeyVersion(detailDto.sk, version); // Try to get from history let item = await this.historyService.getItem({ pk: detailDto.pk, sk: skWithVersion, }); // Fallback to latest if not in history if (!item) { item = await this.dataService.getItem(detailDto); } if (!item) { throw new NotFoundException("Product not found"); } return new ProductDataEntity(item); } } ``` ## Best Practices {#best-practices} ### 1. Always Use Invoke Context Pass invoke context for audit trail: ```ts await this.commandService.publishAsync(command, { invokeContext: opts.invokeContext, }); ``` ### 2. Use Optimistic Locking Include version in partial updates: ```ts const command: CommandPartialInputModel = { pk: existing.pk, sk: existing.sk, version: existing.version, // This enables optimistic locking // ... }; ``` ### 3. Prefer Async Operations Use async methods for better responsiveness: ```ts // Recommended: Returns immediately await this.commandService.publishAsync(command, opts); // Use only when you need to wait for processing await this.commandService.publishSync(command, opts); ``` ### 4. Combine DynamoDB and RDS Queries Use DynamoDB for single-item reads, RDS for complex queries: ```ts // Single item: Use DataService (DynamoDB) const item = await this.dataService.getItem({ pk, sk }); // Complex query: Use Prisma (RDS) const items = await this.prismaService.product.findMany({ where: { category: "electronics", inStock: true }, orderBy: { price: "asc" }, }); ``` ## Related Documentation - [Backend Development](/docs/backend-development) - Core backend patterns - [Controllers](/docs/controllers) - Controller layer that delegates to services - [Command Service](/docs/command-service) - CommandService API reference - [Data Service](/docs/data-service) - DataService query methods - [Anti-Patterns](/docs/anti-patterns) - Patterns to avoid in service layer - [Prisma](/docs/prisma) - ORM setup for RDS data sync - [Database Selection Guide](/docs/database-selection-guide) - DynamoDB vs RDS - [Version Conflict Guide](/docs/version-conflict-guide) - Handling HTTP 409 optimistic locking conflicts - [API Integration Guide](/docs/api-integration-guide) - Calling external APIs and handling webhooks --- # Modules ## API Reference URL: https://mbc-cqrs-serverless.mbc-net.com/docs/api-reference # API Reference The MBC CQRS Serverless framework provides a comprehensive set of modules for building enterprise-grade serverless applications. Each module is designed to handle specific concerns while maintaining consistency with the CQRS and Event Sourcing patterns. ## Module Overview {#module-overview} ```mermaid graph TB subgraph "Core Modules" A["CommandModule"] B["SequencesModule"] C["TenantModule"] end subgraph "Feature Modules" D["TaskModule"] E["MasterModule"] F["ImportModule"] G["DirectoryStorageModule"] H["SurveyTemplateModule"] end subgraph "Support Modules" I["NotificationModule"] J["SettingModule"] end A --> B A --> C C --> D C --> E C --> F D --> I ``` ## Core Modules {#core-modules} | Module | Package | Description | |------------|-------------|-----------------| | Command Module | `@mbc-cqrs-serverless/core` | CQRS command handling, data synchronization, Event Sourcing | | Sequence Module | `@mbc-cqrs-serverless/sequence` | Thread-safe sequential ID generation | | Tenant Module | `@mbc-cqrs-serverless/tenant` | Multi-tenant data isolation and management | ## Feature Modules {#feature-modules} | Module | Package | Description | |------------|-------------|-----------------| | Task Module | `@mbc-cqrs-serverless/task` | Async task execution with Step Functions | | Master Module | `@mbc-cqrs-serverless/master` | Master data and settings management | | Import Module | `@mbc-cqrs-serverless/import` | Large-scale CSV import with Distributed Map | | Directory Module | `@mbc-cqrs-serverless/directory` | S3-backed file and folder management | | Survey Template Module | `@mbc-cqrs-serverless/survey-template` | Survey template management | ## Support Modules {#support-modules} | Module | Package | Description | |------------|-------------|-----------------| | Queue Module | `@mbc-cqrs-serverless/core` | SNS and SQS messaging (globally registered) | | Notification Module | `@mbc-cqrs-serverless/core` | Real-time notifications via AppSync (GraphQL subscriptions default; Events API opt-in since v1.3.0) | | Email Service | `@mbc-cqrs-serverless/core` | Send transactional emails via Amazon SES | | Setting Module | `@mbc-cqrs-serverless/ui-setting` | User interface configuration storage | ## Quick Start {#quick-start} Install the core package: ```bash npm install @mbc-cqrs-serverless/core ``` Register the CommandModule in your application: ```typescript import { Module } from '@nestjs/common'; import { CommandModule } from '@mbc-cqrs-serverless/core'; @Module({ imports: [ CommandModule.register({ tableName: 'your-table-name', }), ], }) export class YourModule {} ``` ## Common Patterns {#common-patterns} ### Service Injection All services are available for injection in your NestJS providers: ```typescript import { Injectable } from '@nestjs/common'; import { CommandService, DataService } from '@mbc-cqrs-serverless/core'; @Injectable() export class YourService { constructor( private readonly commandService: CommandService, private readonly dataService: DataService, ) {} } ``` ### Multi-Tenant Context Most operations require tenant context for data isolation: ```typescript async createItem(tenantCode: string, data: CreateDto, invokeContext: IInvoke) { return this.commandService.publishAsync({ pk: `ITEM#${tenantCode}`, sk: data.id, tenantCode, // ... other fields }, { invokeContext }); } ``` ## Module Documentation {#module-documentation} Explore each module's detailed documentation: ```mdx-code-block import DocCardList from '@theme/DocCardList'; ``` ## Related Documentation - [Command Service](/docs/command-service) - CommandService detailed documentation - [Data Service](/docs/data-service) - DataService query methods - [Notification Module](/docs/notification-module) - AppSync real-time notification documentation - [Email Service](/docs/email-service) - SES email sending documentation - [Interfaces](/docs/interfaces) - TypeScript interfaces reference - [Modules](/docs/modules) - Available modules --- ## CommandService URL: https://mbc-cqrs-serverless.mbc-net.com/docs/command-service # CommandService ## Overview {#overview} The `CommandService` is a core component of the framework that facilitates the management and synchronization of commands. It primarily provides methods for publishing both full commands and partial commands, allowing for their processing either synchronously or asynchronously, thereby enhancing the overall efficiency and flexibility of command handling within the system. ## CommandModule Configuration {#commandmodule-configuration} ![CommandModule structure](./images/CommandModule.png) The `CommandModule` is a dynamic module used to register data sync handlers and provide services associated with a table name. When importing this module, you must provide a specific option for use. ### Register Options | Property | Description | | ----------------------------- | -------------------------------------------------------------------- | | `tableName: string` | Provide table name | | `skipError?: boolean` | Reserved for future use. Not yet implemented. | | `dataSyncHandlers?: Type[]` | Register data sync handlers | | `disableDefaultHandler?: boolean` | If set to `true`, disables the default DynamoDB data sync handler| ### Registration Example ```typescript import { CommandModule } from '@mbc-cqrs-serverless/core'; import { Module } from '@nestjs/common'; @Module({ imports: [ CommandModule.register({ tableName: 'cat', dataSyncHandlers: [CatDataSyncRdsHandler], }), ], }) export class CatModule {} ``` Here, the `CommandModule` registers with the `cat` table name and provides the `CatDataSyncRdsHandler` to the data sync handlers. ## Using CommandService {#using-commandservice} In the example for the method below, assume you import the `CommandModule` into your module as follows: ```ts import { CommandModule } from "@mbc-cqrs-serverless/core"; import { Module } from "@nestjs/common"; import { CatDataSyncRdsHandler } from "./handler/cat-rds.handler"; import { CatController } from "./cat.controller"; import { CatService } from "./cat.service"; @Module({ imports: [ CommandModule.register({ tableName: "cat", dataSyncHandlers: [CatDataSyncRdsHandler], }), ], controllers: [CatController], providers: [CatService], }) export class CatModule {} ``` Then, the `CommandService` and `DataService` will be ready for injection into other services for your use. :::tip For Implementation Patterns For complete CRUD implementation patterns using CommandService, see [Service Patterns](/docs/service-patterns). ::: ## Methods {#methods} ### *async* `publishAsync(input: CommandInputModel, options: ICommandOptions): Promise` {#publishasync} Utilize this method to publish a full command, as it will insert the command data into the **command** table. The method provides immediate feedback by returning the command data right away, allowing you to proceed without waiting for the command to be processed. Subsequently, the command is handled asynchronously in the background, ensuring that your application remains responsive while the processing occurs. **Return Value:** Returns `Promise` — the CommandModel on success, or `null` when the command is not dirty (no changes detected compared to the existing command). For example, you can publish a new cat command as below: ```ts import { basename } from 'path'; import { generateId, getCommandSource, VERSION_FIRST, } from "@mbc-cqrs-serverless/core"; // class CatCommandDto extends CommandDto {} const catCommand = new CatCommandDto({ pk: catPk, sk: catSk, tenantCode, id: generateId(catPk, catSk), code, type: "CAT", name: attributes.name, version: VERSION_FIRST, attributes, }); const commandSource = getCommandSource( basename(__dirname), this.constructor.name, "createCatCommand" ); const item = await this.commandService.publishAsync(catCommand, { source: commandSource, invokeContext, }); ``` ### *async* `publishPartialUpdateAsync( input: CommandPartialInputModel, options: ICommandOptions): Promise` {#publishpartialupdateasync} This method allows you to create new command data based on the previous command with the same `pk` and `sk` (primary key) values. Like `publishAsync`, this method immediately returns the updated command data without waiting for the command to be processed. **Return Value:** Returns `Promise` — the CommandModel on success, or `null` when the command is not dirty (no changes detected compared to the existing partial command). For example, you want to update cat's name: ```ts import { basename } from 'path'; import { generateId, getCommandSource } from "@mbc-cqrs-serverless/core"; // ... const catCommand: CommandPartialInputModel = { pk: catPk, sk: catSk, version: storedItem.version, name: attributes.name, }; const commandSource = getCommandSource( basename(__dirname), this.constructor.name, "updateCatCommand" ); const item = await this.commandService.publishPartialUpdateAsync(catCommand, { invokeContext, }); ``` ### *async* `publishSync( input: CommandInputModel, options: ICommandOptions): Promise` {#publishsync-audit-trail} This method serves as a synchronous counterpart to the `publishAsync` method, meaning that it will halt the execution of the code until the command has been fully processed. This ensures that you receive the result of the command before proceeding with any further operations in your code. :::danger Breaking Change (v1.2.0) Since [v1.2.0](/docs/changelog#v120), `publishSync()` and `publishPartialUpdateSync()` return `null` when the command is not dirty (no-op). Always null-check the result before accessing properties: ```ts const result = await this.commandService.publishSync(command, { invokeContext }); if (!result) return; // no-op: command was not dirty, nothing was written console.log(result.pk); // safe after null check ``` ::: :::info Version Note (v1.1.4+) Since [v1.1.4](/docs/changelog#v114), `publishSync` writes a full audit trail matching the async pipeline: - An immutable event is written to the Command table with `syncMode: 'SYNC'` marker - The History table is populated, providing complete Event Sourcing parity - Command lifecycle: `publish_sync:STARTED` → `finish:FINISHED` (or `publish_sync:FAILED` on error) - Returns `null` when the command is not dirty (no-op), matching `publishAsync` behavior - DynamoDB Stream filter excludes `syncMode=SYNC` records to prevent Step Functions double-execution Prior to v1.1.4, `publishSync` bypassed the Command table to avoid triggering Step Functions, resulting in a missing audit trail and no History table entries. ::: :::warning Known Issue (Fixed in v1.3.2) In versions prior to v1.3.2, `publishSync` had two bugs: the versioned `sk` was not assigned to `command.sk` before handler dispatch (causing `IDataSyncHandler.up()` to receive the wrong sort key), and errors from `updateStatus` were silently masked. See also: [Changelog v1.3.2](/docs/changelog#v132) ::: Returns `null` if no changes are detected (dirty check optimization). For example: ```ts import { generateId, getCommandSource, VERSION_FIRST, } from "@mbc-cqrs-serverless/core"; // class CatCommandDto extends CommandDto {} const catCommand = new CatCommandDto({ pk: catPk, sk: catSk, tenantCode, id: generateId(catPk, catSk), code, type: "CAT", name: attributes.name, version: VERSION_FIRST, attributes, }); const commandSource = getCommandSource( basename(__dirname), this.constructor.name, "createCatCommandSync" ); const item = await this.commandService.publishSync(catCommand, { source: commandSource, invokeContext, }); ``` ### *async* `publishPartialUpdateSync( input: CommandPartialInputModel, options: ICommandOptions): Promise` This method is a synchronous version of the `publishPartialUpdateAsync` method. It will block the execution of the code until the command is processed. :::danger Breaking Change (v1.2.0) Since [v1.2.0](/docs/changelog#v120), this method returns `null` when the command is not dirty (no-op). Always null-check the result. See [publishSync null return](/docs/command-service#publishsync-audit-trail) for details. ::: :::warning Version Matching This method requires the `version` field in the input to match the current version of the existing item. If the item is not found or the version does not match, a `BadRequestException` is thrown with the message "Invalid input: item not found or version mismatch" (prior to v1.0.25, the message was "The input is not a valid, item not found or version not match"). ::: For example, you want to update cat's name: ```ts import { basename } from 'path'; import { generateId, getCommandSource } from "@mbc-cqrs-serverless/core"; // ... const catCommand: CommandPartialInputModel = { pk: catPk, sk: catSk, version: storedItem.version, name: attributes.name, }; const commandSource = getCommandSource( basename(__dirname), this.constructor.name, "updateCatCommandSync" ); const item = await this.commandService.publishPartialUpdateSync(catCommand, { source: commandSource, invokeContext, }); ``` ### *async* `publish(input: CommandInputModel, options: ICommandOptions): Promise` removed :::danger Removed in v1.1.0 This method was removed in [v1.1.0](/docs/changelog#v110). Use [`publishAsync` method](#publishasync) instead. ::: For example, you can publish a new cat command as below: ```ts import { basename } from 'path'; import { generateId, getCommandSource, VERSION_FIRST, } from "@mbc-cqrs-serverless/core"; // class CatCommandDto extends CommandDto {} const catCommand = new CatCommandDto({ pk: catPk, sk: catSk, tenantCode, id: generateId(catPk, catSk), code, type: "CAT", name: attributes.name, version: VERSION_FIRST, attributes, }); const commandSource = getCommandSource( basename(__dirname), this.constructor.name, "createCatCommand" ); const item = await this.commandService.publish(catCommand, { source: commandSource, invokeContext, }); ``` The method returns the command data. ### *async* `publishPartialUpdate( input: CommandPartialInputModel, options: ICommandOptions): Promise` removed :::danger Removed in v1.1.0 This method was removed in [v1.1.0](/docs/changelog#v110). Use [`publishPartialUpdateAsync` method](#publishpartialupdateasync) instead. ::: This method allows you to create new command data based on the previous command. For example, you want to update cat's name: ```ts import { basename } from 'path'; import { generateId, getCommandSource } from "@mbc-cqrs-serverless/core"; // ... const catCommand: CommandPartialInputModel = { pk: catPk, sk: catSk, version: storedItem.version, name: attributes.name, }; const commandSource = getCommandSource( basename(__dirname), this.constructor.name, "updateCatCommand" ); const item = await this.commandService.publishPartialUpdate(catCommand, { source: commandSource, invokeContext, }); ``` The method returns the updated command data. ### *async* `reSyncData(): Promise` If you want to reapply the data sync handler, this method is designed for you to use. You only need to call the function as follows: ```ts await this.commandService.reSyncData(); ``` ### *async* `getItem(key: DetailKey): Promise` Retrieves a command item by its primary key. If the sort key does not include the version separator (`@`, e.g. `CAT#cat001@2`), it automatically calls `getLatestItem` to get the latest version. Returns `null` when no matching item is found. ```ts import { DetailKey } from "@mbc-cqrs-serverless/core"; // Get a specific version of a command const command = await this.commandService.getItem({ pk: "CAT#tenant1", sk: "CAT#cat001@2", // Includes version number }); // If no version in sk, automatically gets latest version const latestCommand = await this.commandService.getItem({ pk: "CAT#tenant1", sk: "CAT#cat001", }); ``` ### *async* `getLatestItem(key: DetailKey): Promise` Retrieves the latest version of a command item by its primary key. This method uses a lookup algorithm that starts from the data table's version and searches up/down to find the most recent command version. Returns `null` when no matching item is found. ```ts import { DetailKey } from "@mbc-cqrs-serverless/core"; const latestCommand = await this.commandService.getLatestItem({ pk: "CAT#tenant1", sk: "CAT#cat001", // Sort key without version }); if (latestCommand) { console.log(`Latest version: ${latestCommand.version}`); } ``` ### *async* `getNextCommand(currentKey: DetailKey): Promise` Retrieves the next version of a command based on the current command's key. Returns `undefined` if the next version does not exist. This is useful for processing command chains or implementing retry logic. ```ts import { DetailKey } from "@mbc-cqrs-serverless/core"; const currentKey: DetailKey = { pk: "CAT#tenant1", sk: "CAT#cat001@2", }; const nextCommand = await this.commandService.getNextCommand(currentKey); // Returns command with sk: "CAT#cat001@3" if it exists, otherwise undefined if (nextCommand) { // Process the next command } ``` ### *async* `updateStatus(key: DetailKey, status: string, notifyId?: string): Promise` Updates the status of a command and sends an SNS notification. This is commonly used to update task or process statuses and notify subscribers of the change. ```ts import { DetailKey } from "@mbc-cqrs-serverless/core"; const key: DetailKey = { pk: "CAT#tenant1", sk: "CAT#cat001@1", }; // Update status and send SNS notification await this.commandService.updateStatus(key, "COMPLETED"); // With custom notification ID await this.commandService.updateStatus(key, "FAILED", "custom-notify-id"); ``` The SNS notification payload includes: - `action`: `"command-status"` - `pk`, `sk`: The command key - `table`: The command table name - `id`: Notification ID (custom or auto-generated) - `tenantCode`: Extracted from pk - `content`: Object containing `status` and `source` ### *async* `duplicate(key: DetailKey, options: ICommandOptions): Promise` Creates a duplicate of an existing command with an incremented version number. The duplicated command will have `source` set to `"duplicated"` and updated metadata (timestamp, user, IP). ```ts import { DetailKey, getCommandSource } from "@mbc-cqrs-serverless/core"; import { basename } from "path"; const key: DetailKey = { pk: "CAT#tenant1", sk: "CAT#cat001@1", }; const commandSource = getCommandSource( basename(__dirname), this.constructor.name, "duplicateCatCommand" ); const duplicatedCommand = await this.commandService.duplicate(key, { source: commandSource, invokeContext, }); // The duplicated command has: // - version incremented by 1 // - source set to "duplicated" // - updated timestamps and user info ``` ### *async* `updateTaskToken(key: DetailKey, token: string): Promise` {#updatetasktoken} Stores an AWS Step Functions task token on a command item. This is used when integrating with Step Functions to enable callback patterns. ```ts import { DetailKey } from "@mbc-cqrs-serverless/core"; const key: DetailKey = { pk: "CAT#tenant1", sk: "CAT#cat001@1", }; // Store the Step Functions task token await this.commandService.updateTaskToken(key, event.taskToken); // Later, use the token to send task success/failure // via SendTaskSuccessCommand or SendTaskFailureCommand ``` ### *async* `updateTtl(key: DetailKey): Promise` Updates the TTL (Time To Live) of the previous version of a command. This is typically used internally to manage command history retention. Returns `null` if the version is too low or the previous command doesn't exist. ```ts import { DetailKey } from "@mbc-cqrs-serverless/core"; const key: DetailKey = { pk: "CAT#tenant1", sk: "CAT#cat001@3", // Version 3 }; // Updates TTL of version 2 (previous version) const result = await this.commandService.updateTtl(key); ``` :::note This method is primarily used internally by the framework for command history management. Direct usage is rarely needed in application code. ::: ### `dataSyncHandlers` (getter): IDataSyncHandler[] Returns the array of registered data sync handlers for this CommandService instance. This is useful when you need to inspect or iterate over the handlers programmatically. ```ts // Get all registered data sync handlers const handlers = this.commandService.dataSyncHandlers; handlers.forEach((handler) => { console.log(`Handler: ${handler.constructor.name}, Type: ${handler.type}`); }); ``` ### `getDataSyncHandler(name: string): IDataSyncHandler | undefined` Retrieves a specific data sync handler by its class name. Returns `undefined` if no handler with the specified name is found. ```ts // Get a specific handler by name const rdsHandler = this.commandService.getDataSyncHandler('CatDataSyncRdsHandler'); if (rdsHandler) { // Use the handler directly await rdsHandler.up(commandModel); } ``` ### `isNotCommandDirty(item: CommandModel, input: CommandInputModel): boolean` Compares an existing command item with a new input to determine if there are any actual changes. Returns `true` if the command is NOT dirty (no changes), returns `false` if there ARE changes. This method is used internally by publish methods to skip unnecessary writes when no changes are detected. You can also use it directly to check if an update would result in any changes before calling publish. ```ts // Check if an update would result in changes const existingCommand = await this.commandService.getItem({ pk, sk }); if (existingCommand && this.commandService.isNotCommandDirty(existingCommand, newInput)) { // No changes detected, skip the update console.log('Command has no changes, skipping update'); return existingCommand; } // Proceed with the update const result = await this.commandService.publishAsync(newInput, options); ``` ### `tableName` (getter/setter): string Gets or sets the DynamoDB table name for this CommandService instance. The table name is configured when registering the `CommandModule`, but can be changed at runtime if needed. ```ts // Get the current table name const currentTable = this.commandService.tableName; console.log(`Operating on table: ${currentTable}`); // Set a different table name this.commandService.tableName = 'another-table'; ``` :::note Changing the table name at runtime is an advanced use case. In most applications, you should configure the table name through `CommandModule.register()` and not change it afterwards. ::: --- ## Read-Your-Writes (RYW) Consistency {#read-your-writes} :::info Version Note Read-Your-Writes consistency was added in [v1.2.0](/docs/changelog#v120). ::: ### The Problem: Stale Reads After publishAsync `publishAsync` returns immediately after writing to the Command table, then the DynamoDB Stream pipeline asynchronously syncs the data to the read model (Data table). This creates a short window where a subsequent read by the same user returns stale data — for example, a "Create Order" button submits successfully, the user is redirected to the order list, but the new order does not appear yet. ```text User Action DynamoDB Stream User Sees ────────────────────────────────────────────────────────────────── POST /orders ──────────► publishAsync() ─────► Command table ✓ │ GET /orders ──────────► DataService.list() ─► Data table ✗ ← stale! │ [Stream sync ~1-3s] │ ↓ ▼ GET /orders ──────────► DataService.list() ─► Data table ✓ ← fresh ``` ### The Solution: Session-Based Merge RYW bridges this gap by temporarily caching the version number of the pending command in a short-lived session table. When reading through `Repository`, the session entry is detected and the pending command is fetched from the Command table and merged into the response — making the write immediately visible to the user who made it. ```text User Action With RYW User Sees ────────────────────────────────────────────────────────────────── POST /orders ──────────► publishAsync() ├─► Command table ✓ └─► Session table (TTL=5m) GET /orders ──────────► Repository.listItemsByPk() ├─► Session table ─► found! version=3 ├─► Command table ─► fetch pending cmd └─► merge into result ✓ ← fresh! ``` ### How it Works (Internals) 1. **Write path** — after a successful `publishAsync`, `CommandService` writes a session entry to `{NODE_ENV}-{APP_NAME}-session` table: - PK: `{userId}#{tenantCode}` - SK: `{moduleTableName}#{itemId}` - `version`: the version number of the published command - `ttl`: current time + `RYW_SESSION_TTL_MINUTES` seconds (DynamoDB TTL auto-deletes the entry) - **Note**: if `RYW_SESSION_TTL_MINUTES` is unset, this write is skipped entirely. `Repository` reads still work correctly — they simply find no session entries and fall back to `DataService`. 2. **Read path** — `Repository` checks the session table before returning data: - For `getItem`: if a session entry exists, fetches the exact versioned command and merges it with any existing data item - For `listItemsByPk`: fetches all session entries for the user/tenant/module, then applies updates, deletes, and create-new prepends to the base list result - For `listItems` (external sources like RDS): same merge logic, with `transformCommand` to convert the command shape to the external query shape. Unlike `listItemsByPk` (which derives `tenantCode` from the `pk` argument), `listItems` derives `tenantCode` from `getUserContext(options.invokeContext)` — so `options` with a valid `invokeContext` is required for RYW to activate 3. **Session expiry** — entries expire automatically via DynamoDB TTL. Once the Stream sync completes (typically 1–3 seconds), the session entry is redundant and will be cleaned up within the TTL window. ### Proactive Session Cleanup (v1.2.6+) {#ryw-session-cleanup} :::info Version Note Proactive RYW session cleanup and the `getVersion` short-circuit were added in [v1.2.6](/docs/changelog#v126). ::: Prior to v1.2.6, RYW sessions persisted until their TTL expired, even after the data table had absorbed the corresponding write. This created a "stale override" window: if another user or external system updated the same item to a newer version, the session would still cause the originating user to see their own older command merged on top of the data table — effectively reverting visible state until the TTL elapsed. Since v1.2.6, `Repository` actively purges sessions in the background once the data table version meets or exceeds the session version. This guarantees that: - The originating user immediately sees the latest data once the Stream sync completes — no more stale-override window - Subsequent reads skip the unnecessary command-table lookup, reducing latency - The session table stays small even when the configured TTL is generous Cleanup is **fire-and-forget** — failures are logged as warnings (`Failed to delete RYW session (non-fatal): ...`) and never block the read path. All behavior is automatic when `RYW_SESSION_TTL_MINUTES` is enabled; no application code changes are required. #### Optional: `getVersion` short-circuit for RDS read models `Repository.listItems()` (the RDS path) now accepts an optional `mergeOptions.getVersion` callback. If your RDS rows already carry a `version` column, supplying this callback lets the merge loop skip the extra DynamoDB GetItem when the existing RDS row already proves caught-up: ```ts await repository.listItems( () => rdsQuery(), { latestFlg: true, transformCommand: (cmd) => ({ id: cmd.id, version: cmd.version, // ...map other CommandModel fields to your RDS row shape }), getVersion: (item) => item.version, // new in v1.2.6 }, options, ) ``` **Note:** `getVersion` only short-circuits the **update path** (when the session's `itemId` matches an existing RDS row). Create-new items (session present but not yet reflected in RDS) still fetch the command as before — there is no existing row to derive a version from. ### Enabling RYW {#enabling-ryw} #### Step 1: Set the environment variable Set [`RYW_SESSION_TTL_MINUTES`](/docs/environment-variables#ryw-session-configuration) to a positive integer. A value of `5` (minutes) is a safe default — it covers any Stream sync delay while keeping the session table small. ```bash RYW_SESSION_TTL_MINUTES=5 ``` If unset (or set to 0 or a non-number), the feature is completely disabled: session writes are skipped and `Repository` behaves identically to `DataService`. #### Step 2: Create the session DynamoDB table Add `dynamodbs/session.json` to your project (same location as your other table definitions): ```json { "TableName": "${NODE_ENV}-${APP_NAME}-session", "BillingMode": "PAY_PER_REQUEST", "KeySchema": [ { "AttributeName": "pk", "KeyType": "HASH" }, { "AttributeName": "sk", "KeyType": "RANGE" } ], "AttributeDefinitions": [ { "AttributeName": "pk", "AttributeType": "S" }, { "AttributeName": "sk", "AttributeType": "S" } ], "TimeToLiveSpecification": { "AttributeName": "ttl", "Enabled": true } } ``` The table name is automatically computed as `{NODE_ENV}-{APP_NAME}-session` (e.g. `dev-myapp-session`). ### Using Repository {#repository-api} `Repository` is a drop-in replacement for `DataService` for read operations that need RYW consistency. It is exported from `@mbc-cqrs-serverless/core` (via `CommandModule`). Register it in your module and inject it in your service. #### Module registration ```ts import { CommandModule } from '@mbc-cqrs-serverless/core' @Module({ imports: [ CommandModule.register({ tableName: 'order', dataSyncHandlers: [OrderDataSyncHandler], }), ], providers: [OrderService], exports: [OrderService], }) export class OrderModule {} ``` `Repository` is automatically provided and exported by `CommandModule.register()` — no extra configuration is needed. #### `getItem` — single item with RYW merge ```ts import { DetailKey, ICommandOptions, Repository } from '@mbc-cqrs-serverless/core' import { Injectable } from '@nestjs/common' @Injectable() export class OrderService { constructor(private readonly repository: Repository) {} async getOrder(key: DetailKey, options: ICommandOptions) { // If the user just published an async command for this item, // the pending version is merged in — the user always sees their own write. return this.repository.getItem(key, options) } } ``` #### `listItemsByPk` — DynamoDB list with RYW merge Pass `{ latestFlg: true }` as the third argument to enable merging. If `mergeOptions` is omitted or `latestFlg` is `false`/`undefined`, the method behaves identically to `DataService.listItemsByPk`. ```ts async listOrders(pk: string, options: ICommandOptions) { return this.repository.listItemsByPk( pk, { limit: 20, order: 'desc' }, // standard DynamoDB query options { latestFlg: true }, // enable RYW merge options, ) } ``` Merge behaviour per operation: | Operation | Result | |---|---| | create-new | Prepended to the top of the list (sorted by `updatedAt` desc among multiple pending creates) | | update | Replaced in-place — original sort position preserved | | delete | Removed from the result | :::note Sort order after create-new Newly created items are prepended to the top of the list, not integrated into the caller's sort order (e.g. by `name` or `code`). They appear at the top until the DynamoDB Stream sync completes and the next read returns fully sorted data. This is intentional — RYW guarantees *visibility*, not sort position. ::: #### `listItems` — external source (RDS / Elasticsearch) with RYW merge For services that query an external source (e.g. RDS via TypeORM or Prisma), use `listItems` with a `transformCommand` function to convert the pending command into the same shape as your query result. ```ts import { IMergeOptions, ICommandOptions, Repository } from '@mbc-cqrs-serverless/core' import { Injectable } from '@nestjs/common' import { OrderDto } from './dto/order.dto' @Injectable() export class OrderService { constructor( private readonly repository: Repository, private readonly orderRepository: TypeOrmOrderRepository, ) {} async searchOrders( filter: OrderFilterDto, options: ICommandOptions, ): Promise<{ total: number; items: OrderDto[] }> { const mergeOptions: IMergeOptions = { latestFlg: true, // Convert a pending CommandModel into an OrderDto transformCommand: (cmd, existing) => ({ id: cmd.id, code: cmd.code, name: cmd.name, status: cmd.attributes?.status ?? existing?.status, // carry over join fields that don't exist in the command customerName: existing?.customerName ?? '', createdAt: cmd.createdAt, updatedAt: cmd.updatedAt, }), // Only prepend create-new items that match the current search filter matchesFilter: (item) => !filter.status || item.status === filter.status, } return this.repository.listItems( // The base query — runs as normal () => this.orderRepository.search(filter), mergeOptions, options, ) } } ``` :::note Pagination with listItems `total` is adjusted after merge (incremented for create-new, decremented for delete). However, prepended items sit outside the RDS `LIMIT`/`OFFSET` window, so on page 2+ there may be a one-item overlap or gap until the Stream sync completes. For most UX patterns (redirect-after-write, optimistic UI) this is unnoticeable. ::: ### Behaviour Summary | Scenario | `RYW_SESSION_TTL_MINUTES` unset | `RYW_SESSION_TTL_MINUTES=5` | |---|---|---| | `getItem` — item just created | Returns stale (empty) | Returns pending command data | | `getItem` — after Stream sync | Returns fresh data | Returns fresh data (session expired or not found) | | `listItemsByPk` with `latestFlg` | Returns stale list | Returns list with pending item prepended | | Performance overhead | None | 1–2 extra DynamoDB reads per pending item | | Impact on existing code | None | None — `DataService` is unchanged | ### Limitations and Trade-offs - **Visibility guarantee, not ordering**: Pending create-new items appear at the top of lists, not in the caller's sort order. - **Single-user scope**: RYW only applies to the user who published the command. Other users still see the eventual-consistent read until the Stream sync completes. - **`listItems` pagination overlap**: On paginated external queries (RDS), a create-new item may appear on both page 1 (prepended) and page 2 (after sync). This resolves on the next full reload. - **Session table cost**: Each `publishAsync` call writes one DynamoDB item per updated entity. With `RYW_SESSION_TTL_MINUTES=5` the items are small and short-lived. Cost is negligible for typical workloads. ## Related Documentation - [Data Service](/docs/data-service) - Querying data with DynamoDB - [Unit Testing](/docs/unit-test) - Unit testing CommandService with mocks - [Service Patterns](/docs/service-patterns) - Complete CRUD service implementation patterns - [Event Handling Patterns](/docs/event-handling-patterns) - Creating data sync handlers - [Version Conflict Guide](/docs/version-conflict-guide) - Handling optimistic locking conflicts - [Interfaces](/docs/interfaces) - TypeScript interfaces for CommandInputModel and more - [Versioning Rules](/docs/version-rules) - VERSION_FIRST, version sequences, and optimistic locking semantics - [Serialization](/docs/serialization) - Serialization of command data with class-transformer --- ## DataService URL: https://mbc-cqrs-serverless.mbc-net.com/docs/data-service # DataService ## Overview {#overview} The `DataService` is the query side of the CQRS pattern, providing efficient read operations for data stored in DynamoDB. It handles all read operations from the data table (the read model) which is optimized for queries. :::tip Read-Your-Writes Consistency `DataService` reads from the DynamoDB data table, which is updated asynchronously via DynamoDB Streams. If you need a user to immediately see their own writes (before the stream propagates), use `Repository` instead — it is a drop-in replacement for `DataService` that adds RYW session caching. See [Read-Your-Writes in CommandService](/docs/command-service#read-your-writes) for setup and usage. ::: ```mermaid graph LR subgraph "CQRS Query Side" A["Application"] --> B["DataService"] B --> C["Data Table"] C --> D["Query Results"] end ``` Before using the DataService, you need to set up the CommandModule as described in [the CommandService section](/docs/command-service). ## Methods {#methods} ### *async* `getItem(key: DetailKey): Promise` The `getItem` method returns a set of attributes for the item with the given detail/primary key. If there is no matching item, `getItem` returns `undefined`. Example: ```ts import { DataService, DataModel } from '@mbc-cqrs-serverless/core'; import { Injectable, NotFoundException } from '@nestjs/common'; @Injectable() export class CatService { constructor(private readonly dataService: DataService) {} async getCat(pk: string, sk: string): Promise { const item = await this.dataService.getItem({ pk, sk }); if (!item) { throw new NotFoundException('Cat not found'); } return new CatDataEntity(item as CatDataEntity); } } ``` ### *async* `listItemsByPk(pk: string, opts?: ListItemsOptions): Promise` The `listItemsByPk` method returns one or more items matching the partition key. It supports filtering, pagination, and sorting. #### Basic Usage List all items by primary key (`pk`): ```ts const res = await this.dataService.listItemsByPk(pk); return new CatListEntity(res); ``` #### With Sort Key Filter List items by primary key (`pk`) and use a filter expression on the sort key (`sk`). For example, get items where the sort key starts with `CAT#` and limit to 100 items: ```ts import { KEY_SEPARATOR } from '@mbc-cqrs-serverless/core'; const query = { sk: { skExpression: 'begins_with(sk, :typeCode)', skAttributeValues: { ':typeCode': `CAT${KEY_SEPARATOR}`, }, }, limit: 100, }; const res = await this.dataService.listItemsByPk(pk, query); return new CatDataListEntity(res); ``` #### Pagination Implement pagination using `startFromSk` and `limit`: ```ts async listCatsWithPagination( tenantCode: string, pageSize: number, lastSk?: string ): Promise<{ items: CatDataEntity[]; lastSk?: string }> { const pk = `CAT#${tenantCode}`; const result = await this.dataService.listItemsByPk(pk, { limit: pageSize, startFromSk: lastSk, }); return { items: result.items.map(item => new CatDataEntity(item)), lastSk: result.lastSk, }; } ``` #### Sort Key Operators The following sort key expressions are supported: | Operator | Expression | Description | |-----------|------------|-------------| | Equals | `sk = :value` | Exact match | | Begins With | `begins_with(sk, :prefix)` | Prefix match | | Between | `sk BETWEEN :start AND :end` | Range query | | Less Than | `sk < :value` | Less than comparison | | Greater Than | `sk > :value` | Greater than comparison | Example with range query: ```ts const query = { sk: { skExpression: 'sk BETWEEN :start AND :end', skAttributeValues: { ':start': 'ORDER#2024-01-01', ':end': 'ORDER#2024-12-31', }, }, }; const res = await this.dataService.listItemsByPk(pk, query); ``` ### *async* `publish(cmd: CommandModel): Promise` The `publish` method publishes command data to the data table. This is typically called internally by the framework's data sync handlers, but can be used directly when implementing custom synchronization logic. :::note This method is primarily used internally by the framework. In most cases, you should use `CommandService.publishSync()` or `CommandService.publishAsync()` which automatically handles data synchronization. ::: ```ts import { CommandModel, DataModel, DataService, DataSyncHandler, IDataSyncHandler, } from "@mbc-cqrs-serverless/core"; import { Injectable } from "@nestjs/common"; // Custom data sync handler example @DataSyncHandler('your-command-table-name') @Injectable() export class CustomDataSyncHandler implements IDataSyncHandler { constructor(private readonly dataService: DataService) {} async up(cmd: CommandModel): Promise { // Publish command to data table const dataModel = await this.dataService.publish(cmd); // Additional synchronization to external systems await this.syncToExternalSystem(dataModel); return dataModel; } async down(cmd: CommandModel): Promise { // Handle rollback if needed } private async syncToExternalSystem(data: DataModel): Promise { // Custom sync logic } } ``` The method: - Converts CommandModel to DataModel format - Removes version suffix from sort key - Preserves original creation metadata (createdAt, createdBy, createdIp) - Updates modification metadata (updatedAt, updatedBy, updatedIp) - Stores the data in the data table ## Common Patterns {#common-patterns} ### Search by Code Find an item by its unique code within a tenant: ```ts async findByCode(tenantCode: string, code: string): Promise { const pk = `CAT#${tenantCode}`; const sk = `CAT#${code}`; const item = await this.dataService.getItem({ pk, sk }); return item ? new CatDataEntity(item) : undefined; } ``` ### List with Type Filter List items filtered by type: ```ts async listByType(tenantCode: string, type: string): Promise { const pk = `CAT#${tenantCode}`; const result = await this.dataService.listItemsByPk(pk, { sk: { skExpression: 'begins_with(sk, :type)', skAttributeValues: { ':type': `${type}#`, }, }, }); return result.items.map(item => new CatDataEntity(item)); } ``` ### Error Handling Handle common query errors gracefully: ```ts import { Injectable, InternalServerErrorException, Logger, NotFoundException, } from '@nestjs/common'; import { DataService } from '@mbc-cqrs-serverless/core'; @Injectable() export class CatService { private readonly logger = new Logger(CatService.name); constructor(private readonly dataService: DataService) {} async getItemSafely(pk: string, sk: string): Promise { try { const item = await this.dataService.getItem({ pk, sk }); if (!item) { throw new NotFoundException(`Item not found: ${pk}/${sk}`); } return new CatDataEntity(item); } catch (error) { if (error instanceof NotFoundException) { throw error; } // Log and rethrow unexpected errors this.logger.error('Unexpected error querying item:', error); throw new InternalServerErrorException('Failed to retrieve item'); } } } ``` ## Type Definitions {#type-definitions} ### DetailKey ```ts interface DetailKey { pk: string; // Partition key sk: string; // Sort key } ``` ### ListItemsOptions `ListItemsOptions` is an inline type definition in the method signature, not a separately exported interface. The type structure is as follows: ```ts { sk?: { skExpression: string; skAttributeValues: Record; skAttributeNames?: Record; }; startFromSk?: string; limit?: number; // Default: 10 order?: 'asc' | 'desc'; } ``` ### DataListEntity `DataListEntity` is a class (not an interface) that wraps list query results. It provides a constructor for easy instantiation: ```ts class DataListEntity { items: DataEntity[]; // Array of data entities lastSk?: string; // Sort key for pagination cursor total?: number; // Total count (if available) constructor(data: Partial); } ``` The constructor accepts a partial object, allowing you to create instances from query results: ```ts const result = await this.dataService.listItemsByPk(pk); const listEntity = new DataListEntity(result); ``` ## HistoryService {#history-service} The `HistoryService` provides access to the history table, which stores all previous versions of commands. Use it when you need to retrieve a specific historical version of an entity for audit trails or rollback scenarios. ```ts import { HistoryService, addSortKeyVersion, } from '@mbc-cqrs-serverless/core'; import { Injectable, NotFoundException } from '@nestjs/common'; @Injectable() export class ProductService { constructor(private readonly historyService: HistoryService) {} async findVersion(pk: string, sk: string, version: number) { const skWithVersion = addSortKeyVersion(sk, version); const item = await this.historyService.getItem({ pk, sk: skWithVersion }); if (!item) { throw new NotFoundException(`Version ${version} not found`); } return item; } } ``` ### *async* `getItem(key: DetailKey): Promise` Retrieves a specific versioned item from the history table. The sort key must include the version suffix (use `addSortKeyVersion` to build it). Returns `undefined` if the version does not exist. :::note The history table uses the same key structure as the data table, but the sort key includes the version suffix: `sk@version`. Always use `addSortKeyVersion(sk, version)` to construct the key. ::: ## Best Practices {#best-practices} 1. **Use projection expressions**: Only retrieve the attributes you need to reduce data transfer 2. **Implement pagination**: Always paginate large result sets to avoid memory issues 3. **Cache frequently accessed data**: Consider caching static or slowly changing data 4. **Use appropriate key design**: Design your keys to support your query patterns efficiently 5. **Handle not found cases**: Always check if the item exists before using it ## Related Documentation - [Command Service](/docs/command-service) - Writing commands with CommandService - [Unit Testing](/docs/unit-test) - Unit testing DataService with mocks - [Service Patterns](/docs/service-patterns) - Complete service layer patterns - [Key Patterns](/docs/key-patterns) - DynamoDB key design for efficient queries - [Entity Patterns](/docs/entity-patterns) - Entity definition with DataEntity - [Serialization Helpers](/docs/serialization) - Converting DynamoDB structures to API response format - [Data Sync Handler Examples](/docs/data-sync-handler-examples) - Using DataService.publish() in sync handlers --- ## Directory URL: https://mbc-cqrs-serverless.mbc-net.com/docs/directory # Directory Directory management functionality with S3 integration for the MBC CQRS Serverless framework. ## Installation {#installation} ```bash npm install @mbc-cqrs-serverless/directory ``` ## Overview {#overview} The Directory package provides comprehensive file and folder management in a multi-tenant CQRS architecture. It integrates with Amazon S3 for file storage and supports granular access permissions. ## Features {#features} - **Directory CRUD Operations**: Create, read, update, and delete folders and files - **S3 Integration**: Full file management with Amazon S3 - **Access Permissions**: Granular permissions for specific folders and files - **Multi-tenant Support**: Tenant-isolated directory management - **Event-Driven Architecture**: Built on CQRS pattern with command/event handling - **RESTful API**: Complete REST API for directory operations - **Version History**: Track and restore previous versions of files and folders ## Basic Setup {#basic-setup} ### Module Configuration ```typescript import { DirectoryStorageModule } from '@mbc-cqrs-serverless/directory'; import { Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; @Module({ imports: [ DirectoryStorageModule.register({ enableController: true, // Enable REST API endpoints prismaService: PrismaService, // Required (the directory service injects it) dataSyncHandlers: [], // Optional data sync handlers }), ], }) export class AppModule {} ``` ## Configurable table name {#configurable-table-name} By default the module uses the `directory` DynamoDB table. You can override the table name and related identifiers with backward-compatible options — omitting them keeps the current behavior. Both `register` and `registerAsync` accept them. | Option | Default | Description | |--------|---------|-------------| | `tableName` | `directory` | Raw DynamoDB base table name. Physical tables become `${NODE_ENV}-${APP_NAME}-${tableName}` with `-command` / `-data` / `-history` suffixes. | | `pkPrefix` | `DIRECTORY` | Partition-key prefix (before the `#` separator). | | `prismaModelName` | `directory` | Prisma model accessor used for RDS reads. | ```typescript // Opt in to running the directory module as "document" DirectoryStorageModule.register({ enableController: true, prismaService: PrismaService, tableName: 'document', pkPrefix: 'DOCUMENT', prismaModelName: 'document', }); // Async configuration — the factory must resolve and return the PrismaService instance DirectoryStorageModule.registerAsync({ tableName: 'document', pkPrefix: 'DOCUMENT', prismaModelName: 'document', imports: [PrismaModule], inject: [PrismaService], useFactory: (prisma) => ({ prismaService: prisma }), }); ``` :::warning Set the three options together Changing only `tableName` leaves the partition key as `DIRECTORY#` and reads `prismaService.directory`, which is inconsistent. Set `tableName`, `pkPrefix`, and `prismaModelName` together. ::: ### Provisioning and data migration Using a custom table name requires application-side provisioning: 1. **DynamoDB:** add the raw base name (e.g. `"document"`) — not the `-command`/`-data`/`-history` variants — to `prisma/dynamodbs/cqrs.json`, then run `npm run migrate:ddb`. This creates the three physical tables. A custom name must be added manually (only the master module's postinstall auto-adds its default). Mirror the tables in your IaC. 2. **RDS:** add the matching Prisma model (e.g. `model Document { ... }`) to `schema.prisma`, then run `npm run migrate:rds`. 3. **Data:** the migrate commands only create empty tables. Copying existing data from `directory-*` / `DIRECTORY#` / the `directory` model to the new targets is your responsibility. :::info Version Note Configurable table names (`tableName`, `pkPrefix`, `prismaModelName`) and `registerAsync` were added in [version 1.4.0](/docs/changelog#v140). ::: ## API Endpoints {#api-endpoints} | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/api/directory/` | Create a new file or folder | | GET | `/api/directory/summary` | Get tenant file size summary | | GET | `/api/directory/:id` | Get details for a specific file or folder | | GET | `/api/directory/:id/history` | Get version history of a file or folder | | POST | `/api/directory/:id/history/:version/restore` | Restore a specific version | | PUT | `/api/directory/:id/restore` | Restore a temporarily deleted item | | PATCH | `/api/directory/:id` | Update a specific file or folder | | PATCH | `/api/directory/:id/permission` | Update permissions for a file or folder | | PATCH | `/api/directory/:id/rename` | Rename a file or folder | | PATCH | `/api/directory/:id/copy` | Copy a file or folder | | PATCH | `/api/directory/:id/move` | Move a file or folder | | DELETE | `/api/directory/:id` | Soft delete a file or folder | | DELETE | `/api/directory/:id/bin` | Permanently delete a file and remove from S3 | | POST | `/api/directory/file/view` | Generate a presigned URL for viewing a file | | POST | `/api/directory/file` | Generate a presigned URL for uploading a file | ## Creating Folders {#creating-folders} ```typescript import { DirectoryService, DirectoryCreateDto, DirectoryDataEntity } from '@mbc-cqrs-serverless/directory'; import { IInvoke } from '@mbc-cqrs-serverless/core'; import { Injectable } from '@nestjs/common'; @Injectable() export class FolderService { constructor(private readonly directoryService: DirectoryService) {} async createFolder( createDto: DirectoryCreateDto, invokeContext: IInvoke, ): Promise { return this.directoryService.create(createDto, { invokeContext }); } } ``` ## Uploading Files {#uploading-files} ```typescript async uploadFile( createDto: DirectoryCreateDto, invokeContext: IInvoke, ): Promise { // File upload is handled through the create method with file content return this.directoryService.create(createDto, { invokeContext }); } ``` ## Listing Contents {#listing-contents} ```typescript async getDirectory( detailDto: DetailDto, invokeContext: IInvoke, queryDto: DirectoryDetailDto, ): Promise { return this.directoryService.findOne(detailDto, { invokeContext }, queryDto); } async getDirectoryHistory( detailDto: DetailDto, invokeContext: IInvoke, queryDto: DirectoryDetailDto, ): Promise { return this.directoryService.findHistory(detailDto, { invokeContext }, queryDto); } ``` ## File Operations {#file-operations} ```typescript // Get file attributes async getFileAttributes(detailDto: DetailDto): Promise { return this.directoryService.getItemAttributes(detailDto); } // Get file item async getFile(detailDto: DetailDto): Promise { return this.directoryService.getItem(detailDto); } // Soft delete (marks as deleted) async removeItem( detailDto: DetailDto, invokeContext: IInvoke, queryDto: DirectoryDetailDto, ): Promise { return this.directoryService.remove(detailDto, { invokeContext }, queryDto); } // Permanently remove file and delete from S3 async removeFile( detailDto: DetailDto, invokeContext: IInvoke, queryDto: DirectoryDetailDto, ): Promise { return this.directoryService.removeFile(detailDto, { invokeContext }, queryDto); } ``` ## Updating Items {#updating-items} ```typescript import { DirectoryUpdateDto } from '@mbc-cqrs-serverless/directory'; async updateItem( detailDto: DetailDto, updateDto: DirectoryUpdateDto, invokeContext: IInvoke, ): Promise { return this.directoryService.update(detailDto, updateDto, { invokeContext }); } ``` ## Renaming Items {#renaming-items} ```typescript import { DirectoryRenameDto } from '@mbc-cqrs-serverless/directory'; async renameItem( detailDto: DetailDto, renameDto: DirectoryRenameDto, invokeContext: IInvoke, ): Promise { return this.directoryService.rename(detailDto, renameDto, { invokeContext }); } ``` ## Managing Permissions {#managing-permissions} ### Permission Types The directory package supports different permission types: ```typescript enum FilePermission { GENERAL = 'GENERAL', // General access for everyone RESTRICTED = 'RESTRICTED', // Restricted to specific users DOMAIN = 'DOMAIN', // Restricted to specific email domain TENANT = 'TENANT', // Restricted to tenant members } enum FileRole { READ = 'READ', WRITE = 'WRITE', DELETE = 'DELETE', CHANGE_PERMISSION = 'CHANGE_PERMISSION', TAKE_OWNERSHIP = 'TAKE_OWNERSHIP', } enum EmailType { EMAIL = 'EMAIL', // Individual email address EMAIL_GROUP = 'EMAIL_GROUP', // Email group or distribution list } ``` ### Directory Attributes The DirectoryAttributes interface defines the metadata for files and folders: ```typescript interface DirectoryAttributes { expirationTime?: string; // Expiration time for the item fileSize?: number; // File size in bytes fileType?: string; // MIME type of the file parentId?: string; // Parent folder ID owner: OwnerDto; // Owner information s3Key?: string; // S3 object key ancestors?: string[]; // Array of ancestor folder IDs inheritance?: boolean; // Whether to inherit parent permissions tags?: string[]; // Tags for categorization permission?: PermissionDto; // Permission settings } interface OwnerDto { email: string; // Owner's email address ownerId: string; // Owner's user ID } interface PermissionDto { type: FilePermission; // Permission type role: FileRole; // Default role for this permission domain?: DomainDto; // Domain restriction (for DOMAIN type) users?: UserPermissionDto[]; // User-specific permissions (for RESTRICTED type) } interface DomainDto { email: string; // Email domain (e.g., "example.com") } interface UserPermissionDto { email: string; // User's email address role: FileRole; // Role assigned to this user id: string; // User ID type: EmailType; // Email type (EMAIL or EMAIL_GROUP) } ``` ### Updating Permissions ```typescript import { DirectoryUpdatePermissionDto } from '@mbc-cqrs-serverless/directory'; async updatePermission( detailDto: DetailDto, updateDto: DirectoryUpdatePermissionDto, invokeContext: IInvoke, ): Promise { return this.directoryService.updatePermission(detailDto, updateDto, { invokeContext }); } ``` ### Checking Permissions `hasPermission()` returns `true` when the user holds at least one of the required roles on the item. `getEffectiveRole()` returns the highest role the user holds, or `null` if they have no access. ```typescript // Returns true if user holds any of the required roles on the item async hasPermission( itemId: DetailDto, requiredRole: FileRole[], user?: { email?: string; tenant?: string }, ): Promise { return this.directoryService.hasPermission(itemId, requiredRole, user); } // Returns the effective FileRole the user holds, or null if no access async getEffectiveRole( itemId: DetailDto, user?: { email?: string; tenant?: string }, ): Promise { return this.directoryService.getEffectiveRole(itemId, user); } ``` ## Moving and Copying {#moving-copying} ### Move Item ```typescript import { DirectoryMoveDto } from '@mbc-cqrs-serverless/directory'; async moveItem( detailDto: DetailDto, moveDto: DirectoryMoveDto, invokeContext: IInvoke, ): Promise { return this.directoryService.move(detailDto, moveDto, { invokeContext }); } ``` ### Copy Item ```typescript import { DirectoryCopyDto } from '@mbc-cqrs-serverless/directory'; async copyItem( detailDto: DetailDto, copyDto: DirectoryCopyDto, invokeContext: IInvoke, ): Promise { return this.directoryService.copy(detailDto, copyDto, { invokeContext }); } ``` ## Version History {#version-history} ### Restore Previous Version ```typescript async restoreVersion( detailDto: DetailDto, version: string, queryDto: DirectoryDetailDto, invokeContext: IInvoke, ): Promise { return this.directoryService.restoreHistoryItem(detailDto, version, queryDto, { invokeContext }); } ``` ### Restore Temporarily Deleted Item ```typescript async restoreTemporary( detailDto: DetailDto, queryDto: DirectoryDetailDto, invokeContext: IInvoke, ): Promise { return this.directoryService.restoreTemporary(detailDto, queryDto, { invokeContext }); } ``` ## Directory DTOs {#directory-dtos} The directory package provides several DTOs for different operations: ### DirectoryCreateDto ```typescript interface DirectoryCreateDto { name: string; // Item name type: string; // Item type (e.g., 'folder', 'file') attributes?: DirectoryAttributes; // Optional attributes } ``` ### DirectoryUpdateDto ```typescript interface DirectoryUpdateDto { email: string; // Requester's email for permission check name?: string; // New name (optional) isDeleted?: boolean; // Deletion flag attributes?: DirectoryAttributes; // Updated attributes } ``` ### DirectoryRenameDto ```typescript interface DirectoryRenameDto { name: string; // New name email: string; // Requester's email for permission check } ``` ### DirectoryMoveDto ```typescript interface DirectoryMoveDto { parentId?: string; // Target parent folder ID email: string; // Requester's email for permission check } ``` ### DirectoryCopyDto ```typescript interface DirectoryCopyDto { path: string; // S3 path for the copied file parentId?: string; // Target parent folder ID email: string; // Requester's email for permission check } ``` ### DirectoryDetailDto ```typescript interface DirectoryDetailDto { email: string; // Requester's email for permission check } ``` ### DirectoryUpdatePermissionDto ```typescript interface DirectoryUpdatePermissionDto { email: string; // Requester's email for permission check attributes?: { permission?: PermissionDto; // New permission settings inheritance?: boolean; // Whether to inherit parent permissions }; } ``` ## Directory Structure {#directory-structure} Example directory structure: ```text / ├── documents/ │ ├── reports/ │ │ ├── 2024-Q1-report.pdf │ │ └── 2024-Q2-report.pdf │ └── contracts/ │ └── contract-001.pdf ├── images/ │ ├── logo.png │ └── banner.jpg └── templates/ └── invoice-template.docx ``` ## Multi-tenant Isolation {#multi-tenant-isolation} Directories are automatically isolated by tenant through the invoke context: ```typescript @Controller('api/directory') export class DirectoryController { constructor(private readonly directoryService: DirectoryService) {} @Get(':id') async findOne( @INVOKE_CONTEXT() invokeContext: IInvoke, @DetailKeys() detailDto: DetailDto, @Query() queryDto: DirectoryDetailDto, ): Promise { // Tenant isolation is handled through the pk structure return this.directoryService.findOne(detailDto, { invokeContext }, queryDto); } } ``` ## Event Handling {#event-handling} Handle directory data synchronization using data sync handlers: ```typescript import { CommandModel, IDataSyncHandler } from '@mbc-cqrs-serverless/core'; import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma'; @Injectable() export class DirectoryDataSyncHandler implements IDataSyncHandler { constructor(private readonly prisma: PrismaService) {} async up(cmd: CommandModel): Promise { // Called automatically on every command event — sync to RDS, notify users, update indexes, etc. await this.prisma.directory.upsert({ where: { sk: cmd.sk }, create: { sk: cmd.sk, pk: cmd.pk, name: cmd.name, ...cmd.attributes }, update: { name: cmd.name, ...cmd.attributes }, }); } async down(cmd: CommandModel): Promise { // Reserved for manual rollback — not called automatically by the framework await this.prisma.directory.delete({ where: { sk: cmd.sk } }); } } ``` ## Best Practices {#best-practices} 1. **Use Folders for Organization**: Create a logical folder structure for easy navigation 2. **Set Permissions Early**: Configure permissions when creating directories 3. **Handle Large Files**: For large files, use presigned URLs for direct S3 upload 4. **Clean Up**: Implement retention policies for temporary files 5. **Audit Trail**: Use events to maintain an audit trail of all operations 6. **Use Soft Delete**: Prefer soft delete (remove) over permanent delete (removeFile) for data recovery ## Related Documentation - [Multi-Tenant Patterns](/docs/multi-tenant-patterns) - Tenant file isolation - [Environment Variables](/docs/environment-variables) - S3 configuration - [Interfaces](/docs/interfaces) - Directory interfaces - [Data Sync Handler Examples](/docs/data-sync-handler-examples) - Sync directory events to RDS --- ## EmailService URL: https://mbc-cqrs-serverless.mbc-net.com/docs/email-service # EmailService ## Description {#description} This service is designed to send emails using [AWS SES (Simple Email Service)](https://aws.amazon.com/ses/). ## Usage {#usage} `EmailService` is exported from `@mbc-cqrs-serverless/core` and is registered as a global provider automatically by the framework. Inject it into any service using the standard NestJS constructor injection: ```typescript import { EmailService, EmailNotification } from '@mbc-cqrs-serverless/core'; import { Injectable } from '@nestjs/common'; @Injectable() export class NotificationService { constructor(private readonly emailService: EmailService) {} async sendWelcomeEmail(email: string, name: string): Promise { await this.emailService.sendEmail({ toAddrs: [email], subject: `Welcome, ${name}!`, body: `

Thank you for joining.

`, }); } } ``` Set the `SES_REGION` and optionally `SES_FROM_EMAIL` environment variables for SES configuration. See [Environment Variables](/docs/environment-variables) for details. ## Methods {#methods} ### *async* `sendEmail(msg: EmailNotification): Promise` Composes an email message and immediately queues it for sending. Returns `Promise` — the AWS SES response includes a `MessageId` string you can capture for tracking: ```ts const result = await this.emailService.sendEmail({ toAddrs: ["recipient@example.com"], subject: "Hello", body: "

World

", }); console.log(result.MessageId); // Log MessageId for email tracking ``` #### Basic Example ```ts const email = "cat@example.com"; const subject = "Welcome to MBC CQRS Serverless framework!"; const body = "

Enjoy

"; await this.emailService.sendEmail({ toAddrs: [email], subject, body, }); ``` #### With CC and BCC ```ts await this.emailService.sendEmail({ toAddrs: ["recipient@example.com"], ccAddrs: ["cc@example.com"], bccAddrs: ["bcc@example.com"], subject: "Meeting Invitation", body: "

Please join our meeting.

", }); ``` #### With Attachments You can attach files to emails by providing an array of attachment objects: ```ts await this.emailService.sendEmail({ toAddrs: ["recipient@example.com"], subject: "Report Attached", body: "

Please find the attached report.

", attachments: [ { filename: "report.pdf", content: pdfBuffer, contentType: "application/pdf", }, ], }); ``` #### Multiple Attachments ```ts await this.emailService.sendEmail({ toAddrs: ["recipient@example.com"], subject: "Documents", body: "

Please find the attached documents.

", attachments: [ { filename: "document.pdf", content: pdfBuffer, contentType: "application/pdf", }, { filename: "image.jpg", content: imageBuffer, contentType: "image/jpeg", }, { filename: "data.csv", content: csvBuffer, contentType: "text/csv", }, ], }); ``` ## EmailNotification Interface {#email-notification-interface} | Property | Type | Required | Description | |----------|------|----------|-------------| | `fromAddr` | `string` | No | Sender email address (uses default if not specified) | | `toAddrs` | `string[]` | Yes | List of recipient email addresses | | `ccAddrs` | `string[]` | No | CC recipients | | `bccAddrs` | `string[]` | No | BCC recipients | | `subject` | `string` | Yes | Email subject line | | `body` | `string` | Yes | Email body as HTML | | `replyToAddrs` | `string[]` | No | Reply-to addresses | | `attachments` | `Attachment[]` | No | File attachments | | `emailTags` | `EmailTag[]` | No | AWS SES tags for categorization and filtering | ## Attachment Interface {#attachment-interface} | Property | Type | Required | Description | |----------|------|----------|-------------| | `filename` | `string` | Yes | Filename shown to recipient | | `content` | `Buffer` | Yes | File content as Buffer | | `contentType` | `string` | No | MIME type (e.g., 'application/pdf') | ### *async* `sendInlineTemplateEmail(msg: TemplatedEmailNotification): Promise` {#send-inline-template-email} :::info Version Note `sendInlineTemplateEmail()` was added in [version 1.0.23](/docs/changelog#v1023). ::: Sends a templated email using `{{variableName}}` placeholders in the subject and body. Unlike SES registered templates, the template is inlined in the request — no pre-registration required. ```ts import { EmailService, TemplatedEmailNotification } from '@mbc-cqrs-serverless/core'; await this.emailService.sendInlineTemplateEmail({ toAddrs: ['user@example.com'], template: { subject: '{{orderType}} Confirmation — Order {{orderId}}', html: '

Hello {{name}}!

Your order #{{orderId}} is confirmed.

', text: 'Hello {{name}}, your order #{{orderId}} is confirmed.', }, data: { name: 'Jane Doe', orderId: '12345', orderType: 'Purchase', }, }); ``` For the full interface definition and advanced template features (whitespace trimming, nested data), see [Notification Module — Inline Template Emails](/docs/notification-module#inline-template-emails). ## Testing {#testing} Mock `EmailService` in unit tests to avoid making real SES calls: ```typescript // In your test module setup const mockEmailService = { sendEmail: jest.fn().mockResolvedValue({ MessageId: 'test-message-id' }), }; const module = await Test.createTestingModule({ providers: [ YourService, { provide: EmailService, useValue: mockEmailService }, ], }).compile(); // Assert the email was sent with the expected parameters expect(mockEmailService.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ toAddrs: ['user@example.com'], subject: expect.stringContaining('Welcome'), }), ); ``` ## Related Documentation - [Notification Module](/docs/notification-module) - Real-time notifications with AppSync - [Environment Variables](/docs/environment-variables) - SES configuration environment variables - [Interfaces](/docs/interfaces) - EmailNotification interface reference - [Unit Testing](/docs/unit-test) - Unit testing patterns for services --- ## Master URL: https://mbc-cqrs-serverless.mbc-net.com/docs/master # Master The Master Service provides functionality for managing master data and settings in a multi-tenant environment. ## Overview {#overview} The Master Service consists of two main components: ### Master Setting Service - Implements hierarchical settings management - Supports creation of settings at all levels - Provides update and delete operations for tenant settings - Implements cascading settings retrieval ### Master Data Service - Implements CRUD operations for master data entities - Provides list and retrieval functionality - Includes code validation capabilities - Ensures data integrity across tenant boundaries ## Architecture {#architecture} ```mermaid graph TB subgraph "Settings Hierarchy" A["COMMON"] --> B["Tenant"] B --> C["Group"] C --> D["User"] end subgraph "Master Data" E["MasterDataService"] --> F["Code Tables"] E --> G["Categories"] E --> H["Lookup Values"] end ``` ## Settings Hierarchy Levels {#settings-hierarchy} The Master Setting Service implements a four-level hierarchy. Settings cascade from the most specific level toward the most general — `getSetting` returns the first match found: | Level | Scope | Created with | Use case | |-----------|-----------|-----------------|-------------| | **Common** | System-wide default | `createCommonTenantSetting` | Baseline values shared by all tenants | | **Tenant** | Single tenant | `createTenantSetting` | Tenant-specific overrides of common defaults | | **Group** | Tenant group | `createGroupSetting` | Group-level overrides within a tenant | | **User** | Individual user | `createUserSetting` | Personal preferences within a tenant | `getSetting` resolves a setting code in this order: **User → Group → Tenant → Common**. If no setting exists at any level, a `NotFoundException` is thrown. ## Installation {#installation} ```bash npm install @mbc-cqrs-serverless/master ``` ## Basic Usage {#basic-usage} The solution for customizing the behavior of the `MasterModule` is to pass it an options `object` in the static `register()` method. ### Module Options | Option | Type | Description | |--------|------|-------------| | `enableController` | `boolean` | Enable or disable default master controller | | `dataSyncHandlers` | `Type[]` | Optional handlers to sync master data to external systems (e.g., RDS) | | `prismaService` | `Type` | Prisma service for RDS-backed queries. Required when `enableController: true` | :::warning prismaService Requirement When `enableController: true`, the `prismaService` parameter is **required**. The module will throw an error at startup if `prismaService` is not provided when the controller is enabled. ::: ```ts import { MasterModule } from '@mbc-cqrs-serverless/master' @Module({ imports: [ MasterModule.register({ enableController: true, dataSyncHandlers: [MasterDataRdsSyncHandler], prismaService: PrismaService, })], controllers: [], exports: [], }) ``` ## API Reference {#api-reference} ### MasterSettingService The MasterSettingService interface manages settings at various levels: user, group, tenant, and common. It allows retrieving, updating, creating, and deleting settings. #### `getSetting(dto: GetSettingDto, context: { invokeContext: IInvoke }): Promise` Retrieves a specific setting based on the provided setting code. ```ts const masterSetting = await this.masterSettingService.getSetting( { code: "service", }, { invokeContext } ); ``` #### `createCommonTenantSetting(dto: CommonSettingDto, context: { invokeContext: IInvoke }): Promise` Creates a common tenant setting that is shared across the system. ```ts const masterSetting = await this.masterSettingService.createCommonTenantSetting( { name: "common setting", code: "service", settingValue: { region: "US", plan: "common" } }, { invokeContext } ); ``` #### `createTenantSetting(dto: TenantSettingDto, context: { invokeContext: IInvoke }): Promise` Creates a tenant-specific setting. ```ts const masterSetting = await this.masterSettingService.createTenantSetting( { name: "tenant setting", code: "service", tenantCode: "mbc", settingValue: { region: "US", plan: "tenant" } }, { invokeContext } ); ``` #### `createGroupSetting(dto: GroupSettingDto, context: { invokeContext: IInvoke }): Promise` Creates a group-specific setting within a tenant. ```ts const masterSetting = await this.masterSettingService.createGroupSetting( { name: "group setting", code: "service", tenantCode: "mbc", groupId: "12", settingValue: { region: "US", plan: "USER" } }, { invokeContext } ); ``` #### `createUserSetting(dto: UserSettingDto, context: { invokeContext: IInvoke }): Promise` Creates a user-specific setting within a tenant. ```ts const masterSetting = await this.masterSettingService.createUserSetting( { name: "user setting", code: "service", tenantCode: "mbc", userId: "92ca4f68-9ac6-4080-9ae2-2f02a86206a4", settingValue: { region: "US", plan: "USER" } }, { invokeContext } ); ``` #### `updateSetting(key: DetailKey, dto: UpdateSettingDto, context: { invokeContext: IInvoke }): Promise` Updates an existing setting. ```ts const masterSetting = await this.masterSettingService.updateSetting( { pk: "MASTER#abc", sk: "MASTER_SETTING#service" }, { name: 'Example Master Setting', settingValue: { homepage: "url", desc: "string" } }, { invokeContext } ); ``` #### `deleteSetting(key: DetailKey, context: { invokeContext: IInvoke }): Promise` Deletes a specific setting based on the provided key. ```ts const masterSetting = await this.masterSettingService.deleteSetting( { pk: "MASTER#abc", sk: "MASTER_SETTING#service" }, { invokeContext } ); ``` #### `list(searchDto: MasterSettingSearchDto, invokeContext: IInvoke): Promise` Lists master settings with pagination and filtering. Requires RDS (Prisma) configuration. ```ts const result = await this.masterSettingService.list( { name: "service", // Partial match for name code: "SVC", // Partial match for code keyword: "description", // Search in attributes.description page: 1, pageSize: 10, orderBys: ["-createdAt"], }, invokeContext ); ``` #### `getDetail(key: DetailDto): Promise` Retrieves detailed master setting. Throws NotFoundException if not found. ```ts const masterSetting = await this.masterSettingService.getDetail({ pk: "MASTER#mbc", sk: "MASTER_SETTING#service" }); ``` #### `create(createDto: CommonSettingDto, invokeContext: IInvoke): Promise` Creates a new tenant setting. Wrapper for createTenantSetting with automatic tenant code extraction from context. ```ts const masterSetting = await this.masterSettingService.create( { code: "service", name: "Service Setting", settingValue: { key: "value" } }, invokeContext ); ``` #### `createBulk(createDto: CommonSettingBulkDto, invokeContext: IInvoke): Promise` Creates multiple settings at once. :::warning Create-only Operation `createBulk` internally calls `create` for each item, which throws a `BadRequestException` if the setting already exists (e.g., `"Setting already exists: {code}"`). This method cannot be used to update existing settings. If you need upsert behavior (create or update), see the [Upsert Pattern](#upsert-pattern) section below. ::: ```ts const settings = await this.masterSettingService.createBulk( { items: [ { code: "setting1", name: "Setting 1", settingValue: {} }, { code: "setting2", name: "Setting 2", settingValue: {} } ] }, invokeContext ); ``` #### `update(key: DetailDto, updateDto: MasterSettingUpdateDto, invokeContext: IInvoke): Promise` Updates a master setting. ```ts const result = await this.masterSettingService.update( { pk: "MASTER#mbc", sk: "MASTER_SETTING#service" }, { name: "Updated Setting", attributes: { newKey: "newValue" } }, invokeContext ); ``` #### `delete(key: DetailDto, invokeContext: IInvoke): Promise` Deletes a master setting. Wrapper for deleteSetting. ```ts await this.masterSettingService.delete( { pk: "MASTER#mbc", sk: "MASTER_SETTING#service" }, invokeContext ); ``` #### `checkExistCode(code: string, invokeContext: IInvoke): Promise` Checks if a setting code already exists for the current tenant. ```ts const exists = await this.masterSettingService.checkExistCode("service", invokeContext); if (exists) { // Handle duplicate code } ``` #### `copy(masterCopyDto: MasterCopyDto, opts: { invokeContext: IInvoke }): Promise` Copies master settings and data to other tenants asynchronously using Step Functions. This is useful for initializing new tenants with existing master data. ```ts const task = await this.masterSettingService.copy( { masterSettingId: "MASTER#mbc#MASTER_SETTING#service", targetTenants: ["tenant1", "tenant2"], copyType: CopyType.BOTH, // CopyType.SETTING_ONLY, CopyType.DATA_ONLY, or CopyType.BOTH dataCopyOption: { mode: DataCopyMode.ALL, // or DataCopyMode.PARTIAL // id: ["id1", "id2"] // Required when mode is PARTIAL } }, { invokeContext } ); // Returns a task entity - the copy operation runs asynchronously ``` Copy types: - `CopyType.SETTING_ONLY`: Copies only the setting - `CopyType.DATA_ONLY`: Copies only the data - `CopyType.BOTH`: Copies both setting and data Data copy modes (used when copyType is DATA_ONLY or BOTH): - `DataCopyMode.ALL`: Copies all master data under the setting - `DataCopyMode.PARTIAL`: Copies only specified IDs ### MasterDataService The MasterDataService service provides methods to manage master data and operations. This includes listing, retrieving, creating, updating, and deleting data, as well as checking for the existence of specific codes. #### `list(searchDto: MasterDataSearchDto): Promise` Lists master data based on the provided search criteria. Note: This method does not require an invoke context. ```ts const masterData = await this.masterDataService.list({ tenantCode: "mbc", settingCode: "service" }); ``` #### `get(key: DetailDto): Promise` Get a master data by pk and sk. ```ts const masterData = await this.masterDataService.get( { pk:"MASTER#abc", sk:"service#01" } ); ``` #### `create(data: CreateMasterDataDto, context: { invokeContext: IInvoke }): Promise` Creates a new master data entity ```ts const masterData = await this.masterDataService.create( { code: 'MASTER001', name: 'Example Master Data', settingCode: "service", tenantCode: "common", attributes: { homepage: "http://mbc.com", desc: "description for mbc" } }, { invokeContext } ); ``` #### `update(key: DetailDto, updateDto: UpdateDataSettingDto, context: { invokeContext: IInvoke }): Promise` Updates existing master data. ```ts const masterData = await this.masterDataService.update( { pk: "MASTER#abc", sk: "service#01" }, { name: 'Example Master Data', attributes: { homepage: "http://mbc.com", desc: "description for mbc" } }, { invokeContext } ); ``` #### `delete(key: DetailDto, opts: { invokeContext: IInvoke }): Promise` Deletes specific master data based on the provided key. ```ts const masterData = await this.masterDataService.delete( { pk: "MASTER#abc", sk: "service#01" }, { invokeContext } ); ``` #### `checkExistCode(tenantCode: string, type: string, code: string): Promise` Checks if a specific code exists within the given tenant and type. ```ts const exists = await this.masterDataService.checkExistCode("mbc", "service", "01"); if (exists) { // Handle existing code } ``` #### `getDetail(key: DetailDto): Promise` Retrieves detailed master data including related information. Throws NotFoundException if not found. ```ts const masterData = await this.masterDataService.getDetail({ pk: "MASTER#mbc", sk: "service#01" }); ``` #### `createSetting(createDto: MasterDataCreateDto, invokeContext: IInvoke): Promise` Creates a new master data entity with automatic sequence generation if not provided. :::warning Create-only Operation `createSetting` throws a `BadRequestException` if the master data already exists (e.g., `"Master data already exists"`). If you need upsert behavior (create or update), see the [Upsert Pattern](#upsert-pattern) section below. ::: ```ts const masterData = await this.masterDataService.createSetting( { code: 'MASTER001', name: 'Example Master Data', settingCode: "service", tenantCode: "mbc", attributes: { homepage: "http://mbc.com", desc: "description for mbc" } }, invokeContext ); ``` #### `createBulk(createDto: MasterDataCreateBulkDto, invokeContext: IInvoke): Promise` Creates multiple master data entities in bulk. :::warning Create-only Operation `createBulk` internally calls `createSetting` for each item. It throws a `BadRequestException` if any item already exists. If you need upsert behavior, see the [Upsert Pattern](#upsert-pattern) section below. ::: ```ts const masterDataList = await this.masterDataService.createBulk( { items: [ { code: 'MASTER001', name: 'First Master Data', settingCode: "service", tenantCode: "mbc", attributes: {} }, { code: 'MASTER002', name: 'Second Master Data', settingCode: "service", tenantCode: "mbc", attributes: {} } ] }, invokeContext ); ``` #### `updateSetting(key: DetailDto, updateDto: MasterDataUpdateDto, invokeContext: IInvoke): Promise` Updates an existing master data entity. ```ts const masterData = await this.masterDataService.updateSetting( { pk: "MASTER#mbc", sk: "service#01" }, { name: 'Updated Master Data', attributes: { homepage: "http://updated-mbc.com" } }, invokeContext ); ``` #### `deleteSetting(key: DetailDto, invokeContext: IInvoke): Promise` Deletes a master data entity by key. ```ts const result = await this.masterDataService.deleteSetting( { pk: "MASTER#mbc", sk: "service#01" }, invokeContext ); ``` #### `listByRds(searchDto: CustomMasterDataSearchDto, context: { invokeContext: IInvoke }): Promise` Searches master data in RDS with filtering and pagination. This method is used when Prisma service is configured. ```ts const result = await this.masterDataService.listByRds( { settingCode: "service", // Exact match for master type code keyword: "example", // Partial match (case-insensitive) for name code: "001", // Partial match (case-insensitive) for master code page: 1, pageSize: 10, orderBys: ["seq", "masterCode"], }, { invokeContext } ); ``` ##### Search Parameters {#search-parameters} | Parameter | Type | Required | Match Type | Description | |---------------|----------|--------------|----------------|-----------------| | `settingCode` | `string` | No | Exact match | Filter by master type code (masterTypeCode) | | `keyword` | `string` | No | Partial match (case-insensitive) | Filter by name field | | `code` | `string` | No | Partial match (case-insensitive) | Filter by master code | | `page` | `number` | No | - | Page number (default: 1) | | `pageSize` | `number` | No | - | Items per page (default: 10) | | `orderBys` | `string[]` | No | - | Sort order (default: ["seq", "masterCode"]) | | `isDeleted` | `boolean` | No | Exact match | Filter by deletion status | :::warning Known Issue (Fixed in v1.0.17) In versions prior to v1.0.17, the `settingCode` parameter incorrectly used partial matching (`contains`) instead of exact matching. This caused unintended search results - for example, searching for "PRODUCT" would also return "PRODUCT_TYPE" and "MY_PRODUCT". If you are using v1.0.16 or earlier and need exact matching for `settingCode`, upgrade to v1.0.17 or later. See also: [Changelog v1.0.17](/docs/changelog#v1017) ::: ## Built-in Upsert API {#upsert-pattern} The framework provides built-in upsert methods that automatically handle both creating new records and updating existing ones. These methods check DynamoDB for existing data and decide whether to create or update, skipping unchanged records for efficiency. :::info Version Note Built-in upsert methods (`upsert`, `upsertBulk`, `upsertSetting`, `upsertTenantSetting`) were added in [version 1.1.2](/docs/changelog#v112). In earlier versions, you need to implement custom upsert logic (see [Legacy Upsert Pattern](#legacy-upsert-pattern) below). ::: ### MasterSettingService Upsert Methods #### `upsertTenantSetting(dto: TenantSettingDto, options: { invokeContext: IInvoke }): Promise` Creates or updates a tenant setting. If the setting exists and has changes, it updates. If the setting exists but is unchanged, it returns the existing data without creating a new command. If the setting is deleted, it recreates it. ```ts const result = await this.masterSettingService.upsertTenantSetting( { tenantCode: "mbc", code: "service", name: "Service Setting", settingValue: { region: "US", plan: "Premium" }, }, { invokeContext } ); ``` #### `upsertSetting(createDto: CommonSettingDto, invokeContext: IInvoke): Promise` Wrapper for `upsertTenantSetting` with automatic tenant code extraction from context. ```ts const result = await this.masterSettingService.upsertSetting( { code: "service", name: "Service Setting", settingValue: { region: "US", plan: "Premium" }, }, invokeContext ); ``` #### `upsertBulk(createDto: CommonSettingBulkDto, invokeContext: IInvoke): Promise` Upserts multiple settings sequentially. Items are processed one by one to avoid race conditions. ```ts const results = await this.masterSettingService.upsertBulk( { items: [ { code: "setting1", name: "Setting 1", settingValue: { key: "value1" } }, { code: "setting2", name: "Setting 2", settingValue: { key: "value2" } }, ] }, invokeContext ); ``` ### MasterDataService Upsert Methods #### `upsert(createDto: CreateMasterDataDto, opts: { invokeContext: IInvoke }): Promise` Creates or updates a master data entity. Behaves the same as `create` but does not throw when the record already exists. ```ts const result = await this.masterDataService.upsert( { code: 'MASTER001', name: 'Example Master Data', settingCode: "service", tenantCode: "mbc", attributes: { homepage: "http://mbc.com" }, }, { invokeContext } ); ``` #### `upsertSetting(createDto: MasterDataCreateDto, invokeContext: IInvoke): Promise` Wrapper for `upsert` with automatic sequence generation and tenant code extraction. ```ts const result = await this.masterDataService.upsertSetting( { code: 'MASTER001', name: 'Example Master Data', settingCode: "service", attributes: { homepage: "http://mbc.com" }, }, invokeContext ); ``` #### `upsertBulk(createDto: MasterDataCreateBulkDto, invokeContext: IInvoke): Promise` Upserts multiple master data entities sequentially. Items are processed one by one to avoid seq race conditions. ```ts const results = await this.masterDataService.upsertBulk( { items: [ { code: 'DATA001', name: 'First Data', settingCode: "service", attributes: {} }, { code: 'DATA002', name: 'Second Data', settingCode: "service", attributes: {} }, ] }, invokeContext ); ``` ### Unified Bulk Upsert API (MasterBulkController) {#unified-bulk-upsert} When the controller is enabled (`enableController: true`), the framework provides a unified `/api/master-bulk/` endpoint that can handle both settings and data in a single request. Items are routed based on the presence of `settingCode`: - **With `settingCode`**: Routed to `MasterDataService.upsertBulk` - **Without `settingCode`**: Routed to `MasterSettingService.upsertBulk` ```ts // POST /api/master-bulk/ const requestBody = { items: [ // This item has settingCode → treated as master data { name: "Data Item", code: "DATA001", settingCode: "UserList", seq: 1, attributes: { field: "value" }, }, // This item has no settingCode → treated as master setting { name: "Setting Item", code: "SettingA", attributes: { description: "A setting" }, }, ] }; ``` The response preserves the original input order. Tenant code validation is enforced: if `tenantCode` is specified in an item, it must match the authenticated user's tenant. #### Request Body | Field | Type | Required | Description | |-----------|----------|--------------|-----------------| | `items` | `MasterBulkItemDto[]` | Yes | Array of items to upsert (max 100) | #### MasterBulkItemDto | Field | Type | Required | Description | |-----------|----------|--------------|-----------------| | `name` | `string` | Yes | Name of the setting or data | | `code` | `string` | Yes | Code of the setting or data | | `tenantCode` | `string` | No | Tenant code (must match authenticated user's tenant if specified) | | `settingCode` | `string` | No | If present, item is treated as master data; otherwise as master setting | | `seq` | `number` | No | Sort order (used for master data) | | `attributes` | `object` | Yes | Attributes object. For settings, this is used as settingValue | ### Upsert Behavior Details {#upsert-behavior} The upsert methods follow these rules: 1. **New record**: If no existing record is found, creates a new one (version starts at 0) 2. **Existing record with changes**: If the record exists and the input differs, updates it using the existing version number 3. **Existing record unchanged**: If the record exists and the input is identical, skips the update and returns the existing data 4. **Deleted record**: If the record exists but is marked as deleted (`isDeleted: true`), recreates it using the existing version number ### Legacy Upsert Pattern {#legacy-upsert-pattern} :::warning Deprecated Pattern The custom upsert pattern below was required in versions prior to 1.1.2. For new projects, use the [built-in upsert methods](#upsert-pattern) instead. ::: For versions prior to 1.1.2, implement a custom upsert service that checks for existing records before deciding whether to call create or update. See the [v1.1.1 documentation](https://github.com/mbc-net/mbc-cqrs-serverless/tree/v1.1.1) for the full legacy pattern. ## Related Documentation - [Tenant](/docs/tenant) - Tenant management - [Configuring](/docs/configuring) - Sequence rotation settings - [Service Patterns](/docs/service-patterns) - Master data service patterns - [Master Web](/docs/master-web) - Frontend master data management --- ## Notification URL: https://mbc-cqrs-serverless.mbc-net.com/docs/notification-module # Notification The NotificationModule provides two types of notification capabilities in the MBC CQRS Serverless framework: - **Real-time notifications** via AWS AppSync for WebSocket-based updates - **Email notifications** via AWS SES for sending emails ## Architecture {#architecture} ```mermaid graph TB subgraph "Real-time Notifications" A["DynamoDB Stream"] --> B["NotificationEventHandler"] B --> C["AppSyncService\n(GraphQL Subscription)"] B --> C2["AppSyncEventsService\n(Events API, opt-in)"] C --> D["AppSync GraphQL"] C2 --> D2["AppSync Events API"] D --> E["WebSocket Clients"] D2 --> E end subgraph "Email Notifications" F["Application Code"] --> G["EmailService"] G --> H["AWS SES"] H --> I["Email Recipients"] end ``` ## Real-time Notifications {#real-time-notifications} ### Overview Real-time notifications are automatically sent when data changes occur in DynamoDB. The system uses AWS AppSync to deliver notifications to subscribed WebSocket clients. ### INotification Interface The notification payload structure: ```ts interface INotification { id: string; // Unique notification ID table: string; // Source DynamoDB table name pk: string; // Partition key of the changed item sk: string; // Sort key of the changed item tenantCode: string; // Tenant code for filtering notifications action: string; // Type of change: 'INSERT', 'MODIFY', 'REMOVE' content?: object; // Optional payload with changed data } ``` ### AppSyncService The `AppSyncService` sends real-time notifications to AppSync for WebSocket delivery. #### Method: `sendMessage(msg: INotification): Promise` Sends a notification to AppSync via GraphQL mutation. The notification is delivered to all subscribed WebSocket clients. :::info Version Note The return type of `sendMessage` changed from `Promise` to `Promise` in [version 1.3.0](/docs/changelog#v130). If your tests mock this method with `mockResolvedValue(null)`, update them to `mockResolvedValue(undefined)`. See the [v1.3.0 migration guide](/docs/migration/v1.3.0) for details. ::: ```ts await this.appSyncService.sendMessage({ id: "unique-id", table: "my-table", pk: "ITEM#tenant1", sk: "ITEM#001", tenantCode: "tenant1", action: "MODIFY", content: { status: "updated" }, }); ``` #### Configuration Set the following environment variables: ```bash APPSYNC_ENDPOINT=https://xxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql APPSYNC_API_KEY=da2-xxxxxxxxxx # Optional: Use API key auth instead of IAM ``` #### Usage ```ts import { Injectable } from "@nestjs/common"; import { AppSyncService, INotification } from "@mbc-cqrs-serverless/core"; @Injectable() export class MyService { constructor(private readonly appSyncService: AppSyncService) {} async notifyClients() { const notification: INotification = { id: "notification-123", table: "my-table", pk: "ITEM#tenant1", sk: "ITEM#item001", tenantCode: "tenant1", action: "MODIFY", content: { status: "updated" }, }; await this.appSyncService.sendMessage(notification); } } ``` #### Authentication The AppSyncService supports two authentication methods: 1. **API Key**: Set `APPSYNC_API_KEY` environment variable 2. **IAM Signature V4**: Used automatically when API key is not set ### Automatic Notifications The framework automatically sends notifications when data changes through: 1. DynamoDB Streams trigger the `NotificationEventHandler` 2. Handler extracts change information and creates `INotification` 3. `AppSyncService.sendMessage()` delivers to AppSync 4. Connected clients receive updates via WebSocket subscription ### NotificationEvent The `NotificationEvent` class represents a notification event from SQS. It implements `IEvent` and wraps an SQS record containing notification data. ```ts import { NotificationEvent } from "@mbc-cqrs-serverless/core"; class NotificationEvent implements IEvent, SQSRecord { source: string; messageId: string; receiptHandle: string; body: string; // JSON string containing INotification data attributes: SQSRecordAttributes; messageAttributes: SQSMessageAttributes; md5OfBody: string; eventSource: string; eventSourceARN: string; awsRegion: string; // Creates a NotificationEvent from an SQS record fromSqsRecord(record: SQSRecord): NotificationEvent; } ``` ### NotificationEventHandler The `NotificationEventHandler` is the built-in event handler that processes `NotificationEvent` and sends notifications to AppSync. It is automatically registered when using the notification module. ```ts import { EventHandler, IEventHandler, NotificationEvent } from "@mbc-cqrs-serverless/core"; @EventHandler(NotificationEvent) export class NotificationEventHandler implements IEventHandler { async execute(event: NotificationEvent): Promise { // Parses the notification from event body // Sends to AppSync via sendMessage() } } ``` You typically don't need to interact with this handler directly - it works automatically when notifications are published to the SQS queue. ## AppSync Events API (opt-in) {#appsync-events-service} :::info Version Note `AppSyncEventsService` and dual-publish support were added in [version 1.3.0](/docs/changelog#v130). ::: ### Overview The `AppSyncEventsService` provides an alternative (or complementary) real-time transport based on the **AWS AppSync Events API** — a schema-free HTTP pub/sub service. Unlike the GraphQL Subscription transport, it requires no GraphQL schema and clients subscribe using wildcard channel paths. Opt in by setting `NOTIFICATION_TRANSPORTS=appsync-event`. When `NOTIFICATION_TRANSPORTS=appsync-graphql,appsync-event` and both endpoints are set, the framework **dual-publishes** to both transports simultaneously, enabling a zero-downtime migration. ### Method: `sendMessage(msg: INotification): Promise` Publishes a notification to an AppSync Events channel. The notification is delivered to all subscribed clients. ```ts await this.appSyncEventsService.sendMessage({ id: "command-123", table: "orders-table", pk: "ORDER#tenant1", sk: "ORDER#001", tenantCode: "tenant1", action: "MODIFY", content: { status: "confirmed" }, }); ``` ### Usage ```ts import { Injectable } from "@nestjs/common"; import { AppSyncEventsService, INotification } from "@mbc-cqrs-serverless/core"; @Injectable() export class MyService { constructor(private readonly appSyncEventsService: AppSyncEventsService) {} async notifyClients() { const notification: INotification = { id: "notification-456", table: "my-table", pk: "ITEM#tenant1", sk: "ITEM#item001", tenantCode: "tenant1", action: "MODIFY", content: { status: "updated" }, }; await this.appSyncEventsService.sendMessage(notification); } } ``` ### Channel Structure Every notification is published to a single most-specific channel. Clients subscribe at whatever level of granularity they need using the AppSync Events wildcard (`/*`): ```text /{namespace}/{tenantCode}/{action}/{sanitizedId} seg 1 seg 2 seg 3 seg 4 ``` | Client goal | Subscribe to | |-----------------|-----------------| | All events for a tenant | `/{namespace}/{tenantCode}/*` | | Filtered by action | `/{namespace}/{tenantCode}/{action}/*` | | Track one specific command | `/{namespace}/{tenantCode}/{action}/{sanitizedId}` | ### Configuration ```bash # Enable the Events API transport (Events API only) NOTIFICATION_TRANSPORTS=appsync-event APPSYNC_EVENTS_ENDPOINT=https://xxxx.appsync-api.ap-northeast-1.amazonaws.com/event # Optional: must match a pre-created namespace in your AppSync Event API APPSYNC_EVENTS_NAMESPACE=default ``` See [AppSync Events Environment Variables](/docs/environment-variables#appsync-events-env) for the full reference. ### Migration from GraphQL Subscription Use the dual-publish mode to migrate clients gradually without downtime: **Phase 1 — Dual-publish (both transports active):** ```bash NOTIFICATION_TRANSPORTS=appsync-graphql,appsync-event APPSYNC_EVENTS_ENDPOINT=https://xxxx.appsync-api.ap-northeast-1.amazonaws.com/event APPSYNC_EVENTS_NAMESPACE=default APPSYNC_ENDPOINT=https://xxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql # still set ``` **Phase 2 — Events API only (after all clients have migrated):** ```bash NOTIFICATION_TRANSPORTS=appsync-event APPSYNC_EVENTS_ENDPOINT=https://xxxx.appsync-api.ap-northeast-1.amazonaws.com/event APPSYNC_EVENTS_NAMESPACE=default # APPSYNC_ENDPOINT removed ``` ### CDK Infrastructure Add `appsyncEvents` to your `Config` to provision the `EventApi` and `ChannelNamespace` automatically: ```typescript // infra/config/config.ts export const config: Config = { // ... appsyncEvents: { enabled: true, namespace: 'default', // optional, defaults to 'default' apiKeyExpireDays: 365, // optional, defaults to 365 }, } ``` The CDK stack will output `AppSyncEventsHttpEndpoint` and `AppSyncEventsNamespace`, and will automatically inject `NOTIFICATION_TRANSPORTS`, `APPSYNC_EVENTS_ENDPOINT`, and `APPSYNC_EVENTS_NAMESPACE` into Lambda and ECS. ### Authentication The `AppSyncEventsService` supports two authentication methods for publishing: 1. **IAM SigV4 (recommended for Lambda/ECS)**: Used automatically for publishing. Lambda and ECS task roles are granted `appsync:EventPublish` by the CDK stack via `grantPublish()`. 2. **API Key**: Used by browser clients for subscribing. Set in the client (e.g., Amplify `apiKey` config). Not required on the server side. ### Client-Side Subscription (Browser) {#client-side-subscription} Browser clients subscribe to AppSync Events channels using the AWS Amplify v6 `events` API. The channel path determines which notifications you receive — use the `/*` wildcard to subscribe at any granularity. #### Setup with AWS Amplify v6 ```bash npm install aws-amplify ``` ```typescript import { Amplify } from 'aws-amplify'; import { events } from 'aws-amplify/data'; // Configure Amplify once at app startup (e.g., in _app.tsx or main.ts) Amplify.configure({ API: { Events: { endpoint: 'https://YOUR_APPSYNC_EVENTS_ENDPOINT/event', region: 'ap-northeast-1', defaultAuthMode: 'apiKey', apiKey: 'YOUR_API_KEY', // Output by CDK as AppSyncEventsApiKey }, }, }); ``` #### Subscribe to Notifications ```typescript interface INotification { id: string; table: string; // e.g., "dev-myapp-order-data" pk: string; // Partition key of the changed item sk: string; // Sort key of the changed item tenantCode: string; action: string; // INSERT | MODIFY | REMOVE content?: object; // Optional change payload } // Subscribe to all notifications for a tenant const channel = await events.connect('/default/tenant001/*'); const subscription = channel.subscribe({ next: ({ data }: { data: INotification }) => { console.log(`${data.action} on ${data.table}: pk=${data.pk}`); // Refresh UI or update local state }, error: (err) => { console.error('Subscription error:', err); }, }); // Unsubscribe when the component unmounts subscription.unsubscribe(); ``` #### React Hook Example ```typescript import { useEffect, useRef } from 'react'; import { events } from 'aws-amplify/data'; function useOrderNotifications(tenantCode: string, orderId: string) { const subscriptionRef = useRef<{ unsubscribe(): void } | null>(null); useEffect(() => { // Subscribe to a specific order const channel = `/default/${tenantCode}/MODIFY/${orderId.replace(/#/g, '_')}`; events.connect(channel).then((conn) => { subscriptionRef.current = conn.subscribe({ next: ({ data }) => { console.log('Order updated:', data); // Trigger a refetch or update local state }, error: (err) => console.error(err), }); }); return () => { subscriptionRef.current?.unsubscribe(); }; }, [tenantCode, orderId]); } ``` :::tip Channel Path Format The sanitized item ID in the channel path replaces `#` with `_`. For example, `ORDER#ORD001` becomes `ORDER_ORD001`. Use `sk.replace(/#/g, '_')` on the client side to compute the exact channel path. ::: ## Custom Notification Transports (opt-in) {#custom-transports} :::info Version Note The `@NotificationTransport` decorator for custom transports was added in [version 1.3.0](/docs/changelog#v130). ::: You can register custom notification transports to publish to any external system (e.g., Slack, PagerDuty, custom WebSocket servers) without modifying framework code. ### Implementing a Custom Transport ```typescript import { INotificationTransport, INotification, NotificationTransport, } from '@mbc-cqrs-serverless/core'; // Do not add @Injectable() — @NotificationTransport() already registers a singleton provider @NotificationTransport() export class SlackNotificationTransport implements INotificationTransport { async sendMessage(msg: INotification): Promise { // Send notification to Slack or any custom endpoint await fetch(process.env.SLACK_WEBHOOK_URL, { method: 'POST', body: JSON.stringify({ text: `Change in ${msg.table}: ${msg.action}` }), }); } } ``` :::warning Transport Registration Guidelines - **Do not add `@Injectable()`** on the same class — `@NotificationTransport()` already registers it as a singleton provider. Using both annotations causes a DI scope conflict (AP026). - **Register in module `providers`**: Add the class to your module's `providers` array. - **Failures propagate**: Errors thrown in `sendMessage` are not swallowed by the framework. Implement retry/fallback logic inside the transport class. ::: ### Selecting Transports Active transports are controlled by the `NOTIFICATION_TRANSPORTS` environment variable (comma-separated). Custom transports are always active alongside any built-in transports. Set the env var to control which built-in transports are active: ```bash NOTIFICATION_TRANSPORTS=appsync-graphql # Only GraphQL Subscriptions (default) NOTIFICATION_TRANSPORTS=appsync-event # Only Events API NOTIFICATION_TRANSPORTS=appsync-graphql,appsync-event # Both built-in transports ``` ## Email Notifications {#email-notifications} ### EmailService The `EmailService` sends emails using AWS SES. #### Configuration ```bash SES_FROM_EMAIL=noreply@your-domain.com # Required: Default sender address SES_REGION=ap-northeast-1 # Optional: SES region SES_ENDPOINT= # Optional: Custom endpoint for LocalStack ``` #### Basic Usage ```ts import { Injectable } from "@nestjs/common"; import { EmailService, EmailNotification } from "@mbc-cqrs-serverless/core"; @Injectable() export class MyService { constructor(private readonly emailService: EmailService) {} async sendWelcomeEmail(userEmail: string) { const email: EmailNotification = { toAddrs: [userEmail], subject: "Welcome to Our Service", body: "

Welcome!

Thank you for signing up.

", }; await this.emailService.sendEmail(email); } } ``` #### Email with Attachments ```ts import { EmailNotification, Attachment } from "@mbc-cqrs-serverless/core"; import * as fs from "fs"; const pdfBuffer = fs.readFileSync("report.pdf"); const email: EmailNotification = { toAddrs: ["user@example.com"], subject: "Monthly Report", body: "

Please find attached your monthly report.

", attachments: [ { filename: "report.pdf", content: pdfBuffer, contentType: "application/pdf", }, ], }; await this.emailService.sendEmail(email); ``` ### Inline Template Emails {#inline-template-emails} The `sendInlineTemplateEmail()` method allows you to send templated emails with dynamic data substitution, without requiring pre-registered SES templates. :::info Version Note Inline template emails (`sendInlineTemplateEmail()`) were added in [version 1.0.23](/docs/changelog#v1023). ::: #### Basic Usage ```ts import { Injectable } from "@nestjs/common"; import { EmailService, TemplatedEmailNotification } from "@mbc-cqrs-serverless/core"; @Injectable() export class MyService { constructor(private readonly emailService: EmailService) {} async sendWelcomeEmail(user: { name: string; email: string }) { const notification: TemplatedEmailNotification = { toAddrs: [user.email], template: { subject: "Welcome, {{name}}!", html: "

Hello {{name}}

Welcome to our service!

", text: "Hello {{name}}, Welcome to our service!", // Optional plain text version }, data: { name: user.name, }, }; await this.emailService.sendInlineTemplateEmail(notification); } } ``` #### Template Syntax Templates use `{{variableName}}` placeholders that are replaced with values from the `data` object: ```ts const notification: TemplatedEmailNotification = { toAddrs: ["user@example.com"], template: { subject: "Order {{orderId}} Confirmation", html: `

Thank you, {{customerName}}!

Your order #{{orderId}} has been confirmed.

Total: {{currency}}{{totalAmount}}

`, }, data: { customerName: "John Doe", orderId: "12345", currency: "$", totalAmount: "99.99", }, }; ``` #### Advanced Template Features {#advanced-template-features} :::info Version Note Nested property access and Unicode key support were added in [version 1.0.25](/docs/changelog#v1025). ::: ##### Nested Property Access You can access nested object properties using dot notation: ```ts const notification: TemplatedEmailNotification = { toAddrs: ["user@example.com"], template: { subject: "Welcome {{user.profile.firstName}}!", html: `

Hello {{user.profile.firstName}} {{user.profile.lastName}},

Your verification code is: {{auth.otp}}

`, }, data: { user: { profile: { firstName: "John", lastName: "Doe", }, }, auth: { otp: "123456", }, }, }; ``` ##### Unicode and Japanese Key Support Template variables support Unicode characters, including Japanese keys: ```ts const notification: TemplatedEmailNotification = { toAddrs: ["user@example.com"], template: { subject: "{{注文.確認番号}} - Order Confirmation", html: `

{{顧客.名前}} 様

ご注文番号: {{注文.確認番号}}

商品: {{注文.詳細.品名}}

`, }, data: { "顧客": { "名前": "山田 太郎", }, "注文": { "確認番号": "ORD-2024-001", "詳細": { "品名": "ワイヤレスイヤホン", }, }, }, }; ``` ##### Whitespace in Placeholders Whitespace inside placeholders is automatically trimmed, so a placeholder with surrounding spaces is equivalent to one without (see the example below): ```ts // Both of these work identically template: { subject: "Hello {{ name }}!", // Whitespace is trimmed html: "

Hello {{name}}!

", // No whitespace } ``` ##### Missing Variables If a variable is not found in the data object, the placeholder is preserved in the output. This helps identify missing data during development: ```ts // If 'missingKey' is not in data, the placeholder is preserved as-is template: { html: "

Value: {{missingKey}}

", } ``` ##### Limitations The following limitations apply to template variable names (for security reasons): | Limitation | Value | Reason | |----------------|-----------|------------| | Maximum variable name length | 255 characters | Prevents ReDoS (Regular Expression Denial of Service) attacks | Variable names exceeding 255 characters will not be replaced and will remain as literal placeholders in the output. #### TemplatedEmailNotification Interface | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `fromAddr` | `string` | No | Sender email (uses SES_FROM_EMAIL if not set) | | `toAddrs` | `string[]` | Yes | List of recipient email addresses | | `ccAddrs` | `string[]` | No | CC recipients | | `bccAddrs` | `string[]` | No | BCC recipients | | `replyToAddrs` | `string[]` | No | Reply-to addresses | | `template` | `InlineTemplateContent` | Yes | Template with subject, HTML, and optional text | | `data` | `Record` | Yes | Data object for template variable substitution | | `configurationSetName` | `string` | No | SES configuration set name for tracking | | `emailTags` | `EmailTag[]` | No | Tags for email categorization (SES Email Tags) | #### InlineTemplateContent Interface | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `subject` | `string` | Yes | Email subject line (supports template variables) | | `html` | `string` | Yes | HTML body (supports template variables) | | `text` | `string` | No | Plain text body (supports template variables) | #### Local Development When running locally without SES access, the method automatically falls back to manual template compilation, allowing you to test email flows during development. #### EmailNotification Interface | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `fromAddr` | `string` | No | Sender email (uses SES_FROM_EMAIL if not set) | | `toAddrs` | `string[]` | Yes | List of recipient email addresses | | `ccAddrs` | `string[]` | No | CC recipients | | `bccAddrs` | `string[]` | No | BCC recipients | | `subject` | `string` | Yes | Email subject line | | `body` | `string` | Yes | Email body as HTML | | `replyToAddrs` | `string[]` | No | Reply-to addresses | | `attachments` | `Attachment[]` | No | File attachments | | `emailTags` | `EmailTag[]` | No | Tags for email categorization (SES Email Tags) | ### Email Tags {#email-tags} Email tags allow you to categorize and track emails sent through AWS SES. Tags are useful for filtering emails in SES analytics, CloudWatch, and event destinations. :::info Version Note EmailTags support was added in [version 1.1.0](/docs/changelog#v110). ::: #### Basic Usage ```ts import { EmailService, EmailNotification, EmailTag } from "@mbc-cqrs-serverless/core"; const email: EmailNotification = { toAddrs: ["user@example.com"], subject: "Order Confirmation", body: "

Your order has been confirmed.

", emailTags: [ { name: "category", value: "order-confirmation" }, { name: "tenant", value: "tenant-123" }, { name: "environment", value: "production" }, ], }; await this.emailService.sendEmail(email); ``` #### EmailTag Interface | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `name` | `string` | Yes | Tag name (e.g., 'category', 'campaign') | | `value` | `string` | Yes | Tag value for categorization | #### Use Cases - **Campaign tracking**: Tag emails by marketing campaign to analyze performance - **Tenant isolation**: Tag by tenant code for multi-tenant email analytics - **Email type categorization**: Distinguish transactional emails from promotional ones - **Environment tagging**: Track emails across development, staging, and production #### Attachment Interface | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `filename` | `string` | Yes | Filename shown to recipient | | `content` | `Buffer` | Yes | File content as Buffer | | `contentType` | `string` | No | MIME type (e.g., 'application/pdf') | ## Related Documentation - [Email Service](/docs/email-service) - SES email sending - [Event Handling Patterns](/docs/event-handling-patterns) - How notifications integrate with events - [Environment Variables](/docs/environment-variables) - AppSync and SES configuration - [Queue](/docs/queue) - SQS for notification queuing - [Common Issues — AppSync Events API not publishing](/docs/common-issues#appsync-events-not-publishing) - Troubleshooting AppSync Events API --- ## Sequence URL: https://mbc-cqrs-serverless.mbc-net.com/docs/sequence # Sequence ## 1. Purpose {#purpose} `SequencesModule` is a service for managing dynamic sequences in the system using DynamoDB as the primary database. This service is designed to: - Generate unique sequence numbers based on parameters such as sequence type, tenant, or date. - Automatically reset sequences based on cycles like: - Daily. - Monthly. - Yearly. - Fiscal Yearly. Format sequence numbers according to specific system requirements (e.g., TODO-PERSONAL-72-001). Ensure data consistency and integrity in multi-tenant systems. ## How It Works {#how-it-works} ```mermaid sequenceDiagram participant Client participant SequencesService participant DynamoDB Client->>SequencesService: generateSequenceItem(dto) SequencesService->>DynamoDB: UpdateItem ADD counter 1 Note over DynamoDB: Atomic increment DynamoDB-->>SequencesService: New counter value SequencesService->>SequencesService: Format ID with pattern SequencesService-->>Client: SequenceEntity ``` ## 2. Usage {#usage} The solution for customizing the behavior of the `SequencesModule` is to pass it an options `object` in the static `register()` method. The options object contains only one property: - `enableController`: enable or disable default sequence controller. We will create a simple example demonstrating how to use the sequence module and customize authentication for the sequence controller. ```ts // seq.controller.ts import { SequencesController } from "@mbc-cqrs-serverless/sequence"; import { Controller } from "@nestjs/common"; import { ApiTags } from "@nestjs/swagger"; import { Auth } from "src/auth/auth.decorator"; import { ROLE } from "src/auth/role.enum"; @Controller("api/sequence") @ApiTags("sequence") @Auth(ROLE.ADMIN) export class SeqController extends SequencesController {} ``` ```ts // seq.module.ts import { SequencesModule } from "@mbc-cqrs-serverless/sequence"; import { Module } from "@nestjs/common"; import { SeqController } from "./seq.controller"; @Module({ imports: [SequencesModule.register({ enableController: false })], controllers: [SeqController], exports: [SequencesModule], }) export class SeqModule {} ``` Besides the controller, we can directly use `SequencesService` to generate sequences by injecting the service. The `SequencesService` has four public methods (two current, one deprecated, one removed in v1.1.0): ### *async* `generateSequenceItem(dto: GenerateFormattedSequenceDto, options?: {invokeContext: IInvoke}): Promise` {#generate-sequence-item} Generates a new sequence based on the parameters provided in the GenerateFormattedSequenceDto object. #### Parameters `dto: GenerateFormattedSequenceDto` The data transfer object that customizes the behavior of the sequence generation. Its properties include: - `date?: Date` - Default: Current date. - Description: Specifies the date for which the sequence is generated. - `rotateBy?: RotateByEnum` - Default: NONE. - Options - FISCAL_YEARLY (`'fiscal_yearly'`) - YEARLY (`'yearly'`) - MONTHLY (`'monthly'`) - DAILY (`'daily'`) - NONE (`'none'`) - Description: Determines when the sequence counter resets to 1. - Rotation strategy reference: | Strategy | Counter resets when | Example | |---|---|---| | `NONE` | Never — counter increments indefinitely | `1, 2, 3, … 9999` | | `DAILY` | The calendar date changes | Resets each midnight | | `MONTHLY` | The calendar month changes | Resets on the 1st of each month | | `YEARLY` | The calendar year changes | Resets every January 1st | | `FISCAL_YEARLY` | The fiscal year changes (start month controlled by `startMonth`) | Resets at fiscal year start; defaults to April (Japanese convention) | - `tenantCode: string` - Required: Yes. - Description: Identifies the tenant and type code for the intended usage. - `typeCode: string` - Required: Yes. - Description: Identifies the type code for the sequence. - `params?: SequenceParamsDto` - Required: No. - Description: Defines parameters to identify the sequence. ```ts import { IsString, IsOptional } from 'class-validator'; export class SequenceParamsDto { @IsString() code1: string @IsString() @IsOptional() code2?: string @IsOptional() @IsString() code3?: string @IsOptional() @IsString() code4?: string @IsOptional() @IsString() code5?: string constructor(partial: Partial) { Object.assign(this, partial) } } ``` - `prefix?: string` - Required: No. - Description: Optional prefix to prepend to the formatted sequence. The prefix is added before the formatted pattern. - Example: If prefix is `'INV-'` and format produces `'2024-001'`, the result will be `'INV-2024-001'`. - `postfix?: string` - Required: No. - Description: Optional postfix to append to the formatted sequence. The postfix is added after the formatted pattern. - Example: If postfix is `'-DRAFT'` and format produces `'2024-001'`, the result will be `'2024-001-DRAFT'`. #### Response The return value of this function is of type `SequenceEntity` as follows: ```ts export class SequenceEntity { id: string no: number formattedNo: string issuedAt: Date constructor(partial: Partial) { Object.assign(this, partial) } } ``` #### Customizable By default, the returned data includes the formattedNo field with the format `%%no%%`, where `no` represents the sequence number. If you want to define your own custom format, you can update the master data in DynamoDB with the following parameters: - PK: `MASTER${KEY_SEPARATOR}${tenantCode}` - SK: `MASTER_DATA${KEY_SEPARATOR}${typeCode}` The data structure should be as follows: ```json { "format": "string", "startMonth": "number", "registerDate": "string" } ``` #### Example For example, if you want to add `code1` to `code5`, `year`, `month`, `day`, `date`, `no` as well as `fiscal_year`, into your format, the format would look like this: ```json { "format": "%%code2#:0>7%%-%%fiscal_year#:0>2%%-%%code3%%%%no#:0>3%%" } ``` In this format: - Variables are written inside `%% %%.` - The `#:0>N` suffix pads the value to `N` characters wide with leading zeros (e.g., `#:0>3` turns `5` into `005`). Omit the suffix to use the value as-is. Format spec reference: | Suffix | Meaning | Input `5` | Result | |---|---|---|---| | (none) | No padding — raw value | `5` | `5` | | `#:0>3` | Pad to width 3 with leading zeros | `5` | `005` | | `#:0>7` | Pad to width 7 with leading zeros | `5` | `0000005` | For instance: - `%%code2#:0>7%%` ensures code2 is formatted to be 7 characters long, padding with leading zeros if necessary. - `%%fiscal_year#:0>2%% `formats fiscal_year to a length of 2 characters. - `%%code3%%` represents the code3 value as it is. - `%%no#:0>3%%` ensures the sequence number (no) is formatted to be 3 digits long, padded with leading zeros if necessary. If you want to calculate the fiscal_year starting from any specific month, you can add the `startMonth` field. For example, if you want the fiscal year to start from March, the format would look like this: ```json { "format": "%%code2#:0>7%%-%%fiscal_year#:0>2%%-%%code3%%%%no#:0>3%%", "startMonth": 3 } ``` In this case: - startMonth: Defines the month to start the fiscal year (e.g., 3 for March). Defaults to 4 (April), following the Japanese fiscal year convention (April–March). If you want to calculate the fiscal year starting from a specific date (e.g. 2005-01-01), you can add the `registerDate` field, like this: ```json { "format": "%%code2#:0>7%%-%%fiscal_year#:0>2%%-%%code3%%%%no#:0>3%%", "registerDate": "2005-01-01" } ``` In this case: - registerDate: Defines the exact start date of the fiscal year (e.g., "2005-01-01"). This allows you to customize the fiscal year calculation according to your specific business needs. ### *async* `generateSequenceItemWithProvideSetting(dto: GenerateFormattedSequenceWithProvidedSettingDto, options?: {invokeContext: IInvoke}): Promise` {#generate-sequence-item-with-provide-setting} This method allows you to generate a sequence with custom settings directly provided in the DTO, without requiring master data configuration in DynamoDB. #### Parameters `dto: GenerateFormattedSequenceWithProvidedSettingDto` The data transfer object that contains both sequence parameters and format settings. Its properties include: - `date?: Date` - Default: Current date. - Description: Specifies the date for which the sequence is generated. - `rotateBy?: RotateByEnum` - Default: NONE. - Options: FISCAL_YEARLY, YEARLY, MONTHLY, DAILY, NONE - Description: Determines when the sequence counter resets. See the rotation strategy table in `generateSequenceItem` above. - `tenantCode: string` - Required: Yes. - Description: Identifies the tenant for the sequence. - `typeCode: string` - Required: Yes. - Description: Identifies the type code for the sequence. - `params?: SequenceParamsDto` - Required: No. - Description: Defines parameters to identify the sequence (code1 to code5). - `prefix?: string` - Required: No. - Description: Optional prefix to prepend to the formatted sequence. - `postfix?: string` - Required: No. - Description: Optional postfix to append to the formatted sequence. - `format: string` - Required: Yes. - Description: Format string defining the structure of the generated sequence. Example: `%%code1%%-%%no#:0>5%%`. - `registerDate?: string` - Required: No. - Description: Optional registration date (ISO 8601 format) to influence fiscal year calculation. - `startMonth?: number` - Required: No. - Description: Starting month of the fiscal year (1-12). Defaults to 4 (April) if not provided. #### Example ```ts import { RotateByEnum } from "@mbc-cqrs-serverless/sequence"; const result = await this.sequencesService.generateSequenceItemWithProvideSetting( { tenantCode: 'tenant001', typeCode: 'INVOICE', format: '%%code1%%-%%no#:0>5%%', rotateBy: RotateByEnum.YEARLY, params: { code1: 'INV' }, }, { invokeContext }, ); // Returns: { formattedNo: 'INV-00001', no: 1, ... } ``` Use this method when you need dynamic sequence settings that vary per request rather than fixed master data configuration. Example with prefix and postfix: ```ts import { RotateByEnum } from "@mbc-cqrs-serverless/sequence"; const result = await this.sequencesService.generateSequenceItemWithProvideSetting( { tenantCode: 'tenant001', typeCode: 'ORDER', format: '%%fiscal_year%%-%%no#:0>4%%', rotateBy: RotateByEnum.FISCAL_YEARLY, startMonth: 4, params: { code1: 'ORD' }, prefix: 'ORD-', // Prepended to formatted sequence postfix: '-DRAFT', // Appended to formatted sequence }, { invokeContext }, ); // Returns: { formattedNo: 'ORD-2024-0001-DRAFT', no: 1, ... } ``` ### *async* `getCurrentSequence(key: DetailKey): Promise` deprecated :::info Deprecated, for removal: This API element is subject to removal in a future version. ::: ### *async* `genNewSequence( dto: GenerateSequenceDto, options: {invokeContext: IInvoke}): Promise` removed {#gen-new-sequence-removed} :::danger Removed in v1.1.0 This method was removed in [v1.1.0](/docs/changelog#v110). Use [`generateSequenceItem`](#generate-sequence-item) or [`generateSequenceItemWithProvideSetting`](#generate-sequence-item-with-provide-setting) instead. ::: ## Related Documentation - [Configuring](/docs/configuring) - SequencesModule configuration options - [Master](/docs/master) - Master data settings for sequence format - [Key Patterns](/docs/key-patterns) - PK/SK design using sequences as sort keys - [Build a Todo App](/docs/build-todo-app) - Practical example using sequences - [Interfaces](/docs/interfaces) - SequencesModuleOptions interface --- ## Serialization Helpers URL: https://mbc-cqrs-serverless.mbc-net.com/docs/serialization # Serialization Helpers ## Overview {#overview} The MBC CQRS Serverless Framework provides helper functions for converting between internal DynamoDB structures and external flat structures. These helpers ensure consistent data transformation while maintaining type safety. ## Data Structure Conversion {#data-structure-conversion} ### Internal DynamoDB Structure ```typescript { pk: "PROJECT#tenant001", sk: "proj-001", name: "Test Project", attributes: { details: { status: "active", category: "development" } } } ``` ### External Flat Structure ```typescript { id: "PROJECT#tenant001#proj-001", // Combination of pk and sk code: "proj-001", // Same as sk name: "Test Project", // First level in DynamoDB details: { // Flattened from attributes status: "active", category: "development" } } ``` ## Usage {#usage} ### Converting Internal to External Format ```typescript import { serializeToExternal } from '@mbc-cqrs-serverless/core'; const internal = { pk: "PROJECT#tenant001", sk: "proj-001", name: "Test Project", attributes: { details: { status: "active", category: "development" } } }; const external = serializeToExternal(internal); ``` ### Converting External to Internal Format ```typescript import { deserializeToInternal, DataEntity } from '@mbc-cqrs-serverless/core'; const external = { id: "PROJECT#tenant001#proj-001", code: "proj-001", name: "Test Project", details: { status: "active", category: "development" } }; // Use DataEntity for data table entities, CommandEntity for command table entities const internal = deserializeToInternal(external, DataEntity); ``` :::tip Fallback Behavior When deserializing, if the `id` field doesn't contain the `#` separator to split into `pk` and `sk`, the `code` field is used as the `sk` value. Any fields not in the metadata field list are automatically placed in the `attributes` object. ::: ## API Reference {#api-reference} ### serializeToExternal ```typescript function serializeToExternal( item: T | null | undefined, options?: SerializerOptions ): Record | null interface SerializerOptions { keepAttributes?: boolean; // Reserved for future use flattenDepth?: number; // Reserved for future use } ``` Parameters: - `item`: Internal entity (CommandEntity or DataEntity). - `options`: Optional serialization options (reserved for future use). Returns: - Flattened external structure or null if input is null/undefined :::note The `SerializerOptions` interface is defined for future extensibility but is not currently used by the function. The function always flattens attributes to the top level. ::: ### deserializeToInternal ```typescript function deserializeToInternal( data: Record | null | undefined, EntityClass: new () => T ): T | null ``` Parameters: - `data`: External flat structure - `EntityClass`: Entity class to instantiate (CommandEntity or DataEntity) Returns: - Internal entity instance or null if input is null/undefined ## Field Mapping {#field-mapping} ### Metadata Fields | Field | Description | |-------|-------------| | id | Primary key | | cpk | Command table primary key| | csk | Command table sort key | | pk | Data table primary key | | sk | Data table sort key | | tenantCode | Tenant code | | type | Entity type (embedded in pk, e.g., "PROJECT") | | seq | Sort order | | code | Code (may be used as part of sk) | | name | Name | | version | Version number | | isDeleted | Deletion flag | | createdBy | Creator's user ID or username | | createdIp | Creator's IP address | | createdAt | Creation timestamp | | updatedBy | Updater's user ID or username (set at creation) | | updatedIp | Updater's IP address (set at creation) | | updatedAt | Update timestamp (set at creation). | | status | Status (for CQRS processing). | | ttl | DynamoDB TTL timestamp (Unix epoch seconds) | ### Serialization Mapping | Internal Field | External Field | Description | |---------------|----------------|-------------| | pk + sk | id | Combined primary key for unique identification | | cpk | cpk | Command table primary key | | csk | csk | Command table sort key | | pk | pk | Data table primary key | | sk | sk | Data table sort key | | sk | code | Sort key used as code identifier | | tenantCode | tenantCode | Tenant identifier | | type | type | Entity type (e.g., PROJECT) | | seq | seq | Sequence number for ordering | | name | name | Entity name (first level property) | | version | version | Entity version for optimistic locking | | isDeleted | isDeleted | Soft delete flag | | createdBy | createdBy | User ID or name of creator | | createdIp | createdIp | IP address of creator | | createdAt | createdAt | Creation timestamp | | updatedBy | updatedBy | User ID or name of last updater | | updatedIp | updatedIp | IP address of last updater | | updatedAt | updatedAt | Last update timestamp | | status | status | CQRS processing status | | ttl | ttl | DynamoDB TTL timestamp (Unix epoch seconds) | | attributes.* | * | Flattened attributes from internal structure | ## Related Documentation - [Data Service](/docs/data-service) - Using DataService with serialization - [Entity Patterns](/docs/entity-patterns) - Entity structure for serialization - [Data Sync Handler Examples](/docs/data-sync-handler-examples) - Sync with deserialization --- ## Task URL: https://mbc-cqrs-serverless.mbc-net.com/docs/tasks # Task The Task package provides comprehensive task management functionality in the MBC CQRS Serverless framework. It enables: - Asynchronous task execution - Task status tracking - Progress monitoring - Error handling and retries - Task queue management - Task history and logging ## Architecture {#architecture} ```mermaid sequenceDiagram participant Client participant TaskService participant DynamoDB participant StepFunctions participant Lambda Client->>TaskService: createTask() / createStepFunctionTask() TaskService->>DynamoDB: Save task (CREATED) TaskService->>StepFunctions: Start execution StepFunctions-->>TaskService: Execution ARN TaskService-->>Client: TaskEntity loop For each sub-task StepFunctions->>Lambda: Execute sub-task Lambda->>DynamoDB: Update status (PROCESSING) Lambda->>Lambda: Process Lambda->>DynamoDB: Update status (COMPLETED/FAILED) end StepFunctions->>DynamoDB: Update parent task status ``` ## Installation {#installation} ```bash npm install @mbc-cqrs-serverless/task ``` ## Usage {#usage} There are 2 types of task processing: - Single task processing - Task processing with Step Functions ### Single task processing 1. Define task event ```ts import { TaskQueueEvent } from "@mbc-cqrs-serverless/task"; export class TaskEvent extends TaskQueueEvent {} ``` 2. Define task event handler ```ts import { EventHandler, IEventHandler } from "@mbc-cqrs-serverless/core"; import { Logger } from "@nestjs/common"; import { TaskEvent } from "./task.event"; @EventHandler(TaskEvent) export class TaskEventHandler implements IEventHandler { private readonly logger = new Logger(TaskEventHandler.name); constructor() {} async execute(event: TaskEvent): Promise { this.logger.debug("executing task event::", event); // this.logger.debug(`Process task completed: ${event.taskEvent.eventID}`); return "Result after process"; } } ``` 3. Implement `ITaskQueueEventFactory` ```ts import { ITaskQueueEventFactory, TaskQueueEvent, } from "@mbc-cqrs-serverless/task"; import { TaskEvent } from "src/sample/handler/task.event"; export class TaskQueueEventFactory implements ITaskQueueEventFactory { async transformTask(event: TaskQueueEvent): Promise { return [new TaskEvent().fromSqsRecord(event)]; } } ``` 4. Custom `TaskModule` ```ts import { TaskModule } from "@mbc-cqrs-serverless/task"; import { Module } from "@nestjs/common"; import { TaskEventHandler } from "src/sample/handler/task.handler"; import { TaskQueueEventFactory } from "./task-queue-event-factory"; @Module({ imports: [ TaskModule.register({ taskQueueEventFactory: TaskQueueEventFactory, enableController: true, // Optional: enable REST endpoints for task management }), ], providers: [TaskEventHandler], exports: [TaskModule], }) export class CustomTaskModule {} ``` 5. Custom `EventFactoryAddedTask` ```ts import { EventFactory, IEvent } from "@mbc-cqrs-serverless/core"; import { EventFactoryAddedTask, TaskEvent } from "@mbc-cqrs-serverless/task"; import { Logger } from "@nestjs/common"; import { DynamoDBStreamEvent } from "aws-lambda"; @EventFactory() export class CustomEventFactory extends EventFactoryAddedTask { private readonly logger = new Logger(CustomEventFactory.name); async transformDynamodbStream(event: DynamoDBStreamEvent): Promise { const curEvents = await super.transformDynamodbStream(event); const taskEvents = event.Records.map((record) => { if ( record.eventSourceARN.endsWith("tasks") || record.eventSourceARN.includes("tasks" + "/stream/") ) { if (record.eventName === "INSERT") { return new TaskEvent().fromDynamoDBRecord(record); } } return undefined; }) .filter((event) => !!event) .filter((event) => event.taskEntity.sk.split("#").length < 3); return [...curEvents, ...taskEvents]; } } ``` 6. Create a Task ```ts const task = await this.taskService.createTask( { taskType: "data-export", tenantCode: "mbc", name: "Export user data", input: { userId: "123", format: "csv" }, }, { invokeContext } ); ``` ### Task processing with Step Functions 1. Define Step Functions task event ```ts import { StepFunctionTaskEvent } from "@mbc-cqrs-serverless/task"; export class SfnTaskEvent extends StepFunctionTaskEvent {} ``` 2. Define Step Functions task event handler ```ts import { EventHandler, IEventHandler, } from "@mbc-cqrs-serverless/core"; import { Logger } from "@nestjs/common"; import { SfnTaskEvent } from "./sfn-task.event"; @EventHandler(SfnTaskEvent) export class SfnTaskEventHandler implements IEventHandler { private readonly logger = new Logger(SfnTaskEventHandler.name); constructor() {} async execute(event: SfnTaskEvent): Promise { this.logger.debug("executing task event::", event); // return "Result after process"; } } ``` 3. Implement `ITaskQueueEventFactory` ```ts import { ITaskQueueEventFactory, StepFunctionTaskEvent, } from "@mbc-cqrs-serverless/task"; import { SfnTaskEvent } from "src/sample/handler/sfn-task.event"; export class TaskQueueEventFactory implements ITaskQueueEventFactory { async transformStepFunctionTask(event: StepFunctionTaskEvent): Promise { return [new SfnTaskEvent(event)]; } } ``` 4. Custom `TaskModule` ```ts import { TaskModule } from "@mbc-cqrs-serverless/task"; import { Module } from "@nestjs/common"; import { TaskEventHandler } from "src/sample/handler/task.handler"; import { TaskQueueEventFactory } from "./task-queue-event-factory"; @Module({ imports: [ TaskModule.register({ taskQueueEventFactory: TaskQueueEventFactory, enableController: true, // Optional: enable REST endpoints for task management }), ], providers: [TaskEventHandler], exports: [TaskModule], }) export class CustomTaskModule {} ``` 5. Custom `EventFactoryAddedTask` ```ts import { EventFactory, IEvent } from "@mbc-cqrs-serverless/core"; import { EventFactoryAddedTask, TaskEvent } from "@mbc-cqrs-serverless/task"; import { Logger } from "@nestjs/common"; import { DynamoDBStreamEvent } from "aws-lambda"; @EventFactory() export class CustomEventFactory extends EventFactoryAddedTask { private readonly logger = new Logger(CustomEventFactory.name); async transformDynamodbStream(event: DynamoDBStreamEvent): Promise { const curEvents = await super.transformDynamodbStream(event); const taskEvents = event.Records.map((record) => { if ( record.eventSourceARN.endsWith("tasks") || record.eventSourceARN.includes("tasks" + "/stream/") ) { if (record.eventName === "INSERT") { return new TaskEvent().fromDynamoDBRecord(record); } } return undefined; }) .filter((event) => !!event) .filter((event) => event.taskEntity.sk.split("#").length < 3); return [...curEvents, ...taskEvents]; } } ``` 6. Create a Step Functions task ```ts const item = [ { key: "value1" }, { key: "value2" }, { key: "value3" }, { key: "value4" }, { key: "value5" }, { key: "value6" }, ]; await this.taskService.createStepFunctionTask( { input: item, taskType: "cat", tenantCode: "mbc", }, { invokeContext } ); ``` ## API Reference {#api-reference} ### TaskService Methods #### `createTask(dto: CreateTaskDto, options: { invokeContext: IInvoke }): Promise` Creates a new task for single task processing. ```ts const task = await this.taskService.createTask( { taskType: "data-export", tenantCode: "mbc", name: "Export user data", input: { userId: "123", format: "csv" }, }, { invokeContext } ); ``` #### `createStepFunctionTask(dto: CreateTaskDto, options: { invokeContext: IInvoke }): Promise` Creates a new task for Step Functions processing. The input array will be processed as subtasks. ```ts const task = await this.taskService.createStepFunctionTask( { taskType: "batch-process", tenantCode: "mbc", name: "Process batch items", input: [{ id: 1 }, { id: 2 }, { id: 3 }], }, { invokeContext } ); ``` #### `getTask(key: DetailKey): Promise` Retrieves a task by its primary key. ```ts const task = await this.taskService.getTask({ pk: "TASK#mbc", sk: "data-export#01HXYZ123", }); ``` #### `listItemsByPk(tenantCode: string, type?: string, options?: ListTaskOptions): Promise` Lists tasks by tenant code and type. **ListTaskOptions:** ```ts interface ListTaskOptions { sk?: { skExpression: string; skAttributeValues: Record; skAttributeNames?: Record; }; startFromSk?: string; // For pagination limit?: number; order?: 'asc' | 'desc'; } ``` ```ts // List all tasks for a tenant const tasks = await this.taskService.listItemsByPk("mbc", "TASK", { limit: 10, order: "desc", }); // List Step Function tasks const sfnTasks = await this.taskService.listItemsByPk("mbc", "SFN_TASK"); // Paginate through results const nextPage = await this.taskService.listItemsByPk("mbc", "TASK", { startFromSk: tasks.lastSk, limit: 10, }); ``` #### `createSubTask(event: TaskQueueEvent): Promise` Creates subtasks from a parent task's input array. Each item in the input array becomes a separate subtask. ```ts // Typically called within a TaskQueueEvent handler const subTasks = await this.taskService.createSubTask(event); // Returns array of TaskEntity for each input item ``` #### `getAllSubTask(subTask: DetailKey): Promise` Retrieves all subtasks for a parent task. ```ts const subTasks = await this.taskService.getAllSubTask({ pk: "SFN_TASK#mbc", sk: "batch-process#01HXYZ123#0", // Any subtask key }); // Returns all subtasks under the parent task ``` #### `updateStatus(key: DetailKey, status: string, attributes?: { result?: any; error?: any }, notifyId?: string): Promise` Updates the status of a task and sends an SNS notification. ```ts // Mark task as completed await this.taskService.updateStatus( { pk: "TASK#mbc", sk: "data-export#01HXYZ123" }, "COMPLETED", { result: { exportedRows: 100 } } ); // Mark task as failed await this.taskService.updateStatus( { pk: "TASK#mbc", sk: "data-export#01HXYZ123" }, "FAILED", { error: { message: "Export failed", code: "EXPORT_ERROR" } } ); ``` #### `updateSubTaskStatus(key: DetailKey, status: string, attributes?: { result?: any; error?: any }, notifyId?: string): Promise` Updates the status of a subtask and sends an SNS notification with action `"sub-task-status"`. ```ts await this.taskService.updateSubTaskStatus( { pk: "SFN_TASK#mbc", sk: "batch-process#01HXYZ123#0" }, "COMPLETED", { result: { processedItem: { id: 1 } } } ); ``` #### `updateStepFunctionTask(key: DetailKey, attributes?: Record, status?: string, notifyId?: string): Promise` Updates a Step Function task with attributes and status, then sends an SNS notification. ```ts await this.taskService.updateStepFunctionTask( { pk: "SFN_TASK#mbc", sk: "batch-process#01HXYZ123" }, { executionArn: "arn:aws:states:..." }, "PROCESSING" ); ``` #### `publishAlarm(event: TaskQueueEvent | StepFunctionTaskEvent, errorDetails: any): Promise` Publishes an alarm notification via SNS when an error occurs during task processing. This method is typically called from error handlers in task processing workflows. ```ts try { // Process task await this.processTask(event); } catch (error) { // Send alarm notification await this.taskService.publishAlarm(event, { message: error.message, stack: error.stack, }); throw error; } ``` The alarm notification includes: - Task key (`pk`, `sk`) - Tenant code - Error details - Action type: `"sfn-alarm"` #### `formatTaskStatus(tasks: TaskEntity[]): Promise` Formats the task status by calculating subtask counts and aggregating status information. Useful for displaying task progress in UI. ```ts // Get all subtasks for a parent task const subTasks = await this.taskService.getAllSubTask({ pk: "SFN_TASK#mbc", sk: "batch-process#01HXYZ123#0" }); const formattedStatus = await this.taskService.formatTaskStatus(subTasks); // Returns: // { // subTaskCount: 10, // Total number of subtasks // subTaskSucceedCount: 7, // Number of completed subtasks // subTaskFailedCount: 1, // Number of failed subtasks // subTaskRunningCount: 2, // Number of in-progress subtasks // subTasks: [ // Array of subtask summaries // { pk: "...", sk: "...", status: "COMPLETED" }, // ... // ] // } ``` The return object structure: ```ts { subTaskCount: number; // Total subtask count subTaskSucceedCount: number; // COMPLETED subtasks subTaskFailedCount: number; // FAILED subtasks subTaskRunningCount: number; // PROCESSING subtasks subTasks: Array<{ // Subtask summary array pk: string; sk: string; status: string; }>; } ``` ### Task Status Values | Status | Description | |------------|-----------------| | `CREATED` | Task has been created but not yet started | | `QUEUED` | Task has been queued for processing | | `STARTED` | Task execution has started | | `PROCESSING` | Task is currently being processed | | `FINISHED` | Task execution has finished | | `COMPLETED` | Task finished successfully | | `ERRORED` | Task encountered an error during execution | | `FAILED` | Task failed with an error | ### CreateTaskDto The `CreateTaskDto` class defines the structure for creating a new task: ```ts interface CreateTaskDto { tenantCode: string; // Required: Tenant identifier taskType: string; // Required: Type/category of the task name?: string; // Optional: Display name (defaults to taskType) input: Record; // Required: Task input data } ``` :::info Input Format for Step Functions When using `createStepFunctionTask()`, the `input` field must be an **array**. Each item in the array becomes a separate subtask that will be processed by the Step Functions Map state. For single task processing with `createTask()`, the input can be any object. ::: ### ITaskQueueEventFactory Interface The `ITaskQueueEventFactory` interface defines optional methods for transforming task events. You only need to implement the method(s) relevant to your use case: ```ts interface ITaskQueueEventFactory { transformTask?(event: TaskQueueEvent): Promise; // Optional: For single task processing transformStepFunctionTask?(event: StepFunctionTaskEvent): Promise; // Optional: For Step Function task processing } ``` Note: Both methods are optional. Implement `transformTask` for single task processing, or `transformStepFunctionTask` for Step Function task processing, or both if your application uses both types. ## Related Documentation - [Import/Export Patterns](/docs/import-export-patterns) - Step Functions integration for imports - [Architecture: Step Functions](/docs/architecture/step-functions) - Workflow orchestration - [Queue Module](/docs/queue) - SNS notifications used by task status updates - [Interfaces](/docs/interfaces) - StepFunctionsEvent interface --- ## Tenant URL: https://mbc-cqrs-serverless.mbc-net.com/docs/tenant # Tenant The TenantModule provides multi-tenant management capabilities in the MBC CQRS Serverless framework. It enables creating, updating, and managing tenants and their group configurations. ## Architecture {#architecture} ```mermaid graph TB subgraph "Tenant Management" A["TenantService"] --> B["CommandService"] A --> C["DataService"] B --> D["DynamoDB"] C --> D end subgraph "Tenant Hierarchy" E["Common Tenant"] --> F["Tenant"] F --> G["Tenant Group"] end ``` ## Installation {#installation} ```bash npm install @mbc-cqrs-serverless/tenant ``` :::info Tenant Code Format Tenant codes are case-insensitive. The `getUserContext()` function in `@mbc-cqrs-serverless/core` normalizes all tenant codes to lowercase. When creating tenants, use lowercase codes (e.g., `tenant001`, `acme-corp`) for consistency. **Upgrading from v1.0.x?** See the [v1.1.0 Migration Guide](/docs/migration/v1.1.0) for data migration instructions. ::: ## Module Registration {#module-registration} ```ts import { Module } from '@nestjs/common'; import { TenantModule } from "@mbc-cqrs-serverless/tenant"; import { TenantDataSyncHandler } from './tenant-data-sync.handler'; // Your IDataSyncHandler implementation @Module({ imports: [ TenantModule.register({ enableController: true, // Enable built-in REST controller dataSyncHandlers: [TenantDataSyncHandler], // Optional: Custom sync handlers }), ], }) export class AppModule {} ``` ## Module Options {#module-options} | Option | Type | Required | Description | |------------|----------|--------------|-----------------| | `enableController` | `boolean` | No | Enable or disable the built-in TenantController | | `dataSyncHandlers` | `Type[]` | No | Custom handlers for syncing tenant data to external systems | ## API Reference {#api-reference} ### TenantService Methods #### `getTenant(key: DetailKey): Promise` Retrieves a tenant by its primary key. ```ts import { Injectable } from "@nestjs/common"; import { TenantService } from "@mbc-cqrs-serverless/tenant"; @Injectable() export class MyService { constructor(private readonly tenantService: TenantService) {} async findTenant(pk: string, sk: string) { const tenant = await this.tenantService.getTenant({ pk, sk }); return tenant; } } ``` #### `createCommonTenant(dto: CommonTenantCreateDto, context): Promise` Creates a common tenant that serves as the base configuration for all tenants. ```ts const commonTenant = await this.tenantService.createCommonTenant( { name: "Common Settings", attributes: { defaultLanguage: "en", timezone: "UTC", }, }, { invokeContext } ); ``` #### `createTenant(dto: TenantCreateDto, context): Promise` Creates a new tenant with the specified code and configuration. ```ts const tenant = await this.tenantService.createTenant( { code: "tenant001", name: "Tenant One", attributes: { industry: "technology", plan: "enterprise", }, }, { invokeContext } ); ``` #### `updateTenant(key: DetailKey, dto: TenantUpdateDto, context): Promise` Updates an existing tenant's information. ```ts const updatedTenant = await this.tenantService.updateTenant( { pk: "TENANT#tenant001", sk: "MASTER" }, { name: "Updated Tenant Name", attributes: { plan: "premium", }, }, { invokeContext } ); ``` #### `deleteTenant(key: DetailKey, context): Promise` Soft deletes a tenant by setting isDeleted to true. ```ts const deletedTenant = await this.tenantService.deleteTenant( { pk: "TENANT#tenant001", sk: "MASTER" }, { invokeContext } ); ``` #### `addTenantGroup(dto: TenantGroupAddDto, context): Promise` Adds a group to a tenant with the specified role. ```ts const result = await this.tenantService.addTenantGroup( { tenantCode: "tenant001", groupId: "group001", role: "admin", }, { invokeContext } ); ``` #### `customizeSettingGroups(dto: TenantGroupUpdateDto, context): Promise` Customizes the setting groups for a specific tenant role. ```ts const result = await this.tenantService.customizeSettingGroups( { tenantCode: "tenant001", role: "admin", settingGroups: ["group001", "group002", "group003"], }, { invokeContext } ); ``` #### `createTenantGroup(tenantGroupCode: string, dto: TenantCreateDto, context): Promise` Creates a sub-tenant or tenant group under an existing tenant. ```ts const tenantGroup = await this.tenantService.createTenantGroup( "tenant001", // Parent tenant code { code: "department-a", name: "Department A", attributes: { department: "engineering", }, }, { invokeContext } ); ``` ## DTOs {#dtos} ### TenantCreateDto | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `code` | `string` | Yes | Unique tenant code | | `name` | `string` | Yes | Tenant display name | | `attributes` | `object` | No | Additional tenant attributes | ### TenantGroupAddDto | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `tenantCode` | `string` | Yes | Target tenant code | | `groupId` | `string` | Yes | Group identifier to add | | `role` | `string` | Yes | Role for the group | ### TenantGroupUpdateDto | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `tenantCode` | `string` | Yes | Target tenant code | | `role` | `string` | Yes | Role to update | | `settingGroups` | `string[]` | Yes | New setting groups array | ### CommonTenantCreateDto | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `name` | `string` | Yes | Common tenant display name | | `attributes` | `object` | No | Additional attributes | ### TenantUpdateDto | Property | Type | Required | Description | |--------------|----------|--------------|-----------------| | `code` | `string` | No | Tenant code (optional for update) | | `name` | `string` | No | Tenant display name | | `attributes` | `object` | No | Additional tenant attributes | ## Interfaces {#interfaces} ### ITenantService The `ITenantService` interface defines the contract for tenant management operations. You can use this interface for dependency injection or creating mock implementations for testing. ```ts import { ITenantService } from "@mbc-cqrs-serverless/tenant"; ``` The interface includes the following methods: - `getTenant(key: DetailKey): Promise` - `createTenant(dto: TenantCreateDto, context): Promise` - `updateTenant(key: DetailKey, dto: TenantUpdateDto, context): Promise` - `deleteTenant(key: DetailKey, context): Promise` - `createCommonTenant(dto: CommonTenantCreateDto, context): Promise` - `addTenantGroup(dto: TenantGroupAddDto, context): Promise` - `customizeSettingGroups(dto: TenantGroupUpdateDto, context): Promise` :::note The `createTenantGroup` method is available on `TenantService` but is not part of the `ITenantService` interface. ::: ## Related Documentation - [Multi-Tenant Patterns](/docs/multi-tenant-patterns) - Tenant isolation patterns - [Authentication](/docs/authentication) - Tenant-based authentication - [Key Patterns](/docs/key-patterns) - Tenant key design - [Interfaces](/docs/interfaces) - TenantModule interfaces --- ## UI Setting URL: https://mbc-cqrs-serverless.mbc-net.com/docs/ui-setting # UI Setting The UI Setting package provides a flexible framework for managing dynamic application settings without code changes. It enables administrators to define custom fields and users to store data conforming to those schemas. ## When to Use This Package {#when-to-use} Use this package when you need to: - Allow admins to define custom forms without developer intervention - Store tenant-specific configuration (themes, preferences, limits) - Create user-editable notification or email template settings - Build configurable dropdown lists or master data tables ## Problems This Package Solves {#problems-solved} | Problem | Solution | |---------|----------| | Every new setting requires code deployment | Define schemas dynamically via API | | Settings structure differs between tenants | Multi-tenant schema support | | No validation for user-entered settings | Field definitions enforce data types and constraints | | Hard to build admin UI for settings | REST API with schema introspection | ## Core Concepts {#core-concepts} The module operates with two main components: 1. **Settings**: Define the schema/structure for data entries. Each setting has a code, name, and a list of fields that describe the data structure. 2. **Data Settings**: Actual data entries that conform to a setting's schema. Each data setting belongs to a specific setting code. ## Installation {#installation} ```bash npm install @mbc-cqrs-serverless/ui-setting ``` ## Module Configuration {#module-configuration} Register the `SettingModule` in your application: ```typescript import { Module } from '@nestjs/common'; import { SettingModule } from '@mbc-cqrs-serverless/ui-setting'; @Module({ imports: [ SettingModule.register({ enableSettingController: true, // Enable REST API for settings enableDataController: true, // Enable REST API for data settings }), ], }) export class AppModule {} ``` ### Configuration Options | Option | Type | Description | |--------|------|-------------| | `enableSettingController` | boolean | Enable the settings REST API controller | | `enableDataController` | boolean | Enable the data settings REST API controller | ## Setting Service {#setting-service} The `SettingService` manages setting definitions. ### Available Methods ```typescript import { Injectable } from '@nestjs/common'; import { SettingService } from '@mbc-cqrs-serverless/ui-setting'; @Injectable() export class MyService { constructor(private readonly settingService: SettingService) {} async example() { // List all settings for a tenant const settings = await this.settingService.list(tenantCode); // Get a specific setting const setting = await this.settingService.get({ pk, sk }); // Create a new setting const newSetting = await this.settingService.create( tenantCode, createDto, { invokeContext } ); // Update a setting const updated = await this.settingService.update( { pk, sk }, updateDto, { invokeContext } ); // Delete a setting const deleted = await this.settingService.delete( { pk, sk }, { invokeContext } ); // Check if a setting code exists const exists = await this.settingService.checkExistSettingCode( tenantCode, code ); } } ``` ### Creating a Setting ```typescript import { CreateSettingDto } from '@mbc-cqrs-serverless/ui-setting'; const createDto: CreateSettingDto = { code: 'user-preferences', name: 'User Preferences', attributes: { description: 'User preference settings', fields: [ { physicalName: 'theme', name: 'Theme', dataType: 'string', isRequired: true, isShowedOnList: true, defaultValue: 'light', }, { physicalName: 'language', name: 'Language', dataType: 'string', isRequired: true, isShowedOnList: true, defaultValue: 'en', }, { physicalName: 'pageSize', name: 'Page Size', dataType: 'number', isRequired: false, isShowedOnList: false, min: '10', max: '100', defaultValue: '20', }, ], }, }; ``` ### Field Definition Each field in a setting can have the following properties: | Property | Type | Required | Description | |----------|------|----------|-------------| | `physicalName` | string | Yes | Unique identifier for the field | | `name` | string | Yes | Display name | | `description` | string | No | Field description | | `dataType` | string | Yes | Data type (string, number, boolean, etc.) | | `min` | string | No | Minimum value for numeric fields | | `max` | string | No | Maximum value for numeric fields | | `length` | string | No | Maximum length for string fields | | `maxRow` | number | No | Maximum rows for multi-line text | | `defaultValue` | string | No | Default value for the field | | `isRequired` | boolean | Yes | Whether the field is required | | `isShowedOnList` | boolean | Yes | Whether to display in list views | | `dataFormat` | string | No | Format specification for the data | ## Data Setting Service {#data-setting-service} The `DataSettingService` manages data entries for defined settings. ### Available Methods ```typescript import { Injectable } from '@nestjs/common'; import { DataSettingService } from '@mbc-cqrs-serverless/ui-setting'; @Injectable() export class MyService { constructor(private readonly dataSettingService: DataSettingService) {} async example() { // List data settings (optionally filter by setting code) const dataList = await this.dataSettingService.list( tenantCode, { settingCode: 'user-preferences' } ); // Get a specific data setting const data = await this.dataSettingService.get({ pk, sk }); // Create a new data setting const newData = await this.dataSettingService.create( tenantCode, createDto, { invokeContext } ); // Update a data setting const updated = await this.dataSettingService.update( { pk, sk }, updateDto, { invokeContext } ); // Delete a data setting const deleted = await this.dataSettingService.delete( { pk, sk }, { invokeContext } ); // Check if a data code exists const exists = await this.dataSettingService.checkExistCode( tenantCode, settingCode, code ); } } ``` ### Creating Data Setting ```typescript const createDto: CreateDataSettingDto = { settingCode: 'user-preferences', code: 'user-001', name: 'User 001 Preferences', attributes: { theme: 'dark', language: 'ja', pageSize: 50, }, }; ``` ## REST API Endpoints {#rest-api-endpoints} When controllers are enabled, the following endpoints are available: ### Setting Endpoints | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/master-setting` | List all settings | | GET | `/api/master-setting/:pk/:sk` | Get a specific setting | | POST | `/api/master-setting` | Create a new setting | | PUT | `/api/master-setting/:pk/:sk` | Update a setting | | DELETE | `/api/master-setting/:pk/:sk` | Delete a setting | | POST | `/api/master-setting/check-exist/:code` | Check if a setting code exists | ### Data Setting Endpoints | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/master-data` | List all data settings | | GET | `/api/master-data/:pk/:sk` | Get a specific data setting | | POST | `/api/master-data` | Create a new data setting | | PUT | `/api/master-data/:pk/:sk` | Update a data setting | | DELETE | `/api/master-data/:pk/:sk` | Delete a data setting | | POST | `/api/master-data/check-exist/:settingCode/:code` | Check if a data setting code exists | ### Unified Bulk Upsert Endpoint :::info Version Note The unified bulk upsert endpoint was added in [version 1.1.2](/docs/changelog#v112). ::: | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/api/master-bulk/` | Upsert settings and data in a single request | This endpoint accepts a mixed array of settings and data items. Items with `settingCode` are routed to the data service; items without are routed to the setting service. See [Unified Bulk Upsert API](/docs/master#unified-bulk-upsert) for details. ## Multi-Tenant Support {#multi-tenant-support} The module automatically handles multi-tenant data isolation. Each tenant's settings and data are stored with tenant-specific keys: ```typescript // Settings are stored with tenant-prefixed keys: // pk: MASTER# // sk: MASTER_SETTING# // Data settings are stored with setting-prefixed keys: // pk: MASTER# // sk: # ``` ## Example Use Cases {#example-use-cases} ### Use Case 1: User Notification Preferences Scenario: Each user can configure their notification preferences (email on/off, SMS on/off). Implementation: Create a "notification-settings" schema, then store each user's preferences as data entries. ```typescript @Injectable() export class AppConfigService { constructor( private readonly settingService: SettingService, private readonly dataSettingService: DataSettingService, ) {} async initializeDefaultSettings(tenantCode: string, invokeContext: IInvoke) { // Create a setting schema for notification preferences const settingDto: CreateSettingDto = { code: 'notification-settings', name: 'Notification Settings', attributes: { description: 'Configure notification preferences', fields: [ { physicalName: 'emailEnabled', name: 'Email Notifications', dataType: 'boolean', isRequired: true, isShowedOnList: true, defaultValue: 'true', }, { physicalName: 'smsEnabled', name: 'SMS Notifications', dataType: 'boolean', isRequired: true, isShowedOnList: true, defaultValue: 'false', }, ], }, }; await this.settingService.create(tenantCode, settingDto, { invokeContext }); } async getUserNotificationSettings( tenantCode: string, userId: string, ) { const { items } = await this.dataSettingService.list(tenantCode, { settingCode: 'notification-settings', }); return items.find(item => item.code === userId); } } ``` ## Related Documentation - [Modules](/docs/modules) - Framework module overview - [Master](/docs/master) - Master data management - [Frontend Project Structure](/docs/frontend-project-structure) - UI integration - [Interfaces](/docs/interfaces) - UI setting interfaces --- ## Versioning Rules URL: https://mbc-cqrs-serverless.mbc-net.com/docs/version-rules # Versioning Rules The MBC CQRS Serverless Framework implements optimistic locking using version numbers to ensure data consistency in distributed systems. This guide explains the versioning rules and provides examples of their implementation. ## Basic Rules {#basic-rules} 1. Sequential Versioning for Same PK/SK - The first command for a pk/sk is sent with version 0 (`VERSION_FIRST`); the stored item then becomes version 1, and later versions increase sequentially - Each update increments the version number by 1 - Only the first request with a given version will succeed - Subsequent requests with the same version will fail with a conflict error 2. Independent Version Sequences - Different pk/sk combinations each start their own version sequence from 1 - Version sequences are managed independently for each pk/sk combination - This allows parallel operations on different items without version conflicts 3. Optimistic Locking - Used to prevent concurrent updates to the same item - Version number is automatically incremented with each update - Throws BadRequestException on version conflicts (publishSync) - Throws ConditionalCheckFailedException for concurrent duplicate key writes (DynamoDB-level) - Ensures data consistency in distributed environments ## VERSION Constants {#version-constants} ### VERSION_FIRST — New Entity Use `VERSION_FIRST` (= `0`) as the version when creating a new entity. The framework verifies the item does not yet exist, then stores it at version `1`. ```typescript import { VERSION_FIRST } from '@mbc-cqrs-serverless/core'; await this.commandService.publishAsync( { pk: 'ORDER#tenant001', sk: 'ORD-001', version: VERSION_FIRST, // Create new entity — framework stores at version 1 type: 'ORDER', tenantCode: 'tenant001', attributes: { total: 150 }, }, { invokeContext }, ); ``` ### VERSION_LATEST — Skip Version Check Use `VERSION_LATEST` (= `-1`) to instruct the framework to auto-resolve to the latest version, bypassing optimistic locking ("last writer wins"). Use only when concurrent conflicts are acceptable and the latest value always wins. ```typescript import { VERSION_LATEST } from '@mbc-cqrs-serverless/core'; await this.commandService.publishPartialUpdateAsync( { pk: 'ORDER#tenant001', sk: 'ORD-001', version: VERSION_LATEST, // Update without version check — last writer wins attributes: { status: 'shipped' }, }, { invokeContext }, ); ``` :::warning `VERSION_LATEST` bypasses optimistic locking. If two concurrent requests both use `VERSION_LATEST`, the second write silently overwrites the first. Reserve it for idempotent fields (e.g., status flags) where the latest value is always correct. ::: ## Implementation Examples {#implementation-examples} ### Basic Version Handling ```typescript describe('Version Handling', () => { it('should handle sequential versions correctly', async () => { // Initial create with version 0 const createPayload = { pk: 'TEST#tenant001', sk: 'TEST#item-1', id: 'TEST#tenant001#TEST#item-1', name: 'Version Test', version: 0, type: 'TEST', } const createRes = await request(config.apiBaseUrl) .post('/items') .send(createPayload) expect(createRes.statusCode).toBe(201) expect(createRes.body.version).toBe(1) // Update with correct version const updatePayload = { ...createPayload, version: 1, name: 'Updated Name', } const updateRes = await request(config.apiBaseUrl) .put(`/items/${createPayload.id}`) .send(updatePayload) expect(updateRes.statusCode).toBe(200) expect(updateRes.body.version).toBe(2) }) }) ``` ### Version Conflict Handling ```typescript describe('Version Conflicts', () => { it('should handle concurrent updates correctly', async () => { const createPayload = { pk: 'TEST#tenant001', sk: 'TEST#conflict-1', id: 'TEST#tenant001#TEST#conflict-1', name: 'Conflict Test', version: 0, type: 'TEST', } // First create the item const createRes = await request(config.apiBaseUrl) .post('/items') .send(createPayload) expect(createRes.statusCode).toBe(201) const updatePayload = { ...createPayload, version: 1, name: 'Updated Name', } // First update with version 1 succeeds const res1 = await request(config.apiBaseUrl) .put(`/items/${createPayload.id}`) .send(updatePayload) // Second update with same version 1 fails const res2 = await request(config.apiBaseUrl) .put(`/items/${createPayload.id}`) .send(updatePayload) expect(res1.statusCode).toBe(200) expect(res2.statusCode).toBe(409) // Conflict }) }) ``` ### Independent Version Sequences ```typescript describe('Independent Versioning', () => { it('should maintain independent version sequences', async () => { const item1 = { pk: 'TEST#seq1', sk: 'TEST#item-1', id: 'TEST#seq1#TEST#item-1', name: 'Sequence 1', version: 0, type: 'TEST', } const item2 = { pk: 'TEST#seq2', sk: 'TEST#item-1', id: 'TEST#seq2#TEST#item-1', name: 'Sequence 2', version: 0, type: 'TEST', } // Both items start at version 1 const res1 = await request(config.apiBaseUrl) .post('/items') .send(item1) const res2 = await request(config.apiBaseUrl) .post('/items') .send(item2) expect(res1.body.version).toBe(1) expect(res2.body.version).toBe(1) // Update first item const updateRes = await request(config.apiBaseUrl) .put(`/items/${item1.id}`) .send({ ...item1, version: 1 }) expect(updateRes.body.version).toBe(2) // Second item still at version 1 const getRes = await request(config.apiBaseUrl) .get(`/items/${item2.id}`) expect(getRes.body.version).toBe(1) }) }) ``` ## Best Practices {#best-practices} 1. Always include version number in update operations 2. Handle version conflict errors gracefully in your application 3. Use appropriate retry strategies for handling conflicts 4. Consider implementing exponential backoff for retries 5. Document version handling in your API documentation ## Related Documentation - [Command Service](/docs/command-service) - publishSync with version handling - [Error Catalog](/docs/error-catalog) - Version conflict errors - [Version Conflict Guide](/docs/version-conflict-guide) - Retry strategies and recovery patterns - [Service Patterns](/docs/service-patterns) - Optimistic locking patterns --- # Infrastructure ## CDK Infrastructure URL: https://mbc-cqrs-serverless.mbc-net.com/docs/architecture/cdk-infrastructure # CDK Infrastructure This document describes the AWS infrastructure provisioned by the MBC CQRS Serverless framework using AWS CDK. Understanding this architecture helps you customize deployments and troubleshoot issues. ## System Architecture Overview The following diagram shows the complete AWS infrastructure created by the framework: ```mermaid flowchart TB subgraph Internet Client[Client Applications] end subgraph CDN["Content Delivery"] CF_API[CloudFront
API Distribution] CF_Static[CloudFront
Static Assets] end subgraph Auth["Authentication"] Cognito[Cognito User Pool] CognitoClient[User Pool Client] end subgraph API["API Layer"] APIGW[API Gateway
HTTP API] AppSync[AppSync
GraphQL API] end subgraph Compute["Compute Layer"] Lambda[Lambda Function
NestJS Application] Layer[Lambda Layer
Dependencies] end subgraph Orchestration["Workflow Orchestration"] SFN_Cmd[Command Handler
State Machine] SFN_Task[Task Handler
State Machine] SFN_Import[Import CSV
State Machine] end subgraph Messaging["Event-Driven Messaging"] SNS_Main[SNS Main Topic] SNS_Alarm[SNS Alarm Topic] SQS_Task[Task Queue] SQS_Notify[Notification Queue] SQS_SubTask[Sub-Task Queue] SQS_Import[Import Queue] SQS_DLQ[Dead Letter Queue] end subgraph Storage["Data Storage"] DDB[(DynamoDB
Event Store)] S3_Data[(S3 DDB Bucket)] S3_Public[(S3 Public Bucket)] RDS[(RDS Aurora
PostgreSQL)] end subgraph Streams["Change Data Capture"] DDB_Stream[DynamoDB Streams] end subgraph Network["Network"] VPC[VPC] Subnets[Private Subnets] SG[Security Groups] end subgraph Monitoring["Observability"] CW_Logs[CloudWatch Logs] XRay[X-Ray Tracing] CW_Alarms[CloudWatch Alarms] end Client --> CF_API Client --> CF_Static CF_API --> APIGW CF_Static --> S3_Public APIGW --> Cognito APIGW --> Lambda AppSync --> Lambda Lambda --> Layer Lambda --> DDB Lambda --> S3_Data Lambda --> RDS Lambda --> SNS_Main Lambda --> SFN_Cmd Lambda --> SFN_Task Lambda --> SFN_Import SNS_Main --> SQS_Task SNS_Main --> SQS_Notify SNS_Main --> SQS_SubTask SNS_Main --> SQS_Import SNS_Alarm --> SQS_DLQ SQS_Task --> Lambda SQS_Notify --> Lambda SQS_SubTask --> Lambda SQS_Import --> Lambda DDB --> DDB_Stream DDB_Stream --> Lambda SFN_Cmd --> Lambda SFN_Task --> Lambda SFN_Import --> Lambda SFN_Import --> S3_Data Lambda --> VPC VPC --> Subnets Subnets --> SG Lambda --> CW_Logs Lambda --> XRay SFN_Cmd --> CW_Logs SFN_Task --> CW_Logs SFN_Import --> CW_Logs ``` ## AWS Resources Created ### Authentication (Cognito) Amazon Cognito provides user authentication and authorization: ```mermaid flowchart LR subgraph Cognito UP[User Pool] UPC[User Pool Client] CustomAttrs[Custom Attributes] end subgraph Attributes tenant[tenant] company[company_code] member[member_id] roles[roles] end UP --> UPC UP --> CustomAttrs CustomAttrs --> tenant CustomAttrs --> company CustomAttrs --> member CustomAttrs --> roles ``` **CDK Implementation:** ```typescript import * as cognito from 'aws-cdk-lib/aws-cognito'; // Create User Pool const userPool = new cognito.UserPool(this, 'UserPool', { userPoolName: `${env}-${appName}-user-pool`, selfSignUpEnabled: false, signInAliases: { username: true, preferredUsername: true, }, passwordPolicy: { minLength: 6, requireLowercase: false, requireUppercase: false, requireDigits: false, requireSymbols: false, }, mfa: cognito.Mfa.OFF, accountRecovery: cognito.AccountRecovery.NONE, deletionProtection: true, customAttributes: { tenant: new cognito.StringAttribute({ maxLen: 50, mutable: true }), company_code: new cognito.StringAttribute({ maxLen: 50, mutable: true }), member_id: new cognito.StringAttribute({ maxLen: 2048, mutable: true }), roles: new cognito.StringAttribute({ mutable: true }), }, }); // Create User Pool Client const userPoolClient = userPool.addClient('UserPoolClient', { authFlows: { userPassword: true, userSrp: true, }, }); ``` ### API Gateway HTTP API provides RESTful endpoints with Cognito authorization: ```mermaid flowchart TB subgraph Routes Health["GET / (Public)"] Event["POST /event/{proxy+} (IAM)"] API["* /{proxy+} (Cognito)"] end subgraph Authorization NoAuth[No Auth] IAMAuth[IAM Authorizer] CognitoAuth[Cognito Authorizer] end Health --> NoAuth Event --> IAMAuth API --> CognitoAuth ``` **CDK Implementation:** ```typescript import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2'; import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations'; import * as apigatewayv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers'; // Create HTTP API const httpApi = new apigatewayv2.HttpApi(this, 'HttpApi', { apiName: `${env}-${appName}-api`, corsPreflight: { allowOrigins: ['*'], allowMethods: [apigatewayv2.CorsHttpMethod.ANY], allowHeaders: ['*'], maxAge: cdk.Duration.hours(1), }, }); // Lambda integration const lambdaIntegration = new apigatewayv2Integrations.HttpLambdaIntegration( 'LambdaIntegration', lambdaFunction ); // Cognito authorizer const cognitoAuthorizer = new apigatewayv2Authorizers.HttpUserPoolAuthorizer( 'CognitoAuthorizer', userPool, { userPoolClients: [userPoolClient] } ); // Public health check route httpApi.addRoutes({ path: '/', methods: [apigatewayv2.HttpMethod.GET], integration: lambdaIntegration, }); // Protected API routes httpApi.addRoutes({ path: '/{proxy+}', methods: [ apigatewayv2.HttpMethod.GET, apigatewayv2.HttpMethod.POST, apigatewayv2.HttpMethod.PUT, apigatewayv2.HttpMethod.DELETE, apigatewayv2.HttpMethod.PATCH, ], integration: lambdaIntegration, authorizer: cognitoAuthorizer, }); ``` ### Lambda Function The main compute layer runs your NestJS application: **CDK Implementation:** ```typescript import * as lambda from 'aws-cdk-lib/aws-lambda'; // Create Lambda Layer for dependencies const lambdaLayer = new lambda.LayerVersion(this, 'MainLayer', { layerVersionName: `${env}-${appName}-main-layer`, code: lambda.Code.fromAsset('dist_layer'), compatibleRuntimes: [lambda.Runtime.NODEJS_20_X], compatibleArchitectures: [lambda.Architecture.ARM_64], }); // Create Lambda Function const lambdaFunction = new lambda.Function(this, 'LambdaApi', { functionName: `${env}-${appName}-lambda-api`, runtime: lambda.Runtime.NODEJS_20_X, architecture: lambda.Architecture.ARM_64, handler: 'main.handler', code: lambda.Code.fromAsset('dist'), memorySize: 512, timeout: cdk.Duration.seconds(30), layers: [lambdaLayer], tracing: lambda.Tracing.ACTIVE, loggingFormat: lambda.LoggingFormat.JSON, vpc: vpc, vpcSubnets: { subnets: privateSubnets }, securityGroups: securityGroups, environment: { NODE_OPTIONS: '--enable-source-maps', TZ: 'Asia/Tokyo', NODE_ENV: env, APP_NAME: appName, LOG_LEVEL: 'info', S3_BUCKET_NAME: ddbBucket.bucketName, SNS_TOPIC_ARN: mainSnsTopic.topicArn, COGNITO_USER_POOL_ID: userPool.userPoolId, DATABASE_URL: databaseUrl, }, }); ``` ### SNS and SQS (Event-Driven Messaging) Message routing with filtering for different event types: ```mermaid flowchart TB SNS[SNS Main Topic] subgraph Filters F1["action = 'task-execute'"] F2["action IN ['command-status', 'task-status']"] F3["action = 'sub-task-status'"] F4["action = 'import-execute'"] end subgraph Queues Q1[Task Queue] Q2[Notification Queue] Q3[Sub-Task Queue] Q4[Import Queue] DLQ[Dead Letter Queue] end SNS --> F1 --> Q1 SNS --> F2 --> Q2 SNS --> F3 --> Q3 SNS --> F4 --> Q4 Q1 -.->|Failed messages| DLQ ``` **CDK Implementation:** ```typescript import * as sns from 'aws-cdk-lib/aws-sns'; import * as sqs from 'aws-cdk-lib/aws-sqs'; import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions'; // Create SNS Topics const mainSnsTopic = new sns.Topic(this, 'MainSnsTopic', { topicName: `${env}-${appName}-main-sns`, }); const alarmSnsTopic = new sns.Topic(this, 'AlarmSnsTopic', { topicName: `${env}-${appName}-alarm-sns`, }); // Create Dead Letter Queue const taskDlq = new sqs.Queue(this, 'TaskDLQ', { queueName: `${env}-${appName}-task-dead-letter-queue`, }); // Create Task Queue with DLQ const taskQueue = new sqs.Queue(this, 'TaskQueue', { queueName: `${env}-${appName}-task-action-queue`, deadLetterQueue: { queue: taskDlq, maxReceiveCount: 5, }, }); // Create Notification Queue const notificationQueue = new sqs.Queue(this, 'NotificationQueue', { queueName: `${env}-${appName}-notification-queue`, }); // Create Sub-task Status Queue const subTaskStatusQueue = new sqs.Queue(this, 'SubTaskStatusQueue', { queueName: `${env}-${appName}-sub-task-status-queue`, }); // Create Import Queue const importQueue = new sqs.Queue(this, 'ImportQueue', { queueName: `${env}-${appName}-import-action-queue`, }); // Subscribe queues with message filtering mainSnsTopic.addSubscription( new subscriptions.SqsSubscription(taskQueue, { filterPolicy: { action: sns.SubscriptionFilter.stringFilter({ allowlist: ['task-execute'], }), }, rawMessageDelivery: true, }) ); mainSnsTopic.addSubscription( new subscriptions.SqsSubscription(notificationQueue, { filterPolicy: { action: sns.SubscriptionFilter.stringFilter({ allowlist: ['command-status', 'task-status'], }), }, rawMessageDelivery: true, }) ); mainSnsTopic.addSubscription( new subscriptions.SqsSubscription(subTaskStatusQueue, { filterPolicy: { action: sns.SubscriptionFilter.stringFilter({ allowlist: ['sub-task-status'], }), }, rawMessageDelivery: true, }) ); mainSnsTopic.addSubscription( new subscriptions.SqsSubscription(importQueue, { filterPolicy: { action: sns.SubscriptionFilter.stringFilter({ allowlist: ['import-execute'], }), }, rawMessageDelivery: true, }) ); ``` ### S3 Buckets Storage for application data and static assets: **CDK Implementation:** ```typescript import * as s3 from 'aws-cdk-lib/aws-s3'; // DynamoDB attributes bucket const ddbBucket = new s3.Bucket(this, 'DdbAttributesBucket', { bucketName: `${env}-${appName}-ddb-attributes`, versioned: false, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, removalPolicy: cdk.RemovalPolicy.DESTROY, cors: [ { allowedMethods: [ s3.HttpMethods.GET, s3.HttpMethods.PUT, s3.HttpMethods.POST, ], allowedOrigins: ['*'], allowedHeaders: ['*'], maxAge: 3000, }, ], }); // Public assets bucket const publicBucket = new s3.Bucket(this, 'PublicBucket', { bucketName: `${env}-${appName}-public`, versioned: false, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, removalPolicy: cdk.RemovalPolicy.DESTROY, }); // Grant Lambda access ddbBucket.grantReadWrite(lambdaFunction); publicBucket.grantReadWrite(lambdaFunction); ``` ### CloudFront Distributions CDN for API and static assets with geo-restrictions: **CDK Implementation:** ```typescript import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'; // Origin Access Identity for S3 const oai = new cloudfront.OriginAccessIdentity(this, 'OAI'); publicBucket.grantRead(oai); // Static assets distribution const staticDistribution = new cloudfront.Distribution(this, 'StaticDistribution', { defaultBehavior: { origin: new origins.S3Origin(publicBucket, { originAccessIdentity: oai, }), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD, }, priceClass: cloudfront.PriceClass.PRICE_CLASS_200, geoRestriction: cloudfront.GeoRestriction.allowlist('JP', 'VN'), }); // API distribution const apiDistribution = new cloudfront.Distribution(this, 'ApiDistribution', { defaultBehavior: { origin: new origins.HttpOrigin(httpApiDomain, { customHeaders: { 'X-Origin-Verify': originVerifyToken, }, }), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED, originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER, responseHeadersPolicy: cloudfront.ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS, allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL, }, priceClass: cloudfront.PriceClass.PRICE_CLASS_200, geoRestriction: cloudfront.GeoRestriction.allowlist('JP', 'VN'), }); ``` ### AppSync (GraphQL API) Real-time GraphQL API with subscriptions: **CDK Implementation:** ```typescript import * as appsync from 'aws-cdk-lib/aws-appsync'; // Create AppSync API const graphqlApi = new appsync.GraphqlApi(this, 'GraphqlApi', { name: `${env}-${appName}-realtime`, definition: appsync.Definition.fromFile('asset/schema.graphql'), authorizationConfig: { defaultAuthorization: { authorizationType: appsync.AuthorizationType.API_KEY, apiKeyConfig: { expires: cdk.Expiration.after(cdk.Duration.days(365)), }, }, additionalAuthorizationModes: [ { authorizationType: appsync.AuthorizationType.IAM }, { authorizationType: appsync.AuthorizationType.USER_POOL, userPoolConfig: { userPool }, }, ], }, xrayEnabled: true, }); // None data source for local resolvers const noneDataSource = graphqlApi.addNoneDataSource('NoneDataSource'); // Mutation resolver noneDataSource.createResolver('SendMessageResolver', { typeName: 'Mutation', fieldName: 'sendMessage', requestMappingTemplate: appsync.MappingTemplate.fromString(` { "version": "2017-02-28", "payload": $util.toJson($context.arguments.message) } `), responseMappingTemplate: appsync.MappingTemplate.fromString( '$util.toJson($context.result)' ), }); ``` ### AppSync Events API (opt-in) {#appsync-events-api} :::info Version Note AppSync Events API CDK provisioning (`appsyncEvents` config key) was added in [version 1.3.0](/docs/changelog#v130). ::: An alternative real-time transport using AWS AppSync Events API (schema-free HTTP pub/sub). Opt in by adding `appsyncEvents` to your environment config: **Config type definition** (`infra/config/type.ts`): ```typescript export interface Config { // ... appsyncEvents?: { enabled: boolean; // Set to true to provision the Event API namespace?: string; // Channel namespace name (default: 'default') apiKeyExpireDays?: number; // API key TTL in days (default: 365) }; } ``` **Environment config example** (`infra/config/dev/index.ts`): ```typescript const config: Config = { env: 'dev', appName: 'your-app', // ... appsyncEvents: { enabled: true, namespace: 'default', apiKeyExpireDays: 365, }, }; ``` **CDK Stack Behavior:** When `appsyncEvents.enabled` is `true`, the CDK stack automatically: 1. Provisions an `aws_appsync.EventApi` with IAM + API Key auth 2. Creates a `ChannelNamespace` matching the configured `namespace` 3. Injects three environment variables into Lambda and ECS: - `NOTIFICATION_TRANSPORTS=appsync-event` - `APPSYNC_EVENTS_ENDPOINT=https://xxx.appsync-api.region.amazonaws.com/event` - `APPSYNC_EVENTS_NAMESPACE=` 4. Grants `appsync:EventPublish` to Lambda and ECS task roles via `grantPublish()` **Stack Outputs:** | Output | Description | |-----------|-----------------| | `AppSyncEventsHttpEndpoint` | HTTP endpoint for server-side publishing | | `AppSyncEventsNamespace` | Active channel namespace | | `AppSyncEventsApiKey` | API key for browser client subscriptions | See [AppSync Events API documentation](/docs/notification-module#appsync-events-service) for server-side usage and [Client-Side Subscription](/docs/notification-module#client-side-subscription) for browser integration. ### Step Functions State Machines Workflow orchestration for long-running processes: ```mermaid flowchart TB subgraph CommandSM["Command Handler State Machine"] C1[check_version] --> C2{Version OK?} C2 -->|Yes| C3[set_ttl_command] C2 -->|No| C4[wait_prev_command] C2 -->|Error| CF[Fail] C4 --> C3 C3 --> C5[history_copy] C5 --> C6[transform_data] C6 --> C7[sync_data_all
Map State] C7 --> C8[finish] C8 --> CS[Success] end subgraph TaskSM["Task Handler State Machine"] T1[Map State
Concurrency: 2] T1 --> T2[iterator] T2 --> T3[Complete] end subgraph ImportSM["Import CSV State Machine"] I1[Distributed Map
Concurrency: 50] I1 --> I2[csv_rows_handler] I2 --> I3[Complete] end ``` **CDK Implementation:** ```typescript import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks'; // Helper function to create Lambda invoke tasks const createLambdaTask = ( stateName: string, integrationPattern: sfn.IntegrationPattern = sfn.IntegrationPattern.REQUEST_RESPONSE ) => { const payload: Record = { 'source': 'step-function', 'context.$': '$$', 'input.$': '$', }; if (integrationPattern === sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN) { payload['taskToken'] = sfn.JsonPath.taskToken; } return new tasks.LambdaInvoke(this, stateName, { lambdaFunction, payload: sfn.TaskInput.fromObject(payload), stateName, outputPath: '$.Payload[0][0]', integrationPattern, retryOnServiceExceptions: true, }); }; // Define states const fail = new sfn.Fail(this, 'fail', { stateName: 'fail', causePath: '$.cause', errorPath: '$.error', }); const success = new sfn.Succeed(this, 'success', { stateName: 'success', }); const finish = createLambdaTask('finish').next(success); // Map state for parallel data sync const syncData = createLambdaTask('sync_data'); const syncDataAll = new sfn.Map(this, 'sync_data_all', { stateName: 'sync_data_all', maxConcurrency: 0, // Unlimited itemsPath: sfn.JsonPath.stringAt('$'), }) .itemProcessor(syncData) .next(finish); const transformData = createLambdaTask('transform_data').next(syncDataAll); const historyCopy = createLambdaTask('history_copy').next(transformData); const setTtlCommand = createLambdaTask('set_ttl_command').next(historyCopy); // Callback pattern for async waiting const waitPrevCommand = createLambdaTask( 'wait_prev_command', sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN ).next(setTtlCommand); // Version check choice const checkVersionResult = new sfn.Choice(this, 'check_version_result') .when(sfn.Condition.numberEquals('$.result', 0), setTtlCommand) .when(sfn.Condition.numberEquals('$.result', 1), waitPrevCommand) .when(sfn.Condition.numberEquals('$.result', -1), fail) .otherwise(waitPrevCommand); const checkVersion = createLambdaTask('check_version').next(checkVersionResult); // Create log group const commandSfnLogGroup = new logs.LogGroup(this, 'CommandSfnLogGroup', { logGroupName: `/aws/vendedlogs/states/${prefix}-command-handler-logs`, removalPolicy: cdk.RemovalPolicy.DESTROY, retention: logs.RetentionDays.SIX_MONTHS, }); // Create state machine const commandStateMachine = new sfn.StateMachine(this, 'CommandStateMachine', { stateMachineName: `${prefix}command-handler`, definitionBody: sfn.DefinitionBody.fromChainable(checkVersion), tracingEnabled: true, logs: { destination: commandSfnLogGroup, level: sfn.LogLevel.ALL, }, }); ``` ### DynamoDB Event Sources Configure DynamoDB Streams to trigger Lambda: **CDK Implementation:** ```typescript import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources'; // Tables to monitor for changes const tableNames = ['tasks', 'sample-command', 'import_tmp']; for (const tableName of tableNames) { // Lookup existing table const tableDesc = new cdk.custom_resources.AwsCustomResource( this, `${tableName}-desc`, { onCreate: { service: 'DynamoDB', action: 'describeTable', parameters: { TableName: `${prefix}${tableName}` }, physicalResourceId: cdk.custom_resources.PhysicalResourceId.fromResponse( 'Table.TableArn' ), }, policy: cdk.custom_resources.AwsCustomResourcePolicy.fromSdkCalls({ resources: cdk.custom_resources.AwsCustomResourcePolicy.ANY_RESOURCE, }), } ); const table = dynamodb.Table.fromTableAttributes(this, `${tableName}-table`, { tableArn: tableDesc.getResponseField('Table.TableArn'), tableStreamArn: tableDesc.getResponseField('Table.LatestStreamArn'), }); // Add event source with INSERT filter lambdaFunction.addEventSource( new lambdaEventSources.DynamoEventSource(table, { startingPosition: lambda.StartingPosition.TRIM_HORIZON, batchSize: 1, filters: [ lambda.FilterCriteria.filter({ eventName: lambda.FilterRule.isEqual('INSERT'), }), ], }) ); } ``` ## Environment Configuration The framework supports multiple deployment environments: ### Configuration Structure ```typescript // config/type.ts export interface Config { env: 'dev' | 'stg' | 'prod'; appName: string; domain: { http: string; // e.g., api.example.com appsync: string; // e.g., graphql.example.com }; userPoolId?: string; // Optional: use existing Cognito pool vpc: { id: string; subnetIds: string[]; securityGroupIds: string[]; }; rds: { accountSsmKey: string; // SSM parameter for DB credentials endpoint: string; dbName: string; }; logLevel?: { lambdaSystem?: 'INFO' | 'DEBUG'; lambdaApplication?: 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; level?: 'verbose' | 'debug' | 'info' | 'warn' | 'error'; }; frontBaseUrl: string; fromEmailAddress: string; wafArn?: string; // Optional: WAF for CloudFront ecs?: { maxInstances: number; minInstances: number; cpu: number; memory: number; cpuThreshold?: number; autoRollback?: boolean; }; } ``` ### Environment Examples **Development:** ```typescript // config/dev/index.ts export const config: Config = { env: 'dev', appName: 'myapp', logLevel: { lambdaSystem: 'DEBUG', lambdaApplication: 'TRACE', level: 'verbose', }, // ... other config }; ``` **Production:** ```typescript // config/prod/index.ts export const config: Config = { env: 'prod', appName: 'myapp', logLevel: { lambdaSystem: 'DEBUG', lambdaApplication: 'INFO', level: 'info', }, ecs: { maxInstances: 2, minInstances: 1, cpu: 2048, memory: 4096, cpuThreshold: 70, autoRollback: true, }, // ... other config }; ``` ## IAM Permissions The Lambda function is granted the following permissions: ```mermaid flowchart TB Lambda[Lambda Function] subgraph Permissions Cognito[Cognito Admin] S3[S3 Read/Write] SNS[SNS Publish] SQS[SQS Send] DDB[DynamoDB CRUD] SFN[Step Functions Execute] SES[SES Send Email] SSM[SSM/KMS Access] AppSync[AppSync Mutation] end Lambda --> Cognito Lambda --> S3 Lambda --> SNS Lambda --> SQS Lambda --> DDB Lambda --> SFN Lambda --> SES Lambda --> SSM Lambda --> AppSync ``` **CDK Implementation:** ```typescript // Cognito permissions lambdaFunction.addToRolePolicy( new iam.PolicyStatement({ actions: [ 'cognito-idp:AdminGetUser', 'cognito-idp:AdminCreateUser', 'cognito-idp:AdminDeleteUser', 'cognito-idp:AdminUpdateUserAttributes', 'cognito-idp:AdminSetUserPassword', 'cognito-idp:AdminResetUserPassword', 'cognito-idp:AdminEnableUser', 'cognito-idp:AdminDisableUser', 'cognito-idp:AdminAddUserToGroup', ], resources: [userPool.userPoolArn], }) ); // DynamoDB permissions lambdaFunction.addToRolePolicy( new iam.PolicyStatement({ actions: [ 'dynamodb:PutItem', 'dynamodb:UpdateItem', 'dynamodb:GetItem', 'dynamodb:Query', ], resources: [`arn:aws:dynamodb:${region}:${account}:table/${prefix}*`], }) ); // Step Functions permissions lambdaFunction.addToRolePolicy( new iam.PolicyStatement({ actions: [ 'states:StartExecution', 'states:GetExecutionHistory', 'states:DescribeExecution', ], resources: [commandStateMachine.stateMachineArn], }) ); // SES permissions lambdaFunction.addToRolePolicy( new iam.PolicyStatement({ actions: ['ses:SendEmail'], resources: ['*'], }) ); // Grant S3, SNS, SQS access ddbBucket.grantReadWrite(lambdaFunction); publicBucket.grantReadWrite(lambdaFunction); mainSnsTopic.grantPublish(lambdaFunction); alarmSnsTopic.grantPublish(lambdaFunction); taskQueue.grantSendMessages(lambdaFunction); notificationQueue.grantSendMessages(lambdaFunction); ``` ## Deployment Outputs After deployment, the following outputs are available: | Output | Description | |--------|-------------| | `userPoolId` | Cognito User Pool ID | | `userPoolClientId` | Cognito App Client ID | | `graphqlApiUrl` | AppSync GraphQL endpoint | | `graphqlApiKey` | AppSync API key | | `httpApiUrl` | HTTP API endpoint | | `httpDistributionDomain` | CloudFront custom domain | | `stateMachineArn` | Command Handler State Machine ARN | | `sfnTaskStateMachineArn` | Task Handler State Machine ARN | ## CI/CD Pipeline The framework includes a CodePipeline for automated deployments: ```mermaid flowchart LR subgraph Source GitHub[GitHub Repository] end subgraph Build Test[Run Tests] Synth[CDK Synth] end subgraph Deploy Dev[Dev Environment] Stg[Staging Environment] Prod[Production Environment] end GitHub -->|develop branch| Test GitHub -->|staging branch| Test GitHub -->|main branch| Test Test --> Synth Synth -->|develop| Dev Synth -->|staging| Stg Synth -->|main| Prod ``` ## Best Practices ### Security 1. **VPC Isolation**: Deploy Lambda in private subnets 2. **Secrets Management**: Store credentials in SSM Parameter Store with KMS encryption 3. **API Security**: Use Cognito for authentication, IAM for internal routes 4. **S3 Security**: Block all public access, use CloudFront OAI ### Performance 1. **Lambda Optimization**: Use ARM64 architecture with optimized layers 2. **CloudFront Caching**: Cache static assets, disable caching for API 3. **Connection Pooling**: Use RDS Proxy for database connections ### Monitoring 1. **CloudWatch Logs**: JSON formatted logs with structured data 2. **X-Ray Tracing**: Enable distributed tracing for debugging 3. **CloudWatch Alarms**: Set up alerts for failures and latency ## Related Documentation - [Step Functions](/docs/architecture/step-functions) - Workflow orchestration details - [System Overview](/docs/architecture/system-overview) - High-level architecture - [Deployment Guide](/docs/deployment-guide) - Deploying CDK infrastructure to AWS - [Installation](/docs/installation) - Getting started guide --- ## Step Functions URL: https://mbc-cqrs-serverless.mbc-net.com/docs/architecture/step-functions # Step Functions AWS Step Functions provides serverless workflow orchestration for coordinating distributed applications. In the MBC CQRS Serverless framework, Step Functions are used for: - Long-running workflow orchestration - Saga pattern implementation for distributed transactions - Parallel batch processing with Distributed Map - Asynchronous task coordination with callback patterns ## Architecture Overview ```mermaid flowchart TB subgraph Triggers DDBStream[DynamoDB Streams] SQS[SQS Queue] API[API Gateway] end subgraph StepFunctions CommandSM[Command State Machine] TaskSM[Task State Machine] ImportSM[Import CSV State Machine] end subgraph Processing Lambda[Lambda Functions] DDB[(DynamoDB)] S3[(S3)] end DDBStream --> CommandSM DDBStream --> TaskSM SQS --> ImportSM API --> CommandSM CommandSM --> Lambda TaskSM --> Lambda ImportSM --> Lambda Lambda --> DDB Lambda --> S3 ``` ## State Machines The framework provides three pre-configured state machines: ### Command State Machine Handles data synchronization workflows with version control and parallel processing. ```mermaid flowchart LR A[check_version] --> B{version ok?} B -->|Yes| C[set_ttl_command] B -->|No| D[wait_prev_command] D --> C C --> E[history_copy] E --> F[transform_data] F --> G[sync_data_all] G --> H[finish] ``` Key features: - **Version checking**: Ensures command ordering and prevents conflicts - **Async callback**: Waits for previous commands using task tokens - **Parallel sync**: Uses Map state to sync data across multiple targets - **TTL management**: Automatically sets expiration on records ### Task State Machine Executes parallel sub-tasks with controlled concurrency. ```mermaid flowchart LR A[Start] --> B[Map State] B --> C[iteration 1] B --> D[iteration 2] B --> E[iteration N] C --> F[End] D --> F E --> F ``` Key features: - **Controlled concurrency**: Limits parallel executions (default: 2) - **Status tracking**: Real-time task status updates - **Error handling**: Automatic failure detection and reporting ### Import CSV State Machine Processes large CSV files using AWS Distributed Map for massive parallelism. ```mermaid flowchart TB A[Start] --> B[Read CSV from S3] B --> C[Distributed Map] C --> D[Batch 1] C --> E[Batch 2] C --> F[Batch N] D --> G[Transform & Validate] E --> G F --> G G --> H[Create Commands] H --> I[End] ``` Key features: - **S3 native integration**: Reads CSV directly from S3 - **Batch processing**: Groups rows for efficient processing - **High concurrency**: Supports up to 50 concurrent batch processors - **EXPRESS execution**: Uses express workflows for child state machines ## System Configuration Example The following diagram shows how Step Functions integrate with other AWS services in a typical production environment: ```mermaid flowchart TB subgraph Client Web[Web Application] Mobile[Mobile App] end subgraph APILayer["API Layer"] APIGW[API Gateway] AppSync[AppSync GraphQL] end subgraph Compute["Compute Layer"] Lambda[Lambda Function
NestJS Application] end subgraph StepFunctions["Step Functions"] CommandSFN[Command Handler
State Machine] TaskSFN[Task Handler
State Machine] ImportSFN[Import CSV
State Machine] end subgraph EventDriven["Event-Driven Layer"] SNS[SNS Topics] SQS[SQS Queues] DDBStream[DynamoDB Streams] end subgraph Storage["Storage Layer"] DDB[(DynamoDB
Event Store)] S3[(S3
File Storage)] RDS[(RDS Aurora
Read Models)] end subgraph Monitoring["Monitoring"] CWLogs[CloudWatch Logs] XRay[X-Ray Tracing] CWAlarms[CloudWatch Alarms] end Web --> APIGW Mobile --> APIGW Web --> AppSync APIGW --> Lambda AppSync --> Lambda Lambda --> DDB Lambda --> S3 Lambda --> RDS Lambda --> SNS DDBStream --> Lambda DDB --> DDBStream SNS --> SQS SQS --> Lambda Lambda --> CommandSFN Lambda --> TaskSFN Lambda --> ImportSFN CommandSFN --> Lambda TaskSFN --> Lambda ImportSFN --> Lambda ImportSFN --> S3 CommandSFN --> CWLogs TaskSFN --> CWLogs ImportSFN --> CWLogs Lambda --> XRay CWLogs --> CWAlarms ``` ### Data Flow Example Here is a typical data flow for a command execution with Step Functions: ```mermaid sequenceDiagram participant Client participant API as API Gateway participant Lambda participant DDB as DynamoDB participant Stream as DynamoDB Stream participant SFN as Step Functions participant SNS participant SQS Client->>API: POST /orders API->>Lambda: Invoke Lambda->>DDB: PutItem (Command) DDB-->>Lambda: Success Lambda-->>API: 202 Accepted API-->>Client: Order Created DDB->>Stream: INSERT Event Stream->>Lambda: Trigger Lambda->>SFN: StartExecution loop Each State SFN->>Lambda: Invoke (state handler) Lambda->>DDB: Read/Write Lambda-->>SFN: State Output end SFN->>Lambda: Finish State Lambda->>SNS: Publish Event SNS->>SQS: Route Message SQS->>Lambda: Process Async ``` ## CDK Implementation Examples ### Complete Command State Machine The following CDK code shows how to create a complete command handler state machine: ```typescript import * as cdk from 'aws-cdk-lib'; import * as sfn from 'aws-cdk-lib/aws-stepfunctions'; import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as logs from 'aws-cdk-lib/aws-logs'; import { Construct } from 'constructs'; export class CommandStateMachineConstruct extends Construct { public readonly stateMachine: sfn.StateMachine; constructor(scope: Construct, id: string, props: { lambdaFunction: lambda.IFunction }) { super(scope, id); const { lambdaFunction } = props; // Helper function to create Lambda invoke tasks const createLambdaTask = ( stateName: string, integrationPattern: sfn.IntegrationPattern = sfn.IntegrationPattern.REQUEST_RESPONSE ) => { const payload: Record = { 'source': 'step-function', 'context.$': '$$', 'input.$': '$', }; // Add task token for callback pattern if (integrationPattern === sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN) { payload['taskToken'] = sfn.JsonPath.taskToken; } return new tasks.LambdaInvoke(this, stateName, { lambdaFunction, payload: sfn.TaskInput.fromObject(payload), stateName, outputPath: '$.Payload[0][0]', integrationPattern, retryOnServiceExceptions: true, }); }; // Define states const fail = new sfn.Fail(this, 'fail', { stateName: 'fail', causePath: '$.cause', errorPath: '$.error', }); const success = new sfn.Succeed(this, 'success', { stateName: 'success', }); // Create task states const finish = createLambdaTask('finish').next(success); const syncData = createLambdaTask('sync_data'); // Map state for parallel data sync const syncDataAll = new sfn.Map(this, 'sync_data_all', { stateName: 'sync_data_all', maxConcurrency: 0, // Unlimited concurrency itemsPath: sfn.JsonPath.stringAt('$'), }) .itemProcessor(syncData) .next(finish); const transformData = createLambdaTask('transform_data').next(syncDataAll); const historyCopy = createLambdaTask('history_copy').next(transformData); const setTtlCommand = createLambdaTask('set_ttl_command').next(historyCopy); // Callback pattern for waiting on previous command const waitPrevCommand = createLambdaTask( 'wait_prev_command', sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN ).next(setTtlCommand); // Choice state for version checking const checkVersionResult = new sfn.Choice(this, 'check_version_result', { stateName: 'check_version_result', }) .when(sfn.Condition.numberEquals('$.result', 0), setTtlCommand) .when(sfn.Condition.numberEquals('$.result', 1), waitPrevCommand) .when(sfn.Condition.numberEquals('$.result', -1), fail) .otherwise(waitPrevCommand); const checkVersion = createLambdaTask('check_version').next(checkVersionResult); // Create log group const logGroup = new logs.LogGroup(this, 'StateMachineLogGroup', { logGroupName: '/aws/vendedlogs/states/command-handler-logs', removalPolicy: cdk.RemovalPolicy.DESTROY, retention: logs.RetentionDays.SIX_MONTHS, }); // Create state machine this.stateMachine = new sfn.StateMachine(this, 'CommandHandlerStateMachine', { stateMachineName: 'command-handler', comment: 'Handles command stream processing with version control', definitionBody: sfn.DefinitionBody.fromChainable(checkVersion), tracingEnabled: true, logs: { destination: logGroup, level: sfn.LogLevel.ALL, }, }); } } ``` ### Task State Machine with Controlled Concurrency ```typescript export class TaskStateMachineConstruct extends Construct { public readonly stateMachine: sfn.StateMachine; constructor(scope: Construct, id: string, props: { lambdaFunction: lambda.IFunction }) { super(scope, id); const { lambdaFunction } = props; // Iterator task for each item const iteratorTask = new tasks.LambdaInvoke(this, 'iterator', { lambdaFunction, payload: sfn.TaskInput.fromObject({ 'source': 'step-function', 'context.$': '$$', 'input.$': '$', }), stateName: 'iterator', outputPath: '$.Payload[0][0]', }); // Map state with concurrency limit const mapState = new sfn.Map(this, 'TaskMapState', { stateName: 'map_state', maxConcurrency: 2, // Process 2 items at a time inputPath: '$', itemsPath: sfn.JsonPath.stringAt('$'), }).itemProcessor(iteratorTask); // Create log group const logGroup = new logs.LogGroup(this, 'TaskLogGroup', { logGroupName: '/aws/vendedlogs/states/task-handler-logs', removalPolicy: cdk.RemovalPolicy.DESTROY, retention: logs.RetentionDays.SIX_MONTHS, }); // Create state machine this.stateMachine = new sfn.StateMachine(this, 'TaskHandlerStateMachine', { stateMachineName: 'task-handler', comment: 'Handles parallel task execution with concurrency control', definitionBody: sfn.DefinitionBody.fromChainable(mapState), timeout: cdk.Duration.minutes(15), tracingEnabled: true, logs: { destination: logGroup, level: sfn.LogLevel.ALL, }, }); } } ``` ### Distributed Map for CSV Import For processing large CSV files, use Distributed Map which provides native S3 integration: ```typescript import { Map as SfnMap, ProcessorMode, ProcessorConfig, IChainable, JsonPath } from 'aws-cdk-lib/aws-stepfunctions'; // Types for Distributed Map S3 item reader configuration interface DistributedMapItemReader { Resource: string; ReaderConfig?: { InputType: 'CSV' | 'JSON' | 'MANIFEST'; CSVHeaderLocation?: 'FIRST_ROW' | 'GIVEN'; CSVHeaders?: string[]; MaxItems?: number; }; Parameters?: Record; } // Types for Distributed Map batch processing configuration interface DistributedMapItemBatcher { MaxInputBytesPerBatch?: number; MaxItemsPerBatch?: number; BatchInput?: Record; } // Custom Distributed Map class for S3 CSV processing export class DistributedMap extends SfnMap { public itemReader?: DistributedMapItemReader; public itemBatcher?: DistributedMapItemBatcher; public label?: string; public override toStateJson(): object { const mapStateJson = super.toStateJson(); return { ...mapStateJson, ItemReader: this.itemReader, ItemBatcher: this.itemBatcher, Label: this.label, }; } public itemProcessor(processor: IChainable, config: ProcessorConfig = {}): DistributedMap { super.itemProcessor(processor, { ...config, mode: ProcessorMode.DISTRIBUTED, }); return this; } public setItemReader(itemReader: DistributedMapItemReader): DistributedMap { this.itemReader = itemReader; return this; } public setItemBatcher(itemBatcher: DistributedMapItemBatcher): DistributedMap { this.itemBatcher = itemBatcher; return this; } public setLabel(label: string): DistributedMap { this.label = label; return this; } } // Usage in your stack const csvRowsHandler = new tasks.LambdaInvoke(this, 'csv_rows_handler', { lambdaFunction, payload: sfn.TaskInput.fromObject({ 'source': 'step-function', 'context.$': '$$', 'input.$': '$', }), stateName: 'csv_rows_handler', }); const importCsvDefinition = new DistributedMap(this, 'import-csv', { maxConcurrency: 50, // Process up to 50 batches in parallel }) .setLabel('import-csv') .setItemReader({ Resource: 'arn:aws:states:::s3:getObject', ReaderConfig: { InputType: 'CSV', CSVHeaderLocation: 'FIRST_ROW', }, Parameters: { 'Bucket.$': '$.bucket', 'Key.$': '$.key', }, }) .setItemBatcher({ MaxInputBytesPerBatch: 10, BatchInput: { 'Attributes.$': '$', }, }) .itemProcessor(csvRowsHandler, { executionType: sfn.ProcessorType.EXPRESS, // Use EXPRESS for child executions }); const importCsvStateMachine = new sfn.StateMachine(this, 'ImportCsvStateMachine', { stateMachineName: 'import-csv', comment: 'Processes large CSV files with distributed batch processing', definitionBody: sfn.DefinitionBody.fromChainable(importCsvDefinition), tracingEnabled: true, }); ``` ### Event Source Configuration Configure DynamoDB Streams and SQS to trigger Step Functions: ```typescript // DynamoDB Stream event source const tableNames = ['tasks', 'commands', 'import_tmp']; for (const tableName of tableNames) { const table = dynamodb.Table.fromTableAttributes(this, `${tableName}-table`, { tableArn: `arn:aws:dynamodb:${region}:${account}:table/${prefix}${tableName}`, tableStreamArn: `arn:aws:dynamodb:${region}:${account}:table/${prefix}${tableName}/stream/*`, }); lambdaFunction.addEventSource( new lambdaEventSources.DynamoEventSource(table, { startingPosition: lambda.StartingPosition.TRIM_HORIZON, batchSize: 1, filters: [ lambda.FilterCriteria.filter({ eventName: lambda.FilterRule.isEqual('INSERT'), }), ], }) ); } // SQS event sources const queues = ['task-action-queue', 'notification-queue', 'import-action-queue']; for (const queueName of queues) { const queue = sqs.Queue.fromQueueArn( this, queueName, `arn:aws:sqs:${region}:${account}:${prefix}${queueName}` ); lambdaFunction.addEventSource( new lambdaEventSources.SqsEventSource(queue, { batchSize: 1, }) ); } ``` ## Implementation Guide ### Step 1: Infrastructure Setup The framework automatically provisions Step Functions infrastructure using AWS CDK. Key resources include: ```typescript // State machine definition in CDK const commandStateMachine = new sfn.StateMachine(this, 'CommandHandler', { stateMachineName: 'command', definitionBody: sfn.DefinitionBody.fromChainable(definition), timeout: Duration.minutes(15), tracingEnabled: true, logs: { destination: logGroup, level: sfn.LogLevel.ALL, }, }); ``` ### Step 2: Define Step Function Events Create event classes that extend the base Step Function event: ```typescript import { IEvent } from '@mbc-cqrs-serverless/core'; import { StepFunctionsContext } from '@mbc-cqrs-serverless/core'; export class CustomWorkflowEvent implements IEvent { source: string; context: StepFunctionsContext; input?: WorkflowInput; taskToken?: string; } ``` ### Step 3: Implement Event Handlers Create handlers that process Step Function events: ```typescript import { EventHandler, IEventHandler, StepFunctionStateInput } from '@mbc-cqrs-serverless/core'; import { Logger } from '@nestjs/common'; @EventHandler(CustomWorkflowEvent) export class CustomWorkflowHandler implements IEventHandler { private readonly logger = new Logger(CustomWorkflowHandler.name); async execute(event: CustomWorkflowEvent): Promise { const stateName = event.context.State.Name; switch (stateName) { case 'initialize': return this.handleInitialize(event); case 'process': return this.handleProcess(event); case 'finalize': return this.handleFinalize(event); default: throw new Error(`Unknown state: ${stateName}`); } } private async handleInitialize(event: CustomWorkflowEvent) { // Initialization logic return { status: 'initialized', data: event.input }; } private async handleProcess(event: CustomWorkflowEvent) { // Processing logic return { status: 'processed' }; } private async handleFinalize(event: CustomWorkflowEvent) { // Finalization logic return { status: 'completed' }; } } ``` ### Step 4: Configure Event Factory Register your Step Function events in the event factory: ```typescript import { EventFactory, IEvent, StepFunctionsEvent } from '@mbc-cqrs-serverless/core'; @EventFactory() export class CustomEventFactory { async transformStepFunction(event: StepFunctionsEvent): Promise { const stateMachineName = event.context.StateMachine.Name; if (stateMachineName.includes('custom-workflow')) { return [new CustomWorkflowEvent(event)]; } return []; } } ``` ### Step 5: Trigger State Machine Execution Start a state machine execution from your service: ```typescript import { StepFunctionService } from '@mbc-cqrs-serverless/core'; import { Injectable } from '@nestjs/common'; @Injectable() export class WorkflowService { constructor(private readonly sfnService: StepFunctionService) {} async startWorkflow(input: WorkflowInput): Promise { const executionArn = await this.sfnService.startExecution({ stateMachineArn: process.env.WORKFLOW_STATE_MACHINE_ARN, input: JSON.stringify(input), name: `workflow-${Date.now()}`, }); return executionArn; } } ``` ## Use Cases ### Use Case 1: Data Synchronization Synchronize data across multiple tables with version control and conflict resolution. **Scenario**: When a command is created, sync the data to multiple read models. ```typescript // Trigger: DynamoDB Stream INSERT event // Flow: check_version -> set_ttl -> history_copy -> transform -> sync_all -> finish await this.commandService.publishAsync( { pk: 'ORDER#tenant1', sk: 'ORDER#order123', id: 'order-uuid', code: 'order123', name: 'Order', type: 'ORDER', version: 1, tenantCode: 'tenant1', attributes: { status: 'confirmed', total: 1000 }, }, { invokeContext }, ); // This triggers the command state machine automatically ``` ### Use Case 2: Batch Task Processing Execute multiple related tasks in parallel with controlled concurrency. **Scenario**: Process multiple items in a batch job with status tracking. ```typescript // Create tasks that will be processed by the task state machine const items = [ { itemId: 'item1', action: 'process' }, { itemId: 'item2', action: 'process' }, { itemId: 'item3', action: 'process' }, ]; await this.taskService.createStepFunctionTask({ input: items, taskType: 'batch-processor', tenantCode: 'tenant1', }, { invokeContext }); ``` ### Use Case 3: Large-Scale CSV Import Import millions of rows from CSV files with distributed processing. **Scenario**: Import a large CSV file from S3 with validation and transformation. ```typescript import { ProcessingMode } from '@mbc-cqrs-serverless/import'; // Trigger CSV import via API or direct invocation await this.importService.createCsvImport({ s3Bucket: 'my-bucket', s3Key: 'imports/data.csv', tableName: 'products', processingMode: ProcessingMode.STEP_FUNCTION, }); // The import-csv state machine will: // 1. Read CSV from S3 // 2. Batch rows (default: 10 per batch) // 3. Process up to 50 batches concurrently // 4. Transform and validate each row // 5. Create import commands ``` ### Use Case 4: Async Callback Pattern Wait for external events using task tokens. **Scenario**: Wait for approval before proceeding with a workflow. ```typescript // In your state machine definition { "WaitForApproval": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken", "Parameters": { "FunctionName": "${LambdaFunction}", "Payload": { "taskToken.$": "$$.Task.Token", "requestId.$": "$.requestId" } }, "Next": "ProcessApproval" } } // In your handler, store the task token async handleWaitForApproval(event: ApprovalEvent) { await this.approvalService.createApprovalRequest({ requestId: event.input.requestId, taskToken: event.taskToken, // Store for later callback }); } // When approval is received, resume the workflow async approveRequest(requestId: string) { const request = await this.approvalService.getRequest(requestId); await this.sfnService.sendTaskSuccess({ taskToken: request.taskToken, output: JSON.stringify({ approved: true }), }); } ``` ## Callback Patterns with Task Tokens The framework implements callback patterns using AWS Step Functions task tokens for coordinating long-running workflows and waiting for external events. ### How Callback Patterns Work When a Step Function state uses the `WAIT_FOR_TASK_TOKEN` integration pattern, the execution pauses until an external process sends a success or failure response with the task token. ```mermaid sequenceDiagram participant SFN as Step Functions participant Lambda participant DDB as DynamoDB participant External as External Process SFN->>Lambda: Invoke with taskToken Lambda->>DDB: Store taskToken Lambda-->>SFN: Return (execution pauses) Note over SFN: Waiting for callback... External->>Lambda: Trigger callback Lambda->>DDB: Retrieve taskToken Lambda->>SFN: SendTaskSuccess(taskToken) Note over SFN: Execution resumes SFN->>Lambda: Continue to next state ``` ### StepFunctionService Implementation The `StepFunctionService` provides methods for starting executions and resuming paused workflows: ```typescript import { Injectable } from '@nestjs/common'; import { SFNClient, SendTaskSuccessCommand, StartExecutionCommand, } from '@aws-sdk/client-sfn'; @Injectable() export class StepFunctionService { private readonly client: SFNClient; constructor(private readonly config: ConfigService) { this.client = new SFNClient({ endpoint: config.get('SFN_ENDPOINT'), region: config.get('SFN_REGION'), }); } // Start a new state machine execution startExecution(arn: string, input: any, name?: string) { return this.client.send( new StartExecutionCommand({ stateMachineArn: arn, name: name && name.length <= 80 ? name : undefined, input: JSON.stringify(input), }), ); } // Resume a paused execution using task token async resumeExecution(taskToken: string, output: any = {}) { // Wrap output in the expected format for Lambda integration const wrappedOutput = { Payload: [[output]], }; return await this.client.send( new SendTaskSuccessCommand({ taskToken: taskToken, output: JSON.stringify(wrappedOutput), }), ); } } ``` ### Version-Based Command Chaining The command state machine uses callback patterns to ensure commands are processed in version order: ```typescript // Wait for previous command to complete using task token protected async waitConfirmToken( event: DataSyncCommandSfnEvent, ): Promise { // Store task token in DynamoDB for later callback await this.commandService.updateTaskToken(event.commandKey, event.taskToken); return { result: { token: event.taskToken, }, }; } // When a command finishes, check if next version is waiting protected async checkNextToken( event: DataSyncCommandSfnEvent, ): Promise { const nextCommand = await this.commandService.getNextCommand( event.commandKey, ); if (!nextCommand) { return null; // No next command, chain ends } if (nextCommand.taskToken) { // Resume the waiting command try { await this.sfnService.resumeExecution(nextCommand.taskToken, { result: 'resumed_by_prev_version', prevVersion: event.commandRecord.version, }); } catch (e) { this.logger.warn( `Could not resume command v${nextCommand.version}: ${e.message}`, ); } } return null; } ``` ### CDK Configuration for Callback Pattern Configure the state to wait for task token in your CDK stack: ```typescript // Create a state that waits for callback const waitPrevCommand = new tasks.LambdaInvoke(this, 'wait_prev_command', { lambdaFunction, payload: sfn.TaskInput.fromObject({ 'input.$': '$', 'context.$': '$$', 'taskToken': sfn.JsonPath.taskToken, // Include task token in payload }), stateName: 'wait_prev_command', outputPath: '$.Payload[0][0]', // Use WAIT_FOR_TASK_TOKEN integration pattern integrationPattern: sfn.IntegrationPattern.WAIT_FOR_TASK_TOKEN, }); ``` ## Long-Running Workflow Strategies The framework provides several strategies for handling long-running workflows: ### ZIP Import Orchestration For complex multi-file imports, the framework uses a hierarchical orchestration pattern: ```mermaid flowchart TB subgraph ZipOrchestrator["ZIP Orchestrator State Machine"] A[Start] --> B[Extract ZIP] B --> C[Map: Process Each CSV] C --> D1[CSV 1: trigger_single_csv_and_wait] C --> D2[CSV 2: trigger_single_csv_and_wait] C --> D3[CSV N: trigger_single_csv_and_wait] D1 --> E[finalize_zip_job] D2 --> E D3 --> E E --> F[End] end subgraph CsvStateMachine["CSV State Machine (per file)"] G[csv_loader] --> H[Distributed Map] H --> I[csv_rows_handler x N] I --> J[finalize_parent_job] end D1 -.->|taskToken| G J -.->|SendTaskSuccess| D1 ``` ### Task Token Propagation for Child Workflows When triggering child workflows, the parent stores the task token for later callback: ```typescript import { ProcessingMode } from '@mbc-cqrs-serverless/import'; // Trigger a child CSV job and wait for completion private async triggerSingleCsvJob(event: ZipImportSfnEvent) { const s3Key = event.input?.s3Key || event.input; const { taskToken } = event; // Task token from parent workflow const { masterJobKey, parameters } = event.context.Execution.Input; // Create CSV job with stored task token await this.importService.createCsvJobWithTaskToken( { processingMode: ProcessingMode.STEP_FUNCTION, bucket: parameters.bucket, key: s3Key, tenantCode: parameters.tenantCode, tableName: tableName, }, taskToken, // Store for callback when CSV processing completes masterJobKey, ); } ``` ### Workflow Timeout Configuration Set appropriate timeouts for long-running workflows: ```typescript const taskStateMachine = new sfn.StateMachine(this, 'task-handler', { stateMachineName: 'task-handler', definitionBody: sfn.DefinitionBody.fromChainable(sfnTaskMapState), timeout: cdk.Duration.minutes(15), // Overall workflow timeout tracingEnabled: true, logs: { destination: logGroup, level: sfn.LogLevel.ALL, }, }); ``` ## Integration with Import/Export Patterns The framework integrates Step Functions with the import module for scalable data processing: ### CSV Import Flow The CSV import uses a two-phase approach with Step Functions: ```typescript // Phase 1: Create import job and trigger Step Function async handleCsvImport( dto: CreateCsvImportDto, options: ICommandOptions, ): Promise { if (dto.processingMode === ProcessingMode.DIRECT) { // Process directly in Lambda (for small files) return this._processCsvDirectly(dto, options); } else { // Create job and let Step Function handle processing return this.createCsvJob(dto, options); } } // Phase 2: Step Function handler processes rows @EventHandler(CsvImportSfnEvent) export class CsvImportSfnEventHandler { async handleStepState(event: CsvImportSfnEvent): Promise { if (event.context.State.Name === 'csv_loader') { // Count total rows and initialize job const totalRows = await this.countCsvRows(input); await this.importService.updateImportJob(parentKey, { set: { totalRows }, }); return this.loadCsv(input); } if (event.context.State.Name === 'finalize_parent_job') { return this.finalizeParentJob(event); } // Process batch of rows const items = event.input.Items; for (const item of items) { const transformedData = await strategy.transform(item); await strategy.validate(transformedData); await this.importService.createImport(createImportDto, options); } } } ``` ### Progress Tracking with Atomic Counters The import service uses atomic DynamoDB counters for accurate progress tracking: ```typescript // Atomically increment progress counters async incrementParentJobCounters( parentKey: DetailKey, childSucceeded: boolean, ): Promise { const countersToIncrement: { [key: string]: number } = { processedRows: 1, }; if (childSucceeded) { countersToIncrement.succeededRows = 1; } else { countersToIncrement.failedRows = 1; } // Use atomic update expression const command = new UpdateItemCommand({ TableName: this.tableName, Key: marshall(parentKey), UpdateExpression: 'SET #processedRows = if_not_exists(#processedRows, :start) + :inc', ExpressionAttributeNames: { '#processedRows': 'processedRows' }, ExpressionAttributeValues: marshall({ ':start': 0, ':inc': 1 }), ReturnValues: 'ALL_NEW', }); const response = await this.dynamoDbService.client.send(command); const updatedEntity = unmarshall(response.Attributes) as ImportEntity; // Check if job is complete and update final status if (updatedEntity.totalRows > 0 && updatedEntity.processedRows >= updatedEntity.totalRows) { const finalStatus = updatedEntity.failedRows > 0 ? ImportStatusEnum.FAILED : ImportStatusEnum.COMPLETED; await this.updateStatus(parentKey, finalStatus); } return updatedEntity; } ``` ### Processing Mode Selection Choose the appropriate processing mode based on data size: | Processing Mode | Use Case | Max Rows | Concurrency | |---------------------|--------------|--------------|-----------------| | `DIRECT` | Small files, immediate feedback | ~1,000 | Single Lambda | | `STEP_FUNCTION` | Large files, background processing | Millions | Up to 50 | ```typescript import { ProcessingMode } from '@mbc-cqrs-serverless/import'; // Example: Selecting processing mode based on file size const processingMode = estimatedRows > 1000 ? ProcessingMode.STEP_FUNCTION : ProcessingMode.DIRECT; await importService.handleCsvImport({ bucket: 'my-bucket', key: 'data/large-file.csv', tableName: 'products', tenantCode: 'tenant1', processingMode, }, { invokeContext }); ``` ## Step Functions Context Every Step Function event includes context information about the execution: ```typescript interface StepFunctionsContext { Execution: { Id: string; // Execution ARN Input: object; // Original input Name: string; // Execution name RoleArn: string; // IAM role StartTime: string; // ISO timestamp }; State: { EnteredTime: string; // When this state started Name: string; // Current state name RetryCount: number; // Retry attempt number }; StateMachine: { Id: string; // State machine ARN Name: string; // State machine name }; } ``` ## Error Handling Implement robust error handling in your state machines: ### Handler-Level Error Handling The framework provides built-in error handling patterns for Step Function handlers: ```typescript // Command event handler with status tracking and error handling @Injectable() export class CommandEventHandler { async execute( event: DataSyncCommandSfnEvent, ): Promise { // Update status to STARTED before processing await this.commandService.updateStatus( event.commandKey, getCommandStatus(event.stepStateName, CommandStatus.STATUS_STARTED), event.commandRecord.requestId, ); try { const ret = await this.handleStepState(event); // Update status to FINISHED on success await this.commandService.updateStatus( event.commandKey, getCommandStatus(event.stepStateName, CommandStatus.STATUS_FINISHED), event.commandRecord.requestId, ); return ret; } catch (error) { // Update status to FAILED and publish alarm on error await this.commandService.updateStatus( event.commandKey, getCommandStatus(event.stepStateName, CommandStatus.STATUS_FAILED), event.commandRecord.requestId, ); await this.publishAlarm(event, (error as Error).stack); throw error; } } } ``` ### Task Error Handling with Continuation For task handlers, the framework supports continuing execution even after errors: ```typescript // Task handler with error handling that allows workflow continuation @EventHandler(StepFunctionTaskEvent) export class TaskSfnEventHandler implements IEventHandler { async execute(event: StepFunctionTaskEvent): Promise { const taskKey = event.taskKey; try { await this.taskService.updateSubTaskStatus(taskKey, TaskStatusEnum.PROCESSING); const events = await this.eventFactory.transformStepFunctionTask(event); const result = await Promise.all( events.map((event) => this.eventBus.execute(event)), ); // Update status to COMPLETED on success await this.taskService.updateSubTaskStatus(taskKey, TaskStatusEnum.COMPLETED, { result, }); } catch (error) { // Update status to FAILED and publish alarm, but don't throw this.logger.error(error); await Promise.all([ this.taskService.updateSubTaskStatus(taskKey, TaskStatusEnum.FAILED, { error: (error as Error).stack, }), this.taskService.publishAlarm(event, (error as Error).stack), ]); // Note: Error is not re-thrown to allow Step Function to continue // throw error // Uncomment to fail the entire workflow on error } } } ``` ### Alarm Publishing The framework publishes alarms to SNS for monitoring and alerting: ```typescript // Publish alarm notification to SNS topic async publishAlarm( event: DataSyncCommandSfnEvent, errorDetails: any, ): Promise { const alarm: INotification = { action: 'sfn-alarm', id: `${event.commandKey.pk}#${event.commandKey.sk}`, table: this.options.tableName, pk: event.commandKey.pk, sk: event.commandKey.sk, tenantCode: event.commandKey.pk.substring( event.commandKey.pk.indexOf('#') + 1, ), content: { errorMessage: errorDetails, sfnId: event.context.Execution.Id, }, }; await this.snsService.publish(alarm, this.alarmTopicArn); } ``` ### State machine error handling configuration: ```json { "ProcessStep": { "Type": "Task", "Resource": "${LambdaArn}", "Retry": [ { "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2 } ], "Catch": [ { "ErrorEquals": ["States.ALL"], "Next": "HandleError", "ResultPath": "$.error" } ], "Next": "NextStep" } } ``` ## Best Practices ### Design Principles 1. **Idempotency**: Design each state to be safely retryable 2. **Single Responsibility**: Each state should do one thing well 3. **Timeout Configuration**: Set appropriate timeouts for each state 4. **Logging**: Enable comprehensive logging for debugging ### Performance Optimization 1. **Use Express Workflows**: For high-volume, short-duration workflows 2. **Batch Processing**: Group items to reduce state transitions 3. **Concurrency Limits**: Set appropriate limits to prevent throttling 4. **S3 Integration**: Use native S3 integration for large data processing ### Monitoring 1. **CloudWatch Metrics**: Monitor execution counts, failures, and duration 2. **X-Ray Tracing**: Enable distributed tracing for debugging 3. **CloudWatch Logs**: Capture detailed execution logs 4. **Alarms**: Set up alerts for failure rates and execution times ## Related Documentation - [Task Module](/docs/tasks) - Task management with Step Functions - [Import/Export Patterns](/docs/import-export-patterns) - CSV import with Distributed Map - [Event Sourcing](/docs/architecture/event-sourcing) - Event-driven architecture - [CQRS Flow](/docs/architecture/cqrs-flow) - Command and query separation --- ## DynamoDB URL: https://mbc-cqrs-serverless.mbc-net.com/docs/dynamodb # DynamoDB ## Overview {#overview} MBC CQRS Serverless uses DynamoDB as its primary data store, implementing CQRS and Event Sourcing patterns through a structured table design. Understanding the table structure is essential for building efficient applications. ## Table Architecture {#table-architecture} ```mermaid graph TB subgraph "Table Types" A["Command Table
entity-command"] B["Data Table
entity-data"] C["History Table
entity-history"] end subgraph "System Tables" D["tasks"] E["sequences"] F["import_tmp"] end A -->|"DynamoDB Streams"| B A -->|"Event Sourcing"| C ``` In the MBC CQRS Serverless, DynamoDB tables are organized into the following types: ### Entity Tables | Table Type | Naming Convention | Purpose | |------------|-------------------|---------| | Command Table | `entity-command` | Stores write commands (write model) | | Data Table | `entity-data` | Stores current state (read model) | | History Table | `entity-history` | Stores all versions for Event Sourcing | :::info Deployed Table Names The actual table names are prefixed with the environment and application name: `{NODE_ENV}-{APP_NAME}-{entity}-{type}` (for example `dev-myapp-order-command`). The names above are the logical suffixes used by `CommandModule.register({ tableName })`. ::: ### System Tables | Table | Purpose | |--------|---------| | `tasks` | Stores information about long-running asynchronous tasks | | `sequences` | Holds sequence data for ID generation | | `import_tmp` | Stores temporary data for import operations via Step Functions | | `session` | Tracks Read-Your-Writes sessions (v1.2.0+) | ## Table Definition {#table-definition} Table definitions are stored in the `prisma/dynamodbs` folder. To add a new entity table: ### Step 1: Define Table in Configuration Add the table name to `prisma/dynamodbs/cqrs.json`: ```json ["cat", "dog", "order"] ``` ### Step 2: Run Migration For local development: ```bash # Migrate DynamoDB tables only npm run migrate:ddb # Migrate both DynamoDB and RDS npm run migrate ``` ### System Table Definitions {#system-table-definitions} System tables (`tasks`, `sequences`, `import_tmp`, `session`) have their own JSON definition files in the `prisma/dynamodbs/` folder. These are automatically created during migration: | File | Table | Purpose | |------|---------|---------| | `tasks.json` | `tasks` | Task management with DynamoDB Streams | | `sequences.json` | `sequences` | Sequence ID generation | | `import_tmp.json` | `import_tmp` | Temporary import data with DynamoDB Streams for [ImportModule](/docs/import) | | `session.json` | `session` | Read-Your-Writes session tracking (v1.2.0+), see [Command Service](/docs/command-service#read-your-writes) | :::info Version Note The `import_tmp.json` template was added in [version 1.1.1](/docs/changelog#v111). If you created your project with an earlier version and use the ImportModule, you need to add this file manually. See [Common Issues](/docs/common-issues#missing-import-tmp-table) for details. ::: ## Key Design Patterns {#key-design-patterns} ### Standard Key Structure All entity tables use a composite primary key. The DATA table and COMMAND table use the same `pk` format but differ in `sk`: | Table | Key | Format | Example | |-------|-----|--------|---------| | DATA / HISTORY | `pk` | `TYPE#tenantCode` | `ORDER#ACME` | | DATA / HISTORY | `sk` | `TYPE#code` | `ORDER#ORD-000001` | | COMMAND | `pk` | `TYPE#tenantCode` | `ORDER#ACME` | | COMMAND | `sk` | `TYPE#code@version` | `ORDER#ORD-000001@1` | The COMMAND table sort key includes an `@{version}` suffix appended by the framework. Use `removeSortKeyVersion(sk)` (imported from `@mbc-cqrs-serverless/core`) to strip it when querying the DATA table. ### Entity Key Examples ```typescript // Order entity const orderKey = { pk: `ORDER#${tenantCode}`, sk: `ORDER#${orderId}`, }; // User entity const userKey = { pk: `USER#${tenantCode}`, sk: `USER#${userId}`, }; // Hierarchical data (e.g., organization) const departmentKey = { pk: `ORG#${tenantCode}`, sk: `DEPT#${parentId}#${deptId}`, }; ``` ## Table Attributes {#table-attributes} ### Common Attributes All entity tables share these common attributes: | Attribute | Type | Description | |-----------|------|-------------| | `pk` | String | Partition key | | `sk` | String | Sort key | | `id` | String | Unique identifier (`pk#sk`, @version stripped from sk) | | `code` | String | Business code | | `name` | String | Display name | | `tenantCode` | String | Tenant identifier | | `type` | String | Entity type | | `version` | Number | Version for optimistic locking | | `attributes` | Map | Custom entity attributes | | `createdBy` | String | Creator user ID | | `createdIp` | String | Creator IP address | | `createdAt` | String | Creation timestamp (ISO 8601) | | `updatedBy` | String | Last modifier user ID | | `updatedIp` | String | Last modifier IP address | | `updatedAt` | String | Last update timestamp (ISO 8601) | | `seq` | Number | Sequence number for ordering | ### Command and Data Attributes | Attribute | Type | Description | |-----------|------|-------------| | `source` | String | Command source identifier | | `requestId` | String | Request tracking ID | ## Secondary Indexes {#secondary-indexes} ### Adding Global Secondary Indexes The default table configuration does not include GSIs. You can add them based on your query patterns. A common pattern is adding a code-index for fast lookups by business code: Example GSI definition (add to your table configuration): ```json { "GlobalSecondaryIndexes": [ { "IndexName": "code-index", "KeySchema": [ { "AttributeName": "tenantCode", "KeyType": "HASH" }, { "AttributeName": "code", "KeyType": "RANGE" } ], "Projection": { "ProjectionType": "ALL" } } ] } ``` Example usage with custom GSI: ```typescript // Find entity by code (requires code-index GSI) const params = { TableName: 'entity-data', IndexName: 'code-index', KeyConditionExpression: 'tenantCode = :tenant AND code = :code', ExpressionAttributeValues: { ':tenant': tenantCode, ':code': entityCode, }, }; ``` ## Best Practices {#best-practices} ### Key Design 1. **Keep partition keys broad**: Distribute data evenly across partitions 2. **Use hierarchical sort keys**: Enable efficient range queries 3. **Include tenant in partition key**: Ensure data isolation ### Query Optimization 1. **Use Query over Scan**: Always use partition key in queries 2. **Limit result sets**: Use pagination for large datasets 3. **Project needed attributes**: Only retrieve required fields ### Capacity Planning 1. **Use on-demand capacity**: Recommended for unpredictable workloads 2. **Monitor consumed capacity**: Set up CloudWatch alarms 3. **Consider DAX**: For read-heavy workloads requiring microsecond latency ## Local Development {#local-development} ### DynamoDB Local The framework includes DynamoDB Local for development: ```bash # Start DynamoDB Local (included in docker-compose) docker-compose up -d dynamodb-local # Access DynamoDB Local Admin UI open http://localhost:8001 ``` ### Environment Variables ```bash # Local DynamoDB endpoint DYNAMODB_ENDPOINT=http://localhost:8000 DYNAMODB_REGION=ap-northeast-1 ``` ## Related Documentation - [Key Patterns](/docs/key-patterns): Detailed key design strategies - [Entity Patterns](/docs/entity-patterns): Entity modeling guidelines - [Database Selection Guide](/docs/database-selection-guide): When to use DynamoDB vs RDS - [Sequence](/docs/sequence): Sequence ID generation - [CommandService](/docs/command-service): Command handling and data sync --- ## Prisma URL: https://mbc-cqrs-serverless.mbc-net.com/docs/prisma # Prisma In MBC CQRS Serverless, we use prisma as an ORM. It helps developers be more productive when working with databases. A common scenario when working with Prisma is needing to make changes to the database, such as creating tables, updating fields in tables, etc. Follow these steps: 1. Update prisma/schema.prisma file. 2. For local development, create and apply migrations with command npm run migrate:dev. ## Setup {#setup} ### PrismaService The `mbc new` CLI command generates a `PrismaService` in `src/prisma/prisma.service.ts`. It extends `PrismaClient` and integrates with NestJS's module lifecycle: ```typescript // src/prisma/prisma.service.ts (generated by CLI) import { Injectable, Logger, OnModuleInit, Inject, Optional } from '@nestjs/common'; import { Prisma, PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit { private readonly logger = new Logger(PrismaService.name); async onModuleInit() { // Lazy connection by default — avoids holding connections between Lambda invocations // Set explicitConnect: true in prismaServiceOptions only if you need startup connection } } ``` ### Register in AppModule {#register-prisma-module} Register `PrismaModule` as a global module in `src/main.module.ts` so `PrismaService` is available in all modules without re-importing: ```typescript import { Module } from '@nestjs/common'; import { PrismaModule } from './prisma'; @Module({ imports: [ PrismaModule.forRoot({ isGlobal: true, // Make PrismaService available everywhere without re-importing prismaServiceOptions: { explicitConnect: false, // Recommended for Lambda: lazy connection per invocation prismaOptions: { log: process.env.NODE_ENV !== 'local' ? ['error'] : ['info', 'error', 'warn', 'query'], }, }, }), // ... other modules ], }) export class MainModule {} ``` :::info Lambda Connection Management Set `explicitConnect: false` (the default) for Lambda functions. Prisma establishes the RDS connection lazily on the first query and does not hold it between invocations. Use RDS Proxy in production to pool connections across concurrent Lambdas and prevent connection exhaustion. ::: ## Migration Scripts {#migration-scripts} | Command | When to use | What it does | |------------|-----------------|-----------------| | `npm run migrate:dev` | Local development only | Creates a new Prisma migration file and applies it to the local RDS database. Use this when you change `schema.prisma`. | | `npm run migrate` | Local setup and CI | Applies existing Prisma migrations to RDS (without creating new ones), then runs DynamoDB table migration. Use this after cloning or pulling. | | `npm run migrate:ddb` | DynamoDB only | Creates or updates DynamoDB tables defined in `prisma/dynamodbs/*.json` without touching the RDS schema. | :::warning For local development, please make sure to set the correct `DATABASE_URL` environment variable. ```bash # Example DATABASE_URL="mysql://root:RootCqrs@localhost:3306/cqrs" ``` ::: > You could view [prisma-client documentation](https://www.prisma.io/docs/orm/prisma-client) for more information ## Design table convention {#design-table-convention} When creating an RDS table that maps to a DynamoDB table, ensure you add the necessary fields and indexes to the RDS table accordingly. The `cpk`/`csk` fields store the original command table keys (used to link back to the DynamoDB command record). Include them when you need full traceability from RDS to DynamoDB; omit them if you only need the data table keys (`pk`/`sk`). ```prisma model YourEntity { id String @id cpk String // Command PK csk String // Command SK pk String // Data PK sk String // Data SK tenantCode String @map("tenant_code") // Tenant code seq Int @default(0) // Sort order, uses sequence feature code String // Record code name String // Record name version Int // Version isDeleted Boolean @default(false) @map("is_deleted") // Deleted flag createdBy String @default("") @map("created_by") // Created by createdIp String @default("") @map("created_ip") // Created IP, supports IPv6 createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(0) // Created at updatedBy String @default("") @map("updated_by") // Updated by updatedIp String @default("") @map("updated_ip") // Updated IP, supports IPv6 updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(0) // Updated at // domain-specific properties // relations // index @@unique([cpk, csk]) @@unique([pk, sk]) @@unique([tenantCode, code]) @@index([tenantCode, name]) } ``` ## Related Documentation - [Database Selection Guide](/docs/database-selection-guide) - Choosing between DynamoDB and RDS - [Data Sync Handler Examples](/docs/data-sync-handler-examples) - Sync DynamoDB data to RDS - [Environment Variables](/docs/environment-variables) - Database connection configuration - [Deployment Guide](/docs/deployment-guide) - Database migration in deployment --- # Frontend Development ## API Integration Patterns URL: https://mbc-cqrs-serverless.mbc-net.com/docs/api-integration-patterns # API Integration Patterns This guide explains how to connect frontend applications to MBC CQRS Serverless backends using auto-generated TypeScript SDKs. Type-safe API integration catches errors at compile time and provides excellent developer experience with autocomplete. ## When to Use This Guide {#when-to-use} Use this guide when you need to: - Connect a Next.js frontend to an MBC CQRS Serverless API - Generate TypeScript types from OpenAPI specification - Add authentication headers automatically to API requests - Handle API errors consistently across the application - Support multi-tenant API calls with tenant headers ## Problems This Pattern Solves {#problems-solved} | Problem | Solution | |---------|----------| | Frontend types don't match backend API | Generate SDK from OpenAPI spec - types always match | | Forgetting to add auth token to requests | Use interceptors to add headers automatically | | Inconsistent error handling across components | Centralize error handling in API wrapper | | Tenant header missing in some requests | Add tenant interceptor that reads from store | | Hard to update when API changes | Regenerate SDK with one command | ## SDK Generation Setup {#sdk-generation-setup} ### Use Case: Generate Type-Safe API Client Scenario: Backend team updates the API, and you need frontend types to match. Solution: Generate SDK from OpenAPI specification file that backend exports. ### Installing Dependencies ```bash npm install @hey-api/client-fetch npm install -D @hey-api/openapi-ts ``` ### Configuration ```typescript // openapi-ts.config.ts import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ client: '@hey-api/client-fetch', input: './openapi.json', // or URL to OpenAPI spec output: { path: 'src/services/sdk', format: 'prettier', }, services: { asClass: true, }, types: { enums: 'javascript', }, }); ``` ### Package.json Scripts ```json { "scripts": { "generate-sdk": "openapi-ts", "generate-sdk:watch": "openapi-ts --watch" } } ``` ## Generated SDK Structure {#generated-sdk-structure} After running `npm run generate-sdk`, the following files are created: ```text src/services/sdk/ ├── client/ │ └── client.ts # HTTP client configuration ├── types.gen.ts # Generated TypeScript types ├── services.gen.ts # Generated service classes └── index.ts # Main exports ``` ### Generated Types Example These types are generated from your OpenAPI spec and match your backend exactly: ```typescript // src/services/sdk/types.gen.ts (auto-generated) export interface Product { id: string; pk: string; sk: string; code: string; name: string; price: number; status: ProductStatus; attributes: ProductAttributes; version: number; createdAt: string; updatedAt: string; } export interface CreateProductDto { code: string; name: string; price: number; attributes?: ProductAttributes; } export interface UpdateProductDto { name?: string; price?: number; status?: ProductStatus; attributes?: ProductAttributes; version: number; // Required for optimistic locking } export interface ProductListResponse { items: Product[]; count: number; hasMore: boolean; } ``` ### Generated Services Example Service classes provide typed methods for each API endpoint: ```typescript // src/services/sdk/services.gen.ts (auto-generated) export class ProductService { static list(options?: { query?: ProductListParams }): Promise; static get(options: { path: { pk: string; sk: string}}): Promise; static create(options: { body: CreateProductDto }): Promise; static update(options: { path: { pk: string; sk: string }; body: UpdateProductDto }): Promise; static delete(options: { path: { pk: string; sk: string}}): Promise; } ``` ## Client Configuration {#client-configuration} ### Use Case: Add Authentication to All Requests Scenario: Every API request needs a Bearer token from Cognito. Problem: Manually adding headers to each request is error-prone. Solution: Use interceptors to add authentication header automatically. ```typescript // src/lib/api/client.ts import { client } from '@/services/sdk/client'; import { fetchAuthSession } from 'aws-amplify/auth'; import { getTenantCode as getTenantFromStore } from '@/store/tenant'; // Configure the base URL client.setConfig({ baseUrl: process.env.NEXT_PUBLIC_API_URL, }); // Add authentication interceptor client.interceptors.request.use(async (request) => { try { const session = await fetchAuthSession(); const token = session.tokens?.idToken?.toString(); if (token) { request.headers.set('Authorization', `Bearer ${token}`); } } catch (error) { console.error('Failed to get auth token:', error); } return request; }); // Add tenant header interceptor client.interceptors.request.use((request) => { const tenantCode = getTenantFromStore(); // Get from Zustand store if (tenantCode) { request.headers.set('X-Tenant-Code', tenantCode); } return request; }); // Add error handling interceptor client.interceptors.response.use((response) => { if (!response.ok) { // Handle specific error codes if (response.status === 401) { // Redirect to login window.location.href = '/login'; } } return response; }); export { client }; ``` ### Use Case: Create API Wrapper with Error Handling Scenario: Components need clean APIs that throw meaningful errors. Problem: Generated SDK returns `{ data, error }` which requires handling in every component. Solution: Create wrapper functions that throw on errors for use with React Query. ```typescript // src/services/api/products.ts import { ProductService, CreateProductDto, UpdateProductDto } from '@/services/sdk'; import type { Product, ProductListResponse } from '@/services/sdk'; export interface ProductFilters { status?: string; category?: string; search?: string; page?: number; limit?: number; } export const productApi = { async list(filters: ProductFilters = {}): Promise { const { data, error } = await ProductService.list({ query: { status: filters.status, category: filters.category, q: filters.search, page: filters.page ?? 1, limit: filters.limit ?? 20, }, }); if (error) { throw new Error(error.message || 'Failed to fetch products'); } return data; }, async get(pk: string, sk: string): Promise { const { data, error } = await ProductService.get({ path: { pk, sk }, }); if (error) { throw new Error(error.message || 'Failed to fetch product'); } return data; }, async create(dto: CreateProductDto): Promise { const { data, error } = await ProductService.create({ body: dto, }); if (error) { throw new Error(error.message || 'Failed to create product'); } return data; }, async update(pk: string, sk: string, dto: UpdateProductDto): Promise { const { data, error } = await ProductService.update({ path: { pk, sk }, body: dto, }); if (error) { throw new Error(error.message || 'Failed to update product'); } return data; }, async delete(pk: string, sk: string): Promise { const { error } = await ProductService.delete({ path: { pk, sk }, }); if (error) { throw new Error(error.message || 'Failed to delete product'); } }, }; ``` ## React Query Integration {#react-query-integration} ### Use Case: Data Fetching with Caching Scenario: Display product list and detail pages with efficient caching. Solution: Create React Query hooks that use the API wrapper. ```typescript // src/hooks/useProducts.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { productApi, ProductFilters } from '@/services/api/products'; import type { CreateProductDto, UpdateProductDto } from '@/services/sdk'; export const productKeys = { all: ['products'] as const, lists: () => [...productKeys.all, 'list'] as const, list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const, details: () => [...productKeys.all, 'detail'] as const, detail: (pk: string, sk: string) => [...productKeys.details(), pk, sk] as const, }; export function useProducts(filters: ProductFilters = {}) { return useQuery({ queryKey: productKeys.list(filters), queryFn: () => productApi.list(filters), staleTime: 60 * 1000, // 1 minute }); } export function useProduct(pk: string, sk: string) { return useQuery({ queryKey: productKeys.detail(pk, sk), queryFn: () => productApi.get(pk, sk), enabled: !!pk && !!sk, }); } export function useCreateProduct() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (dto: CreateProductDto) => productApi.create(dto), onSuccess: () => { queryClient.invalidateQueries({ queryKey: productKeys.lists() }); }, }); } export function useUpdateProduct() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ pk, sk, dto }: { pk: string; sk: string; dto: UpdateProductDto }) => productApi.update(pk, sk, dto), onSuccess: (_, { pk, sk }) => { queryClient.invalidateQueries({ queryKey: productKeys.lists() }); queryClient.invalidateQueries({ queryKey: productKeys.detail(pk, sk) }); }, }); } export function useDeleteProduct() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ pk, sk }: { pk: string; sk: string }) => productApi.delete(pk, sk), onSuccess: () => { queryClient.invalidateQueries({ queryKey: productKeys.lists() }); }, }); } ``` ### Use Case: Product List with Filtering Scenario: Display filterable, paginated product table. ```typescript // src/containers/products/ProductList.tsx 'use client'; import { useProducts, useDeleteProduct } from '@/hooks/useProducts'; import { Button } from '@/components/ui/Button'; import { Table } from '@/components/ui/Table'; import { useState } from 'react'; export function ProductList() { const [filters, setFilters] = useState({ page: 1, limit: 20 }); const { data, isLoading, error } = useProducts(filters); const deleteProduct = useDeleteProduct(); if (isLoading) return
Loading...
; if (error) return
Error: {error.message}
; const handleDelete = async (pk: string, sk: string) => { if (confirm('Are you sure you want to delete this product?')) { await deleteProduct.mutateAsync({ pk, sk }); } }; return ( ( ), }, ]} pagination={{ page: filters.page, limit: filters.limit, total: data?.count ?? 0, onChange: (page) => setFilters((f) => ({ ...f, page })), }} /> ); } ``` ## Error Handling {#error-handling} ### Use Case: Structured Error Responses Scenario: Backend returns structured errors with field-level validation details. Solution: Create error types that match backend response format. ```typescript // src/types/api-errors.ts export interface ApiError { statusCode: number; message: string; error?: string; details?: Record; } export class ApiException extends Error { constructor( public statusCode: number, message: string, public details?: Record ) { super(message); this.name = 'ApiException'; } } ``` ### Use Case: Centralized Error Handler Scenario: Convert various error types to consistent ApiException. ```typescript // src/lib/api/error-handler.ts import { ApiException } from '@/types/api-errors'; export function handleApiError(error: unknown): never { if (error instanceof ApiException) { throw error; } if (error instanceof Error) { throw new ApiException(500, error.message); } throw new ApiException(500, 'An unexpected error occurred'); } // Usage in API wrapper export const productApi = { async list(filters: ProductFilters = {}): Promise { try { const { data, error } = await ProductService.list({ query: filters, }); if (error) { throw new ApiException( error.statusCode ?? 500, error.message ?? 'Request failed', error.details ); } return data; } catch (error) { handleApiError(error); } }, }; ``` ### Use Case: Display Errors with Field Details Scenario: Show validation errors returned by the server. ```typescript // src/components/ApiError.tsx import { ApiException } from '@/types/api-errors'; import { Alert } from '@/components/ui/Alert'; interface ApiErrorProps { error: Error | null; } export function ApiError({ error }: ApiErrorProps) { if (!error) return null; const isApiException = error instanceof ApiException; return (

{error.message}

{isApiException && error.details && (
    {Object.entries(error.details).map(([field, messages]) => (
  • {field}: {messages.join(', ')}
  • ))}
)}
); } ``` ## Multi-Tenant API Calls {#multi-tenant-api-calls} ### Use Case: Tenant Context for SaaS Applications Scenario: User can switch between tenants, and all API calls should use the selected tenant. Solution: Store tenant in context/store and add to API headers automatically. ```typescript // src/contexts/TenantContext.tsx 'use client'; import { createContext, useContext, ReactNode } from 'react'; import type { Tenant } from '@/stores/useTenantStore'; import { useTenantStore } from '@/stores/useTenantStore'; interface TenantContextValue { tenantCode: string | null; setTenant: (code: string) => void; } const TenantContext = createContext(undefined); export function TenantProvider({ children }: { children: ReactNode }) { const { currentTenant, setCurrentTenant } = useTenantStore(); return ( setCurrentTenant({ code } as Tenant), }} > {children} ); } export function useTenant() { const context = useContext(TenantContext); if (!context) { throw new Error('useTenant must be used within TenantProvider'); } return context; } ``` ### Use Case: Tenant-Scoped Queries Scenario: Product list should only show products for the current tenant. ```typescript // src/hooks/useTenantProducts.ts import { useQuery } from '@tanstack/react-query'; import { useTenant } from '@/contexts/TenantContext'; import { productApi } from '@/services/api/products'; export function useTenantProducts() { const { tenantCode } = useTenant(); return useQuery({ queryKey: ['products', tenantCode], queryFn: () => productApi.list(), enabled: !!tenantCode, }); } ``` ## File Upload Integration {#file-upload-integration} ### Use Case: Upload Files to S3 via API Scenario: User uploads product images that need to be stored in S3. ```typescript // src/services/api/files.ts import { FileService } from '@/services/sdk'; export const fileApi = { async upload(file: File, path: string): Promise<{ url: string }> { const formData = new FormData(); formData.append('file', file); formData.append('path', path); const { data, error } = await FileService.upload({ body: formData, }); if (error) { throw new Error(error.message || 'Upload failed'); } return data; }, async getPresignedUrl(key: string): Promise<{ url: string; expiresIn: number }> { const { data, error } = await FileService.getPresignedUrl({ query: { key }, }); if (error) { throw new Error(error.message || 'Failed to get presigned URL'); } return data; }, }; ``` ## Best Practices {#best-practices} ### 1. Always Regenerate SDK After Backend Changes When: Backend team deploys API changes. Why: Ensures frontend types match backend exactly. ```bash # After backend API changes npm run generate-sdk ``` ### 2. Use Type Guards When: Working with unknown data from external sources. ```typescript function isProduct(data: unknown): data is Product { return ( typeof data === 'object' && data !== null && 'id' in data && 'pk' in data && 'sk' in data ); } ``` ### 3. Handle Loading and Error States When: Displaying data from API queries. ```typescript function ProductDetail({ pk, sk }: { pk: string; sk: string }) { const { data, isLoading, error, refetch } = useProduct(pk, sk); if (isLoading) { return ; } if (error) { return ( refetch()} /> ); } if (!data) { return ; } return ; } ``` ### 4. Version Handling for Updates When: Updating entities that use optimistic locking. Why: MBC CQRS Serverless uses version field to prevent concurrent update conflicts. ```typescript function useUpdateProductWithVersion() { const queryClient = useQueryClient(); const updateProduct = useUpdateProduct(); return { ...updateProduct, mutateAsync: async ({ pk, sk, dto }: UpdateParams) => { // Get current version from cache const cached = queryClient.getQueryData( productKeys.detail(pk, sk) ); if (!cached) { throw new Error('Product not found in cache'); } return updateProduct.mutateAsync({ pk, sk, dto: { ...dto, version: cached.version }, }); }, }; } ``` ### 5. Retry Configuration When: Configuring React Query client. Why: Avoid retrying client errors (4xx) that will always fail. ```typescript // src/lib/api/query-client.ts import { QueryClient } from '@tanstack/react-query'; export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, retry: (failureCount, error) => { // Don't retry on 4xx errors if (error instanceof ApiException && error.statusCode < 500) { return false; } return failureCount < 3; }, }, mutations: { retry: false, // Don't retry mutations by default }, }, }); ``` ## Related Documentation - [API Integration Guide](/docs/api-integration-guide) - API integration guidance - [Frontend Project Structure](/docs/frontend-project-structure) - Project organization - [State Management Patterns](/docs/state-management-patterns) - Frontend state management - [Form Handling Patterns](/docs/form-handling-patterns) - Form handling --- ## Form Handling Patterns URL: https://mbc-cqrs-serverless.mbc-net.com/docs/form-handling-patterns # Form Handling Patterns This guide explains how to build type-safe forms with validation using React Hook Form and Zod. These patterns ensure data integrity before sending to the API and provide clear feedback to users. ## When to Use This Guide {#when-to-use} Use this guide when you need to: - Build forms for creating and editing entities (products, users, orders) - Validate user input before submitting to the API - Display field-level error messages to users - Handle complex forms with dynamic fields (order items, tags) - Show conditional fields based on other form values ## Problems This Pattern Solves {#problems-solved} | Problem | Solution | |---------|----------| | Invalid data sent to API | Zod validates before submission | | Type mismatch between form and API | Infer TypeScript types from Zod schema | | Form re-renders on every keystroke | React Hook Form uses uncontrolled inputs | | Hard to show validation errors | Automatic error state per field | | Dynamic fields are complex to manage | useFieldArray handles add/remove | ## Technology Stack {#technology-stack} | Library | Purpose | |---------|---------| | React Hook Form | Form state management | | Zod | Schema validation | | @hookform/resolvers | Zod integration | | shadcn/ui Form | Form UI components | ## Installation {#installation} ```bash npm install react-hook-form zod @hookform/resolvers ``` ## Form Component Architecture {#form-architecture} The form system uses a layered component architecture: | Component | Role | |-----------|------| | `Form` | Context provider that wraps the entire form (uses FormProvider from react-hook-form) | | `FormField` | Connects a field to form state using Controller | | `FormItem` | Container for a single form field (label, input, error) | | `FormLabel` | Label that auto-connects to the field and shows error state | | `FormControl` | Passes form field props to the input element | | `FormMessage` | Displays validation error message | | `FormDescription` | Optional help text for the field | ## Basic Form Structure {#basic-form-structure} ### Use Case: Product Create Form Scenario: User needs to create a new product with code, name, price, and status. Solution: Define schema with validation rules, use Form components for consistent error display. ### Zod Schema Definition Define validation rules that match your API requirements: ```typescript // src/schemas/product.schema.ts import { z } from 'zod'; export const createProductSchema = z.object({ code: z .string() .min(1, 'Code is required') .max(50, 'Code must be 50 characters or less') .regex(/^[A-Z0-9-]+$/, 'Code must be uppercase alphanumeric with hyphens'), name: z .string() .min(1, 'Name is required') .max(200, 'Name must be 200 characters or less'), price: z .number() .min(0, 'Price must be positive') .max(999999999, 'Price exceeds maximum'), description: z .string() .max(2000, 'Description must be 2000 characters or less') .optional(), categoryId: z.string().min(1, 'Category is required'), status: z.enum(['ACTIVE', 'INACTIVE', 'DRAFT']), }); export type CreateProductInput = z.infer; // Update schema with optional fields and version export const updateProductSchema = createProductSchema.partial().extend({ version: z.number().int().min(0), }); export type UpdateProductInput = z.infer; ``` ### Form Component Connect the schema to React Hook Form using Form components: ```typescript // src/components/forms/ProductForm.tsx 'use client'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { createProductSchema, CreateProductInput, } from '@/schemas/product.schema'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; interface ProductFormProps { onSubmit: (data: CreateProductInput) => Promise; defaultValues?: Partial; isLoading?: boolean; } export function ProductForm({ onSubmit, defaultValues, isLoading, }: ProductFormProps) { const form = useForm({ resolver: zodResolver(createProductSchema), defaultValues: { status: 'DRAFT', ...defaultValues, }, }); return (
( Code )} /> ( Name )} /> ( Price field.onChange(Number(e.target.value))} disabled={isLoading} /> )} /> ( Description