Aggregate
The aggregate is the centerpiece of the domain layer in the Codebricks framework.
Aggregates execute commands that encapsulate domain logic and produce events.
In a CQRS/ES application, an aggregate serves as a transaction boundary. This means that any given aggregate should be able to execute its commands without needing to communicate with other aggregates.
Since the write side is used solely to perform commands, your aggregate can be compact and only needs to maintain the state required for command execution.
See Martin Fowler's definition for aggregates within the DDD paradigm: Martin Fowler on DDD Aggregates.
Implementation
Aggregate
In our example, the aggregate class is located at: Task/src/shared/domain/aggregate/TaskAggregate.ts
import { Aggregate, Event, OverwriteProtectionBody, UuidGenerator, Clock, ConflictError, PreconditionFailedError, ValidationError } from "@codebricks/codebricks-framework";
import { TaskAggregateState, defaultState } from "./TaskAggregateState";
import { AddTaskProperties, CompleteTaskProperties, CloseTaskProperties } from "./taskMethodsProperties";
import { TaskAddedEvent } from "shared/domain/events/TaskAddedEvent";
import { TaskCompletedEvent } from "shared/domain/events/TaskCompletedEvent";
import { TaskClosedEvent } from "shared/domain/events/TaskClosedEvent";
export class TaskAggregate extends Aggregate<TaskAggregateState> {
static readonly aggregateName: string = 'Task';
readonly applyMethods = {
[TaskAddedEvent.name]: this.applyTaskAddedEvent.bind(this),
[TaskCompletedEvent.name]: this.applyTaskCompletedEvent.bind(this),
[TaskClosedEvent.name]: this.applyTaskClosedEvent.bind(this)
};
constructor(id: string) {
super(id, 0, defaultState);
}
@OverwriteProtectionBody(false)
addTask(properties: AddTaskProperties): void {
const taskAddedEvent = new TaskAddedEvent(
{
id: UuidGenerator.uuid(),
aggregateId: this.id,
aggregateVersion: this.version + 1,
payload: {
title: properties.title.value,
description: properties.description.value,
assigneeId: properties.assigneeId.value,
},
occurredAt: Clock.now()
}
);
this.addEvent(taskAddedEvent);
}
@OverwriteProtectionBody(false)
applyTaskAddedEvent(event: TaskAddedEvent): TaskAggregateState {
const newAggregateState: TaskAggregateState = { ...this.state };
return newAggregateState;
}
@OverwriteProtectionBody(false)
completeTask(properties: CompleteTaskProperties): void {
const taskCompletedEvent = new TaskCompletedEvent(
{
id: UuidGenerator.uuid(),
aggregateId: this.id,
aggregateVersion: this.version + 1,
payload: {
completionNode: properties.completionNode.value,
},
occurredAt: Clock.now()
}
);
this.addEvent(taskCompletedEvent);
}
@OverwriteProtectionBody(false)
applyTaskCompletedEvent(event: TaskCompletedEvent): TaskAggregateState {
const newAggregateState: TaskAggregateState = { ...this.state };
return newAggregateState;
}
@OverwriteProtectionBody(false)
closeTask(properties: CloseTaskProperties): void {
const taskClosedEvent = new TaskClosedEvent(
{
id: UuidGenerator.uuid(),
aggregateId: this.id,
aggregateVersion: this.version + 1,
payload: {
},
occurredAt: Clock.now()
}
);
this.addEvent(taskClosedEvent);
}
@OverwriteProtectionBody(false)
applyTaskClosedEvent(event: TaskClosedEvent): TaskAggregateState {
const newAggregateState: TaskAggregateState = { ...this.state };
return newAggregateState;
}
}
Key Concepts
aggregateName
property: Required to persist your events correctly.applyMethods
property: Maps events to their respective apply methods.- Constructor: Initializes the aggregate with an
id
, version0
, and the default state (from Aggregate State). - Command methods (e.g.,
addTask
,completeTask
): Implement business logic and create events. - Apply methods (e.g.,
applyTaskAddedEvent
): Handle state changes when events are applied to the aggregate.
Aggregate State
The aggregate state class is located at: Task/src/shared/domain/aggregate/TaskAggregateState.ts
export interface TaskAggregateState {
completed: boolean;
}
export const defaultState: TaskAggregateState = {
completed: false,
};
Components of Aggregate State
TaskAggregateState
interface: Defines the values persisted inside the aggregate state.defaultState
constant: Sets the default state for your aggregate.
Detailed Implementation
Command Methods
Command methods are where business logic is implemented. They create events based on the command and add them to the aggregate.
For example, the addTask
method:
@OverwriteProtectionBody(false)
addTask(properties: AddTaskProperties): void {
const taskAddedEvent = new TaskAddedEvent({
id: UuidGenerator.uuid(),
aggregateId: this.id,
aggregateVersion: this.version + 1,
payload: {
title: properties.title.value,
description: properties.description.value,
assigneeId: properties.assigneeId.value,
},
occurredAt: Clock.now()
});
this.addEvent(taskAddedEvent);
}
Apply Methods
Apply methods handle state transitions when an event is applied. For example, the applyTaskAddedEvent
method:
@OverwriteProtectionBody(false)
applyTaskAddedEvent(event: TaskAddedEvent): TaskAggregateState {
const newAggregateState: TaskAggregateState = { ...this.state };
// Modify newAggregateState based on event payload
return newAggregateState;
}
Best Practices
- Keep aggregates small: Include only the state and behavior that logically belong to the aggregate.
- Avoid dependencies: Aggregates should not directly depend on other aggregates.
- Consistency boundary: Ensure all business rules and invariants are enforced within the aggregate boundary.
Aggregates should produce the errors ConflictError
, PreconditionFailedError
, and ValidationError
to ensure robust command execution, as these Errors are rendered for you by default.