Skip to main content

DDD Migration Guide

This guide explains how to migrate existing services to the Domain-Driven Design (DDD) architecture and how to create new bounded contexts following the established patterns.

Migration Strategy

Phase 1: Identify the Domain

Before migrating code, understand the domain:

  1. What is the core entity? (e.g., Guild, Member, Event)
  2. What are its responsibilities? (e.g., Guild manages logo, settings, archive state)
  3. What are its invariants? (e.g., Guild can't use custom logo without uploading one)
  4. What events does it emit? (e.g., GuildCreated, GuildArchived)

Phase 2: Extract Business Logic

Current pattern (❌):

// Service contains business logic
@Injectable()
export class GuildService {
async archiveGuild(id: string) {
const guild = await this.prisma.guild.findUnique({ where: { id } });
if (!guild.active) throw new Error('Already archived');

await this.prisma.guild.update({
where: { id },
data: { active: false, archivedAt: new Date() },
});
}
}

DDD pattern (✅):

// Domain entity contains business logic
export class Guild {
archive(): void {
if (!this.active) {
throw new Error('Guild is already archived');
}
this.active = false;
this.archivedAt = new Date();
this.addDomainEvent(new GuildArchivedEvent(this.id!, this.archivedAt));
}
}

// Service orchestrates, doesn't contain logic
@Injectable()
export class ArchiveGuildUseCase {
async execute(guildId: string): Promise<void> {
const guild = await this.guildRepository.findById(guildId);
if (!guild) throw new NotFoundException();

guild.archive(); // Business logic in domain
await this.guildRepository.save(guild);
await this.eventPublisher.publishEventsForAggregate(guild);
}
}

Phase 3: Create Domain Layer

  1. Entities - Objects with identity and lifecycle
  2. Value Objects - Immutable objects without identity
  3. Repository Interfaces - Persistence contracts
  4. Domain Events - Things that happened

Phase 4: Create Infrastructure Layer

  1. Repositories - Implement domain interfaces using Prisma
  2. Mappers - Transform between Prisma models and domain entities
  3. Event Publisher - Publish domain events to NestJS event system

Phase 5: Create Application Layer

  1. DTOs - Input/output data structures
  2. Use Cases - Orchestrate domain logic for specific user actions

Phase 6: Create Interfaces Layer

  1. Domain Facade - Public API for other bounded contexts
  2. Event Contracts - Public event payload interfaces

Creating a New Bounded Context

Follow this structure for each new domain:

src/domains/[domain-name]/
├── domain/ # Core business logic (no dependencies)
│ ├── entities/ # Entities with identity
│ ├── value-objects/ # Immutable values
│ ├── repositories/ # Persistence interfaces
│ ├── events/ # Domain events
│ └── index.ts # Domain layer exports
├── application/ # Orchestration layer
│ ├── dto/ # Input/output DTOs
│ ├── use-cases/ # User stories/actions
│ └── index.ts # Application layer exports
├── infrastructure/ # Technical implementations
│ ├── repositories/ # Prisma implementations
│ ├── mappers/ # Prisma ↔ Domain mapping
│ ├── event-publisher.service.ts
│ └── index.ts # Infrastructure layer exports
├── interfaces/ # Public API (bounded context boundary)
│ ├── [domain]-domain.interface.ts # Facade interface
│ ├── [domain]-domain.facade.ts # Facade implementation
│ ├── [domain]-domain-events.interface.ts # Event contracts
│ └── index.ts # PUBLIC API - other domains import from here
└── [domain].module.ts # NestJS module wiring

Step-by-Step: Migrating an Existing Service

Example: Migrating MembershipService

Step 1: Analyze current service

// Current: apps/api/src/domains/membership/membership.service.ts
@Injectable()
export class MembershipService {
async addMember(userId: string, guildId: string, roleId: string) {
// Business logic mixed with persistence
const existing = await this.prisma.membership.findFirst({
where: { userId, guildId },
});

if (existing) throw new Error('Already a member');

return this.prisma.membership.create({
data: { userId, guildId, roleId, status: 'trial' },
});
}
}

Step 2: Extract domain entities

// New: apps/api/src/domains/membership/domain/entities/membership.entity.ts
export class Membership {
private constructor(
private readonly _id: string | undefined,
private readonly _userId: string,
private readonly _guildId: string,
private _roleId: string,
private _status: MembershipStatus,
private readonly _joinedAt: Date,
private _leftAt: Date | null
) {}

static create(userId: string, guildId: string, roleId: string): Membership {
return new Membership(
undefined, // Prisma generates ID
userId,
guildId,
roleId,
MembershipStatus.trial(),
new Date(),
null
);
}

promote(newRoleId: string): void {
if (this._status.isTrial()) {
this._status = MembershipStatus.active();
}
this._roleId = newRoleId;
// Emit MembershipPromotedEvent
}
}

Step 3: Create repository interface

// New: apps/api/src/domains/membership/domain/repositories/membership.repository.interface.ts
export interface IMembershipRepository {
save(membership: Membership): Promise<Membership>;
findById(id: string): Promise<Membership | null>;
findByUserAndGuild(
userId: string,
guildId: string
): Promise<Membership | null>;
findByGuild(guildId: string): Promise<Membership[]>;
delete(id: string): Promise<void>;
}

export const MEMBERSHIP_REPOSITORY = Symbol('MEMBERSHIP_REPOSITORY');

Step 4: Implement repository

// New: apps/api/src/domains/membership/infrastructure/repositories/membership.repository.ts
@Injectable()
export class MembershipRepository implements IMembershipRepository {
constructor(private readonly prisma: PrismaService) {}

async save(membership: Membership): Promise<Membership> {
const data = MembershipMapper.toPrisma(membership);

if (membership.id) {
const updated = await this.prisma.membership.update({
where: { id: membership.id },
data: MembershipMapper.toPrismaUpdate(membership) as any,
});
return MembershipMapper.toDomain(updated);
} else {
const created = await this.prisma.membership.create({
data: MembershipMapper.toPrismaCreate(membership) as any,
});
return MembershipMapper.toDomain(created);
}
}

async findByUserAndGuild(
userId: string,
guildId: string
): Promise<Membership | null> {
const prismaResult = await this.prisma.membership.findFirst({
where: { userId, guildId },
});

return prismaResult ? MembershipMapper.toDomain(prismaResult) : null;
}
}

Step 5: Create use case

// New: apps/api/src/domains/membership/application/use-cases/add-member.use-case.ts
@Injectable()
export class AddMemberUseCase {
constructor(
@Inject(MEMBERSHIP_REPOSITORY)
private readonly membershipRepository: IMembershipRepository,
@Inject(GUILD_DOMAIN_FACADE)
private readonly guildDomain: IGuildDomainFacade
) {}

async execute(dto: AddMemberDto): Promise<MembershipResponseDto> {
// Verify guild exists (cross-domain call via facade)
const guildExists = await this.guildDomain.guildExists(dto.guildId);
if (!guildExists) {
throw new NotFoundException('Guild not found');
}

// Check if already a member
const existing = await this.membershipRepository.findByUserAndGuild(
dto.userId,
dto.guildId
);

if (existing) {
throw new Error('User is already a member of this guild');
}

// Create domain entity
const membership = Membership.create(dto.userId, dto.guildId, dto.roleId);

// Persist
const saved = await this.membershipRepository.save(membership);

// Publish events
await this.eventPublisher.publishEventsForAggregate(saved);

return this.toDto(saved);
}
}

Step 6: Update module

// Updated: apps/api/src/domains/membership/membership.module.ts
@Module({
imports: [
GuildModule, // Import to get GUILD_DOMAIN_FACADE
],
providers: [
MembershipService, // Keep for backward compatibility initially
AddMemberUseCase,
{
provide: MEMBERSHIP_REPOSITORY,
useClass: MembershipRepository,
},
],
exports: [MEMBERSHIP_REPOSITORY, AddMemberUseCase],
})
export class MembershipModule {}

Step 7: Gradually migrate controller

// Updated: apps/api/src/domains/membership/membership.controller.ts
@Controller('memberships')
export class MembershipController {
constructor(
private readonly membershipService: MembershipService, // Old
private readonly addMemberUseCase: AddMemberUseCase // New
) {}

@Post()
async addMember(@Body() dto: AddMemberDto) {
// New DDD approach
return this.addMemberUseCase.execute(dto);

// Old approach (gradually remove)
// return this.membershipService.addMember(dto.userId, dto.guildId, dto.roleId);
}
}

Testing Strategy

Domain Layer Tests

// Pure unit tests, no mocking needed
describe('Membership', () => {
it('should create trial membership', () => {
const membership = Membership.create('user-1', 'guild-1', 'role-1');
expect(membership.status).toBe('trial');
});
});

Application Layer Tests

// Mock repositories and domain facade
describe('AddMemberUseCase', () => {
it('should add member to guild', async () => {
// Mock dependencies
vi.mocked(guildDomain.guildExists).mockResolvedValue(true);
vi.mocked(membershipRepository.findByUserAndGuild).mockResolvedValue(null);

// Execute
await useCase.execute({
userId: 'user-1',
guildId: 'guild-1',
roleId: 'role-1',
});

// Assert
expect(membershipRepository.save).toHaveBeenCalled();
});
});

Infrastructure Layer Tests

// Integration tests with real Prisma (or mock PrismaService)
describe('MembershipRepository', () => {
it('should save membership', async () => {
const membership = Membership.create('user-1', 'guild-1', 'role-1');
const saved = await repository.save(membership);
expect(saved.id).toBeDefined();
});
});

Common Pitfalls

❌ Don't: Expose domain entities to controllers

// ❌ BAD
@Post()
async create(@Body() guild: Guild) { // Don't use domain entity as DTO
return this.useCase.execute(guild);
}
// ✅ GOOD
@Post()
async create(@Body() dto: CreateGuildDto) { // Use DTO
return this.useCase.execute(dto);
}

❌ Don't: Put business logic in use cases

// ❌ BAD
@Injectable()
export class ArchiveGuildUseCase {
async execute(guildId: string) {
const guild = await this.repo.findById(guildId);
if (!guild.active) throw new Error('Already archived'); // Business logic
guild.active = false; // Mutating directly
await this.repo.save(guild);
}
}
// ✅ GOOD
@Injectable()
export class ArchiveGuildUseCase {
async execute(guildId: string) {
const guild = await this.repo.findById(guildId);
guild.archive(); // Business logic in domain
await this.repo.save(guild);
}
}

❌ Don't: Import domain internals from other contexts

// ❌ BAD
import { Guild } from '../guild/domain/entities/guild.entity';
import { GUILD_REPOSITORY } from '../guild/domain/repositories/guild.repository.interface';
// ✅ GOOD
import { GUILD_DOMAIN_FACADE, IGuildDomainFacade } from '../guild/interfaces';

Rollout Plan

Phase 1: Core Domain Migration (✅ COMPLETED)

Guild Domain - Fully migrated with event-driven cache invalidation

  • ✅ All 10 endpoints migrated to DDD use cases
  • ✅ Domain events: GuildCreatedEvent, GuildArchivedEvent, GuildRestoredEvent, GuildDeletedEvent, GuildLogoUpdatedEvent
  • ✅ Event-driven HTTP cache invalidation working for all operations
  • ✅ Domain facade for cross-domain access
  • ✅ Complete test coverage

Character Domain - Core operations migrated

  • ✅ Archive/Restore with domain events
  • ✅ Delete with validation
  • ✅ Create manual character
  • ✅ Cache invalidation

Guild Member Domain - Fully migrated

  • ✅ All membership endpoints using DDD patterns
  • ✅ Role history tracking
  • ✅ Domain events for membership changes

Phase 2: Migrate remaining domains (🚧 In Progress)

  • Event domain (events, participations)
  • Role domain (if not yet migrated)
  • Application domain

Phase 3: Remove legacy patterns

  • Once all controllers use new use cases, remove old services
  • Update imports to use facades exclusively
  • Clean up deprecated patterns

Phase 4: Microservice preparation

  • Add gRPC facades alongside HTTP
  • Extract bounded contexts into separate services if needed

Benefits After Migration

Business logic is testable - Pure domain entities, no infrastructure dependencies ✅ Clear boundaries - Each domain is independent and composable ✅ Microservice-ready - Facades can be swapped for HTTP/gRPC clients ✅ Event-driven - Async communication via domain events ✅ Maintainable - Changes are localized to specific layers ✅ Extensible - New domains follow proven patterns