
Wolverine 6.32 is out. In collaboration with a JasperFx customer, we've extended Wolverine's declarative persistence — the [Entity] parameters and Storage.Store() return values that help your handler code be pure functions while still having data loaded from or persisted to storage.
Previously, we've supported that feature set for Marten, EF Core, Polecat, Fisher, RavenDb, and Cosmos DB. In 6.32, we've added support for Amazon S3, Azure Blob Storage, and Redis. Each one supports documents and sagas. Alongside that, the S3 and Azure Blob claim check stores fold into the same packages, and there is a new explicit alternative to [Entity] for the cases where you want the store named in the source.
This feature set came from client needs — they're looking to reduce some AWS hosting costs by moving some data out of PostgreSQL to S3 storage, and supporting [Entity] and Wolverine's Storage helpers is making that transition easier for them
Not everything lives in a database
A lot of real application state is not in Postgres. An invoice's rendered PDF, a generated report, a scanned document: those live in a bucket, addressed by a key the application decides. A rate-limit tally, a shipping quote that stops being interesting after half an hour, a cached read model: those live in Redis.
Until 6.32, a Wolverine handler that needed one of those dropped out of declarative persistence entirely. Inject the client, await it, and then hand-write the "what if it isn't there" half in every single handler that reads it:
public static async Task<IResult> Handle(
ApproveInvoice command,
IAmazonS3 s3,
IDocumentSession session)
{
// the part Wolverine could have written for you
GetObjectResponse response;
try
{
response = await s3.GetObjectAsync("invoice-content", $"invoices/{command.Id}.json");
}
catch (AmazonS3Exception e) when (e.StatusCode == HttpStatusCode.NotFound)
{
return Results.NotFound();
}
var content = await JsonSerializer.DeserializeAsync<InvoiceContent>(response.ResponseStream);
// ...and now, finally, the part you actually cared about
}Three new persistence providers make all of that declarative now.
Amazon S3 and Azure Blob Storage
The two object store packages are siblings — same shape, same registration model, same guarantees — so they are worth reading together. Start with S3:
dotnet add package WolverineFx.AmazonS3builder.Services.AddSingleton<IAmazonS3>(sp => new AmazonS3Client(/* ... */));
builder.Host.UseWolverine(opts =>
{
opts.UseAmazonS3Persistence(s3 =>
{
s3.Store<InvoiceContent>(x =>
{
x.BucketName = "invoice-content";
x.KeyFor = ctx => $"invoices/v7/{ctx.TenantId}/{ctx.Id}.json";
});
});
});and Azure Blob Storage, which differs only in vocabulary:
dotnet add package WolverineFx.AzureBlobStoragebuilder.Services.AddSingleton(new BlobServiceClient(/* ... */));
builder.Host.UseWolverine(opts =>
{
opts.UseAzureBlobStoragePersistence(blobs =>
{
blobs.Store<InvoiceContent>(x =>
{
x.ContainerName = "invoice-content";
x.BlobNameFor = ctx => $"invoices/v7/{ctx.TenantId}/{ctx.Id}.json";
});
});
});In both cases the client comes from your registration, so it keeps whatever credential chain, region, endpoint, and retry policy the rest of your application already uses. Wolverine does not create one and does not create the bucket or container either.
That whole first handler above collapses to this:
[WolverineGet("/api/invoices/{id}/content")]
public static InvoiceContent Get(
[Entity(OnMissing = OnMissing.ProblemDetailsWith404,
MissingMessage = "That invoice's content has not been written yet")]
InvoiceContent content) => content;and writing is the ordinary declarative return value:
public static IStorageAction<InvoiceContent> Handle(RenderInvoice command)
{
return Storage.Store(new InvoiceContent(command.Id, command.Body));
}Registration is explicit on purpose
Both BucketName/ContainerName and the key function are required, and there is no default key layout. That is not an oversight. The identity-to-key mapping is the one part of this only the application knows, and a convention Wolverine invented would not survive contact with a bucket that already has ten years of objects in it.
Explicit registration is also what keeps these providers selective. Wolverine resolves an entity type to the first persistence provider that claims it, and it consults selective providers ahead of catch-all document stores. A provider that claimed anything an object store could theoretically hold would start competing with Marten and EF Core for their own documents. These claim only what you registered, and are otherwise invisible — which is why a single handler can take one of each with no configuration at all:
public static IMartenOp Handle(
ApproveInvoice command,
[Entity] Invoice invoice, // Marten
[Entity] InvoiceContent content) // S3
{
// ...
}There is no atomicity across the two, and Wolverine does not pretend otherwise. The Marten write commits in its transaction; the S3 write already happened. If that ordering matters, write the object from a projection or a follow-on message triggered by the committed Marten write.
New saga storage options
Sagas are registered separately from documents, and each registration refuses the other's type:
opts.UseAmazonS3Persistence(s3 =>
{
s3.Saga<OrderSaga>(x =>
{
x.BucketName = "order-sagas";
x.KeyFor = ctx => $"sagas/{ctx.TenantId}/{ctx.Id}.json";
});
});Saga writes are conditional; document writes are not. A document is last-write-wins, because a PutObject overwrites whatever is at the key. A saga is a read-modify-write, so two messages for the same saga arriving at once would silently lose one update. Wolverine writes a saga with the store's conditional put instead — If-None-Match: * when starting one, If-Match against the ETag it read when updating one — and turns the refusal into SagaConcurrencyException. That derives from the same JasperFx.ConcurrencyException that Marten, EF Core, and Cosmos DB raise, so one policy still covers every store you have:
opts.Policies.OnException<ConcurrencyException>().RetryTimes(3);The Azure implementation is where this got interesting. Blob Storage does not report conditional failures the way S3 does, and a straight port of the S3 check would have let every duplicate saga start through while looking perfectly correct in review:
| operation | S3 | Azure Blob Storage |
|---|---|---|
If-None-Match: * over an existing object | 412 | 409 BlobAlreadyExists |
stale If-Match | 412 | 412 ConditionNotMet |
If-Match against a deleted object | 404 | 412 ConditionNotMet |
Both statuses are translated. That last row is a small gift: completing a saga twice concurrently surfaces as a concurrency failure rather than as a resurrection, and no special case had to be written for it.
The claim check packages moved
We're sorry for the churn here, but we think this was the right call with the extended usage of S3 and Azure Blob Storage
WolverineFx.ClaimCheck.AmazonS3 and WolverineFx.ClaimCheck.AzureBlobStorage are deprecated. The claim check stores now ship inside WolverineFx.AmazonS3 and WolverineFx.AzureBlobStorage alongside the persistence providers.
The types and their namespaces are unchanged, so migration is a package reference swap and nothing else. One caveat worth knowing before you upgrade: keeping both packages referenced produces ambiguous-type compiler errors, so swap rather than add.
Document persistence and claim check storage remain entirely independent features — they share a package and a client, and nothing else. Use either, or both.
Docs: Amazon S3 · Azure Blob Storage · claim checks
Redis
We lean heavily on "compliance test suites" that standardize testing and functionality across different technologies inside Wolverine. Having those compliance tests made it pretty simple for us to add the Redis support at the same time
Redis persistence folded into the existing WolverineFx.Redis package rather than shipping as a new one. Wolverine's packages are scoped by technology, not by capability, and you already add WolverineFx.Redis to talk to Redis. No new project, no new NuGet id, and the Redis Streams transport surface is untouched.
builder.Services.AddSingleton<IConnectionMultiplexer>(
ConnectionMultiplexer.Connect("localhost:6379"));
builder.Host.UseWolverine(opts =>
{
opts.UseRedisPersistence(redis =>
{
redis.Store<ShippingQuote>(x =>
{
x.KeyFor = ctx => $"quote:{ctx.TenantId}:{ctx.Id}";
x.ExpiresAfter = TimeSpan.FromMinutes(30);
});
});
});The multiplexer is deliberately not taken from the Redis transport. The transport owns its multiplexer's lifetime, is frequently pointed at a different Redis than the application's data, and does not have to be configured at all for this to be useful.
ExpiresAfter is the option the object stores could not honestly offer. Redis expires keys natively, and Wolverine re-applies the TTL on every write, so the window slides forward from the last write rather than from the first. Leave it null and the key never expires; Wolverine never removes a TTL it did not set.
Docs: Redis persistence, and in particular when to use this, and when not to.
[FromMarten], [FromEfCore], and naming the store explicitly
[Entity] is deliberately store-agnostic. It asks every registered persistence provider which of them claims the entity type, and the first one that says yes builds the load. That is the right default, and it is what lets the same handler compile against Marten, EF Core, or anything else you have configured.
Sometimes you want the store named in the code instead, and 6.32 adds an explicit attribute per provider, following the naming convention of the ASP.NET Core binding attributes ([FromServices], [FromBody], and friends):
public static OrderSummary Handle(
ReadOrder command,
[FromMarten] Order order)
=> new(order.Id, order.Total);There are two reasons to reach for one. The first is plain explicitness — some teams prefer a parameter to say where it comes from, especially in a codebase where more than one persistence tool is in play and a reader cannot tell from the entity type alone.
The second is disambiguation, and it is the more substantial one. Marten's provider claims every document type, because Marten genuinely can persist any document. Wolverine consults selective providers first, so an entity mapped in an EF Core DbContext resolves to EF Core no matter which integration was registered first. That precedence rule is correct as a default — but if the type also lives in Marten and that is the copy you wanted, [Entity] has no way to hear it:
public static Report Handle(
BuildReport command,
// Mapped in a DbContext AND stored in Marten. A plain [Entity] would resolve to EF Core;
// this says which one was meant.
[FromMarten] Coupon coupon)
=> Report.For(coupon);These attributes are [Entity] in every other respect. They inherit its implementation outright rather than reimplementing it, so Required, OnMissing, MissingMessage, MaybeSoftDeleted, the identity conventions, the explicit argument name constructor, the ValueSource options, and availability in Before/Validate methods all behave identically. The only thing that changes is which provider builds the load.
Better failures, at startup
Because you named the store, Wolverine can say exactly what went wrong at code generation time instead of quietly falling through to a different provider. There are two distinct failures with two different remedies, and both are thrown while the handler or endpoint is being compiled — so the mistake surfaces at startup rather than on the first message:
- The store is not integrated at all.
[FromMarten]in an application that never calledIntegrateWithWolverine()fails naming the parameter, its declaring method, and the bootstrapping call you are missing. - The store is integrated but does not know the type.
[FromEfCore]on a class that no registeredDbContextmaps fails saying exactly that, rather than reporting a generic "could not determine a matching persistence service."
The EF Core extras
This has been an occasional feature request in the past
[FromEfCore] carries two loading options that only mean something to EF Core:
public static OrderSummary Handle(
ReadOrder command,
[FromEfCore(AsNoTracking = true, Include = "Lines.Product")] Order order)
=> OrderSummary.For(order);AsNoTracking loads the entity detached from the change tracker — cheaper for a read-only handler, though note that mutating a detached entity will not be picked up by the transactional middleware's SaveChangesAsync, which is the whole point of asking for it.
Include eagerly loads a navigation property, and Includes takes several at once. A dotted path chains, so Include = "Lines.Product" is EF Core's Include(x => x.Lines).ThenInclude(x => x.Product). They are strings rather than lambdas because attribute arguments have to be compile-time constants; they map onto EF Core's own string Include overload, which is what makes a ThenInclude chain expressible at all.
There is a real behavioral difference hiding in there, and it is why the attribute only makes the switch when you ask. With neither option set, [FromEfCore] emits the same DbContext.FindAsync load that [Entity] does, which can answer straight from the change tracker without touching the database. FindAsync supports neither Include nor AsNoTracking, so asking for either switches the generated load to a Set<T>() query terminated by FirstOrDefaultAsync on the primary key.
Every include path is walked against the EF Core model while the chain is compiled, so a typo is a startup error naming the bad segment and listing the navigations that do exist on that type. Nothing is silently dropped: a request Wolverine cannot honor — an unknown navigation, or a composite primary key, which the query form cannot express — is a codegen error rather than a load that quietly ignores half of what you asked for.
[FromMarten] and [FromEfCore] ship first, and the family is being extended to the other persistence providers on exactly the same shape.
Docs: Naming the Store Explicitly
Everything else in 6.32
The release also carries a batch of fixes and diagnostics work worth linking directly. The full CHANGELOG has the reasoning behind each one.
Core messaging
- #4213 — a shutting-down node no longer dead-letters work whose handler never ran. Building an executor resolves services, so a draining node could reach it after the
IServiceProviderwas already gone; every envelope caught in that window was classified as a permanent configuration error. Reaches every transport. (PR #4218) - #4215 — a listener whose broker entity was deleted underneath it now heals instead of retrying once a second forever. Amazon SQS and Azure Service Bus classify the failure and re-declare, gated on
AutoProvision. (PR #4222) - #4012 — terminal settle failures are classified on the retry block rather than swallowed in a callback, which also closed a gap where two Azure Service Bus session listeners burned the full retry budget on failures that could never succeed. (PR #4221)
- #4161 —
AddStopConditionIfNullaccepts the null identity its signature declares instead of crashing code generation.
Relational providers
- #4216 — scheduled promotion now matches the whole message identity on SQLite, SQL Server, MySQL, and Oracle, not just PostgreSQL. Under
MessageIdentity.IdAndDestinationa scheduled sibling at another destination could be promoted before it was due, so a message scheduled an hour out executed immediately. (PR #4219) - #4216 — a redelivered inbox row can now be retired when its identity is already handled, which had been stuck under
EnableInboxPartitioning. (PR #4224) - #4209 — PostgreSQL survives a duplicated scheduled identity with a partitioned inbox.
Diagnostics and telemetry
- #4199 —
NativeAckand partitioned listeners report numbers an operator can act on:BufferLimitis null on the modes that do not enforce it, the broker prefetch ceiling is reported as the newInFlightLimit, and a partitioned listener reportsLaneCount,BusiestLaneCountandExemptLaneCount— because 100 messages over ten lanes and 100 piled into one report the identicalQueueCount, and the second is a stalled listener. (PR #4220) MaximumBrokerRedeliveriesis now documented as the delivery count it actually is. The behavior is unchanged.
Marten and EF Core integration
- #4198 — scope priming no longer manufactures the session it is guarding on. Since 6.30.3, every handler that service-located anything opened an outbox-enrolled Marten session it never asked for, so cascading messages left through an uncommitted outbox. Requires JasperFx 2.58.0 or later. (PR #4203)
Event model
- #4204 — a stream-appending handler's return value is a reply, not an event, so a DTO returned for
InvokeAsync<TResponse>is no longer reported as an emitted event of the slice. (PR #4217) - #4205 — a generic message type's slice reads the way source spells it.
Type.Nameis identical for every closed form of an open generic, and a slice name is the merge key across model sources, so two relays carrying different payloads were colliding on one slice namedIEvent`1. (PR #4208)
Documentation
- #4223 — RabbitMQ's
AddResourceSetupOnStartupandAutoProvisionare explained properly.
The three new persistence providers all trace back to #4160, and the S3 work started from PR #4165 by Anne Erdtsieck — thank you. The explicit entity attributes are PR #4214.
Getting it
dotnet add package WolverineFx --version 6.32.0If you have questions or want to talk through whether Redis sagas or object-store documents fit what you are building, come find us in the Critter Stack Discord, or get in touch about a support plan.



