Integrating Marten Projections and IoC Services

Marten 6.2 (see the release notes here) dropped today with a long requested enhancement that makes it easier to consume services from your application’s IoC container within your Marten event projections.

Thanks to Andy Pook for providing the idea for the approach and some of the code for this feature.

As a sample, let’s say that we’re building some kind of system to track road trips, and we’re building a mobile user interface app that users can use to check in and say “I’m here, at this exact GPS location,” but we want our back end to track and show their current location by place name. To that end, let’s say that we’ve got this service with a couple value object types to translate GPS coordinates to the closest place name:

public record LocationDescription(string City, string County, string State);

public record Coordinates(double Latitude, double Longitude);

public interface IGeoLocatorService
{
    Task<LocationDescription> DetermineLocationAsync(Coordinates coordinates);
}

And now, we’d like to ship an aggregated view of a current trip to the client that looks like this:

public class Trip
{
    public Trip(LocationDescription startingLocation, DateTimeOffset started)
    {
        StartingLocation = startingLocation;
        Started = started;
    }

    public Guid Id { get; set; }

    public DateTimeOffset Started { get; }
    public LocationDescription StartingLocation { get; }
    public LocationDescription? CurrentLocation { get; set; }
    public DateTimeOffset? ArrivedAt { get; set; }
}

And we also have some event types for our trip tracking system for starting a new trip, and arriving at a new location within the trip:

public record Started(Coordinates Coordinates);
public record Arrived(Coordinates Coordinates);

To connect the dots, and go between the raw GPS coordinates reported in our captured events and somehow convert that to place names in our Trip aggregate, we need to invoke our IGeoLocatorService within the projection process. The following projection class does exactly that:

public class TripProjection: CustomProjection<Trip, Guid>
{
    private readonly IGeoLocatorService _geoLocatorService;

    // Notice that we're injecting the geoLocatorService
    // and that's okay, because this TripProjection object will
    // be built by the application's IoC container
    public TripProjection(IGeoLocatorService geoLocatorService)
    {
        _geoLocatorService = geoLocatorService;

        // Making the Trip be built per event stream
        AggregateByStream();
    }

    public override async ValueTask ApplyChangesAsync(
        DocumentSessionBase session, 
        EventSlice<Trip, Guid> slice, 
        CancellationToken cancellation,
        ProjectionLifecycle lifecycle = ProjectionLifecycle.Inline)
    {
        foreach (var @event in slice.Events())
        {
            if (@event is IEvent<Started> s)
            {
                var location = await _geoLocatorService.DetermineLocationAsync(s.Data.Coordinates);
                slice.Aggregate = new Trip(location, s.Timestamp);
            }
            else if (@event.Data is Arrived a)
            {
                slice.Aggregate!.CurrentLocation = await _geoLocatorService.DetermineLocationAsync(a.Coordinates);
            }
        }

        if (slice.Aggregate != null)
        {
            session.Store(slice.Aggregate);
        }
    }
}

Finally, we need to register our new projection with Marten inside our application in a such a way that Marten can ultimately build the actual TripProjection object through our application’s underlying IoC container. That’s done with the new AddProjectionWithServices<T>() method used in the sample code below:

using var host = await Host.CreateDefaultBuilder()
    .ConfigureServices(services =>
    {
        services.AddMarten("some connection string")

            // Notice that this is chained behind AddMarten()
            .AddProjectionWithServices<TripProjection>(
                // The Marten projection lifecycle
                ProjectionLifecycle.Live,

                // And the IoC lifetime
                ServiceLifetime.Singleton);

    }).StartAsync();

In this particular case, I’m assuming that the IGeoLocationService itself is a singleton scoped within the IoC container, so I tell Marten the projection itself can have a singleton lifetime and only be resolved just once at application bootstrapping.

If you need to use scoped or transient (but just note that Marten is going to treat these lifetimes as the same thing in its own logic) services in the projection, you can call the same method with ServiceLifetime.Scoped. When you do that, Marten is actually adding a proxy IProjection to itself that uses scoped containers to create and delegate to your actual IProjection every time it’s used. You would need to do this for instance if you were using DbContext objects from EF Core in your projections.

There are some limitations to this feature in that it will not work with any kind of built in projection type that relies on code generation, so no Single/MultiStreamProjection or EventProjection types with that usage. You’ll have to revert to custom IProjection types or use the CustomAggregation<T, TId> type as a base class like I did in this sample.

Leave a comment