In a previous article, I wrote a little about events’ granularity but I wanted to expand on it here a little.

It’s important to understand and accept that the granularity of events will be different across each system - and that there is no One True Way™ to represent a thing that happened. An event’s job is to represent the thing that happened from the perspective of the system that performed the action. In other words, we just lay it out there and let others make of it what they will.

Our system shouldn’t speculate and shouldn’t guess who our subscribers are or what else they might want to know. That’s their job. If we adapt to our n subscribers then we have n reasons to change. If we just announce what happened from our own perspective then we only have one reason to change, i.e. our own system.

In-process events versus long-lived message contracts

NOTE: For the purposes of this article, I’m not talking about event sourcing. We’ll cover that in another article, and we structure, broker and apply our events slightly differently when we’re doing that.

Within a single codebase and a single process, lightweight events (domain events or otherwise) are an excellent way to decouple concerns whilst still ensuring transactional consistency. Events can be simple POCOs, are refactor-friendly, and are able to be changed within the space of a single commit without affecting any downstream applications.

By contrast, a long-lived event is generally written to a durable, persisted store of some description (and for some value of “persisted”) such as Kafka, Rabbit, ASB, Kinesis etc. These event types are generally intended to be consumed by other applications (unless we’re also using event sourcing and persisted events are our primary source of truth), and thus form part of our application’s API contract - and should be treated in the same way as any other part of that contract, namely being versioned and supported for agreed periods of time. We generally publish these event schemas, either via a schema definition like Apache Avro or in a pre-packaged “message DTOs” package or similar.

Reprojecting fine-grained events into coarse-grained events

In this article I’m going to use the term “domain event” to mean an event that occurred within a single domain and which is not visible in that form outside of that domain, and the term “business event” to mean an event that occurred within one domain but that is visible outside of that domain. Those definitions might blur slightly (or a lot) depending on context and perspective but they’ll serve well enough to illustrate the points I’d like to make here.

It’s entirely possible (and reasonable) to map domain events to business events and vice versa. For a concrete example, let’s say that we’re using some simple, in-process domain event brokerage. We’ll use a sample Barista class that looks something like this:

public class Barista
{
    // ...

    public void MakeCoffeeFor(Customer customer, Order order, IEspressoMachine espressoMachine)
    {
        if (!order.IsPaid) throw new OrderNotPaidException("No coffee for you!");
        if (customer.IsShouty) throw new UnrulyCustomerException("Manners maketh [wo]man.");

        var coffee = espressoMachine.PullShots(order.NumberOfShots);
        customer.Accept(coffee);

        DomainEvents.Raise(new BaristaMadeCoffeeForCustomerEvent(this, customer, coffee))
    }
}

Contrived examples notwithstanding, we model our real-world domain by following the AAA pattern and first giving our Barista the autonomy to refuse to make coffee if it hasn’t been paid for, and to refuse service to unruly customers1. Once our preconditions are satisfied, the coffee is (presumably) made, and a BaristaMadeCoffeeForCustomerEvent domain event is raised. This event is brokered in-process during the same unit of work in which the Barista makes coffee.

Once our unit of work completes, we can map our in-process domain events to business events, which can then be published externally to a Kafka topic or similar. A useful pattern is something along these lines:

public interface IMapToBusinessEvent<TDomainEvent> where TDomainEvent : IDomainEvent
{
    IEnumerable<IBusinessEvent> Map(TDomainEvent domainEvent);
}

with a sample implementation looking something like this:

public class BaristaMadeCoffeeForCustomerEventMapper : IMapToBusinessEvent<BaristaMadeCoffeeForCustomerEvent>
{
    // ...

    public IEnumerable<IBusinessEvent> Map(BaristaMadeCoffeeForCustomerEvent domainEvent)
    {
        yield return new BaristaMadeCoffeeForCustomerBusinessEvent
        {
            Timestamp = _clock.Now,
            BaristaId = domainEvent.Barista.Id,
            CustomerId = domainEvent.Customer.Id,
            Description = domainEvent.Coffee.ToString()
        };
    }
}

This pattern gives us a few options:

  1. We can use rich object references in our in-process domain events. This means that we don’t have to resolve the same entity multiple times from a repository just to deal with it in the same unit of work.
  2. We can downcast our rich object references (which won’t serialise very well) to just identifier references when we map to the business event. This also allows us to include only the information that should be externally-visible.
  3. We can choose to publish multiple business events based on a single domain event.
  4. We can choose to not publish any business event at all from a domain event. It’s entirely legitimate that there will be domain events that are purely internal to our application and which we do not want to publish to the broader app ecosystem.
  5. We can refactor our internal, in-process domain event whenever we like, as long as we project it into the same shape of externally-visible business event as before.

Reprojecting coarse-grained events into fine-grained events

It’s entirely possible (and very likely) that one system will care about events at a finer granularity than another.

Our contrived barista example

To continue our barista example, let’s look at two domains: ordering and fulfilment. Our ordering system is responsible for recording a customer’s order, taking payment and then announcing, “Order up!” via an OrderPlaced event. According to our ordering system, an order is an order is an order. Our stream might look something like this:

OrderPlaced { customerId: 1, sku: 'FLATWHITE', size: 'M', sugars: 1, milk: 'FULLFAT' }
OrderPlaced { customerId: 2, sku: 'CAPUCCINO', size: 'M', sugars: 0, milk: 'SKIM' }
OrderPlaced { customerId: 3, sku: 'MOCHA', size: 'L', sugars: 2, milk: 'FULLFAT' }
OrderPlaced { customerId: 4, sku: 'TOASTED SANDWICH', fillings: 'SURPRISE ME' }

