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:
- What is the core entity? (e.g., Guild, Member, Event)
- What are its responsibilities? (e.g., Guild manages logo, settings, archive state)
- What are its invariants? (e.g., Guild can't use custom logo without uploading one)
- 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
- Entities - Objects with identity and lifecycle
- Value Objects - Immutable objects without identity
- Repository Interfaces - Persistence contracts
- Domain Events - Things that happened
Phase 4: Create Infrastructure Layer
- Repositories - Implement domain interfaces using Prisma
- Mappers - Transform between Prisma models and domain entities
- Event Publisher - Publish domain events to NestJS event system
Phase 5: Create Application Layer
- DTOs - Input/output data structures
- Use Cases - Orchestrate domain logic for specific user actions
Phase 6: Create Interfaces Layer
- Domain Facade - Public API for other bounded contexts
- 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