Custom Middleware
While reviewing a very large system that used asynchronous messaging I noticed a common pattern in many of the message handlers:
- Attempt to load account data referenced by the incoming command
- If the account didn't exist, log that the account referenced by the command didn't exist and stop the processing
Like this code:
public static async Task Handle(DebitAccount command, IDocumentSession session, ILogger logger)
{
// Try to find a matching account for the incoming command
var account = await session.LoadAsync<Account>(command.AccountId);
if (account == null)
{
logger.LogInformation("Referenced account {AccountId} does not exist", command.AccountId);
return;
}
// do the real processing
}
That added up to a lot of repetitive code, and it'd be nice if we introduced some kind of middleware to eliminate the duplication -- so let's do just that!
Using Wolverine's conventional middleware approach strategy, we'll start by lifting a common interface for command message types that reference an Account
like so:
public interface IAccountCommand
{
Guid AccountId { get; }
}
So a command message might look like this:
public record CreditAccount(Guid AccountId, decimal Amount) : IAccountCommand;
Skipping ahead a little bit, if we had a handler for the CreditAccount
command type above that was counting on some kind of middleware to just "push" the matching Account
data in, the handler might just be this:
public static class CreditAccountHandler
{
public static void Handle(
CreditAccount command,
// Wouldn't it be nice to just have Wolverine "push"
// the right account into this method?
Account account,
// Using Marten for persistence here
IDocumentSession session)
{
account.Balance += command.Amount;
// Just mark this account as needing to be updated
// in the database
session.Store(account);
}
}
You'll notice at this point that the message handler is synchronous because it's no longer doing any calls to the database. Besides removing some repetitive code, this appproach arguably makes the Wolverine message handler methods easier to unit test now that you can happily "push" in system state rather than fool around with stubs or mocks.
Next, let's build the actual middleware that will attempt to load an Account
matching a command's AccountId
, then determine if the message handling should continue or be aborted. Here's sample code to do exactly that:
// This is *a* way to build middleware in Wolverine by basically just
// writing functions/methods. There's a naming convention that
// looks for Before/BeforeAsync or After/AfterAsync
public static class AccountLookupMiddleware
{
// The message *has* to be first in the parameter list
// Before or BeforeAsync tells Wolverine this method should be called before the actual action
public static async Task<(HandlerContinuation, Account?, OutgoingMessages)> LoadAsync(
IAccountCommand command,
ILogger logger,
// This app is using Marten for persistence
IDocumentSession session,
CancellationToken cancellation)
{
var messages = new OutgoingMessages();
var account = await session.LoadAsync<Account>(command.AccountId, cancellation);
if (account == null)
{
logger.LogInformation("Unable to find an account for {AccountId}, aborting the requested operation", command.AccountId);
messages.RespondToSender(new InvalidAccount(command.AccountId));
return (HandlerContinuation.Stop, null, messages);
}
// messages would be empty here
return (HandlerContinuation.Continue, account, messages);
}
}
Now, some notes about the code above:
- Wolverine has a convention that generates a call to the middleware's
LoadAsync()
method before the actual message handler method (CreditAccountHandler.Handle()
) - The
ILogger
would be theILogger<T>
for the message type that is currently being handled. So in the case of theCreditAccount
, the logger would beILogger<CreditAccount>
- Wolverine can wire up the
Account
object returned from the middleware method to the actualHandle()
method'sAccount
argument - By returning
HandleContinuation
from theLoadAsync()
method, we can conditionally tell Wolverine to abort the message processing
Lastly, let's apply the newly built middleware to only the message handlers that work against some kind of IAccountCommand
message:
builder.Host.UseWolverine(opts =>
{
// This middleware should be applied to all handlers where the
// command type implements the IAccountCommand interface that is the
// "detected" message type of the middleware
opts.Policies.ForMessagesOfType<IAccountCommand>().AddMiddleware(typeof(AccountLookupMiddleware));
opts.UseFluentValidation();
// Explicit routing for the AccountUpdated
// message handling. This has precedence over conventional routing
opts.PublishMessage<AccountUpdated>()
.ToLocalQueue("signalr")
// Throw the message away if it's not successfully
// delivered within 10 seconds
.DeliverWithin(10.Seconds())
// Not durable
.BufferedInMemory();
});