Best practices for designing domain events

I just listened to the excellent “Data Migration” podcast with Steven van Beelen (Data Migration - with Steven van Beelen). Somewhere around the 15th minute, there is a discussion about upcasters and the design of events.

Steven mentions that, while he was a youngster in the game :grinning: , he designed events with value objects. Because of constant development, cycles, changes in value objects cause an increasing number of upcasters. At that point, I wasn’t quite sure where the problem was. Was it with value objects or with an unknown domain?

In this sense, I’m interested to hear some established best (or worst :slight_smile: ) practices around designing the payload of events. For example, how should payload data be structured? Should the payload be as flat as possible, or is nesting acceptable? Should it be avoided to build events from value objects? If yes, what should they contain? Only primitives? And of course, what has Steven done at that concrete project mentioned? How did he approach the event redesign?

Thank you.

4 Likes

I think it’s time we’re doing a message design podcast in the future.
There’s plenty to talk about, at least!

Anyhow, let me give my two cents on the subject:

  • It’s better to make messages as flat as possible - this will decouple your components further
  • Be careful to introduce value objects to your messages. The sole VO’s I (right now) deem acceptable are ubiquitous VO’s, like Money, Address, Name. And even then, it should be a conscious decision.
  • A message would thus exist out of primitives and the project’s common VO’s

We went this route purely because nobody warned us about the predicaments of VO’s within messages. We simply hit that roadblock whilst in the trenches, giving us a harsh lesson.

The domain was well-known to the team, and the value objects made sense.
The VO’s changed because we went from a minimal quick release to expanding it every two weeks or so. Some of the VO’s due to this changed every two weeks.

We steered away from the malificent VO entirely within the messages. The DTO’s and the Query Model received their own variant of the VO to ensure changes in the DTO’s did not affect the models. The messages, in turn, changed towards the use of primitives and some enumerations entirely.

3 Likes

Thank you very much for this answer, Steven.
I will be very interested to listen to that podcast about message design.
I Hope @Sara_Torrey will see this :grinning:

2 Likes

Thank you for starting this great discussion @dmurat & thanks so much for the quick replies @Steven_van_Beelen! :raised_hands:

We’re having a great discussion around this topic (as we speak) internally Damir, thanks to your wonderful questions. And I’m definitely planning on having more podcast discussions around it as well. Stay tuned! :wink:

Thanks @Steven_van_Beelen to provide your opinion and some best practices about designing domain events!

I’m not sure in what kind of context the word ‘domain event’ should be interpreted in this topic?
Is it a business event propagated to services that belong to the same bounded context,
or can it also be a ‘milestone event’ that is published towards remote bounded contexts?
This is of course an important distinction to make.

To provide some more information why I’m asking this:
Our development team has different opinions how to design a domain event.

We have 2 types of events:

  • integration events
  • domain events

Integration events represent (inbound & outbound) milestone events that live at the edge (adapter) of our Hexagonal Architecture. These events are fully decoupled from Axon and published/consumed outside of Axon Server (Standard Edition). We’ve modeled this kind of events as flat as possible, nested structures are represented by so called TOs (= Transfer Objects).

Domain events - the way we approach them - belong to our core-api and thus make part of our domain model. They are meant to be consumed by services that belong to the same bounded context. In most cases, read-models are a primary candidate to consume it. (In our case read-models have a ‘conformist’ relationship with it’s backend service)
Projections are developed in a separate repository from the command model and thus separately deployed. One of the rationales is to be able to horizontally scale command and read-model separately.

Different opinions exist about the design of domain events:

One opinion says to fully allow Value Objects (also nested structures) in domain events because they make integral part of the domain.
In other words: when the domain changes, read-models have to adapt accordingly and upcasters should take care of compatibility. The nice thing about this approach is that you don’t need Transfer Objects to decouple Value Objects from your domain events. (no mapping back and forth)

Second opinion says to only allow basic Value Objects like Address, Period etc.
To remain ubiquitous, the property name should be sufficient to provide the necessary context (eg: Address homeAddress vs Address workAddress).
Concerning nested structures, the second opinion strive to be as flat as possible. This could lead to things like this:
Map<String, List<FlightData>>
List<FlightLegPriorityData>
etc

