Skip to main content

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, version 0, 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.
Error Rendering

Aggregates should produce the errors ConflictError, PreconditionFailedError, and ValidationError to ensure robust command execution, as these Errors are rendered for you by default.