MessageHandler MH Sign Up

How to implement Projections

P

It is the responsibility of a projection to summarize streams of events into a human readable format.

Defining a Projection

To define a Projection, create a class which implements the IProjection<TTarget, TEvent> interface, where TTarget is the type of the model being projected onto and TEvent the event type where the data comes from.

The IProjection interface is part of the MessageHandler.EventSourcing nuget package.

PM> Install-Package MessageHandler.EventSourcing

It can be found in the MessageHandler.EventSourcing.Projections namespace.

using MessageHandler.EventSourcing.Projections

In the implementation of the Project method, you can fill up the target instance.

Either with information from the event message or hard code information based on the event type as was done in the example below.

public class BookingProjection :
    IProjection<Booking, BookingStarted>
{
    public void Project(Booking booking, BookingStarted msg)
    {
        booking.Status = "Pending";
    }
}

Multiple projections

Very often, the state of a target object needs to be restored from one or more events from one or more event streams.

The projection framework supports resolving projections from one or more implementations.

So you could define the projection like this:

public class BookingStartedProjection :
    IProjection<Booking, BookingStarted>,
    IProjection<Booking, SalesOrderConfirmed>
{
    public void Project(Booking booking, BookingStarted msg)
    {
        booking.Status = "Pending";
    }

    public void Project(Booking booking, SalesOrderConfirmed msg)
    {
        booking.Status = "Confirmed";
    }
}

Or in two different classes, like this:

public class BookingStartedProjection :
    IProjection<Booking, BookingStarted>
{
    public void Project(Booking booking, BookingStarted msg)
    {
        booking.Status = "Pending";
    }
}
public class SalesOrderConfirmedProjection :
    IProjection<Booking, SalesOrderConfirmed>
{
    public void Project(Booking booking, SalesOrderConfirmed msg)
    {
        booking.Status = "Confirmed";
    }
}

Unittesting

As projections are just plain old classes, testing singular projections is very straight forward, just pass an entity of the target type and an event into the Project method and validate the target object.

[Fact]
public void GivenBookingProcessStarted_WhenProjectingBooking_ThenBookingShouldHaveStatusStarted()
{
    // given
    var booking = new Booking();

    // when
    var projection = new BookingProjection();
    projection.Project(booking, new BookingStarted { PurchaseOrder = new PurchaseOrder() });

    // then
    Assert.Equal("Pending", booking.Status);
}

Projecting single streams

Working with a stream of events and/or multiple projection classes that map those events onto a single target instance requires a bit more logic to resolve the correct Project operation from the correct projection instance.

There is a helper class for this though, available in MessageHandler.EventSourcing.Testing, called TestProjectionInvoker, which simplifies this task.

[Fact]
public void GivenBookingProcessStarted_AndSalesOrderConfirmed_WhenProjectingBooking_ThenBookingShouldHaveStatusConfirmed()
{
    // given
    var history = new SourcedEvent[]
    {
        new BookingStarted
        {
            PurchaseOrder = new PurchaseOrder()
        },
        new SalesOrderConfirmed()

    };
    var booking = new Booking();

    // when
    var invoker = new TestProjectionInvoker( 
        new BookingStartedProjection(), 
        new SalesOrderConfirmedProjection() 
    );
    invoker.Invoke(booking, history);

    // then
    Assert.Equal("Confirmed", booking.Status);
}

TestProjectionInvoker does however assume that all events are being projected onto the same target instance.

Projecting multiple streams

When the event history represents multiple streams to be projected into different target instances, the resolution logic becomes even more complex.

To facilitate this scenario, you can use the TestProjectionRestorer helper class, which extends the TestProjectionInvoker logic to deal with object instantiation.

For each unique SourceId, the restoration logic will create a new instance of the target class.

Construction of the target instance can be provided using a callback factory method, but it is optional in case the target type has a default constructor.

[Fact]
public void GivenMultipleBookingProcesses_WhenProjectingBookings_ThenMultipleBookingsShouldHaveBeenProjected()
{
    // given
    var history = new SourcedEvent[]
    {
        new BookingStarted
        {
            SourceId = "1",
            PurchaseOrder = new PurchaseOrder()
        },
        new SalesOrderConfirmed{
            SourceId = "1",
        },
        new BookingStarted
        {
            SourceId = "2",
            PurchaseOrder = new PurchaseOrder()
        },
        new BookingStarted
        {
            SourceId = "3",
            PurchaseOrder = new PurchaseOrder()
        }
    };

    // when
    var projections = new [] { 
        new BookingStartedProjection(), 
        new SalesOrderConfirmedProjection() 
    }
    var restoration = new TestProjectionRestorer<Booking>(projections);

    var instances = await restoration.Restore(history, (id) => new Booking());

    // then
     Assert.Equal(2, instances.Count);
}

Resolving helpers from the IOC container

The runtime helpers ProjectionInvoker&ltBooking> and ProjectionsRestorer&ltBooking> are also available for resolution from the IOC container, if the service collection was passed into the handler runtime configuration.

The ProjectionInvoker&ltBooking> is registered with its interface IInvokeProjections&ltBooking> and can be injected like this:

public class HomeController
{
    public HomeController(IInvokeProjections<Booking> invoker)
    {
        
    }
}

Similarly the ProjectionsRestorer&ltBooking> is registered with its interface IRestoreProjections&ltBooking>.

public class HomeController
{
    public HomeController(IRestoreProjections<Booking> restorer)
    {
        
    }
}

Sign up to our newsletter to get notified about new content and releases

You can unsubscribe at any time by clicking the link in the footer of your emails. I use Mailchimp as my marketing platform. By clicking subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp's privacy practices here.