Adding a New Domain
Step-by-step guide to creating a new bounded context in the DDD architecture.
Prerequisites
- Understand the DDD Architecture
- Read the Migration Guide
- Have a clear domain concept to implement
Step 1: Define the Domain
Before writing code, answer these questions:
- What is the aggregate root? (e.g., Event, Raid, Application)
- What are its invariants? (rules that must always be true)
- What operations can be performed? (create, cancel, reschedule, etc.)
- What events should it emit? (EventCreated, EventCancelled, etc.)
- How does it relate to other domains? (Events belong to Guilds, etc.)
Step 2: Create Directory Structure
mkdir -p src/domains/[domain-name]/{domain/{entities,value-objects,repositories,events},application/{dto,use-cases},infrastructure/{repositories,mappers},interfaces/integration-examples}
Example for event domain:
src/domains/event/
├── domain/
│ ├── entities/
│ │ ├── event.entity.ts
│ │ └── event.entity.spec.ts
│ ├── value-objects/
│ │ ├── event-type.vo.ts
│ │ └── event-type.vo.spec.ts
│ ├── repositories/
│ │ └── event.repository.interface.ts
│ ├── events/
│ │ ├── domain-event.base.ts (copy from guild)
│ │ ├── event-created.event.ts
│ │ └── event-cancelled.event.ts
│ └── index.ts
├── application/
│ ├── dto/
│ │ ├── create-event.dto.ts
│ │ └── event-response.dto.ts
│ ├── use-cases/
│ │ ├── create-event.use-case.ts
│ │ └── create-event.use-case.spec.ts
│ └── index.ts
├── infrastructure/
│ ├── repositories/
│ │ ├── event.repository.ts
│ │ └── event.repository.spec.ts
│ ├── mappers/
│ │ ├── event.mapper.ts
│ │ └── event.mapper.spec.ts
│ ├── event-publisher.service.ts (copy from guild)
│ └── index.ts
├── interfaces/
│ ├── event-domain.interface.ts
│ ├── event-domain.facade.ts
│ ├── event-domain.facade.spec.ts
│ ├── event-domain-events.interface.ts
│ └── index.ts
└── event.module.ts
Step 3: Domain Layer
Create Aggregate Root Entity
// src/domains/event/domain/entities/event.entity.ts
export class Event {
private domainEvents: DomainEvent[] = [];
private constructor(
private readonly _id: string | undefined,
private _title: string,
private readonly _guildId: string,
private _type: EventType,
private _scheduledAt: Date,
private _status: EventStatus,
private readonly _createdBy: string,
private readonly _createdAt: Date
) {}
// Factory method for creation
static create(
title: string,
guildId: string,
type: EventType,
scheduledAt: Date,
createdBy: string
): Event {
return new Event(
undefined, // Prisma generates ID
title,
guildId,
type,
scheduledAt,
EventStatus.scheduled(),
createdBy,
new Date()
);
}
// Factory method for reconstitution from persistence
static fromPersistence(props: EventProps): Event {
return new Event(
props.id,
props.title,
props.guildId,
props.type,
props.scheduledAt,
props.status,
props.createdBy,
props.createdAt
);
}
// Business logic methods
cancel(): void {
if (this._status.isCancelled()) {
throw new Error('Event is already cancelled');
}
this._status = EventStatus.cancelled();
if (this._id) {
this.addDomainEvent(new EventCancelledEvent(this._id));
}
}
reschedule(newDate: Date): void {
if (this._status.isCancelled()) {
throw new Error('Cannot reschedule cancelled event');
}
this._scheduledAt = newDate;
if (this._id) {
this.addDomainEvent(new EventRescheduledEvent(this._id, newDate));
}
}
// Getters
get id(): string | undefined {
return this._id;
}
get title(): string {
return this._title;
}
get guildId(): string {
return this._guildId;
}
get status(): EventStatus {
return this._status;
}
// Domain events
getDomainEvents(): DomainEvent[] {
return [...this.domainEvents];
}
clearDomainEvents(): void {
this.domainEvents = [];
}
private addDomainEvent(event: DomainEvent): void {
this.domainEvents.push(event);
}
}
Create Value Objects
// src/domains/event/domain/value-objects/event-type.vo.ts
export enum EventTypeEnum {
RAID = 'raid',
MYTHIC_PLUS = 'mythic_plus',
PVP = 'pvp',
SOCIAL = 'social',
}
export class EventType {
private constructor(private readonly value: EventTypeEnum) {}
static raid(): EventType {
return new EventType(EventTypeEnum.RAID);
}
static mythicPlus(): EventType {
return new EventType(EventTypeEnum.MYTHIC_PLUS);
}
static fromString(value: string): EventType {
const typeEnum = Object.values(EventTypeEnum).find((e) => e === value);
if (!typeEnum) {
throw new Error(`Invalid event type: ${value}`);
}
return new EventType(typeEnum);
}
toString(): string {
return this.value;
}
equals(other: EventType): boolean {
return this.value === other.value;
}
}
Create Repository Interface
// src/domains/event/domain/repositories/event.repository.interface.ts
export interface IEventRepository {
save(event: Event): Promise<Event>;
findById(id: string): Promise<Event | null>;
findByGuildId(guildId: string): Promise<Event[]>;
findUpcoming(guildId: string): Promise<Event[]>;
delete(id: string): Promise<void>;
}
export const EVENT_REPOSITORY = Symbol('EVENT_REPOSITORY');
Create Domain Events
// src/domains/event/domain/events/event-created.event.ts
import { DomainEvent } from './domain-event.base';
export class EventCreatedEvent extends DomainEvent {
constructor(
public readonly eventId: string,
public readonly guildId: string,
public readonly scheduledAt: Date
) {
super();
}
getEventName(): string {
return 'event.created';
}
getAggregateId(): string {
return this.eventId;
}
}
Export Domain Layer
// src/domains/event/domain/index.ts
export * from './entities/event.entity';
export * from './value-objects/event-type.vo';
export * from './value-objects/event-status.vo';
export * from './repositories/event.repository.interface';
export * from './events/domain-event.base';
export * from './events/event-created.event';
export * from './events/event-cancelled.event';
Step 4: Infrastructure Layer
Create Mapper
// src/domains/event/infrastructure/mappers/event.mapper.ts
import { Event as PrismaEvent } from '../../../../generated/prisma/client';
import { Event, EventType, EventStatus } from '../../domain';
export class EventMapper {
static toDomain(prismaEvent: PrismaEvent): Event {
return Event.fromPersistence({
id: prismaEvent.id,
title: prismaEvent.title,
guildId: prismaEvent.guildId,
type: EventType.fromString(prismaEvent.type),
scheduledAt: prismaEvent.scheduledAt,
status: EventStatus.fromString(prismaEvent.status),
createdBy: prismaEvent.createdBy,
createdAt: prismaEvent.createdAt,
});
}
static toPrismaCreate(event: Event): any {
const prismaData = this.toPrisma(event);
const { id: _id, ...createData } = prismaData;
return createData;
}
static toPrismaUpdate(event: Event): any {
const prismaData = this.toPrisma(event);
const { id: _id, createdAt: _createdAt, ...updateData } = prismaData;
return updateData;
}
private static toPrisma(event: Event): any {
return {
id: event.id,
title: event.title,
guildId: event.guildId,
type: event.type.toString(),
scheduledAt: event.scheduledAt,
status: event.status.toString(),
createdBy: event.createdBy,
createdAt: event.createdAt,
};
}
}
Create Repository Implementation
// src/domains/event/infrastructure/repositories/event.repository.ts
@Injectable()
export class EventRepository implements IEventRepository {
constructor(private readonly prisma: PrismaService) {}
async save(event: Event): Promise<Event> {
if (event.id) {
// Update existing
const updated = await this.prisma.event.update({
where: { id: event.id },
data: EventMapper.toPrismaUpdate(event),
});
return EventMapper.toDomain(updated);
} else {
// Create new
const created = await this.prisma.event.create({
data: EventMapper.toPrismaCreate(event),
});
const domainEvent = EventMapper.toDomain(created);
// Manually add EventCreatedEvent (since Prisma assigned the ID)
domainEvent['addDomainEvent'](
new EventCreatedEvent(
domainEvent.id!,
domainEvent.guildId,
domainEvent.scheduledAt
)
);
return domainEvent;
}
}
async findById(id: string): Promise<Event | null> {
const prismaEvent = await this.prisma.event.findUnique({
where: { id },
});
return prismaEvent ? EventMapper.toDomain(prismaEvent) : null;
}
async findByGuildId(guildId: string): Promise<Event[]> {
const prismaEvents = await this.prisma.event.findMany({
where: { guildId },
orderBy: { scheduledAt: 'desc' },
});
return prismaEvents.map(EventMapper.toDomain);
}
async delete(id: string): Promise<void> {
await this.prisma.event.delete({ where: { id } });
}
}
Step 5: Application Layer
Create DTOs
// src/domains/event/application/dto/create-event.dto.ts
export class CreateEventDto {
title: string;
guildId: string;
type: 'raid' | 'mythic_plus' | 'pvp' | 'social';
scheduledAt: Date;
createdBy: string;
}
// src/domains/event/application/dto/event-response.dto.ts
export class EventResponseDto {
id: string;
title: string;
guildId: string;
type: string;
scheduledAt: Date;
status: string;
createdBy: string;
createdAt: Date;
}
Create Use Cases
// src/domains/event/application/use-cases/create-event.use-case.ts
@Injectable()
export class CreateEventUseCase {
constructor(
@Inject(EVENT_REPOSITORY)
private readonly eventRepository: IEventRepository,
@Inject(GUILD_DOMAIN_FACADE)
private readonly guildDomain: IGuildDomainFacade, // Cross-domain dependency
private readonly eventPublisher: DomainEventPublisher
) {}
async execute(dto: CreateEventDto): Promise<EventResponseDto> {
// Verify guild exists (cross-domain call)
const guildExists = await this.guildDomain.guildExists(dto.guildId);
if (!guildExists) {
throw new NotFoundException('Guild not found');
}
// Create domain entity
const event = Event.create(
dto.title,
dto.guildId,
EventType.fromString(dto.type),
dto.scheduledAt,
dto.createdBy
);
// Persist
const savedEvent = await this.eventRepository.save(event);
// Publish domain events
await this.eventPublisher.publishEventsForAggregate(savedEvent);
// Map to DTO
return this.toDto(savedEvent);
}
private toDto(event: Event): EventResponseDto {
return {
id: event.id!,
title: event.title,
guildId: event.guildId,
type: event.type.toString(),
scheduledAt: event.scheduledAt,
status: event.status.toString(),
createdBy: event.createdBy,
createdAt: event.createdAt,
};
}
}
Step 6: Interfaces Layer (Public API)
Create Domain Facade
// src/domains/event/interfaces/event-domain.interface.ts
export interface IEventDomainFacade {
createEvent(dto: CreateEventDto): Promise<EventResponseDto>;
getEventById(eventId: string): Promise<EventResponseDto>;
cancelEvent(eventId: string): Promise<void>;
eventExists(eventId: string): Promise<boolean>;
}
export const EVENT_DOMAIN_FACADE = Symbol('EVENT_DOMAIN_FACADE');
// src/domains/event/interfaces/event-domain.facade.ts
@Injectable()
export class EventDomainFacade implements IEventDomainFacade {
constructor(
private readonly createEventUseCase: CreateEventUseCase,
private readonly cancelEventUseCase: CancelEventUseCase,
@Inject(EVENT_REPOSITORY)
private readonly eventRepository: IEventRepository
) {}
async createEvent(dto: CreateEventDto): Promise<EventResponseDto> {
return this.createEventUseCase.execute(dto);
}
async eventExists(eventId: string): Promise<boolean> {
const event = await this.eventRepository.findById(eventId);
return event !== null;
}
}
Define Event Contracts
// src/domains/event/interfaces/event-domain-events.interface.ts
export interface EventCreatedEventPayload {
eventId: string;
guildId: string;
title: string;
scheduledAt: Date;
}
export const EVENT_CREATED_EVENT = 'event.created';
Step 7: NestJS Module
// src/domains/event/event.module.ts
@Module({
imports: [
GuildModule, // For GUILD_DOMAIN_FACADE
EventEmitterModule.forRoot(),
],
controllers: [EventController],
providers: [
// Use Cases
CreateEventUseCase,
CancelEventUseCase,
// Infrastructure
DomainEventPublisher,
{
provide: EVENT_REPOSITORY,
useClass: EventRepository,
},
// Public Facade
{
provide: EVENT_DOMAIN_FACADE,
useClass: EventDomainFacade,
},
],
exports: [
EVENT_DOMAIN_FACADE, // Public API for other domains
EVENT_REPOSITORY,
],
})
export class EventModule {}
Step 8: Write Tests
# Run tests for new domain
pnpm test:api -- domains/event
Ensure:
- Domain entity tests (business logic)
- Value object tests
- Use case tests (with mocked dependencies)
- Repository tests (with mocked PrismaService)
- Mapper tests
- Facade tests
Step 9: Register Module
// src/app.module.ts
@Module({
imports: [
// ... existing modules
EventModule, // Add new domain
],
})
export class AppModule {}
Checklist
Before considering the domain complete:
- Domain entities with business logic
- Value objects for complex values
- Repository interface in domain layer
- Repository implementation in infrastructure
- Mappers (Prisma ↔ Domain)
- Domain events
- Event publisher service
- DTOs for input/output
- Use cases for user actions
- Domain facade (public API)
- Event contracts (public payloads)
- Module wiring (DI configuration)
- Comprehensive tests (>80% coverage)
- Integration with other domains via facades only
Next Steps
- Add controller endpoints
- Document API in Swagger/OpenAPI
- Add integration tests
- Consider adding event listeners for cross-domain reactions