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<Booking> and ProjectionsRestorer<Booking> are also available for resolution from the IOC container, if the service collection was passed into the handler runtime configuration.
The ProjectionInvoker<Booking> is registered with its interface IInvokeProjections<Booking> and can be injected like this:
public class HomeController
{
public HomeController(IInvokeProjections<Booking> invoker)
{
}
}
Similarly the ProjectionsRestorer<Booking> is registered with its interface IRestoreProjections<Booking>.
public class HomeController
{
public HomeController(IRestoreProjections<Booking> restorer)
{
}
}