Time isn't just a timestamp—it's a business event. Learn how treating the passage of time as an event enables elegant business rules like "plants need attention after 2 days without water."

Growing Marijuana with Event‑Sourcing — Time as a Business Event

The problem with checking time

After building our aggregate with validation, I felt confident. My plants couldn't be watered when dead, and invalid operations were caught. But then I realized something important: plants need attention when they haven't been watered in a while.

My first instinct was to add a check in the aggregate:

needsAttention(): boolean {
  const now = new Date();
  const daysSinceWatering = (now.getTime() - this.state.lastWatered.getTime()) / (1000 * 60 * 60 * 24);
  return daysSinceWatering > 2;
}

This works, but there's a subtle problem: new Date() makes this non-deterministic. If I call needsAttention() now, I get one answer. If I call it tomorrow, I get a different answer—even with the exact same event history.

This breaks a fundamental property of event sourcing: given the same events, we should always get the same state.

Why we can't just compare dates

You might think: "Why not compare lastWatered directly with occured_at from the events?"

// ❌ This doesn't work!
needsAttention(): boolean {
  const lastEvent = this.events[this.events.length - 1];
  const daysSince = (lastEvent.occured_at.getTime() - this.state.lastWatered.getTime()) / (1000 * 60 * 60 * 24);
  return daysSince > 2;
}

The problem is: events record when actions happened, not when time passed.

Consider this sequence:

// It's November 1st: I plant a seed and water it
const plantHistory: PlantEvent[] = [
  { type: "Seeded", plantId: "plant1", occured_at: new Date("2025-11-01T10:00:00") },
  { type: "Watered", plantId: "plant1", occured_at: new Date("2025-11-01T11:00:00") },
  // ... then 5 days pass with no actions ...
];

// Now it's November 6th - let's check on the plant
const plantToday = PlantAggregate.reconstitute(plantHistory);

When we reconstitute this plant on November 6th, how does the aggregate know 5 days have passed? There are no events recording the passage of time. The last event is from November 1st, but that doesn't tell the aggregate what "today" is.

We face two bad options:

  1. Use new Date() — Non-deterministic. Replaying the same events gives different results.
  2. Use the last event's timestamp — Wrong. It tells us when the last action happened, not what day it is now.

Neither option works because the passage of time isn't recorded in our events.

Why time matters

In the real world, many business rules depend on time:

These rules aren't triggered by user actions—they're triggered by the passage of time itself.

Time as an event

What if we treated time as just another event? Instead of asking "What time is it now?", we record when each day passes:

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 }
  | { type: "DayStarted"; date: Date }; // 🌟 Time as an event

The DayStarted event doesn't describe an action on a plant—it describes the passage of time.

Tracking when plants need attention

Instead of storing dates, let's track a simple counter:

interface PlantState {
  id: string;
  isAlive: boolean;
  totalWaterings: number;
  totalTrimCount: number;
  daysSinceLastWatering: number; // 🌟 Simple counter
}

Every time a DayStarted event occurs, we increment the counter. Every time a Watered event occurs, we reset it to zero.

Now our aggregate can process DayStarted events:

class PlantAggregate {
  // ...
  
  private apply(event: PlantEvent): void {
    switch (event.type) {
      // ...
      case "DayStarted":
        this.state.daysSinceLastWatering += 1;
        break;
    }
  }

  newDay(date: Date = new Date()): void {
    const event: PlantEvent = {
      type: "DayStarted",
      date: date,
    };

    // The date will be serialized and stored permanently in the event store
    this.recordThat(event);
  }
  
  // ...
}

How needsAttention() works

The magic is that needsAttention() no longer depends on new Date()—it only looks at the recorded state:

needsAttention(): boolean {
  // Dead plants don't need attention
  if (!this.state.isAlive) {
    return false;
  }
  
  // Never been watered? Definitely needs attention!
  if (this.state.totalWaterings === 0) {
    return true;
  }
  
  // Check if it's been more than 2 days since last watering
  return this.state.daysSinceLastWatering > 2;
}

This is deterministic: given the same events, needsAttention() always returns the same result.

Notice the separate conditions:

  1. Dead plants return false - they're beyond help
  2. Never watered plants return true - they need immediate attention
  3. Recently watered plants are checked against the 2-day threshold

The beauty of this approach: no date math, no timestamps, just a simple counter.

Why this approach works

By treating time as an event, we get several benefits:

1. Deterministic behavior

Replaying the same events always produces the same result:

// A complete history including time passage
const completeHistory: PlantEvent[] = [
  { type: "Seeded", plantId: "plant1", occured_at: new Date("2025-11-01T10:00:00") },
  { type: "DayStarted", date: new Date("2025-11-01") },
  { type: "Watered", plantId: "plant1", occured_at: new Date("2025-11-01T11:00:00") },
  { type: "DayStarted", date: new Date("2025-11-02") },
  { type: "DayStarted", date: new Date("2025-11-03") },
  { type: "DayStarted", date: new Date("2025-11-04") },
];

