Modeling Internal and External Events Using Polymorphic Messaging

Modeling Internal and External Events Using Polymorphic Messaging

Originally published for Afterman Software

Introduction

Polymorphic messaging is a powerful concept that can assist with message contract migration in production environments as well as build message inheritance hierarchies for groups of related use cases.

One often overlooked use for polymorphic messaging is to partition data along boundary lines between "internal" events and "external" events.

In this post, we'll learn:

  • What internal and external events are and how they differ from one another
  • How identifying stable business abstractions can lead to better data partitioning decisions
  • How data partitioning leads to higher cohesion, better encapsulation and less coupling
  • How to use polymorphic messaging to tie it all together

What is an Internal and External Event?

An internal event is an event which is published and handled within one boundary. An external event is an event which is published by one boundary and handled across "n" boundaries.

A tie-back to this concept in DDD is "Domain Events" (internal events) and "Integration Events" (external events) with the caveat that we're not tying any type of synchronous vs. asynchronous processing to a specific event type as DDD advocates. We're not even advocating for a certain type of implementation.

Instead, we're focusing on partitioning an event's data along boundary lines by determining what should be shared with external boundaries vs. what should stay encapsulated within the publishing boundary.

Event Data and Stable Business Abstractions

If you look at each boundary as a vertical "slice"

  • Internal events are subscribed to and handled in the same boundary in which they're published
  • External events are subscribed to and handled in 1 to "n" boundaries outside of the boundary in which they're published.

InternalExternalEvents

Depending on the type of event, you want to be very deliberate with what data you choose to put on it.

  • Internal Events: can be "data heavy". More specifically, since the data on the event is owned by the boundary that is publishing it, and subsequently handling it, any data the boundary owns can safely be put on the event without worrying about data coupling outside the boundary. The boundary owns all the data, and uses that data to do its job. Internally, how that boundary wants to leverage messaging is its own business.
  • External Events: should be "data light". More specifically, since the data on the event is meant to travel outside the publishing boundary and be consumed by one to many other boundaries, the data on this event should represent only stable business abstractions

What is a stable business abstraction? It's a concept in a domain which shouldn't change much, if at all. Stable business abstractions are less a technical artifact and more an artifact of the domain and/or business.

Examples of stable business abstractions are things like date/time stamps (when did something happen?) and correlation id's (the id of a thing to which something happened).

Again, all pretty vague, right? Let's look at a concrete example.

Reserving a Hotel Room

For a domain-specific example, let's look at hotel room reservations. More specifically, the process of starting a reservation when browsing for a hotel room.

After searching for a hotel room at a specific hotel given a check-in and check-out date, you find a room type you like and click on it. By clicking on that room, you've started a workflow that collects your personal information, payment information, and hopefully results in a reservation.

Represented as a command, starting a reservation could look like this

public class CreatePendingReservation
{
    public Guid ReservationId { get; set; }
    public int RoomTypeId { get; set; }
    public DateTime CheckIn { get; set; }
    public DateTime CheckOut { get; set; }
    public string SessionId { get; set; }
}

Notice the command is named CreatePendingReservation. Why? Until the user decides to complete the reservation, the reservation is in a pending state.

When CreatePendingReservation is handled, the information on the command is saved in the database, then a PendingReservationCreated event is published assigning the data from the command to the event.

public interface PendingReservationCreated
{
    Guid ReservationId { get; set; }f
    int RoomTypeId { get; set; }
    DateTime CheckIn { get; set; }
    DateTime CheckOut { get; set; }
    public string SessionId { get; set; }
}

Examining the event payload:

  • ReservationId: a sender-side generated unique id (correlation id)
  • RoomTypeId: the id for a type of room (a queen room, king room, suite, etc...)
  • CheckIn and CheckOut: dates that define the length of the stay
  • SessionId: ???

What is SessionId?

It's a unique id that represents the user who is creating a reservation. Technically, it could be a UserPrincipal or HttpContext.Session.Id, but implementation aside, it logically represents the user that is creating the reservation.

What is SessionId used for?

In this example, part of creating a reservation involves creating time-outs. When we set aside a room the user has chosen for their reservation, we need to enforce a timeout on how long the reservation will be honored (the Reservation Pattern). That time period, determined by the hotel chain is 15 minutes. If the timeout expires before the user completes their reservation, their reservation expires, they lose their room and have to start the process over again.

So the user is not caught off guard, we also want to display a 5-minute warning on the UI so they know they're approaching the end of the window in which they can complete their reservation. To alert the user to both of these time-outs we'll use Saga time-outs combined with SignalR to push those messages to the user's UI correlated by SessionId.

