Eventsourcing Patterns: Multi-temporal Events

Within a Domain Event, use separate timestamps to distinguish when the event occurred and when it was captured.

By Mathias Verraes
Published on 22 March 2022



Multi-temporal Events

Within a Domain Event, use separate timestamps to distinguish when the event occurred and when it was captured.

Problem

A Domain Event typically has a timestamp. A common pattern is to have the eventstore add the timestamp at the moment when the event is written. For example, there could be a database field named recorded_at, with a value that defaults to now(). The field is considered part of the metadata of the event (as opposed to the domain-specific payload). The field is accessible in the userland code that consumes the event, and can be used for infrastructural tasks (such as ordering the events chronologically) or domain-specific operations, projections, analytics…

This is fine in many situations. For most purposes, the recorded time coincides with the time the event happened. Many events are generated by the system, or are a direct consequence of a user-generated action. Even when there is a small delay between, say, a user clicking a button and the resulting event being persisted, the difference is often negligible. After all, a lot of business processes operate at a granularity of minutes, hours, days, and even months and quarters.

However, sometimes we actually care about the difference. Say that every midnight we receive a statement with the day’s bank transactions. We record each transaction as a Domain Event, and its recorded_at timestamp is somewhere after midnight. However, the transaction happened somewhere during the day before. This matters if the payment date has an impact on, say, the calculation of interests, fiscal benefits, legal implications, or other time-sensitive aspects.

Another example is tracking car crashes in a fleet management system. When we persist the event, we have the time when we recorded it. But when did the crash occur, and when was it reported?

Solution

Identify Domain Event types where the time they occurred is different from the time they are recorded, and where the difference matters. In the Domain Events’ schema, add a domain-specific property that reflects the time the event occurred. Name the property after its purpose in the domain. Do not have a default now() value, and instead rely on the Event Producer to fill in the property.

Consumers of the event can now use that property to make relevant decisions.

Example

In our bank statements example, the schema could look like this:

# (pseudocode, details omitted)
- Event
  - eventId: UUID
  - recorded_at: timestamp
  - type: BankAccountWasCredited
  - payload:
    - amount: number
    - currency: string
    - deposited_at: timestamp  

Discussion

The deposited_at property represents the actual transaction time, which happened before the recorded_at persistence time. The event is multi-temporal, as it represents two moments in time.

Why don’t we simply inject the event somewhere in the eventstore at the point where the transaction happened? It would simplify the design of the event itself. But it’s actually a bad idea. Eventstores are ordered chronologically, so we’d have to insert the event and shift all the events that come after. More importantly, in eventsourcing, the events are published after they have been persisted. If we inject the event somewhere within the history, we’d have to tell all consumers that this particularly event is out of order. It creates additional complexity for the consumers.

Good habits

I recommend this technique even for events with no special timestamping requirements. That means that every event type’s payload will have at least 1 timestamp.

- Event
  - eventId: UUID
  - recorded_at: timestamp
  - type: BankAccountWasCredited
  - payload:
    - amount: number
    - currency: string
    - deposited_at: timestamp
    - statement_received_at: timestamp

Note that the new statement_received_at contains duplicate information, as the timestamp will be equal to the recorded_at one. This is, in my opinion, a small price to pay, compared to the benefits:

  • We now have a domain-specific name for that property, which communicates the intent of the design better. Another developer can understand the meaning of the property within the domain, and doesn’t have to guess whether they can use the recorded_at field for their needs.
  • We have better decoupling: consumers only need to worry about the payload, and don’t need to rely on the metadata. This makes sense, as it decouples the infrastructural needs from the domain-specific needs. With no dependence on the metadata, you could (in theory) replace the eventstore with a different vendor, without affecting the domain model.
  • It is easier to evolve the model itself as well. If we depend on recorded_at, and later we decide that we need to track occurrence, we’ll need to adapt all the consumers’ code. If we already had the domain-specific properties, we only need to change the producer. Let’s look at an example.

Example 2

In the first version of the model, we track car crashes:

- Event
  - eventId: UUID
  - recorded_at: timestamp
  - type: CarHasCrashed
  - payload:
    - carId: UUID
    - driverId: UUID 
    - crashed_at: timestamp # using now()

Later, we realise that the crash time can’t possibly coincide with the time the crash is reported. Perhaps the insurance company requires us to have both the crash time and the reported time. We can simply adjust the schema and the producer, but existing consumers can keep using crashed_at.

- Event
  - eventId: UUID
  - recorded_at: timestamp
  - type: CarHasCrashed_V2
  - payload:
    - carId: UUID
    - driverId: UUID 
    - crashed_at: timestamp # no longer using now(), the user has to fill this in
    - reported_at: timestamp  # using now()

Then, after observing the phone operators, we realise another discrepancy: After the conversation, the operators first need to do a number of actions, before they have time to enter the crash report in the system. We can now have the phone operators provide the reported_at time, and introduce a new field `report_entered_at’.

- Event
  - eventId: UUID
  - recorded_at: timestamp
  - type: CarHasCrashed_V3
  - payload:
    - carId: UUID
    - driverId: UUID 
    - crashed_at: timestamp # no longer using now(), the user has to fill this in
    - reported_at: timestamp # no longer using now(), the user has to fill this in
    - report_entered_at: timestamp  # using now()

At every step, the domain model underlying the schema remains consistent with our updated understanding of the domain, and impact on consumers is limited.

Timestamp Precision

As we have domain-specific timestamps now, we can choose to constrain them to the precision that is a relevant to our context. We don’t need car crash or bank transaction events with a precision in milliseconds, when we only care about the hours and minutes, or the day, or even only the month in which something happened. That said, the problem can also be easily solved in the presentation layer, by rounding the timestamps to the needed precision.