Skip to main content

Adding a New Domain

Step-by-step guide to creating a new bounded context in the DDD architecture.

Prerequisites

Step 1: Define the Domain

Before writing code, answer these questions:

  1. What is the aggregate root? (e.g., Event, Raid, Application)
  2. What are its invariants? (rules that must always be true)
  3. What operations can be performed? (create, cancel, reschedule, etc.)
  4. What events should it emit? (EventCreated, EventCancelled, etc.)
  5. 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