Event Modeling: When a Distinction Stops Being True

If a system sends three kinds of email, how many kinds of “delivered” does it need?

The instinct is three. Everything else about those messages is already three: three types, three triggers, three code paths.

But delivery does not care which kind of message it was. Building it as though it does leaves you with three near-identical types that never diverge, and a duplicate of every handler that touches them. All to be kept in step forever.

The general rule underneath: a difference that is real when something is created is not automatically real later in its life. Here is how that played out in a support system I worked on.

Three kinds of message

The system runs customer support conversations over email. A customer writes in, and the system can send three kinds of message back:

  1. An automated reply. A bot answers the question.
  2. An agent reply. A human types it.
  3. An information request. The system asks the customer for something it needs, a receipt or a reference number, before the case can progress.

The system records what happens as a log of facts. A message being composed is a fact. A message being delivered is a fact. A separate process reads that log and builds the conversation view an agent sees on screen.

Those three kinds are genuinely different, and the code says so in every place it can. Each has its own type. Each is triggered by something different: a classification result, a human clicking send, a case needing more detail. Each affects the conversation differently. An automated reply marks the conversation as answered. The other two leave that alone, because a human still needs to look.

Every one of those decisions says the same thing: these are three different kinds. That agreement turns out to be the trap.

Then delivery tracking arrives

The email provider reports on every message it sends: delivered, bounced, deferred. That belongs on the timeline, so an agent can tell the difference between a message the customer is ignoring and one that never reached them.

Which means recording delivery as a fact too. How many kinds of delivery fact? The question is small enough to answer without noticing you answered it.

The tempting answer

Give each kind its own delivery type. An automated reply delivered, an agent reply delivered, an information request delivered.

This is where the agreement becomes misleading. Everything else in the system distinguishes these three kinds, so surely this should too. Matching what is around you is usually a good instinct, and here it looks like plain consistency.

Carried through, it gives every kind its own private track:

graph LR A1["AutoReplyDrafted"] --> D1["AutoReplyDelivered"] A2["AgentReplyDrafted"] --> D2["AgentReplyDelivered"] A3["InfoRequestDrafted"] --> D3["InfoRequestDelivered"] style A1 fill:#f5a623,stroke:#d79b00,color:#333 style A2 fill:#f5a623,stroke:#d79b00,color:#333 style A3 fill:#f5a623,stroke:#d79b00,color:#333 style D1 fill:#f5a623,stroke:#d0021b,stroke-width:3px,color:#333 style D2 fill:#f5a623,stroke:#d0021b,stroke-width:3px,color:#333 style D3 fill:#f5a623,stroke:#d0021b,stroke-width:3px,color:#333

Look at the right-hand column. Three types, same fields, same handling, forever. And every new kind of outgoing message would force the split again.

Ask the question at each stage, not once

The three kinds differ in intent. A reply answers the customer and closes a loop. An information request asks the customer for something and opens one. Different purpose, different trigger, different effect on the conversation. That difference is real at the moment the message is composed, which is why three separate types there is correct.

Delivery is a different question. “Did this email reach the recipient?” does not vary by intent. The answer means the same thing, arrives the same way, and gets handled the same way whether the message answered a question or asked one. Delivery is a fact about the transport, not about what the message was for.

So the right shape is asymmetric. The kinds stay separate where they differ, and converge where they do not:

graph LR A1["AutoReplyDrafted"] --> D["EmailDelivered"] A2["AgentReplyDrafted"] --> D A3["InfoRequestDrafted"] --> D style A1 fill:#f5a623,stroke:#d79b00,color:#333 style A2 fill:#f5a623,stroke:#d79b00,color:#333 style A3 fill:#f5a623,stroke:#d79b00,color:#333 style D fill:#f5a623,stroke:#7ed321,stroke-width:3px,color:#333

Three types where intent differs. One where it does not.

That asymmetry looks like an inconsistency. It is not. It is the model reporting accurately that a distinction real at one stage is not real at the next.

A test you can apply: can whatever produces the fact even see the distinction? Delivery is reported by the email provider, and the provider has no idea whether it just sent a bot’s answer or a human’s. Your intent taxonomy is invisible to it. So the three delivered types are not merely unlikely to diverge. Nothing upstream is capable of making them differ.

That is a better question than asking whether the types will ever diverge, because nobody can answer that one. Ask instead what would have to supply the difference, and look to see whether it exists.

When the test does not settle it, the two mistakes are not equally expensive. The better-known one is forcing genuinely different scenarios into a single type because they look similar. This is its mirror image: one fact, split along a boundary borrowed from an earlier stage. It is also the costlier of the two.

In an append-only log, three types you regret are permanent: every projection handles all three forever, even after you add a unified fourth. One type you regret is cheap. You start emitting specific types from that point on, and the history stays readable.

Merging also loses nothing. A single delivery type still has to say which message it refers to, and that identifier leads back to the drafted event, which carries the intent. Intent stays derivable. You are declining to store it twice, not throwing it away. So when you cannot decide, start with one.

A distinction that does survive

Not every difference collapses downstream. Email and SMS arrive from different providers, with different failure modes and different delivery semantics, so separate delivery types there are a genuine split. Change the channel and you change who reports the fact and what they are able to report. Change the intent and you change neither.

That is the test working, not an exception to it. The rule is not “never split delivery”. It is “check whether anything upstream can actually make this fact differ”, and sometimes the answer is yes.

The takeaway

When you introduce a type distinction, decide how far down the lifecycle it travels. It usually does not travel all the way. Intent matters at creation. Transport does not care about intent. Billing is its own stage again, with its own answer.

Ask, at every stage: can whatever produces the fact at this stage even see the distinction? Where nothing can, one type is right.