Reconstitution rebuilds state, but doesn't prevent invalid operations. Learn how aggregates enforce business rules to keep your system consistent.
Growing Marijuana with Event‑Sourcing — Aggregates
The problem with pure reconstitution
After learning about reconstitution, I was excited to replay Grandma's successful care routine. I loaded her events and started experimenting:
const myNewPlant: PlantEvent[] = [
{ type: "Seeded", plantId: "myNewPlant", occured_at: new Date("2025-11-01T10:00:00") },
{ type: "Watered", plantId: "myNewPlant", occured_at: new Date("2025-11-02T08:30:00") },
{ type: "Watered", plantId: "myNewPlant", occured_at: new Date("2025-11-03T08:30:00") },
];
console.log(reconstitute(myNewPlant));
// { id: "myNewPlant", isAlive: true, totalWaterings: 2, totalTrimCount: 0 }
Great! But then I got curious. What if I tried something invalid?
const invalidSequence: PlantEvent[] = [
{ type: "Seeded", plantId: "testPlant", occured_at: new Date("2025-11-01T10:00:00") },
{ type: "Died", plantId: "testPlant", occured_at: new Date("2025-11-15T14:00:00") },
{ type: "Watered", plantId: "testPlant", occured_at: new Date("2025-11-16T08:30:00") }, // ⚠️ Watering a dead plant!
];
console.log(reconstitute(invalidSequence));
// { id: "testPlant", isAlive: false, totalWaterings: 1, totalTrimCount: 0 }
Wait... I just "watered" a dead plant, and the reconstitution function happily processed it. The state now says isAlive: false but totalWaterings: 1.
This is wrong. You can't water a dead plant. The reconstitution function doesn't validate—it just mechanically applies events.
The real problem
The issue is clear: it doesn't make sense to water a dead plant.
Reconstitution blindly replays events—it trusts that the event stream is valid. But when I'm trying to water a plant right now, I need something that checks: "Is this plant even alive?"
That's where aggregates come in.
Enter the aggregate
An aggregate wraps our reconstitution logic and adds validation. Before performing an action like watering, it checks if that action makes sense given the current state.
Let me build a PlantAggregate:
type PlantEvent =
| {
type: "Seeded";
plantId: string;
occured_at: Date;
}
| { type: "Watered"; plantId: string; occured_at: Date }
| { type: "Trimmed"; plantId: string; occured_at: Date }
| { type: "Died"; plantId: string; occured_at: Date };
interface PlantState {
id: string;
isAlive: boolean;
totalWaterings: number;
totalTrimCount: number;
}
class PlantAggregate {
state: PlantState = {
id: "",
isAlive: false,
totalWaterings: 0,
totalTrimCount: 0,
};
private uncommittedEvents: PlantEvent[] = [];
private constructor(history: PlantEvent[]) {
// Reconstitute current state from history
for (const event of history) {
this.apply(event);
}
}
// Static constructor: Create a new plant
static seed(plantId: string): PlantAggregate {
const event: PlantEvent = {
type: "Seeded",
plantId: plantId,
occured_at: new Date(),
};
const aggregate = new PlantAggregate([event]);
return aggregate;
}
// Static constructor: Reconstitute from event history
static reconstitute(history: PlantEvent[]): PlantAggregate {
return new PlantAggregate(history);
}
private apply(event: PlantEvent): void {
switch (event.type) {
case "Seeded":
this.state.id = event.plantId;
this.state.isAlive = true;
break;
case "Watered":
this.state.totalWaterings += 1;
break;
case "Trimmed":
this.state.totalTrimCount += 1;
break;
case "Died":
this.state.isAlive = false;
break;
}
}
private recordThat(event: PlantEvent): void {
this.apply(event);
this.uncommittedEvents.push(event);
}
// Command: Water the plant
water(): void {
if (!this.state.isAlive) {
throw new Error("Cannot water a dead plant");
}
const event: PlantEvent = {
type: "Watered",
plantId: this.state.id,
occured_at: new Date(),
};
this.recordThat(event);
}
// Command: Trim the plant
trim(): void {
if (!this.state.isAlive) {
throw new Error("Cannot trim a dead plant");
}
const event: PlantEvent = {
type: "Trimmed",
plantId: this.state.id,
occured_at: new Date(),
};
this.recordThat(event);
}
getUncommittedEvents(): PlantEvent[] {
return [...this.uncommittedEvents];
}
}
How it works
Let's trace through what happens:
// Start with history of a dead plant
const aggregate = PlantAggregate.reconstitute([
{ type: "Seeded", plantId: "plant1", occured_at: new Date("2025-11-01T10:00:00") },
{ type: "Died", plantId: "plant1", occured_at: new Date("2025-11-15T14:00:00") },
]);
console.log(aggregate.state);
// { id: "plant1", isAlive: false, totalWaterings: 0, totalTrimCount: 0 }
// Try to water (INVALID - plant is dead)
aggregate.water();
// ❌ Error: "Cannot water a dead plant"
And with a living plant:
// Start with a living plant
const aggregate = PlantAggregate.seed("plant2");
// Try to water (valid - plant is alive)
aggregate.water();
console.log(aggregate.state);
// { id: "plant2", isAlive: true, totalWaterings: 1, totalTrimCount: 0 }
// Try to trim (valid - plant is alive)
aggregate.trim();
console.log(aggregate.state);
// { id: "plant2", isAlive: true, totalWaterings: 1, totalTrimCount: 1 }
// Get uncommitted events
console.log(aggregate.getUncommittedEvents());
// [
// { type: "Seeded", plantId: "plant2", occured_at: Date(...) },
// { type: "Watered", plantId: "plant2", occured_at: Date(...) },
// { type: "Trimmed", plantId: "plant2", occured_at: Date(...) }
// ]
Business rules as guard conditions
The aggregate enforces business rules through simple checks:
- Rule: A plant must be alive to be watered
- Rule: A plant must be alive to be trimmed
These rules make sense in the domain. You wouldn't water a dead plant in real life, and you can't trim one either.
Separating concerns
The separation is clean:
// ✅ Reconstitution: trust history, no validation
function reconstitute(events: PlantEvent[]): PlantState {
// Pure state computation
// No business rules
// Fast and deterministic
}
// ✅ Aggregate: validate before acting
class PlantAggregate {
constructor(history: PlantEvent[]) {
this.state = reconstitute(history); // Uses reconstitution internally
}
water(): PlantEvent {
// Business rules checked HERE
if (!this.state.isAlive) {
throw new Error("Cannot water a dead plant");
}
return { type: "Watered", plantId: this.state.id, occured_at: new Date() };
}
}
Reconstitution trusts the event stream. Aggregates validate before allowing actions.
Why this matters
Without aggregates protecting our event stream, we could end up with nonsensical data:
// ⚠️ Nothing validates this
const events: PlantEvent[] = [
{ type: "Seeded", plantId: "test", occured_at: new Date() },
{ type: "Died", plantId: "test", occured_at: new Date() },
{ type: "Watered", plantId: "test", occured_at: new Date() }, // Impossible!
{ type: "Trimmed", plantId: "test", occured_at: new Date() }, // Also impossible!
];
With aggregates, we catch these problems:
// ✅ Aggregate prevents invalid operations
const aggregate = PlantAggregate.reconstitute([
{ type: "Seeded", plantId: "test", occured_at: new Date() },
{ type: "Died", plantId: "test", occured_at: new Date() },
]);
aggregate.water(); // ❌ Error: "Cannot water a dead plant"
aggregate.trim(); // ❌ Error: "Cannot trim a dead plant"
Summary
Aggregates add validation to reconstitution:
- Reconstitution rebuilds state from history without validation
- Aggregates check if an action makes sense before allowing it
- Business rules are enforced through guard conditions (if statements)
- Invalid operations are rejected before they can create nonsensical events
In Part 1, we learned that events answer "How did we get here?" In Part 2, we learned that reconstitution answers "What was the state at any point?" In Part 3, we learned that aggregates answer "Can I do this action right now?"
Next: We've seen how to reconstitute state and validate actions, but in real systems plants need more than just isAlive. They track last watering time, health scores, and can die from neglect. In the next part, we'll expand our aggregate to handle more realistic plant care tracking.
Further reading:
- Aggregates in Event Sourcing - Patchlevel documentation on aggregate patterns
- Event Sourcing Core Concepts - RailsEventStore guide to event sourcing fundamentals