| name | mbc-review |
| description | Review code for MBC CQRS Serverless best practices and anti-patterns. Use this when reviewing code that uses MBC CQRS Serverless framework, checking for common mistakes, or validating implementation patterns. |
Pre-flight Check (Version Update)
Before executing this skill, check for updates:
- Run
mbc install-skills --check to check if a newer version is available
- If the output shows "Update available: X.Y.Z → A.B.C", ask the user:
- "A newer version of MBC skills is available (X.Y.Z → A.B.C). Would you like to update before proceeding?"
- If the user agrees, run
mbc install-skills --force to update
- If the user declines or skills are up-to-date, proceed with the skill
Note: Skip this check if the user explicitly says to skip updates or if you've already checked in this session.
MBC CQRS Serverless Code Review
This skill reviews code for MBC CQRS Serverless best practices and identifies anti-patterns.
⚠️ Important: Two Anti-Pattern Code Systems
This codebase currently has two independent AP00X numbering systems that are NOT interchangeable:
- Skill
AP codes (this document) — used in human-facing reviews and documentation. Stable identifiers for discussion.
- Detector
AP codes (output of the mbc_check_anti_patterns tool, defined in src/tools/analyze.ts) — used by the static analysis tool. Numbered in detector implementation order.
Only AP016, AP017, AP018, AP019, and AP021 happen to refer to the same concept in both systems. All other AP numbers diverge — for example, the detector's AP005: Hardcoded Tenant corresponds to this document's AP002: Missing tenantCode. Do not assume the codes match.
Cross-reference table (detector → skill doc):
| Detector code (analyze.ts) | Skill doc code (this file) | Topic |
|---|
| AP001 Direct DynamoDB Write | AP012 | Bypassing CommandService / DataService |
| AP002 Ignored Version Mismatch | AP005 | ConditionalCheckFailedException handling |
| AP003 N+1 Query Pattern | — (detector only) | Query in a loop |
| AP004 Full Table Scan | — (detector only) | .scan() usage |
| AP005 Hardcoded Tenant | AP002 | Multi-tenant code/key handling |
| AP006 Missing Tenant Validation | AP002 | Trusting client-provided tenantCode |
| AP007 Throwing in Sync Handler | — (detector only) | DataSyncHandler error escape |
| AP008 Hardcoded Secret | — (detector only) | Secrets in code |
| AP009 Manual JWT Parsing | — (detector only) | Bypassing Cognito authorizer |
| AP010 Heavy Module Import | — (detector only) | Cold-start optimization |
| AP011 Deprecated Method Usage | AP010 | publish() / publishPartialUpdate() removal |
| AP012 Uppercase COMMON Tenant Key | — (detector only) | v1.1.0 tenant key migration |
| AP013 publishSync Null Return Unchecked | AP001 (related) | publishSync v1.2.0+ return |
| AP014 Deprecated genNewSequence | AP010 (related) | v1.2.0 sequence API change |
| AP015 Duplicate TaskModule Registration | — (detector only) | v1.2.4 global TaskModule |
| AP016 Missing Error Logging Before Rethrow | AP016 | ✅ same code |
| AP017 Incorrect Attribute Merging | AP017 | ✅ same code |
| AP018 Missing Swagger Documentation | AP018 | ✅ same code |
| AP019 Missing Pagination in List Queries | AP019 | ✅ same code |
| AP020 Missing getCommandSource for Tracing | AP011 | Tracing/audit |
| AP021 Event Emit After publishAsync | AP021 | ✅ same code |
| AP022 Use of eval() or Function() Constructor | — (detector only) | RCE/XSS sink (CWE-95) |
| AP023 Shell Command Built from String Concatenation | — (detector only) | Command injection (CWE-78) |
| AP024 HTTP Request Without Timeout | — (detector only) | Local DoS via stalled upstream (CWE-400) |
| AP025 Logging process.env or full request object | — (detector only) | Sensitive data exposure (CWE-312, CWE-532) |
| AP026 @Injectable instead of @NotificationTransport | — (detector only) | INotificationTransport implementor never invoked |
| AP027 GroupRoleResolver also annotated with @Injectable | AP022 | Incorrect group-based role resolver (v1.3.1+) |
| AP028 Duplicate DataSyncHandler Registration | AP023 | ✅ same code |
| AP029 Reserved DataSyncHandler Type | AP024 | ✅ same code |
| AP030 Fully-Qualified Table Name in @DataSyncHandler | AP025 | ✅ same code |
When you receive mbc_check_anti_patterns output, look up the detector code in this table to find the corresponding skill-doc section for full context and recommended fixes. Future versions of this framework should consolidate the two systems; until then, treat them as separate identifier spaces.
Anti-Patterns to Detect
AP001: Using publishSync Instead of publishAsync
Severity: Warning
Pattern:
await this.commandService.publishSync(command, options)
await this.commandService.publishAsync(command, options)
Explanation: publishAsync is the recommended default. Only use publishSync when immediate consistency is absolutely required, as it blocks until processing completes.
AP002: Missing tenantCode in Multi-Tenant Operations
Severity: Error
Pattern:
const pk = `ORDER#${code}`
const { tenantCode } = getUserContext(invokeContext)
const pk = `ORDER#${tenantCode}`
Explanation: All operations must include tenantCode for proper tenant isolation.
AP003: Hardcoded Version Numbers
Severity: Error
Pattern:
version: 1,
version: 0,
version: VERSION_FIRST,
version: existingItem.version,
Explanation: Use VERSION_FIRST constant for new entities. For updates, always fetch the current version to enable optimistic locking.
AP004: Missing DataSyncHandler Registration
Severity: Error
Pattern:
CommandModule.register({
tableName: 'order',
}),
CommandModule.register({
tableName: 'order',
dataSyncHandlers: [OrderDataSyncRdsHandler],
}),
Explanation: If you have DataSyncHandlers, they must be registered in the module.
AP005: Not Handling ConditionalCheckFailedException
Severity: Warning
Pattern:
await this.commandService.publishAsync(command, options)
try {
await this.commandService.publishAsync(command, options)
} catch (error) {
if (error.name === 'ConditionalCheckFailedException') {
throw new ConflictException('Data was modified by another user')
}
throw error
}
Explanation: Optimistic locking can cause version conflicts that should be handled gracefully.
AP006: Using Wrong PK/SK Format
Severity: Error
Pattern:
const pk = `order-${tenantCode}`
const sk = `order_${code}`
const pk = `ORDER#${tenantCode}`
const sk = `ORDER#${code}`
Explanation: Follow the ENTITY#value convention for partition and sort keys.
AP007: Missing invokeContext in Service Methods
Severity: Error
Pattern:
async create(dto: CreateOrderDto) {
}
async create(dto: CreateOrderDto, invokeContext: IInvoke) {
const { tenantCode, userId } = getUserContext(invokeContext);
}
Explanation: invokeContext is required to extract user information and tenant context.
AP008: Not Using generateId for Entity IDs
Severity: Warning
Pattern:
id: uuid(),
id: `${pk}-${sk}`,
id: generateId(pk, sk),
Explanation: Use generateId() for consistent ID generation across the framework.
AP009: Missing DTO Validation Decorators
Severity: Warning
Pattern:
export class CreateOrderDto {
code: string
name: string
}
export class CreateOrderDto {
@IsString()
@IsNotEmpty()
code: string
@IsString()
@IsNotEmpty()
name: string
}
Explanation: Always use class-validator decorators for input validation.
AP010: Deprecated Method Usage
Severity: Warning
Pattern:
await this.commandService.publish(command, options)
await this.commandService.publishPartialUpdate(command, options)
await this.commandService.publishAsync(command, options)
await this.commandService.publishPartialUpdateAsync(command, options)
Explanation: The non-Async methods are deprecated. Use publishAsync and publishPartialUpdateAsync.
AP011: Missing getCommandSource for Tracing
Severity: Warning
Pattern:
await this.commandService.publishAsync(command, {
invokeContext,
})
const commandSource = getCommandSource(
basename(__dirname),
this.constructor.name,
'create',
)
await this.commandService.publishAsync(command, {
source: commandSource,
invokeContext,
})
Explanation: Always include source in publish options for debugging and audit trails.
AP012: Direct DynamoDB Access Instead of DataService
Severity: Warning
Pattern:
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'
const client = new DynamoDBClient({})
const result = await client.send(
new GetItemCommand({
TableName: 'my-table',
Key: { pk: { S: 'ORDER#tenant' }, sk: { S: 'ORDER#123' } },
}),
)
const result = await this.dataService.getItem({ pk, sk })
Explanation: Use DataService for read operations. It provides caching, consistent interfaces, and proper error handling.
AP013: Missing Type Declaration in DataSyncHandler
Severity: Error
Pattern:
@DataSyncHandler({})
export class OrderDataSyncRdsHandler implements IDataSyncHandler {
}
@DataSyncHandler({ type: 'ORDER' })
export class OrderDataSyncRdsHandler implements IDataSyncHandler {
}
Explanation: The type property in @DataSyncHandler decorator must match your entity's type field to route events correctly.
AP014: Not Using DetailKey Type
Severity: Info
Pattern:
async findOne(pk: string, sk: string) {
return this.dataService.getItem({ pk, sk });
}
import { DetailKey } from '@mbc-cqrs-serverless/core';
async findOne(key: DetailKey) {
return this.dataService.getItem(key);
}
Explanation: Use the DetailKey type for consistency and type safety across the application.
AP015: Hardcoded Table Names
Severity: Warning
Pattern:
CommandModule.register({
tableName: 'production-orders',
dataSyncHandlers: [OrderDataSyncRdsHandler],
}),
CommandModule.register({
tableName: process.env.ORDER_TABLE_NAME || 'orders',
dataSyncHandlers: [OrderDataSyncRdsHandler],
}),
Explanation: Table names should come from environment variables to support multiple environments (dev, staging, production).
AP016: Missing Error Logging
Severity: Warning
Pattern:
try {
await this.commandService.publishAsync(command, options);
} catch (error) {
throw new InternalServerErrorException();
}
private readonly logger = new Logger(OrderService.name);
try {
await this.commandService.publishAsync(command, options);
} catch (error) {
this.logger.error(`Failed to create order: ${error.message}`, error.stack);
if (error.name === 'ConditionalCheckFailedException') {
throw new ConflictException('Data was modified by another user');
}
throw error;
}
Explanation: Always log errors with context before handling them for debugging purposes.
AP017: Incorrect Attribute Merging
Severity: Error
Pattern:
await this.commandService.publishPartialUpdateAsync(
{
pk: key.pk,
sk: key.sk,
version: existingItem.version,
attributes: dto.attributes,
},
options,
)
await this.commandService.publishPartialUpdateAsync(
{
pk: key.pk,
sk: key.sk,
version: existingItem.version,
attributes: { ...existingItem.attributes, ...dto.attributes },
},
options,
)
Explanation: When updating, merge new attributes with existing ones to avoid data loss.
AP018: Missing Swagger Documentation
Severity: Info
Pattern:
@Controller('orders')
export class OrderController {
@Post()
async create(@Body() dto: CreateOrderDto) {}
}
@ApiTags('orders')
@Controller('orders')
export class OrderController {
@Post()
@ApiOperation({ summary: 'Create a new order' })
@ApiResponse({ status: 201, description: 'Order created successfully' })
@ApiResponse({ status: 409, description: 'Order already exists' })
async create(@Body() dto: CreateOrderDto) {}
}
Explanation: Add Swagger decorators for API documentation and better developer experience.
AP019: Not Handling Pagination Correctly
Severity: Warning
Pattern:
async search(dto: SearchOrderDto, invokeContext: IInvoke) {
const { tenantCode } = getUserContext(invokeContext);
return this.dataService.listByPk({
pk: `ORDER#${tenantCode}`,
});
}
async search(dto: SearchOrderDto, invokeContext: IInvoke) {
const { tenantCode } = getUserContext(invokeContext);
return this.dataService.listByPk({
pk: `ORDER#${tenantCode}`,
limit: dto.limit || 20,
cursor: dto.cursor,
});
}
Explanation: Always implement pagination to avoid returning large datasets and causing performance issues.
AP020: Circular Module Dependencies
Severity: Error
Pattern:
@Module({
imports: [ProductModule],
})
export class OrderModule {}
@Module({
imports: [OrderModule],
})
export class ProductModule {}
@Module({
imports: [forwardRef(() => ProductModule)],
})
export class OrderModule {}
@Module({
imports: [SharedModule],
})
export class OrderModule {}
Explanation: Avoid circular dependencies. Use forwardRef() as a last resort, or better, restructure your modules.
AP021: Emitting Events Directly in XxxCommandService After publishAsync
Severity: Error
Pattern:
async createOrder(params: CreateOrderParams, context: UserContext): Promise<string> {
await this.commandService.publishAsync({ pk, sk, ... }, { invokeContext });
this.eventEmitter.emit('order.created', { orderId, tenantCode, ... });
return orderId;
}
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent) {
const order = await this.dataService.getItem({ pk, sk });
}
@Injectable()
export class OrderDataSyncHandler implements IDataSyncHandler {
constructor(private readonly eventEmitter: EventEmitter2) {}
async up(cmd: CommandModel): Promise<void> {
if (!cmd.pk.startsWith('ORDER#')) return;
if (cmd.isDeleted) return;
if (cmd.version === VERSION_FIRST + 1) {
this.eventEmitter.emit('order.created', {
orderId: cmd.id,
tenantCode: cmd.tenantCode,
});
}
}
async down(cmd: CommandModel): Promise<void> {
this.eventEmitter.emit('order.deleted', { orderId: cmd.id, tenantCode: cmd.tenantCode });
}
}
Explanation: publishAsync writes only to the DynamoDB command table. The data table (read by DataService.getItem()) is populated asynchronously via DynamoDB Streams → Step Functions → IDataSyncHandler.up(). Emitting events in XxxCommandService immediately after publishAsync means any @OnEvent handler that calls DataService.getItem() will find no data, causing "entity not found" errors.
The correct pattern is to implement a custom IDataSyncHandler and emit events in up() / down(). By the time these methods run, the data table write has already completed. Register the handler in CommandModule.register({ dataSyncHandlers: [...] }).
If you need to include previous field values for change-detection (e.g., statusChanged), embed them as attributes._prev in the publishAsync call and read them in the handler. Strip _prev from RDS sync handlers to prevent internal metadata from leaking into the database.
Related (v1.2.6+): If you only need read-your-writes consistency for the same user within the same request flow (e.g. POST → immediate GET in the API client), the Repository's RYW session mechanism (when RYW_SESSION_TTL_MINUTES is enabled) is sufficient — it merges the pending command-table write into the next read by the originating user, no event handler required.
The IDataSyncHandler.up() pattern above is still required when other users, background jobs, or cross-module event subscribers need to react to the change — RYW only covers the writer's own subsequent reads.
AP022: Incorrect Group-Based Role Resolver Implementation (v1.3.1+)
Detector counterpart: AP027 (mbc_check_anti_patterns). Note this skill-doc AP022 is unrelated to the detector's AP022 (eval/Function) — see the cross-reference table above.
Problem: Group-based roles (@GroupRoleResolver, custom:groups) are implemented in ways that break bootstrap or leak across requests.
❌ Incorrect — adding @Injectable() alongside @GroupRoleResolver():
@GroupRoleResolver()
@Injectable({ scope: Scope.REQUEST })
export class AppGroupRoleResolver implements IGroupRoleResolver {
}
❌ Incorrect — swallowing resolver errors to "fail open":
async resolveRoles(input) {
try {
return await this.lookup(input);
} catch {
return ['admin'];
}
}
❌ Incorrect — more than one resolver, or stateful instance shared across requests:
@GroupRoleResolver() class ResolverA ...
@GroupRoleResolver() class ResolverB ...
✅ Correct:
import {
GroupRoleResolver,
IGroupRoleResolver,
ResolveGroupRolesInput,
} from '@mbc-cqrs-serverless/core'
@GroupRoleResolver()
export class AppGroupRoleResolver implements IGroupRoleResolver {
async resolveRoles({
tenantCode,
groupIds,
claims,
}: ResolveGroupRolesInput): Promise<string[]> {
return this.mapGroupsToRoles(tenantCode, groupIds)
}
}
Explanation: @GroupRoleResolver() already applies @Injectable() with the default (singleton) scope. Adding another @Injectable() — especially with REQUEST/TRANSIENT scope — overrides it and breaks bootstrap, which resolves a single instance once at startup. Exactly one resolver is allowed per app. The default RolesGuard deliberately rethrows resolver failures so a backend outage surfaces as a 5xx instead of a silent 403; do not catch-and-grant inside the resolver. Use tenantRoles / tenantGroupIds from getUserContext() for direct/group context; do not rely on the deprecated RolesGuard.getUserRole().
AP023: Duplicate DataSyncHandler Registration Across Modules
Detector counterpart: AP028 (mbc_check_anti_patterns).
Severity: High
Pattern:
@Module({
providers: [TenantConfigRdsHandler],
})
export class TenantModule {}
@Module({
providers: [TenantConfigRdsHandler],
})
export class AgentModule {}
@Module({
providers: [TenantConfigRdsHandler],
})
export class TenantModule {}
Explanation: ExplorerService.exploreDataSyncHandlers() scans the entire NestJS ModulesContainer and collects every provider whose class carries the @DataSyncHandler(tableName) decorator — without deduplication by class reference. If the same handler class appears as a provider in N modules, it is collected N times. CommandService.onModuleInit() then resolves each entry via moduleRef.get(handler, { strict: false }) (which always returns the same singleton instance) and pushes it to the internal handler list N times. When a command event arrives, handler.up() is executed N times in parallel inside Promise.all() — causing N concurrent DB writes that produce duplicate rows or P2002 unique-constraint errors.
This is a copy-paste mistake driven by the misconception that "each module must register the handler to pick it up." In reality, ExplorerService scans globally, so a single registration in any one module is sufficient.
Common symptoms:
- RDS records created in duplicate after a single command
- P2002 / unique constraint violations in Step Functions logs
- Alert/notification handlers firing multiple times per event
Fix: Register each @DataSyncHandler class as a provider in exactly one NestJS module — the module that logically owns that handler's concern. No other module should list it in its providers array or in the dataSyncHandlers option of CommandModule.register().
AP024: Reserved DataSyncHandler Type ('dynamodb')
Detector counterpart: AP029 (mbc_check_anti_patterns).
Severity: High
Pattern:
@DataSyncHandler('my-table')
export class TenantConfigRdsHandler implements IDataSyncHandler {
readonly type = 'dynamodb'
async up(cmd: CommandModel) { ... }
async down(cmd: CommandModel) { ... }
}
@DataSyncHandler('my-table')
export class TenantConfigRdsHandler implements IDataSyncHandler {
readonly type = 'rds'
async up(cmd: CommandModel) { ... }
async down(cmd: CommandModel) { ... }
}
Explanation: CommandService.publishSync() filters out handlers whose type is 'dynamodb' before executing the sync pipeline (this.dataSyncHandlers?.filter(h => h.type !== 'dynamodb')). The string 'dynamodb' is the type used exclusively by the internal DataSyncDdsHandler (the handler that writes the Data table). If a custom handler accidentally sets readonly type = 'dynamodb', it is silently excluded — handler.up() is never called, data is never written, and no error or warning is produced.
Common symptoms:
- Custom RDS / OpenSearch sync appears to work (no errors) but data is never written
- Integration tests pass (mock handlers are not filtered) but production sync is missing
- The symptom first appears in
publishSync paths; async Step Functions path is unaffected (the async path calls all handlers by name, not by type)
Fix: Remove readonly type = 'dynamodb' from custom handlers and use a different value (e.g., 'rds', 'opensearch', 'custom'). If no type is needed, omit the property — a missing type evaluates to undefined, which satisfies !== 'dynamodb' and is included in the pipeline.
AP025: Fully-Qualified Table Name in @DataSyncHandler
Detector counterpart: AP030 (mbc_check_anti_patterns).
Severity: High
Pattern:
@DataSyncHandler('dev-my-table-command')
export class MyRdsHandler implements IDataSyncHandler { ... }
@DataSyncHandler('my-table')
export class MyRdsHandler implements IDataSyncHandler { ... }
CommandModule.register({ tableName: 'my-table', dataSyncHandlers: [] })
Explanation: @DataSyncHandler(commandTableName) stores commandTableName as Reflect metadata on the class. ExplorerService matches handlers to CommandService instances by comparing this metadata value to the tableName option passed to CommandModule.register(). tableName is the raw logical name (e.g., 'my-table'), not the DynamoDB table name (e.g., 'dev-my-table-command' which the framework builds internally with environment prefix and -command suffix). A mismatch causes ExplorerService to find no match and silently skip the handler — no error, no warning, no data sync.
Common symptoms:
- Custom DataSyncHandler is correctly defined and registered as a provider, but
handler.up() is never called
- Data appears to sync correctly for the DynamoDB (Data table) path but the RDS / secondary sync is missing
- No errors in CloudWatch; Step Functions shows
SYNC_DATA state with an empty handler list
Fix: Pass the same raw tableName string to @DataSyncHandler that you pass to CommandModule.register({ tableName: '...' }). Do not include environment prefixes (dev-, stg-, prod-) or the -command suffix — those are added internally by the framework when constructing the actual DynamoDB table name.
Review Checklist
When reviewing MBC CQRS Serverless code, check:
Module Structure
Service Layer
DTOs
Controller
Error Handling
Data Sync Handler
Notification Transport (when implementing INotificationTransport)
Group Role Resolver (when implementing IGroupRoleResolver, v1.3.1+) (AP022)
Output Format
When reviewing, provide output in this format:
## Code Review: [File Name]
### Issues Found
#### [AP00X] Issue Title
- **Severity:** Error/Warning/Info
- **Location:** Line XX
- **Current Code:**
```typescript
// problematic code
- Suggested Fix:
- Explanation: Why this is an issue
Summary
| Severity | Count |
|---|
| Error | X |
| Warning | X |
| Info | X |
Recommendations
- Priority fixes...
- Suggested improvements...