Refactoring
Introduce Parameter Object
Name a repeated parameter group as one type — even when that type is a delegate — so duplicated seams share vocabulary.
A parameter object is a type that carries a group of values which always travel together. When the same group appears at several call sites, give it a name. The anonymous list becomes a single argument; the type carries the vocabulary; the call sites tell you what they actually need.
Fowler’s Refactoring lists this under the long-function chapter: it is the move you reach for when a parameter list has grown past the point where the reader can hold all of it at once. Refactoring.Guru catalogs it as Introduce Parameter Object.
The smell
Hansom had it. Four classes — MessageScheduler, LocalQueueAgent, LocalQueueCatalog, and LocalTransport — each carried the same anonymous seam as a field:
private readonly Func<Envelope, CancellationToken, Task> _dispatch;Four fields with the same anonymous shape. Each site is a duplicate of the others; none has a name yet.
Each one had been touched in a different PR (MessageScheduler in #45, the three Infrastructure sites in #46). The duplication was a smell waiting for a name. A reader scanning the four files had to match Func<Envelope, CancellationToken, Task> against itself four times before they could see that the four sites did the same thing.
The move
PR #47 extracted the group into one type:
namespace MiniVerine.Application.Bus;
internal delegate Task DispatchHandler(Envelope envelope, CancellationToken cancellationToken);
…and retyped every site:
-private readonly Func<Envelope, CancellationToken, Task> _dispatch;
+private readonly DispatchHandler _dispatch;
The public constructors still accept MessageDelivery; the internal constructors and the four fields take DispatchHandler. Existing lambdas convert implicitly at every call site, so UseMiniVerine is byte-for-byte unchanged, and the 271-test suite stayed green.
A parameter object can be a delegate
Most introductions to this technique show a value type — a struct, a record, a class with fields. The Hansom case is the less-discussed variant: the parameter object is a delegate, and the “values” it carries are the parameters of the function it represents. The group is not data; it is a seam.
That changes what naming buys you. With a value type, the name documents the shape of the data. With a delegate, the name documents the role of the seam — what the four sites are for. DispatchHandler is no longer “the function that takes an envelope and a cancellation token.” It is the dispatch seam that the scheduler, the agents, the catalog, and the transport share.
Why not an interface
The first thought was IDispatchHandler. It would be more discoverable, easier to fake in tests, and idiomatic for a seam. It would also have broken the build.
Mediator’s constructor requires IPublishEnqueuer, which is LocalQueueCatalog. LocalQueueCatalog’s dispatch, on the saga-aware path, needs to resolve Mediator. The graph is acyclic today only because the dispatch is a lazy lambda in the UseMiniVerine factories. Registering IDispatchHandler as a singleton over Mediator would make the container throw at startup.
The interface path would force a ServiceProviderDispatchHandler whose DispatchAsync called GetRequiredService<Mediator>() — a hidden service locator in the composition root, for zero behavioral gain and a real regression risk. The named delegate has no such problem: it is resolved at the lambda call site, not eagerly.
That is worth teaching on its own. A lazy lambda is not always a code smell; sometimes it is the load-bearing piece.
When not to apply
The technique is overused in two ways.
Only two sites and no shared vocabulary yet. The parameter group is not yet a group; it is coincidence. Wait for the third site — or for a reviewer to ask “why do these match?” — before naming it.
The new type would be a bag of unrelated primitives. If the values only co-vary by accident, naming them together hides the accident. A Bag(int, string, DateTime) is not a parameter object; it is a confession.
Where to put the type
Same package as the caller of the parameters, not the original definer. In Hansom, four sites across two assemblies shared the seam; the delegate landed in Application/Bus/, the assembly the scheduler sits in and the assembly the Infrastructure types already depend on. Match the access modifier of the new type to the constructor signature it replaces. Here both are internal, which keeps the public API surface unchanged.