Visitor (Fluent)
Visitor separates operations from the objects they operate on. PatternKit provides fluent, type‑safe visitors that dispatch by runtime type and either return a value (result visitor) or perform side effects (action visitor). Use it to add operations like formatting, routing, validation, and projection without modifying your model types.
What It Is
- Type‑based dispatch using a fluent builder:
On<TSub>(...)with optional.Default(...). - First‑match‑wins evaluation. Put specific types before base types.
- Immutable and thread‑safe after
Build(); the builder itself is not thread‑safe. - Non‑intrusive: your domain types do not need to implement
Accept(...)(no classic double‑dispatch required).
Variants: Result
Visitor<TBase, TResult>, side‑effectingActionVisitor<TBase>, and asyncAsyncVisitor/AsyncActionVisitorusingValueTask+CancellationToken.
When To Use
- You have a stable type hierarchy (e.g., AST nodes, payments, UI elements) but frequently add new operations.
- You want to avoid modifying domain classes for every new behavior (formatters, validators, routers).
- You want a clear, centralized, discoverable composition point for type‑specific behavior.
Avoid if:
- You only have a handful of operations and the types themselves are the best home for the logic (simple virtual methods may suffice).
- Pattern matching with
switchexpressions is simpler and sufficient (no need for reusable composition).
TL;DR Example (Result Visitor)
var v = Visitor<Node, string>
.Create()
.On<Add>(_ => "+")
.On<Number>(n => $"#{n.Value}")
.Default(_ => "?")
.Build();
var a = v.Visit(new Add(new Number(1), new Number(2))); // "+"
var b = v.Visit(new Number(7)); // "#7"
If you omit .Default(...) and no handler matches, Visit throws InvalidOperationException. Use TryVisit for a non‑throwing path.
TL;DR Example (Action Visitor)
var v = ActionVisitor<Node>
.Create()
.On<Add>(_ => Log("add"))
.On<Number>(_ => Count++)
.Default(_ => Skip())
.Build();
v.Visit(new Add(new Number(1), new Number(2)));
See also AsyncVisitor<TBase, TResult> and AsyncActionVisitor<TBase> for asynchronous variants.
API Shape
var resultVisitor = Visitor<TBase, TResult>.Create()
.On<Concrete>(static x => /* TResult */)
.Default(static (in TBase x) => /* TResult */) // optional
.Build();
TResult r = resultVisitor.Visit(in node); // throws if no match and no default
bool ok = resultVisitor.TryVisit(in node, out r); // non‑throwing
Key points
- Handlers and predicates use
inparameters to avoid copying large structs. - Registration order is evaluation order; the first matching registration runs.
- A
.Default(...)provides a fallback when nothing matches.
Ordering And Specificity
- Register more specific types before base types to prevent shadowing.
- Group related registrations for readability (e.g., all numeric nodes, then structural nodes).
- For very large hierarchies, consider composing multiple visitors for locality.
Defaults, Errors, And TryVisit
- No default + no match:
VisitthrowsInvalidOperationException. TryVisit(in, out)always avoids throwing on no‑match: it returnsfalseand sets theoutparameter todefault.- Prefer a
.Default(...)for resilience and observability in production code; log and continue.
Performance And Thread Safety
- Dispatch is a tight
forloop over an array of predicates/handlers. No reflection in the hot path. - Built visitors are immutable and safe to share across threads. Builders are not thread‑safe.
- For hot paths with many registrations, keep most frequent types early and consider splitting by module/domain.
Composition & DI
Register built visitors as singletons; capture collaborators in closures or expose a factory method.
// Registration
services.AddSingleton<Visitor<Tender, string>>(sp =>
{
var settings = sp.GetRequiredService<IReceiptSettings>();
return Visitor<Tender, string>
.Create()
.On<Cash>(t => $"Cash {t.Value,8:C}")
.On<Card>(t => $"{t.Brand} ****{t.Last4,4} {t.Value,8:C}")
.On<GiftCard>(t => $"GiftCard {t.Code,-8} {t.Value,8:C}")
.On<StoreCredit>(t => $"StoreCredit {t.CustomerId,-6} {t.Value,8:C}")
.Default(t => settings.ShowRaw ? $"Other {t.Amount:C}" : "Other")
.Build();
});
// Usage
public sealed class ReceiptService(Visitor<Tender, string> renderer)
{
public string LineFor(Tender t) => renderer.Visit(t);
}
Notes
- Built visitors are immutable and thread‑safe; the builder is not.
- For multi‑tenant rules, compose per‑tenant visitors at startup and select by tenant key.
End‑To‑End Example (POS)
PatternKit ships a complete example that renders receipt lines and routes tenders by runtime type.
- Example code:
src/PatternKit.Examples/VisitorDemo/VisitorDemo.cs:15 - Walkthrough:
docs/examples/pos-visitor-routing.md:1
Real‑World Recipes
- API error mapping: translate exceptions to
ProblemDetailsor typed results.- Example:
docs/examples/api-exception-mapping-visitor.md:1
- Example:
- Event processing: route domain events to handlers and orchestrate side effects.
- Example:
docs/examples/event-processor-visitor.md:1
- Example:
- Message routing in workers: dispatch queue messages to specialized processors with cancellation.
- Example:
docs/examples/message-router-visitor.md:1
- Example:
These patterns keep type‑specific behavior in one place, are easy to test, and wire cleanly into DI.
Testing (TinyBDD style)
[Scenario("Result visitor dispatch and default")]
[Fact]
public Task ResultVisitor_Dispatch_And_Default()
=> Given("a result visitor", () =>
Visitor<Node, string>.Create()
.On<Add>(_ => "+")
.On<Number>(n => $"#{n.Value}")
.Default(_ => "?")
.Build())
.When("visit three nodes", v => (
a: v.Visit(new Add(new Number(1), new Number(2))),
b: v.Visit(new Number(7)),
c: v.Visit(new Neg(new Number(1))) // default
))
.Then("Add -> +", r => r.a == "+")
.And("Number -> #7", r => r.b == "#7")
.And("Neg -> ?", r => r.c == "?")
.AssertPassed();
Reference tests: test/PatternKit.Tests/Behavioral/VisitorTests.cs:16
Design Notes
- Non‑intrusive visitor: you don’t need the classic
Accept(Visitor)on domain types. - Zero‑allocation dispatch; strongly‑typed handlers; no hidden boxing for value types when using
in. - Built on
BranchBuilderfor composable construction.
Gotchas
- Missing
.Default(...)+ no match throws. PreferTryVisitfor defensive paths. - Wrong registration order can shadow base types. Put most specific first.
- Actions should be idempotent; guard external side effects.
See Also
ActionVisitor<TBase>— side‑effects only; no returnAsyncVisitor<TBase, TResult>— async resultAsyncActionVisitor<TBase>— async actions- Examples —
docs/examples/pos-visitor-routing.md:1 - Alternatives and comparisons —
docs/patterns/behavioral/visitor/alternatives.md:1