DDD Best Practices
Patterns, anti-patterns, and lessons learned from implementing Domain-Driven Design.
Domain Layer
✅ DO: Keep entities rich with behavior
// ✅ GOOD: 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));
}
}
// ❌ BAD: Anemic entity with just getters/setters
export class Guild {
setActive(active: boolean): void {
this.active = active; // No validation, no business rules
}
}
✅ DO: Use value objects for complex values
// ✅ GOOD: Faction is a value object with behavior
export class Faction {
private constructor(private readonly value: FactionTypeEnum) {}
static alliance(): Faction { return new Faction(FactionTypeEnum.ALLIANCE); }
static horde(): Faction { return new Faction(FactionTypeEnum.HORDE); }
isAlliance(): boolean { return this.value === FactionTypeEnum.ALLIANCE; }
toString(): string { return this.value; }
}
// Usage
if (guild.faction.isAlliance()) { ... }
// ❌ BAD: Using plain strings
export class Guild {
faction: string; // "alliance" or "horde" - no validation, no methods
}
// Usage
if (guild.faction === 'alliance') { ... } // Error-prone string comparison
✅ DO: Enforce invariants in constructors
// ✅ GOOD: Private constructor + factory methods
export class Event {
private constructor(
private readonly _id: string | undefined,
private _scheduledAt: Date
) {
if (_scheduledAt < new Date()) {
throw new Error('Cannot create event in the past');
}
}
static create(scheduledAt: Date): Event {
return new Event(undefined, scheduledAt);
}
}
// ❌ BAD: Public properties, no validation
export class Event {
constructor(
public id: string,
public scheduledAt: Date // Can be set to invalid value
) {}
}
✅ DO: Use domain events for side effects
// ✅ GOOD: Domain emits events, infrastructure reacts
export class Guild {
archive(): void {
this.active = false;
this.archivedAt = new Date();
this.addDomainEvent(new GuildArchivedEvent(this.id!, this.archivedAt));
}
}
// Infrastructure layer
@OnEvent('guild.archived')
async handleGuildArchived(event: GuildArchivedEventPayload) {
// Archive all memberships
// Send notifications
// Update statistics
}
// ❌ BAD: Trigger side effects directly in domain
export class Guild {
async archive(): Promise<void> {
this.active = false;
await this.membershipService.archiveAll(this.id); // Don't inject services!
await this.notificationService.send('Guild archived'); // Don't do side effects!
}
}
✅ DO: Keep domain layer dependency-free
// ✅ GOOD: Pure domain logic, no external dependencies
export class Guild {
private domainEvents: DomainEvent[] = [];
// No @Injectable, no constructor injections
}
// ❌ BAD: Domain depends on infrastructure
import { PrismaService } from '@nestjs/prisma'; // ❌
@Injectable() // ❌ Domain shouldn't be a NestJS service
export class Guild {
constructor(private readonly prisma: PrismaService) {} // ❌
}
Repository Pattern
✅ DO: Define interfaces in domain layer
// ✅ GOOD: Interface in domain, implementation in infrastructure
// domain/repositories/guild.repository.interface.ts
export interface IGuildRepository {
save(guild: Guild): Promise<Guild>;
findById(id: string): Promise<Guild | null>;
}
export const GUILD_REPOSITORY = Symbol('GUILD_REPOSITORY');
✅ DO: Return domain entities from repositories
// ✅ GOOD: Repository returns domain entity
async findById(id: string): Promise<Guild | null> {
const prismaGuild = await this.prisma.guild.findUnique({ where: { id } });
return prismaGuild ? GuildMapper.toDomain(prismaGuild) : null;
}
// ❌ BAD: Repository returns Prisma model
async findById(id: string): Promise<PrismaGuild | null> {
return this.prisma.guild.findUnique({ where: { id } });
}
✅ DO: Let Prisma handle ID generation
// ✅ GOOD: ID is optional in domain entity
export class Guild {
get id(): string | undefined { return this._id; }
}
// Repository creates without ID, Prisma generates it
const guild = Guild.create('Test Guild', 'TestRealm', ...);
const saved = await repository.save(guild); // Prisma adds ID
console.log(saved.id); // Now has ID
// ❌ BAD: Generate ID in domain
export class Guild {
static create(): Guild {
return new Guild(randomUUID(), ...); // Don't do this
}
}
Application Layer
✅ DO: Keep use cases thin (orchestration only)
// ✅ GOOD: Use case orchestrates, domain has 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(); // Domain logic
await this.guildRepository.save(guild);
await this.eventPublisher.publishEventsForAggregate(guild);
}
}
// ❌ BAD: Use case contains business logic
@Injectable()
export class ArchiveGuildUseCase {
async execute(guildId: string): Promise<void> {
const guild = await this.guildRepository.findById(guildId);
if (!guild.active) {
// ❌ Business rule in use case
throw new Error('Already archived');
}
guild.active = false; // ❌ Direct mutation
guild.archivedAt = new Date();
await this.guildRepository.save(guild);
}
}
✅ DO: Use DTOs for input/output
// ✅ GOOD: Separate DTO from domain entity
export class CreateGuildDto {
name: string;
realm: string;
faction: 'alliance' | 'horde';
}
export class GuildResponseDto {
id: string;
name: string;
// ... other fields for API response
}
@Injectable()
export class CreateGuildUseCase {
async execute(dto: CreateGuildDto): Promise<GuildResponseDto> {
const guild = Guild.create(...);
const saved = await this.repository.save(guild);
return this.toDto(saved); // Convert entity to DTO
}
}
// ❌ BAD: Return domain entity directly
@Injectable()
export class CreateGuildUseCase {
async execute(dto: CreateGuildDto): Promise<Guild> {
const guild = Guild.create(...);
return this.repository.save(guild); // ❌ Exposing domain entity
}
}
✅ DO: One use case per user action
// ✅ GOOD: Focused use cases
CreateGuildUseCase;
ArchiveGuildUseCase;
RestoreGuildUseCase;
UpdateGuildLogoUseCase;
// ❌ BAD: God use case
GuildManagementUseCase {
create()
archive()
restore()
updateLogo()
updateSettings()
// ... 20 more methods
}
Cross-Domain Communication
✅ DO: Use domain facades for synchronous calls
// ✅ GOOD: Inject facade interface
@Injectable()
export class AddMemberUseCase {
constructor(
@Inject(GUILD_DOMAIN_FACADE)
private readonly guildDomain: IGuildDomainFacade
) {}
async execute(dto: AddMemberDto) {
// Check if guild exists via facade
const exists = await this.guildDomain.guildExists(dto.guildId);
if (!exists) throw new NotFoundException('Guild not found');
}
}
// ❌ BAD: Import internal repository
import { GUILD_REPOSITORY } from '../guild/domain/repositories/...'; // ❌
@Injectable()
export class AddMemberUseCase {
constructor(
@Inject(GUILD_REPOSITORY) // ❌ Accessing internal implementation
private readonly guildRepo: IGuildRepository
) {}
}
✅ DO: Use events for asynchronous reactions
// ✅ GOOD: Listen to public domain events
import { GUILD_ARCHIVED_EVENT } from '../guild/interfaces';
@Injectable()
export class MembershipEventHandlers {
@OnEvent(GUILD_ARCHIVED_EVENT)
async handleGuildArchived(payload: GuildArchivedEventPayload) {
// Remove all memberships for archived guild
await this.membershipRepository.deleteByGuildId(payload.guildId);
}
}
✅ DO: Keep event payloads simple
// ✅ GOOD: Simple, serializable payload
export interface GuildArchivedEventPayload {
guildId: string;
archivedAt: Date;
}
// ❌ BAD: Complex domain entity in payload
export interface GuildArchivedEventPayload {
guild: Guild; // ❌ Domain entity with methods, circular refs, etc.
}
Testing
✅ DO: Unit test domain entities (no mocking)
// ✅ GOOD: Pure unit test
describe('Guild', () => {
it('should archive active guild', () => {
const guild = Guild.create('Test', 'Realm', ...);
guild.archive();
expect(guild.active).toBe(false);
expect(guild.archivedAt).toBeDefined();
});
it('should throw when archiving archived guild', () => {
const guild = Guild.create('Test', 'Realm', ...);
guild.archive();
expect(() => guild.archive()).toThrow('already archived');
});
});
✅ DO: Mock dependencies in use case tests
// ✅ GOOD: Mock repositories and facades
describe('ArchiveGuildUseCase', () => {
let useCase: ArchiveGuildUseCase;
let mockRepository: IGuildRepository;
let mockEventPublisher: DomainEventPublisher;
beforeEach(() => {
mockRepository = { findById: vi.fn(), save: vi.fn(), ... };
mockEventPublisher = { publishEventsForAggregate: vi.fn() };
useCase = new ArchiveGuildUseCase(mockRepository, mockEventPublisher);
});
it('should archive guild', async () => {
const guild = Guild.create(...);
vi.mocked(mockRepository.findById).mockResolvedValue(guild);
await useCase.execute('guild-123');
expect(mockRepository.save).toHaveBeenCalled();
expect(mockEventPublisher.publishEventsForAggregate).toHaveBeenCalled();
});
});
✅ DO: Integration test repositories
// ✅ GOOD: Test with actual Prisma (or mock PrismaService)
describe('GuildRepository', () => {
let repository: GuildRepository;
let prisma: PrismaService;
beforeEach(() => {
prisma = new PrismaService();
repository = new GuildRepository(prisma);
});
it('should save and retrieve guild', async () => {
const guild = Guild.create('Test', 'Realm', ...);
const saved = await repository.save(guild);
const retrieved = await repository.findById(saved.id!);
expect(retrieved).toBeDefined();
expect(retrieved!.name).toBe('Test');
});
});
Performance
✅ DO: Lazy-load related entities
// ✅ GOOD: Don't automatically load relations
async findById(id: string): Promise<Guild | null> {
const prismaGuild = await this.prisma.guild.findUnique({
where: { id },
// Don't include: { memberships: true, events: true } unless needed
});
return prismaGuild ? GuildMapper.toDomain(prismaGuild) : null;
}
// Separate method for loading with relations
async findByIdWithMemberships(id: string): Promise<Guild | null> {
const prismaGuild = await this.prisma.guild.findUnique({
where: { id },
include: { memberships: true },
});
return prismaGuild ? GuildMapper.toDomain(prismaGuild) : null;
}
✅ DO: Use database indexes
model Guild {
id String @id @default(uuid())
name String
realm String
@@unique([name, realm]) // Index for common queries
@@index([active]) // Index for filtering
}
✅ DO: Batch operations where possible
// ✅ GOOD: Batch update
async archiveGuildMemberships(guildId: string): Promise<void> {
await this.prisma.membership.updateMany({
where: { guildId },
data: { active: false },
});
}
// ❌ BAD: N+1 queries
async archiveGuildMemberships(guildId: string): Promise<void> {
const memberships = await this.prisma.membership.findMany({ where: { guildId } });
for (const membership of memberships) {
await this.prisma.membership.update({
where: { id: membership.id },
data: { active: false },
});
}
}
Common Mistakes
❌ Cyclic dependencies between domains
// ❌ BAD: Guild depends on Membership, Membership depends on Guild
GuildModule imports MembershipModule
MembershipModule imports GuildModule // ❌ Circular!
Solution: Use events for one direction
// ✅ GOOD
GuildModule exports GUILD_DOMAIN_FACADE
MembershipModule imports GuildModule, injects GUILD_DOMAIN_FACADE
// Membership listens to guild events
@OnEvent('guild.archived')
handleGuildArchived() { ... }
❌ Tight coupling via shared models
// ❌ BAD: Shared Prisma model between domains
import { Guild } from '@prisma/client'; // ❌ Don't share across domains
Solution: Each domain has its own entities and DTOs
// ✅ GOOD
// Guild domain
export class Guild {
/* domain entity */
}
// Membership domain (if it needs guild info)
export class GuildSummaryDto {
// Minimal DTO
id: string;
name: string;
}
❌ Business logic in controllers
// ❌ BAD: Controller contains business logic
@Controller('guilds')
export class GuildController {
@Post(':id/archive')
async archive(@Param('id') id: string) {
const guild = await this.repo.findById(id);
if (!guild.active) throw new Error('Already archived'); // ❌
guild.active = false; // ❌
await this.repo.save(guild);
}
}
Solution: Controllers delegate to use cases
// ✅ GOOD
@Controller('guilds')
export class GuildController {
constructor(private readonly archiveUseCase: ArchiveGuildUseCase) {}
@Post(':id/archive')
async archive(@Param('id') id: string) {
return this.archiveUseCase.execute(id); // All logic in use case + domain
}
}
Summary
| Layer | Responsibility | Dependencies |
|---|---|---|
| Domain | Business logic, rules, invariants | None |
| Application | Orchestrate domain, transactions | Domain interfaces only |
| Infrastructure | Technical implementation (DB, APIs) | Domain, Application |
| Interfaces | Public API for other domains | Application, Domain interfaces |
Golden Rule: Dependencies point inward (Infrastructure → Application → Domain).
Public API Rule: Other domains only import from interfaces/index.ts.