
nestjs-dddcqrs-architecture
by HoaiPhuongcoder
SKILL.md
name: NestJS DDD/CQRS Architecture description: Implement features following Domain-Driven Design (DDD) and CQRS patterns with event-driven architecture, Kafka, and BullMQ
NestJS DDD/CQRS Architecture Skill
This skill guides implementation of new features in a NestJS application using Domain-Driven Design (DDD), CQRS (Command Query Responsibility Segregation), and Event-Driven Architecture.
Architecture Overview
Tech Stack
- Framework: NestJS
- ORM: Prisma
- Message Broker: Kafka (for inter-module events)
- Job Queue: BullMQ (for async tasks like emails)
- Email Service: Resend
- Database: PostgreSQL
- Patterns: DDD, CQRS, Event Sourcing, Outbox Pattern
Module Structure
Each module follows Clean Architecture / DDD layering:
src/modules/{module-name}/
├── domain/ # Business logic layer
│ ├── aggregates/ # Aggregate roots (business entities)
│ ├── value-objects/ # Immutable value objects
│ ├── events/ # Domain events
│ ├── repositories/ # Repository interfaces (ports)
│ ├── exceptions/ # Domain-specific exceptions
│ └── enums/ # Domain constants
├── application/ # Use cases layer
│ ├── commands/ # CQRS commands
│ ├── queries/ # CQRS queries (if needed)
│ ├── handlers/ # Command/Query/Event handlers
│ ├── dtos/ # Data transfer objects
│ └── ports/ # Application interfaces
├── infrastructure/ # Technical implementations
│ ├── prisma/ # Prisma repository implementations
│ ├── adapters/ # External service adapters
│ ├── jobs/ # Outbox workers, cron jobs
│ └── kafka/ # Kafka producers/consumers
├── presentation/ # Interface layer
│ ├── http/ # REST controllers
│ └── event-consumers/ # Kafka event consumers
└── {module}.module.ts # Module configuration
Key Patterns
1. Aggregate Pattern
Aggregates are the core of your domain model. They:
- Encapsulate business logic
- Maintain invariants
- Emit domain events
- Have a unique ID and lifecycle
Example Structure:
export class AuthUser extends AggregateRoot {
private constructor(
private _id: string,
private _email: Email,
// ... other properties
) {
super();
}
// Factory method for creation
static register(...): AuthUser {
const user = new AuthUser(...);
user.apply(new UserRegisteredEvent(...));
return user;
}
// Factory method for reconstruction from DB
static restore(...): AuthUser {
return new AuthUser(...);
}
// Domain methods that maintain business rules
verifyOtp(code: string): void {
// Validate business rules
// Emit domain event
this.apply(new UserVerifiedEvent(...));
}
}
2. Value Objects
Immutable objects that represent domain concepts:
export class Email {
constructor(private readonly _value: string) {
// Validation in constructor
if (!this.isValid(_value)) {
throw new InvalidEmailException();
}
}
get value(): string {
return this._value;
}
private isValid(email: string): boolean {
// Validation logic
}
}
3. CQRS Pattern
Separate read and write operations:
Commands (Write):
// Command
export class RegisterUserCommand {
constructor(
public readonly email: string,
public readonly password: string,
) {}
}
// Handler
@CommandHandler(RegisterUserCommand)
export class RegisterUserHandler implements ICommandHandler<RegisterUserCommand> {
async execute(command: RegisterUserCommand): Promise<void> {
// 1. Create value objects
// 2. Create aggregate
// 3. Save via repository
}
}
Queries (Read) - separate if needed.
4. Event-Driven Architecture
Domain Events (within module):
export class UserRegisteredEvent extends DomainEvent {
constructor(
public readonly aggregateId: string,
public readonly email: string,
public readonly otp: string,
) {
super({ aggregateId });
}
}
Flow: Aggregate → Domain Event → Outbox → Kafka → Event Consumer → Event Handler
5. Outbox Pattern
Ensures reliable event publishing:
- Save aggregate + events in same transaction
- Cron job (every 5s) publishes pending events to Kafka
- Events marked as PUBLISHED after successful send
Repository Implementation:
async save(aggregate: Aggregate): Promise<void> {
const domainEvents = aggregate.getUncommittedEvents();
await this.prisma.$transaction(async (tx) => {
// Save aggregate
await tx.user.upsert(...);
// Save events to outbox
for (const event of domainEvents) {
await tx.integrationEventOutbox.create({
data: {
type: event.constructor.name,
payload: event,
status: OutboxStatus.PENDING,
},
});
}
});
aggregate.commit();
}
6. Cross-Module Communication
Auth Module (produces) → Notification Module (consumes):
- Auth emits event:
UserRegisteredEvent - Outbox worker: Publishes to Kafka
- Notification consumer: Receives from Kafka
- Notification handler: Processes event (queue email)
- BullMQ worker: Sends email
Implementation Checklist
Adding a New Command/Feature
Step 1: Domain Layer
- Create domain event in
domain/events/ - Add business method to aggregate in
domain/aggregates/ - Create value objects if needed in
domain/value-objects/ - Add custom exceptions in
domain/exceptions/
Step 2: Application Layer
- Create command in
application/commands/ - Create DTO in
application/dtos/ - Create command handler in
application/handlers/ - Inject required repositories/ports
Step 3: Presentation Layer
- Add endpoint in
presentation/http/{controller}.ts - Use
@ResponseMessage()decorator - Execute command via
CommandBus
Step 4: Registration
- Register handler in
{module}.module.tsproviders
Step 5: Cross-Module Events (if needed)
If event should trigger actions in other modules:
In Notification Module:
- Create domain event in
notification/domain/events/ - Create DTO in
notification/application/dtos/ - Create event handler in
notification/application/handlers/ - Add
@EventPattern()inevent-consumers/auth-events.controller.ts - Register handler in
notification.module.ts
Code Templates
Complete Feature Implementation Template
// ============================================
// DOMAIN LAYER
// ============================================
// domain/events/feature-executed.event.ts
import { DomainEvent } from '@/shared';
export class FeatureExecutedEvent extends DomainEvent {
constructor(
public readonly aggregateId: string,
public readonly data: string,
) {
super({ aggregateId });
}
}
// domain/aggregates/entity.aggregate.ts
executeFeature(data: ValueObject): void {
// Validate business rules
if (!this.canExecute()) {
throw new BusinessRuleException();
}
// Update state
this._data = data;
// Emit event
this.apply(new FeatureExecutedEvent(this.id, data.value));
}
// ============================================
// APPLICATION LAYER
// ============================================
// application/commands/execute-feature.command.ts
export class ExecuteFeatureCommand {
constructor(public readonly data: string) {}
}
// application/dtos/execute-feature.dto.ts
import { IsString, IsNotEmpty } from 'class-validator';
export class ExecuteFeatureDto {
@IsString()
@IsNotEmpty()
data: string;
}
// application/handlers/execute-feature.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
@CommandHandler(ExecuteFeatureCommand)
export class ExecuteFeatureHandler
implements ICommandHandler<ExecuteFeatureCommand> {
constructor(
@Inject(ENTITY_REPOSITORY)
private readonly repository: EntityRepository,
) {}
async execute(command: ExecuteFeatureCommand): Promise<void> {
// 1. Create value objects
const dataVO = new DataValueObject(command.data);
// 2. Load aggregate
const entity = await this.repository.findById(id);
if (!entity) throw new EntityNotFoundException();
// 3. Execute business logic
entity.executeFeature(dataVO);
// 4. Persist
await this.repository.save(entity);
}
}
// ============================================
// PRESENTATION LAYER
// ============================================
// presentation/http/entity.controller.ts
@Controller('entity')
export class EntityController {
constructor(private readonly commandBus: CommandBus) {}
@ResponseMessage('Feature executed successfully')
@Post('execute')
async execute(@Body() dto: ExecuteFeatureDto) {
await this.commandBus.execute(
new ExecuteFeatureCommand(dto.data),
);
}
}
// ============================================
// MODULE REGISTRATION
// ============================================
// entity.module.ts
@Module({
controllers: [EntityController],
imports: [InfrastructureModule, CqrsModule],
providers: [
ExecuteFeatureHandler, // <-- Register here
{
provide: ENTITY_REPOSITORY,
useClass: PrismaEntityRepository,
},
],
})
export class EntityModule {}
Best Practices
1. Naming Conventions
- Commands:
{Action}{Entity}Command(e.g.,RegisterUserCommand) - Events:
{Entity}{Action}Event(e.g.,UserRegisteredEvent) - Handlers:
{CommandName}Handler - DTOs:
{CommandName}Dto - Aggregates:
{EntityName}(e.g.,AuthUser) - Value Objects:
{Concept}(e.g.,Email,Password)
2. Business Logic Location
✅ DO: Put business logic in aggregates
// ✅ Good - in aggregate
user.verifyOtp(code);
❌ DON'T: Put business logic in handlers
// ❌ Bad - in handler
if (user.status === 'ACTIVE') {
throw new Error('Already active');
}
3. Transaction Boundaries
- Use Prisma transactions for aggregate + events persistence
- One transaction per command execution
- Events saved in same transaction as aggregate
4. Error Handling
- Domain exceptions extend
DomainException - Use appropriate HTTP status codes
- Let NestJS exception filters handle formatting
5. Validation
- Value Objects: Validate in constructor
- DTOs: Use
class-validatordecorators - Aggregates: Validate business rules in methods
Common Scenarios
Scenario 1: Add New User Action
Example: Implement "Change Password" feature
- Domain: Add
changePassword()toAuthUseraggregate - Command: Create
ChangePasswordCommandand handler - DTO: Create
ChangePasswordDtowith validation - Controller: Add
PATCH /auth/passwordendpoint - Module: Register
ChangePasswordHandler
Scenario 2: Trigger Email Notification
Example: Send welcome email after verification
- Auth Module: Already emits
UserVerifiedEvent - Notification Module:
- Create
UserVerifiedEvent(notification context) - Create
UserVerifiedEventDto - Create
UserVerifiedEventHandler(queue email job) - Add
@EventPattern('UserVerifiedEvent')to consumer - Register handler
- Create
Scenario 3: Add New Module
- Create folder structure:
domain/,application/,infrastructure/,presentation/ - Define aggregate with business logic
- Create repository interface and Prisma implementation
- Add commands and handlers
- Create controller
- Register in
app.module.ts
Testing Strategy
Unit Tests
- Test aggregates in isolation
- Mock repositories
- Verify events are emitted
Integration Tests
- Test handlers with real DB (testcontainers)
- Verify outbox events created
- Test full command flow
E2E Tests
- Test via HTTP endpoints
- Verify downstream effects (emails queued)
Troubleshooting
Events Not Published
✅ Check outbox worker is running (cron job)
✅ Verify Kafka connection
✅ Check IntegrationEventOutbox table for PENDING events
Handler Not Triggered
✅ Verify handler registered in module
✅ Check @CommandHandler() or @EventsHandler() decorator
✅ Ensure CommandBus/EventBus imported in module
Business Logic Not Working
✅ Check if method called on aggregate
✅ Verify invariants in aggregate
✅ Review domain events emitted
Reference Examples
See existing implementations:
- Full CQRS flow:
ResendVerificationCodeCommand+ handler - Aggregate:
AuthUser.requestNewVerificationCode() - Cross-module events:
UserOtpRequestedEvent(auth → notification) - Outbox worker:
auth-outbox.worker.ts - Event consumer:
auth-events.controller.ts - Email queue:
user-otp-requested-event.handler.ts
Quick Reference Commands
# Start dev server
pnpm run dev
# Build
pnpm run build
# Prisma
pnpm prisma studio # View database
pnpm prisma migrate dev # Create migration
pnpm prisma generate # Generate client
# Docker
docker-compose up # Start all services
docker-compose up -d # Start in background
docker-compose logs -f # View logs
Remember: This architecture prioritizes business logic clarity, testability, and scalability through proper separation of concerns and loose coupling via events.
Score
Total Score
Based on repository quality metrics
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
3ヶ月以内に更新がある
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
Reviews
Reviews coming soon