DDD Aggregates & Aggregate Roots
This document identifies aggregates, their roots, and boundaries for the Guild Platform domain model.
What is an Aggregate?
An aggregate is a cluster of domain objects that are treated as a single unit for data changes. Each aggregate has:
- Aggregate Root: The main entity that controls access
- Boundary: Clear limits on what's inside the aggregate
- Invariants: Business rules that must always hold true
- Consistency: Changes to the aggregate are atomic
Guild Management Context
Aggregate: Guild
Aggregate Root: Guild
Entities Within Aggregate:
- Guild (root)
- Role[] (collection)
Value Objects:
- GuildSettings
- Faction
- SyncSource
- LogoConfiguration
Invariants:
- Guild must have at least one founder role (cannot be deleted)
- Blizzard-synced guilds must have roles with WoW ranks (0-9)
- Standalone guilds have custom roles (wowRank = null)
- Guild name + realm combination must be unique
- Active flag determines visibility
Boundaries:
- ✅ Roles belong to this aggregate (deleted when guild is deleted)
- ❌ Memberships are separate aggregate (managed by Member Management)
- ❌ GuildMembers are separate aggregate (managed by Roster Sync)
- ❌ Events are separate aggregate (managed by Event Scheduling)
Repository Interface:
interface IGuildRepository {
findById(id: string): Promise<Guild | null>;
findAll(includeArchived: boolean): Promise<Guild[]>;
save(guild: Guild): Promise<Guild>;
archive(guild: Guild): Promise<void>;
restore(guild: Guild): Promise<void>;
delete(guild: Guild): Promise<void>;
}
Domain Methods (to be moved to Guild entity):
class Guild {
// Creation
static create(name, realm, faction, foundingCharacter): Guild;
// Logo management
uploadCustomLogo(logoUrl: string): void;
toggleLogoSource(useCustom: boolean): void;
// Lifecycle
archive(): void;
restore(): void;
// Settings
updateSettings(settings: Partial<GuildSettings>): void;
// Roles
addRole(name: string, permissions: Permissions): Role;
updateRole(roleId: string, name: string): void;
// Validation
canBeDeleted(): boolean; // Only standalone guilds
hasFounderRole(): boolean;
}
Domain Events (future):
GuildCreatedGuildArchivedGuildRestoredGuildDeletedGuildSettingsUpdatedCustomLogoUploadedRoleAddedRoleUpdated
Member Management Context
Aggregate: Membership
Aggregate Root: Membership
Entities Within Aggregate:
- Membership (root)
- RoleAssignmentHistory[] (collection)
Value Objects:
- MembershipStatus
- JoinedDate
- LeftDate
Invariants:
- Membership must reference valid Guild, Character, and Role
- Cannot add members manually to Blizzard-synced guilds
- Role changes must be tracked in history
- Soft-delete only (leftAt timestamp, never hard delete)
- One character can have multiple memberships (different guilds)
Boundaries:
- ✅ RoleAssignmentHistory belongs to this aggregate
- ❌ Guild is external aggregate (referenced by ID)
- ❌ Character is external aggregate (referenced by ID)
- ❌ Role is external aggregate (referenced by ID)
Repository Interface:
interface IMembershipRepository {
findById(id: string): Promise<Membership | null>;
findByGuild(guildId: string, activeOnly: boolean): Promise<Membership[]>;
findByCharacter(characterId: string): Promise<Membership[]>;
save(membership: Membership): Promise<Membership>;
remove(membership: Membership): Promise<void>; // Soft delete
}
Domain Methods (to be moved to Membership entity):
class Membership {
// Creation
static create(characterId, guildId, roleId, status): Membership;
// Role management
assignRole(newRoleId: string, changedBy: string, reason?: string): void;
// Status management
setStatus(status: MembershipStatus): void;
promote(newRoleId: string, changedBy: string): void;
demote(newRoleId: string, changedBy: string): void;
// Lifecycle
leave(): void; // Soft delete
// Queries
getRoleHistory(): RoleAssignmentHistory[];
isActive(): boolean;
// Validation
canBeManuallyModified(): boolean; // Check guild sync source
}
Domain Events (future):
MemberJoinedMemberLeftMemberRoleChangedMemberStatusChanged
Roster Sync Context
Aggregate: GuildMember (Roster Entry)
Aggregate Root: GuildMember
Entities Within Aggregate:
- GuildMember (root)
Value Objects:
- ClaimStatus
- WowRank
- LastSeenDate
- LeftGuildDate
Invariants:
- GuildMember is created only through roster sync (not user-generated)
- One GuildMember can be claimed by at most one Character
- Character name + realm + guild combination must be unique
- Last seen date updates on every sync
- Left date is set when character disappears from roster
Boundaries:
- ❌ Guild is external aggregate (referenced by ID)
- ❌ Character (claiming) is external aggregate (optional reference)
- ✅ This is a lightweight, denormalized view for sync purposes
Repository Interface:
interface IGuildMemberRepository {
findById(id: string): Promise<GuildMember | null>;
findByGuild(guildId: string, includeLeft: boolean): Promise<GuildMember[]>;
findByNameAndRealm(name: string, realm: string): Promise<GuildMember | null>;
upsertFromSync(guildId, name, realm, rank): Promise<GuildMember>;
claim(guildMember: GuildMember, characterId: string): Promise<void>;
}
Domain Methods (to be moved to GuildMember entity):
class GuildMember {
// Creation (from sync)
static createFromSync(guildId, name, realm, rank): GuildMember;
// Claiming
claim(characterId: string): void;
unclaim(): void;
// Sync updates
updateFromSync(rank: number, avatarUrl?: string): void;
markAsLeft(): void;
markAsSeen(): void;
// Queries
isClaimed(): boolean;
hasLeftGuild(): boolean;
// Validation
canBeClaimed(): boolean; // Not already claimed
}
Domain Events (future):
RosterEntryClaimedRosterMemberLeftRosterMemberReturned
Event Scheduling Context
Aggregate: Event
Aggregate Root: Event
Entities Within Aggregate:
- Event (root)
- Participation[] (collection)
Value Objects:
- EventType
- EventMetadata
- ScheduledTime
Invariants:
- Event must belong to a guild
- Event must have a creator
- Event must have scheduled time
- Participations track users (not characters)
- Participation status follows valid flow: invited → confirmed → attended/absent
Boundaries:
- ✅ Participations belong to this aggregate
- ❌ Guild is external aggregate (referenced by ID)
- ❌ User (creator and participants) is external aggregate (referenced by ID)
Repository Interface:
interface IEventRepository {
findById(id: string): Promise<Event | null>;
findByGuild(guildId: string): Promise<Event[]>;
findUpcoming(guildId: string): Promise<Event[]>;
save(event: Event): Promise<Event>;
delete(event: Event): Promise<void>;
}
Domain Methods (to be moved to Event entity):
class Event {
// Creation
static create(guildId, type, title, scheduledAt, creatorId, metadata): Event;
// Participation management
inviteUser(userId: string): void;
confirmParticipation(userId: string): void;
markAttended(userId: string): void;
markAbsent(userId: string): void;
benchUser(userId: string): void;
// Queries
getParticipations(): Participation[];
getAttendanceRate(): number;
isUpcoming(): boolean;
isPast(): boolean;
// Updates
reschedule(newTime: DateTime): void;
updateMetadata(metadata: EventMetadata): void;
}
Domain Events (future):
EventScheduledEventRescheduledEventCancelledUserInvitedUserConfirmedAttendanceMarked
Identity & Authentication Context
Aggregate: User
Aggregate Root: User
Entities Within Aggregate:
- User (root)
- Character[] (collection - debatable, might be separate aggregate)
Value Objects:
- DisplayName
- OAuthTokens (BattleNet, Discord)
- LastLoginDate
Invariants:
- User must have at least one OAuth identity (Battle.net or Discord)
- OAuth tokens are encrypted at rest
- Battle.net ID and Discord ID are unique across users
- User can own multiple characters
Boundaries:
- ✅/❌ Characters - This is debatable:
- Option A: Characters are part of User aggregate (simpler, enforces ownership)
- Option B: Characters are separate aggregate (more flexible, allows transfer)
- Recommendation: Start with Option A, extract if needed
Repository Interface:
interface IUserRepository {
findById(id: string): Promise<User | null>;
findByBattleNetId(battleNetId: string): Promise<User | null>;
findByDiscordId(discordId: string): Promise<User | null>;
save(user: User): Promise<User>;
}
Domain Methods (to be moved to User entity):
class User {
// Creation
static createFromOAuth(provider, oauthData): User
// Token management
updateBattleNetTokens(accessToken, refreshToken, expiry): void
refreshBattleNetToken(): void
// Character management
createCharacter(name, realm, template, ...): Character
deactivateCharacter(characterId: string): void
// Authentication
updateLastLogin(): void
// Queries
getActiveCharacters(): Character[]
hasValidBattleNetToken(): boolean
}
Domain Events (future):
UserRegisteredUserLoggedInCharacterCreatedCharacterDeactivated
Aggregate: Character (Alternative Design)
If we extract Character as a separate aggregate:
Aggregate Root: Character
Entities Within Aggregate:
- Character (root)
Value Objects:
- CharacterName
- Realm
- CharacterClass
- Race
- Level
- Faction
- CharacterTemplate
- SyncSource
- Attributes (JSON for template-specific data)
Invariants:
- Character must belong to a user (owner)
- Battle.net ID + realm is unique (for synced characters)
- Manual characters don't have Battle.net ID
- Character template determines available attributes
Repository Interface:
interface ICharacterRepository {
findById(id: string): Promise<Character | null>;
findByUser(userId: string): Promise<Character[]>;
findByBattleNetId(
battleNetId: number,
realm: string
): Promise<Character | null>;
save(character: Character): Promise<Character>;
}
Aggregate Design Principles
Size
- Keep aggregates small (2-3 entities max when possible)
- Large aggregates = concurrency issues and performance problems
Boundaries
- One transaction = one aggregate
- Changes across aggregates use eventual consistency (domain events)
References
- Reference other aggregates by ID, not object reference
- Avoid deep object graphs
Invariants
- Aggregate root enforces all business rules within the boundary
- No external access to internal entities
Repository Pattern
- One repository per aggregate root
- Repository deals with entire aggregate (not individual entities)
Migration Priority
When moving to rich domain models:
- Start with Guild - Core domain, clear boundaries, most business logic
- Then Membership - Central to member management, clear invariants
- Then Event - Isolated, clear aggregate with participation
- Then GuildMember - Simple, mostly data structure
- Finally User/Character - Complex due to OAuth, decide on boundary first
This allows incremental migration without breaking existing functionality.