Using the Wolverine “Side Effect” Model to Simplify Code

First off, let me peel some egg off my face because I had allowed Claude to write quite a bit of code without close enough examination until just now. Arguably, we’re all good because we do have test coverage for the code I just refactored, so it’s all good in the end, but maybe just remember that a human in the loop is a good idea. And also know that all CritterWatch code will be closely reviewed before we flip that to 1.0!

Here’s an HTTP endpoint from CritterWatch, our forthcoming monitoring console for the Critter Stack. It adds a tenant to a monitored service:

[WolverinePost("/api/critterwatch/tenants/{serviceName}/add")]
[Middleware(typeof(RequireMultiTenancyLicense))]
public static async Task AddTenant(
string serviceName,
AddTenantRequest request,
IDocumentSession session,
IMessageBus bus,
[FromServices] AuditLogService auditLog,
HttpContext httpContext)
{
// ... append an event, publish a command ...
await auditLog.LogAsync("AddTenant", serviceName, null,
$"Added tenant '{request.TenantId}' to {serviceName}",
new Dictionary<string, string> { ["tenantId"] = request.TenantId },
initiatedBy: AuditActor.From(httpContext.User));
}

Six parameters. Two of them are there for the audit log alone, and one of those — HttpContext — exists for a single expression: httpContext.User. We don’t read the request, the headers, the response, or anything else on it. We take the entire ASP.NET Core request context as a dependency to get at one ClaimsPrincipal.

That has a cost you feel in the test project. To test this method you need a store, a bus, an audit service and a request context. The audit behaviour — did we record the right action, against the right service, attributed to the right operator? — is only observable by standing all of that up and then querying the audit table afterwards.

Returning the intent instead of performing it

Wolverine has an interface called ISideEffect. It’s about as small as an interface gets:

public interface ISideEffect : IWolverineReturnType, INotToBeRouted;

There’s no method on it. The contract is a convention: return one of these from a handler or HTTP endpoint, and Wolverine will call any public Execute() or ExecuteAsync() method on it after your method returns. The interesting part is what happens to that method’s parameters — Wolverine registers each one as a dependency of the chain and resolves it for you.

So the audit log becomes a record:

public record AuditLog(
string Action,
string ServiceName,
string? TargetUri = null,
string? Details = null,
Dictionary<string, string>? Parameters = null,
string? InitiatedBy = null) : ISideEffect
{
public Task ExecuteAsync(AuditLogService auditLog, ClaimsPrincipal? user)
{
var actor = string.IsNullOrWhiteSpace(InitiatedBy) ? AuditActor.From(user) : InitiatedBy;
return auditLog.LogAsync(Action, ServiceName, TargetUri, Details, Parameters, actor);
}
}

And the endpoint stops mentioning either dependency:

[WolverinePost("/test/audited/{serviceName}")]
public static AuditLog Post(string serviceName)
=> new("TestAction", serviceName, Details: "smoke");

AuditLogService and ClaimsPrincipal are resolved onto the chain because ExecuteAsync asks for them. The endpoint declares neither. The HttpContext parameter didn’t move somewhere else — it stopped existing. The principal is resolved at execution time rather than threaded through a signature that never wanted it.

Pure Functions FTW!

If you want to peruse our published Wolverine Best Practices, we strongly recommend trying to make the behavioral methods of your message handlers or HTTP endpoints be “pure functions” whenever possible. We also recommend trying to simplify code by opting to remove asynchronous code from your handlers as well as a way to reducing noise code, and that was why I reached quickly for the new AuditLog side effect. Combine side effects with other Wolverine goodies like our cascading messages syntax for publishing messages and the aggregate handler workflow and you get the tools to really simplify any application code related to business logic.

And just to make this clear, using pure functions (when possible) is a great approach because that:

  • Isolates business or workflow logic from infrastructure concerns
  • Promotes testability through fast running unit tests
  • Reduces the code noise from asynchronous invocations

When not to reach for this

ISideEffect is for work you want to describe and let the framework perform. It’s a poor fit when the result of the work feeds the rest of your method — if you need the return value, you need the call, and a side effect only runs after you’ve returned.

It’s also not free indirection. A one-line await that nothing else depends on and nobody wants to test in isolation is fine as it is. What made the audit log worth converting was the ratio: two parameters and a request context, carried by five endpoints, to express one fact about an operation that had already happened.

That ratio is the tell. When a dependency exists only to record that something happened — audit, notification, telemetry, an outbound email — you are almost always better off returning a description of it and letting Wolverine make the call.

Summary

Hoo boy, let’s try to summarize this a bit:

  1. I think having a man in the loop for AI built code is still pretty important
  2. Pure functions are great for testability
  3. Code noise has a real cost for your ability to reason about code and how it works — and I think that is still true even with LLMs doing so much more of the grunt work now
  4. I think that our JasperFx curated AI Skills are valuable for developing with Wolverine and the rest of the Critter Stack because it’ll keep your LLM using more idiomatic features that can drastically shrink your code compared to typical .NET codebases, improve testability, and opt into Wolverine or Marten features that enhance performance. You can learn more about our AI Skills here.

Leave a comment