Now that we have a basic example put together, let's examine the PendingReservationCreated event in the context of internal vs. external events.

Let's Talk Boundaries

Everything so far is happening in the Reservation boundary. This boundary deals with creating pending reservations and (hopefully) turning those pending reservations into completed reservations. It also keeps track of both the 5-minute pending reservation warning and the pending reservation expiration.

ReservationBoundary

But there is another boundary that is interested when a pending reservation is created, and that's the Search boundary. The Search boundary uses the data published by the Reservation boundary to filter the list of room types available based on the hotel's fixed supply of physical rooms of a given type combined with how long those rooms will be occupied (check-in/check-out dates). The hotel chain also wants to exclude room types from the search that are in a pending reservation state so as not to overbook the hotel too aggressively.

To do this, the Search boundary needs to able to subscribe to PendingReservationCreated. So, we need to move that event from an internal event to an external event.

MoveEventToExternalEvent

Seems pretty simple. However, we now have a problem.

PendingReservationCreated contains SessionId. SessionId is only meant to be used within the Reservation boundary and has no contextual meaning outside the Reservation boundary. More specifically, if we were to share the SessionId externally, we'd be making it very easy for other boundaries to logically couple to that data.

If other boundaries couple to that data, they could make assumptions about the meaning of what a SessionId is and implement functionality around it which is flawed. Worse, the Reservation boundary could change that way it operates and remove the SessionId from the event altogether (a breaking contract change) or stop populating it with data (you get an empty string now).

Both of these scenarios could leave boundaries that coupled to SessionId completely broken or more insidiously, introduce bugs that are not readily apparent because the value is no longer being populated.

To prevent this type of logical coupling, we can decompose the PendingReservationCreated event into two events

  • One for the internal use of the Reservation boundary's saga, which needs SessionId
  • One for the Search boundary's needs, which does not need SessionId

Partitioning PendingReservationCreated Along Boundary Lines

Since we don't want to expose SessionId outside of the Reservation boundary, but the Search boundary needs the data on PendingReservationCreated to do its job, let's use polymorphic messaging to partition what data can be shared and what can not.

We'll split the PendingReservationCreated class into two interfaces. The first interface is the "base" interface which contains data that represents stable business abstractions that can be safely shared outside of the Reservation boundary.

public interface PendingReservationCreated
{
    Guid ReservationId { get; set; }
    int RoomTypeId { get; set; }
    DateTime CheckIn { get; set; }
    DateTime CheckOut { get; set; }
}

What data are we sharing?

  • Id's: a unique/correlation id and a room type id
  • Date/time stamps: the check-in and check-out dates

This data represents stable business abstractions that carry contextual meaning outside of the Reservation boundary.

Now for the "sub" interface

public interface PendingReservationCreated : External.Events.Reservation.PendingReservationCreated
{
    string SessionId { get; set; }
}

We get the data on the base interface and the SessionId, which is encapsulated in the Reservation boundary.

PolymorphasizedEvent

When publishing PendingReservationCreated from the Reservation boundary, the sub interface is explicitly used and all fields are available to assign thanks to polymorphism.

await context.Publish<Business.Reservation.Events.PendingReservationCreated>(msg =>
{
    msg.ReservationId = message.ReservationId;
    msg.RoomTypeId = message.RoomTypeId;
    msg.CheckIn = message.CheckIn;
    msg.CheckOut = message.CheckOut;
    msg.SessionId = message.SessionId;
});

The single publish action publishes both events and partitions each event's data along the previously mentioned boundary lines. But, we don't get the partitioning for free. We need to explicitly handle it via message routing.

Routing the "internal" event:

settings.RegisterPublisher(typeof(Business.Reservation.Events.PendingReservationCreated), "HotelReservation.Reservation.Endpoint");

Routing the "external" event:

settings.RegisterPublisher(typeof(External.Events.Reservation.PendingReservationCreated), "HotelReservation.Search.Endpoint");

Walla! We've kept the SessionId encapsulated in the Reservation boundary and shared only the data that is needed with the Search boundary using polymorphic messaging.

In Closing

It's very easy to think that by using messaging you can share whatever you want with whoever you want, but at the end of the day, whether you're coupling to data in a database or data on a message, it's still coupling. Coupling creates rigidity, which subsequently creates fragility, both of which we want to avoid in our systems.

Regardless of the technical approach (polymorphic messaging or otherwise), it's important to take a critical look at how data is being shared in a distributed system and more importantly, why it's being shared.

Looking at data on a field by field basis and determining whether or not that data represents stable business abstractions is a great way to minimize coupling in distributed systems and keep your external events lean.

References

Show Comments