Domain Modeling & Aggregates (Deep Dive)
This document provides a detailed exploration of how domain modeling is implemented in Intent, expanding on the basic Aggregate concept introduced in the architecture overview.
Domain-Driven Design in Intent
Intent follows Domain-Driven Design (DDD) principles to model complex business domains. The system organizes domain logic around business concepts, using aggregates as the primary building blocks. This approach ensures that business rules are enforced consistently and that the system accurately reflects the real-world domain it represents.
BaseAggregate: The Foundation
At the core of Intent's domain modeling is the BaseAggregate
abstract class, which provides the foundation for all domain aggregates:
export abstract class BaseAggregate<TState> { abstract aggregateType: string; version = 0; constructor(public id: UUID) {} // Schema versioning static CURRENT_SCHEMA_VERSION = 1; // Abstract methods that must be implemented by concrete aggregates abstract apply(event: any, isSnapshot?: boolean): void; abstract handle(command: any): Event[]; protected abstract applyUpcastedSnapshot(state: TState): void; abstract extractSnapshotState(): TState; // Methods for snapshot handling applySnapshotState(raw: any, incomingVersion?: number): void { /* ... */ } toSnapshot(): Snapshot<TState> { /* ... */ } static fromSnapshot<T extends BaseAggregate<any>>(this: new (id: UUID) => T, event: any): T { /* ... */ } }
Key Features of BaseAggregate
- Generic State Type: Uses a generic type parameter
TState
to define the shape of the aggregate's state - Version Tracking: Maintains a version number for optimistic concurrency control
- Schema Versioning: Supports evolving the aggregate's schema over time via
CURRENT_SCHEMA_VERSION
- Command Handling: The
handle
method processes commands and produces events - Event Application: The
apply
method updates the aggregate's state based on events - Snapshot Support: Methods for creating and applying snapshots to optimize loading
Concrete Aggregate Example: SystemAggregate
To illustrate how aggregates are implemented in practice, let's examine the SystemAggregate
class:
export class SystemAggregate extends BaseAggregate<SystemSnapshotState> { public aggregateType = 'system'; static CURRENT_SCHEMA_VERSION = 1; // State properties id: UUID; version = 0; numberExecutedTests = 0; // Command handling private readonly handlers: Record<SystemCommandType, (cmd: Command) => Event[]> = { [SystemCommandType.LOG_MESSAGE]: this.handleLogMessage, [SystemCommandType.EXECUTE_TEST]: this.handleExecuteTest, // Other command handlers... }; public handle(cmd: Command): Event[] { const handler = this.handlers[cmd.type as SystemCommandType]; if (!handler) { throw new Error(`No handler for command type: ${cmd.type}`); } return handler.call(this, cmd); } // Event application public apply(event: Event, isNew = true): void { switch (event.type) { case SystemEventType.MESSAGE_LOGGED: this.applyMessageLogged(event as Event<MessageLoggedPayload>); break; case SystemEventType.TEST_EXECUTED: this.applyTestExecuted(event as Event<TestExecutedPayload>); break; // Other event handlers... } if (isNew) { this.version++; } } // Specific command handlers private handleLogMessage(cmd: Command<LogMessagePayload>): Event[] { // Business logic and validation return [ createEvent({ type: SystemEventType.MESSAGE_LOGGED, aggregateId: this.id, aggregateType: this.aggregateType, payload: { message: cmd.payload.message }, version: this.version + 1, tenant_id: cmd.tenant_id, }) ]; } // Specific event handlers private applyTestExecuted(event: Event<TestExecutedPayload>): void { this.numberExecutedTests++; } // Snapshot methods protected applyUpcastedSnapshot(state: SystemSnapshotState): void { this.numberExecutedTests = state.numberExecutedTests; this.version = state.version; } extractSnapshotState(): SystemSnapshotState { return { numberExecutedTests: this.numberExecutedTests, version: this.version, }; } }
Key Aspects of the Implementation
- State Definition: The aggregate defines its state properties (e.g.,
numberExecutedTests
) - Command Handler Map: Uses a map to route commands to specific handler methods
- Event Application: Dispatches events to specific handlers based on event type
- Business Rules: Enforces business rules in command handlers
- Version Management: Increments the version when applying new events
- Snapshot Support: Implements methods to create and apply snapshots
Aggregate Registry
Intent maintains a registry of aggregate types, which allows the system to dynamically create and load aggregates based on their type:
export const AggregateRegistry: Record<string, AggregateClass> = { system: SystemAggregate, // Other aggregate types would be registered here };
This registry is crucial for the event sourcing mechanism, as it enables the system to:
- Create the correct aggregate type when loading from events or snapshots
- Route commands to the appropriate aggregate type
- Maintain a catalog of all available aggregate types in the system
Command Processing Flow
When a command is received, it follows this processing flow:
- The command is routed to the appropriate command handler based on its type
- The command handler loads or creates the target aggregate
- The aggregate's
handle
method processes the command and produces events - The events are persisted to the event store
- The events are applied to the aggregate to update its state
This flow is implemented in the command handlers, such as SystemCommandHandler
:
export class SystemCommandHandler implements CommandHandler<Command<any>> { supportsCommand(cmd: Command): boolean { return Object.values(SystemCommandType).includes(cmd.type as SystemCommandType); } async handleWithAggregate(cmd: Command<any>, aggregate: BaseAggregate<any>): Promise<Event<any>[]> { if (!(aggregate instanceof SystemAggregate)) { throw new Error(`Expected SystemAggregate but got ${aggregate.constructor.name} for cmd: ${cmd.type}`); } return aggregate.handle(cmd); } }
Schema Evolution and Versioning
Intent supports evolving aggregate schemas over time through:
- Schema Version Tracking: Each aggregate class defines a
CURRENT_SCHEMA_VERSION
static property - Snapshot Upcasting: When loading a snapshot with an older schema version, the system can upcast it to the current version
- Backward Compatibility: Events are designed to be backward compatible, with upcasters for handling schema changes
This approach allows the system to evolve while maintaining compatibility with historical data.
Benefits of This Approach
The domain modeling approach in Intent provides several benefits:
- Encapsulation: Business rules are encapsulated within the aggregate
- Consistency: Aggregates ensure consistency boundaries are maintained
- Event Sourcing Integration: The design works seamlessly with event sourcing
- Versioning Support: Built-in support for schema evolution
- Clear Responsibility: Each aggregate has a clear responsibility in the domain
- Testability: Aggregates can be tested in isolation from infrastructure
Domain Modeling Principles
Intent follows several key domain modeling principles:
- Ubiquitous Language: The code encourages using domain terminology consistently
- Bounded Contexts: The system is organized into distinct domain slices
- Aggregates as Consistency Boundaries: Each aggregate enforces its own consistency rules
- Rich Domain Model: Business logic is in the domain model, not in application services
- Separation of Concerns: Clear separation between domain logic and infrastructure
Integration with Other Patterns
Domain modeling in Intent integrates with several other architectural patterns:
- Event Sourcing: Aggregates are the source of events
- CQRS: Aggregates are part of the write model
- Temporal Workflows: Complex processes may involve multiple aggregates
- Multi-tenancy: Aggregates maintain tenant isolation throughout the system