Skip to main content

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):

  • GuildCreated
  • GuildArchived
  • GuildRestored
  • GuildDeleted
  • GuildSettingsUpdated
  • CustomLogoUploaded
  • RoleAdded
  • RoleUpdated

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):

  • MemberJoined
  • MemberLeft
  • MemberRoleChanged
  • MemberStatusChanged

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):

  • RosterEntryClaimed
  • RosterMemberLeft
  • RosterMemberReturned

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):

  • EventScheduled
  • EventRescheduled
  • EventCancelled
  • UserInvited
  • UserConfirmed
  • AttendanceMarked

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):

  • UserRegistered
  • UserLoggedIn
  • CharacterCreated
  • CharacterDeactivated

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:

  1. Start with Guild - Core domain, clear boundaries, most business logic
  2. Then Membership - Central to member management, clear invariants
  3. Then Event - Isolated, clear aggregate with participation
  4. Then GuildMember - Simple, mostly data structure
  5. Finally User/Character - Complex due to OAuth, decide on boundary first

This allows incremental migration without breaking existing functionality.