The ‘flat structures’ inside domain events are persisted and used when the aggregate is loaded.
Inside the EventSourcingEventHandler methods, Value Objects are loaded at runtime by providing them a reasonably flat structure, more complex data structures require a dedicated class for loading a Value Object, eg:

List<FlightLegPriorityData> flightLegPriorityData = event.getFlightLegPriorities()
List<FlightLegPriority> flightLegPrioritiesHolder = flightLegPriorityData.stream().map(FlightLegPriority::load).collect(unmodifiableList());
FlightLegPriorities flightLegPriorities = FlightLegPriorities.load(flightLegPrioritiesHolder);
...

or load a specific Value Object from a generally used one:

FlightScheduleDefinition flightScheduleDefinition = FlightScheduleDefinition.load(event.getPeriod(), ...);

This approach tries to prevent serializing/deserializing issues whenever the domain model evolves to a certain extent. Additionally, read-models don’t need to duplicate or share all Value Objects from domain events because these are made as flat as possible. Even when read-models are conformist to the backend.

What do you think about the 2 different approaches above?

Another question - regardless which approach will be chosen - is how can we prevent domain data that is only a concern of the command model to be published to read-models.
What do you suggest?

  1. We create a domain event subclass containing command model specific data:
    class FlightLegConfirmedInternalEvent extends FlightLegConfirmedEvent
    // command model specific properties

    class FlightLegConfirmedEvent
    // properties to be consumed by read-model

    // from command handler method
    apply(new FlightLegConfirmedInternalEvent(...));

  2. We publish a public and private domain event separately from the Command Handler:
    apply(new FlightLegConfirmedInternalEvent(...));
    apply(new FlightLegConfirmedEvent(...))

A lot of questions :slight_smile:
Hopefully you can shed your light on these. Looking forward to read your comments!

Thank you!

1 Like

Sure thing Bram, glad to help out!
I can imagine further questions arise with conversations like this, so although you have a lot of questions, I’ll try to answer them one by one regardless.

I’ll follow the order you’ve posted them in, quoting the exact line(s) referring to the question.
So, here goes:

From my point of view, the scope of a bounded context exists on several levels.
For example, let’s envision an office building for a bank.
Each floor is a separate department.
And within a department, you have separate teams.

Each of these teams might form its own bounded context, given the subject they’re working on.
Although they’re their own bounded context, they still need to communicate with the other teams in their department. Or in different words, with the other bounded contexts within their floor.

A similar point can be made for inter-department communication. Just as with the teams of a department having separate bounded contexts, these departments should just as well share consciously with the other departments. With this line of thought, a department becomes a bounded context containing bounded contexts.

Given this conceptual layering of bounded contexts, I feel that on each level domain, events make sense. It depends on the perspective on how you call them, as for a team, the department events most definitely are milestone events. But for the department context, those are “just domain events.”

Although this is my POV, thus domain events exist on several levels. The most important thing is that you and your team develop a working naming scheme. If you opt for domain events within the context and integration events between the contexts, who am I to say that’s right or wrong. As long as the paradigm is followed throughout, you should be good.

I get the gist here, although I would personally still prefer to keep events as flat as possible. Regardless of whether they’re within one bounded context or shared between bounded contexts. So, option/opinion two from your description.

The “commoners” like Address and Money are, in my mind, fair to be shared since they’ll likely not change too often. But anything else I’d try to steer away from if reasonable. It’s good to gauge how important the Value Object is to the specific bounded context. If it nears things like Address/Money, it should be fair to include. However, if it doesn’t, then the “win” you gain by reusing the class during development might just bite you with version compatibility later on.

Note that this argument holds for Commands, Queries, Query Responses, and Events.

I am not entirely sure why the state in your events is separated between command and read model fields. I would assume that in most cases, the decisions made by your Command Model result in facts that can be shared with anybody inside the context. Regardless of whether the component subscribing to the event is an Event Sourcing Handler or an Event Handler.

However, if this is a hard requirement in your application, I think I would personally opt for option one. Option two feels like polluting your event store too much.

2 Likes

Thank you @Steven_van_Beelen for taking the time to answer my list of questions!
It’s highly appreciated!

1 Like

But of course Bram!
If you’d want to follow my response up with some more questions, be sure to just post here.