Skip to content

Integration with Marten

New in Wolverine 1.10.0 is the Wolverine.Http.Marten library that adds the ability to more deeply integrate Marten into Wolverine.HTTP by utilizing information from route arguments.

To install that library, use:

bash
dotnet add package WolverineFx.Http.Marten

Passing Marten Documents to Endpoint Parameters

Consider this very common use case, you have an HTTP endpoint that needs to work on a Marten document that will be loaded using the value of one of the route arguments as that document's identity. In a long hand way, that could look like this:

cs
{
    [WolverineGet("/invoices/longhand/id")]
    [ProducesResponseType(404)] 
    [ProducesResponseType(200, Type = typeof(Invoice))]
    public static async Task<IResult> GetInvoice(
        Guid id, 
        IQuerySession session, 
        CancellationToken cancellationToken)
    {
        var invoice = await session.LoadAsync<Invoice>(id, cancellationToken);
        if (invoice == null) return Results.NotFound();

        return Results.Ok(invoice);
    }

snippet source | anchor

Pretty straightforward, but it's a little annoying to have to scatter in all the attributes for OpenAPI and there's definitely some repetitive code. So let's introduce the new [Document] parameter and look at an exact equivalent for both the actual functionality and for the OpenAPI metadata:

cs
[WolverineGet("/invoices/{id}")]
public static Invoice Get([Document] Invoice invoice)
{
    return invoice;
}

snippet source | anchor

Notice that the [Document] attribute was able to use the "id" route parameter. By default, Wolverine is looking first for a route variable named "invoiceId" (the document type name + "Id"), then falling back to looking for "id". You can of course explicitly override the matching of route argument like so:

cs
[WolverinePost("/invoices/{number}/approve")]
public static IMartenOp Approve([Document("number")] Invoice invoice)
{
    invoice.Approved = true;
    return MartenOps.Store(invoice);
}

snippet source | anchor

Marten Aggregate Workflow

The http endpoints can play inside the full "critter stack" combination with Marten with Wolverine's specific support for Event Sourcing and CQRS. Originally this has been done by just mimicking the command handler mechanism and having all the inputs come in through the request body (aggregate id, version). Wolverine 1.10 added a more HTTP-centric approach using route arguments.

Using Route Arguments

To opt into the Wolverine + Marten "aggregate workflow", but use data from route arguments for the aggregate id, use the new [Aggregate] attribute from Wolverine.Http.Marten on endpoint method parameters like shown below:

cs
[WolverinePost("/orders/{orderId}/ship2"), EmptyResponse]
// The OrderShipped return value is treated as an event being posted
// to a Marten even stream
// instead of as the HTTP response body because of the presence of 
// the [EmptyResponse] attribute
public static OrderShipped Ship(ShipOrder2 command, [Aggregate] Order order)
{
    return new OrderShipped();
}

snippet source | anchor

Using this version of the "aggregate workflow", you no longer have to supply a command in the request body, so you could have an endpoint signature like this:

cs
[WolverinePost("/orders/{orderId}/ship3"), EmptyResponse]
// The OrderShipped return value is treated as an event being posted
// to a Marten even stream
// instead of as the HTTP response body because of the presence of 
// the [EmptyResponse] attribute
public static OrderShipped Ship3([Aggregate] Order order)
{
    return new OrderShipped();
}

snippet source | anchor

A couple other notes:

  • The return value handling for events follows the same rules as shown in the next section
  • The endpoints will return a 404 response code if the aggregate in question does not exist
  • The aggregate id can be set explicitly like [Aggregate("number")] to match against a route argument named "number", or by default the behavior will try to match first on "{camel case name of aggregate type}Id", then a route argument named "id"
  • This usage will automatically apply the transactional middleware for Marten

Using Request Body

TIP

This usage only requires Wolverine.Marten and does not require the Wolverine.Http.Marten library because there's nothing happening here in regards to Marten that is using AspNetCore

For some context, let's say that we have the following events and Marten aggregate to model the workflow of an Order:

cs
// OrderId refers to the identity of the Order aggregate
public record MarkItemReady(Guid OrderId, string ItemName, int Version);

public record OrderShipped;
public record OrderCreated(Item[] Items);
public record OrderReady;
public record OrderConfirmed;
public interface IShipOrder
{
    Guid OrderId { init; }
}
public record ShipOrder(Guid OrderId) : IShipOrder;
public record ShipOrder2(string Description);
public record ItemReady(string Name);

public class Item
{
    public string Name { get; set; }
    public bool Ready { get; set; }
}

public class Order
{
    public Order(OrderCreated created)
    {
        foreach (var item in created.Items) Items[item.Name] = item;
    }

    // This would be the stream id
    public Guid Id { get; set; }

    // This is important, by Marten convention this would
    // be the
    public int Version { get; set; }

    public DateTimeOffset? Shipped { get; private set; }

    public Dictionary<string, Item> Items { get; set; } = new();

    // These methods are used by Marten to update the aggregate
    // from the raw events
    public void Apply(IEvent<OrderShipped> shipped)
    {
        Shipped = shipped.Timestamp;
    }

    public void Apply(ItemReady ready)
    {
        Items[ready.Name].Ready = true;
    }

    public bool IsReadyToShip()
    {
        return Shipped == null && Items.Values.All(x => x.Ready);
    }

    public bool IsShipped() => Shipped.HasValue;
}

snippet source | anchor

To append a single event to an event stream from an HTTP endpoint, you can use a return value like so:

cs
[AggregateHandler]
[WolverinePost("/orders/ship"), EmptyResponse]
// The OrderShipped return value is treated as an event being posted
// to a Marten even stream
// instead of as the HTTP response body because of the presence of 
// the [EmptyResponse] attribute
public static OrderShipped Ship(ShipOrder command, Order order)
{
    return new OrderShipped();
}

snippet source | anchor

Or potentially append multiple events using the Events type as a return value like this sample:

cs
[AggregateHandler]
[WolverinePost("/orders/itemready")]
public static (OrderStatus, Events) Post(MarkItemReady command, Order order)
{
    var events = new Events();
    
    if (order.Items.TryGetValue(command.ItemName, out var item))
    {
        item.Ready = true;

        // Mark that the this item is ready
        events += new ItemReady(command.ItemName);
    }
    else
    {
        // Some crude validation
        throw new InvalidOperationException($"Item {command.ItemName} does not exist in this order");
    }

    // If the order is ready to ship, also emit an OrderReady event
    if (order.IsReadyToShip())
    {
        events += new OrderReady();
    }

    return (new OrderStatus(order.Id, order.IsReadyToShip()), events);
}

snippet source | anchor

Compiled Query Resource Writer Policy

Marten integration comes with an IResourceWriterPolicy policy that handles compiled queries as return types. Register it in WolverineHttpOptions like this:

cs
opts.UseMartenCompiledQueryResultPolicy();

snippet source | anchor

If you now return a compiled query from an Endpoint the result will get directly streamed to the client as JSON. Short circuiting JSON deserialization.

cs
[WolverineGet("/invoices/approved")]
public static ApprovedInvoicedCompiledQuery GetApproved()
{
    return new ApprovedInvoicedCompiledQuery();
}

snippet source | anchor

cs
public class ApprovedInvoicedCompiledQuery : ICompiledListQuery<Invoice>
{
    public Expression<Func<IMartenQueryable<Invoice>, IEnumerable<Invoice>>> QueryIs()
    {
        return q => q.Where(x => x.Approved);
    }
}

snippet source | anchor

Released under the MIT License.