In the physical world, a barista is likely to project an OrderPlaced event into a bit more granular a mental stream. Specifically, they’re likely to steam more than one coffee’s worth of milk and perhaps attempt to aggregate events into a few streams that might look more like this:

OrderRequiringFullFatMilkPlaced { size: 'M' }
OrderRequiringSkimMilkPlaced { size: 'M' }
OrderRequiringFullFatMilkPlaced { size: 'L' }

But why would they do this?

The reprojection of events in this case is because the barista will take a different action based on the nature of the event. If more full-fat milk is required then they’ll just add more milk to the steaming jug before starting, whereas if skim milk is required then they need to steam that separately. If the order is for a toasted sandwich then the Barista doesn’t care at all so it doesn’t even bother to reproject that OrderPlaced event. They make coffee. Sandwiches are someone else’s problem.

The key point here is that while one system (the ordering system) doesn’t draw a distinction between orders containing different kinds of milk, or even drinks versus food at all, the fulfilment system is likely to take very different actions based on some of the events’ payloads.

A real-world example

Contrived examples aside, let’s consider something more serious: a court order prohibiting a convicted criminal from changing address without first applying to a court to vary its orders.

We have a government agency responsible for knowing where a citizen lives and publishing a CitizenChangedAddressEvent to a Kafka topic whenever such an event occurs. As far as that agency is concerned, this is a legitimate action for a person to take. They’ve moved house; they tell their government2.

CitizenChangedAddressEvent: { id: '<some citizen guid>', address: '123 Imaginary Street, Nowheresville' }

The court systems subscribe to this event stream, and care about the CitizenChangedAddressEvent. When a citizen changes address, the system processes that event and performs a series of checks, then potentially projects that event into a more granular event relevant to its own domain:

namespace WhenACitizenChangesAddress
{
    public class ReprojectTheEventStream : IHandleBusEvent<CitizenChangedAddressEvent>
    {
        // ...

        public async Task Handle<CitizenChangedAddressEvent>(CitizenChangedAddressEvent e)
        {
            if ( /* citizen is convicted criminal and has moved across state boundaries */ )
            {
                // ...
            }

            if ( /* citizen is a child subject to a court order */ )
            {
                var child = _childRepository.Get(e.Id);
                DomainEvents.Raise(new ChildSubjectToCourtOrderMovedWithoutConsentEvent(child));
            }
        }
    }
}

resulting in an event stream that could look something like this:

ConvictedCriminalMovedInterstateEvent: { id: '<some citizen id>' }
PersonSubjectToDomesticViolenceOrderMovedTooCloseToVictimEvent:  { id: '<some citizen id>' }
ChildSubjectToCourtOrderMovedWithoutConsentEvent:  { id: '<some citizen id>' }

Within the court system’s domain, these events are likely to be dealt with completely differently. A convicted criminal crossing state boundaries is likely to have a warrant issued for their arrest; a person subject to a domestic violence order who has moved too close to their victim(s) may or may not have a warrant issued; a child who is named in a court order might have a travel ban imposed so that they cannot leave the country.

The court system can then implement specific handlers for these more-specific event types:

namespace WhenAChildSubjectToACourtOrderMovesWithoutConsent
{
    public class PutThemOntoAnAirportWatchList: IHandleEvent<ChildSubjectToCourtOrderMovedWithoutConsentEvent>
    {
        // ...

        public async Task Handle(ChildSubjectToCourtOrderMovedWithoutConsentEvent e)
        {
            var child = _childRepository.Get(e.Id);
            _airportWatchList.Add(child);
        }
    }
}

This seems like a lot of indirection for not much value. Why wouldn’t we just check the original event type in our handler for the CitizenChangedAddressEvent? Well, commonly we want to do more than one thing as a result of an event like this. Let’s add another behaviour:

namespace WhenAChildSubjectToACourtOrderMovesWithoutConsent
{
    public class PutThemOntoAnAirportWatchList: IHandleEvent<ChildSubjectToCourtOrderMovedWithoutConsentEvent>
    {
        // ...

        public async Task Handle(ChildSubjectToCourtOrderMovedWithoutConsentEvent e)
        {
            var child = _childRepository.Get(e.Id);
            _airportWatchList.Add(child);
        }
    }

    public class NotifyAllGuardiansOfTheChild: IHandleEvent<ChildSubjectToCourtOrderMovedWithoutConsentEvent>
    {
        // ...

        public async Task Handle(ChildSubjectToCourtOrderMovedWithoutConsentEvent e)
        {
            var child = _childRepository.Get(e.Id);
            var guardians = _guardianLookupService.FindGuardiansOf(child);
            foreach (var guardian in guardians)
            {
                _alertService.Notify(guardian, $"Child {child}'s address has changed without permission from the court.");
            }
        }
    }
}

Now it becomes obvious that if we were to include the same check for “Does this CitizenChangedAddressEvent mean that a child named in a court order has changed address without consent?” then we would start to see copious copied/pasted code to determine if this was the kind of event to which we should respond. Our code becomes much less testable, its number of dependencies explodes and we’re left with a single, gigantic orchestration method.

By contrast, if we adopt a “When X, do Y” pattern, we get a much more expressive, rule-based system that is much easier to discover. It also makes it much easier to additionally define “Also, when X happens, do Z.”

The point of this reprojection is that the publishing system neither knows nor cares how downstream systems will consume its events. It’s much tidier to reproject the coarse-grained event into finer-grained ones at the point in the ecosystem where differentiation makes a difference to the action that will be taken.

  1. “Manners maketh [wo]man.” 

  2. Side note: any government which has achieved a “tell us once” principle for common actions performed by citizens deserves some praise. It’s hard.