// Replay on any date - always get the same answer
const plantState = PlantAggregate.reconstitute(completeHistory);
console.log(plantState.needsAttention()); 
// Always returns true, whether we run this on Nov 4th, Dec 1st, or next year

2. Testable time-based logic

Testing becomes trivial—no need to mock Date.now() or wait for time to pass:

test("plant needs attention after 3 days without water", () => {
  const plant = PlantAggregate.seed("test");
  expect(plant.needsAttention()).toBe(true); // Never watered
  
  plant.newDay();
  plant.water();
  expect(plant.needsAttention()).toBe(false); // Just watered
  
  plant.newDay();
  plant.newDay();
  plant.newDay();
  
  expect(plant.needsAttention()).toBe(true); // 3 days without water
});

3. Explicit audit trail

The event log shows exactly when days passed:

[
  { type: "Seeded", plantId: "plant1", occured_at: "2025-11-01T10:00:00" },
  { type: "DayStarted", date: "2025-11-01" },
  { type: "Watered", plantId: "plant1", occured_at: "2025-11-01T11:00:00" },
  { type: "DayStarted", date: "2025-11-02" },
  { type: "DayStarted", date: "2025-11-03" },
  { type: "DayStarted", date: "2025-11-04" },
]

Looking at this log, it's clear that 3 days passed without watering.

How DayStarted events are generated

In practice, a scheduler (like a cron job) generates DayStarted events for each plant:

// Runs at midnight every day
function midnightScheduler() {
  for (const plant of allPlants()) {
    plant.newDay();
    eventStore.save(plant);
  }
}

The scheduler loops through all plants, records that a new day has started, and saves the event. This ensures every plant's event stream includes the passage of time.

Note that cron jobs aren't perfectly precise—they might run a few seconds or even minutes late. But for our business rules (checking if plants need attention after days without water), this delay is completely acceptable.

The scheduler doesn't know about watering rules or business logic. It just announces: "A new day has started." All the domain logic—deciding whether plants need attention—lives in the aggregate.

Business rules driven by time

Now we can build sophisticated rules that react to time. For example, automatically water plants that need attention:

class PlantAggregate {
  // ...
  
  newDay(date: Date = new Date()): void {
    const event: PlantEvent = {
      type: "DayStarted",
      date: date,
    };

    // The date will be serialized and stored permanently in the event store
    this.recordThat(event);
    
    // Business rule: automatically water if needed
    if (this.needsAttention()) {
      this.water();
    }
  }
  
  // ...
}

With this approach, the passage of time can trigger domain actions. When a new day starts, the aggregate checks if it needs attention and automatically waters itself. The business logic is encapsulated right where it belongs—inside the aggregate.

Complete implementation

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 }
  | { type: "DayStarted"; date: Date };

interface PlantState {
  id: string;
  isAlive: boolean;
  totalWaterings: number;
  totalTrimCount: number;
  daysSinceLastWatering: number;
}

class PlantAggregate {
  state: PlantState = {
    id: "",
    isAlive: false,
    totalWaterings: 0,
    totalTrimCount: 0,
    daysSinceLastWatering: 0,
  };

  private uncommittedEvents: PlantEvent[] = [];

  private constructor(history: PlantEvent[]) {
    for (const event of history) {
      this.apply(event);
    }
  }

  static seed(plantId: string): PlantAggregate {
    const event: PlantEvent = {
      type: "Seeded",
      plantId: plantId,
      occured_at: new Date(),
    };

    const aggregate = new PlantAggregate([event]);
    return aggregate;
  }

  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;
        this.state.daysSinceLastWatering = 0;
        break;
      case "Trimmed":
        this.state.totalTrimCount += 1;
        break;
      case "Died":
        this.state.isAlive = false;
        break;
      case "DayStarted":
        this.state.daysSinceLastWatering += 1;
        break;
    }
  }

  private recordThat(event: PlantEvent): void {
    this.apply(event);
    this.uncommittedEvents.push(event);
  }

  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);
  }

  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);
  }

  newDay(date: Date = new Date()): void {
    const event: PlantEvent = {
      type: "DayStarted",
      date: date,
    };

    // The date will be serialized and stored permanently in the event store
    this.recordThat(event);
  }

  needsAttention(): boolean {
    if (!this.state.isAlive) {
      return false;
    }

    if (this.state.totalWaterings === 0) {
      return true; // Never been watered - needs attention!
    }

    return this.state.daysSinceLastWatering > 2;
  }

  getUncommittedEvents(): PlantEvent[] {
    return [...this.uncommittedEvents];
  }
}

Further reading: This pattern is inspired by Mathias Verraes' article on Passage of Time Event, which explores how treating time as a domain event enables elegant decoupling in distributed systems.

Comments

Have questions or suggestions? I'd love to hear from you and help improve this content.