Part 2 of 4: How to pick the right strategy at runtime - without writing a second switch statement
In Part 1 of this series, we took a 50+ case switch statement and turned it into a family of small, independently testable strategy classes, each implementing IHrApprovalStrategy. The service method shrank down to almost nothing - except for one line we left as a placeholder:
// ⚠️ We still need to figure out WHICH strategy to use here.
IHrApprovalStrategy strategy = /* ??? */;
This article is about filling in that blank - and doing it in a way that doesn't quietly reintroduce the exact problem Strategy pattern was supposed to solve.
The Trap: A Factory That's Just a Switch Statement in a Trench Coat
The obvious first move is to write a factory. Given a TargetType and ActionType, it returns the right strategy:
public class HrApprovalWorkflowStrategyFactory : IHrApprovalWorkflowStrategyFactory
{
private readonly IServiceProvider _serviceProvider;
public HrApprovalWorkflowStrategyFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IHrApprovalStrategy Resolve(HrTargetType targetType, HrApprovalActionType actionType)
{
return (targetType, actionType) switch
{
(HrTargetType.LeaveRequest, HrApprovalActionType.Approve)
=> _serviceProvider.GetRequiredService<ApproveLeaveRequestWorkflowAction>(),
(HrTargetType.ExpenseClaim, HrApprovalActionType.Approve)
=> _serviceProvider.GetRequiredService<ApproveExpenseClaimWorkflowAction>(),
(HrTargetType.RoleChange, HrApprovalActionType.Approve)
=> _serviceProvider.GetRequiredService<ApproveRoleChangeWorkflowAction>(),
(HrTargetType.Onboarding, HrApprovalActionType.Approve)
=> _serviceProvider.GetRequiredService<ApproveEmployeeOnboardingWorkflowAction>(),
// ...46 more cases...
_ => throw new InvalidOperationException($"No strategy for {targetType}/{actionType}")
};
}
}
This works. It also fails for exactly the same reasons the original switch statement failed:
- Adding strategy #51 means editing this method, not just adding a file.
- The factory now has compile-time knowledge of every concrete strategy type in the system.
- Two developers adding two new approval types in the same sprint are, once again, editing the same method.
We didn't eliminate the central branching point - we just renamed it from ApproveRequest to Resolve and moved it one file over. If this is where the series stopped, "Strategy pattern" would really just mean "switch statement, but spread across more files," which is not a meaningfully better architecture.
The actual goal is a resolver that needs zero edits when a new strategy is added. That means the mapping from "this kind of request" to "this strategy class" can't live in a method body at all - it has to live as metadata on the strategy classes themselves, discoverable at startup.
The Fix: Let Each Strategy Declare Itself
.NET attributes are built exactly for this: attaching declarative metadata to a type, which something else can read later via reflection. Instead of a central method knowing about every strategy, each strategy class can simply announce what it's for:
[HrWorkflowStrategy(HrTargetType.LeaveRequest, HrApprovalActionType.Approve)]
public class ApproveLeaveRequestWorkflowAction : IHrApprovalStrategy
{
// ...same implementation as Part 1, unchanged...
}
That single line replaces a case in the giant switch. No factory file needs to change. No resolver method needs to know ApproveLeaveRequestWorkflowAction exists. The class declares its own routing key and nothing else has to.
The attribute itself is a small, generic piece of infrastructure, written once and never touched again as strategies are added:
/// <summary>
/// Declares the (TargetType, ActionType, Status) combination that a strategy
/// class handles. A factory scans for this attribute at startup to build its
/// strategy registry - no manual mapping required.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class HrWorkflowStrategyAttribute : Attribute
{
public HrTargetType TargetType { get; }
public HrApprovalActionType ActionType { get; }
/// <summary>
/// Optional. Some strategies only apply when the request is in a specific
/// status (e.g. a "review" step before final approval). Null means the
/// strategy applies regardless of status.
/// </summary>
public ApprovalRequestStatus? Status { get; }
public HrWorkflowStrategyAttribute(HrTargetType targetType, HrApprovalActionType actionType)
{
TargetType = targetType;
ActionType = actionType;
Status = null;
}
public HrWorkflowStrategyAttribute(
HrTargetType targetType,
HrApprovalActionType actionType,
ApprovalRequestStatus status)
{
TargetType = targetType;
ActionType = actionType;
Status = status;
}
}
Two constructors exist because not every routing decision is just "type + action." In the real system this is modeled on, some workflows are status-dependent - a transaction-limit change, for example, needs a different strategy while it's still Pending review versus after it's been Reviewed and is awaiting final sign-off. The attribute supports an optional status without forcing every strategy to specify one. AllowMultiple = true matters too: a single strategy class is allowed to declare more than one routing key, for cases where the same logic legitimately handles two related combinations (we'll see this shortly).
The Factory: Scan Once, Resolve Many
With strategies self-describing via the attribute, the factory's job changes completely. It no longer needs to know about strategies - it just needs to scan the assembly once at startup, build a lookup table from the attributes it finds, and then do simple dictionary lookups at request time.
The top half happens exactly once, when the application boots. The bottom half - the part that runs on every single request - is nothing but a dictionary lookup. Here's the implementation:
public class HrApprovalWorkflowStrategyFactory : IHrApprovalWorkflowStrategyFactory
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<HrApprovalWorkflowStrategyFactory> _logger;
// Built once at construction time; never mutated afterward.
private readonly IReadOnlyDictionary<
(HrTargetType, HrApprovalActionType, ApprovalRequestStatus?), Type> _strategyCache;
public HrApprovalWorkflowStrategyFactory(
IServiceProvider serviceProvider,
ILogger<HrApprovalWorkflowStrategyFactory> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
_strategyCache = DiscoverStrategies();
}
/// <summary>
/// Resolves the correct strategy for the given target type, action type,
/// and optional status. Falls back to a status-agnostic registration if no
/// status-specific one exists.
/// </summary>
public IHrApprovalStrategy Resolve(
HrTargetType targetType,
HrApprovalActionType actionType,
ApprovalRequestStatus? status = null)
{
if (!_strategyCache.TryGetValue((targetType, actionType, status), out var strategyType) &&
!_strategyCache.TryGetValue((targetType, actionType, null), out strategyType))
{
_logger.LogError(
"No strategy found for TargetType={TargetType}, ActionType={ActionType}, Status={Status}",
targetType, actionType, status);
throw new InvalidOperationException(
$"No strategy found for TargetType={targetType}, ActionType={actionType}, Status={status}");
}
return (IHrApprovalStrategy)_serviceProvider.GetRequiredService(strategyType);
}
/// <summary>
/// Scans the assembly once and caches every strategy type decorated with
/// HrWorkflowStrategyAttribute, keyed by its routing tuple.
/// </summary>
private IReadOnlyDictionary<
(HrTargetType, HrApprovalActionType, ApprovalRequestStatus?), Type> DiscoverStrategies()
{
var result = new Dictionary<
(HrTargetType, HrApprovalActionType, ApprovalRequestStatus?), Type>();
var strategies = Assembly.GetExecutingAssembly().GetTypes()
.Where(t => typeof(IHrApprovalStrategy).IsAssignableFrom(t) && !t.IsAbstract)
.SelectMany(t => t.GetCustomAttributes<HrWorkflowStrategyAttribute>()
.Select(attr => new { Type = t, Attribute = attr }));
foreach (var s in strategies)
{
var key = (s.Attribute.TargetType, s.Attribute.ActionType, s.Attribute.Status);
if (!result.TryAdd(key, s.Type))
{
_logger.LogWarning(
"Duplicate strategy registration for {Key}. Keeping {Existing}, ignoring {Duplicate}",
key, result[key].Name, s.Type.Name);
}
}
return result;
}
}
A few details worth calling out:
-
The cache key is a tuple,
(TargetType, ActionType, Status?). This is what lets one strategy be registered for "Approve a leave request regardless of status" while another is registered for "Review a role change only while Pending" - the same dictionary handles both shapes of rule. -
The fallback lookup (
status: nullas a second attempt) means a strategy doesn't have to specify a status to be found. Most strategies won't care about status at all; only the few that genuinely branch on it need to say so. - Duplicate detection happens at startup, not at runtime. If two strategies accidentally claim the same routing key, you find out from a log warning when the app boots - not from a confusing bug report three weeks later when the wrong strategy silently wins.
A log warning is the minimum bar here. Throw an exception from DiscoverStrategies() instead, so the application refuses to start at all - a warning can scroll past in a sea of startup logs, a crash on boot can't. The failure mode is "one of two approval types silently gets the wrong business logic." That's worth failing fast and loud for:
if (!result.TryAdd(key, s.Type))
{
throw new InvalidOperationException(
$"Duplicate strategy registration for {key}: " +
$"both {result[key].Name} and {s.Type.Name} claim it.");
}
A warning keeps the app running, with one strategy silently winning the conflict. An exception turns a routing conflict into a deployment that never goes live. For something that decides which business logic runs on an approval decision, throw.
- The scan runs exactly once, in the constructor, because the factory itself is registered with a long enough lifetime that this only happens at startup. There's no reflection cost on the hot path - every actual request just does a dictionary lookup.
Wiring It Up: Registering Strategies Without Naming Them
There's one more place a naive approach would force you to enumerate every strategy by name: the DI container registration. If you've used AddScoped<IHrApprovalStrategy, ApproveLeaveRequestWorkflowAction>() followed by 49 more nearly identical lines, you've recreated the same problem in your Program.cs.
Scrutor solves this with assembly scanning at the registration level too:
services.Scan(scan => scan
.FromAssemblyOf<IHrApprovalStrategy>()
.AddClasses(classes => classes.AssignableTo<IHrApprovalStrategy>())
.AsSelf() // register by concrete type - the factory resolves by Type
.AsImplementedInterfaces() // also register by interface, for cases that just need "a" strategy
.WithScopedLifetime());
services.AddScoped<IHrApprovalWorkflowStrategyFactory, HrApprovalWorkflowStrategyFactory>();
One registration block, written once, covers every current and future strategy that implements IHrApprovalStrategy. Adding strategy #51 means: write the class, decorate it with [HrWorkflowStrategy(...)], done. No factory edit. No DI registration edit. No resolver method edit.
Putting It Back Together
That /* ??? */ from Part 1 now has a real answer, and the service method looks like this:
public async Task<Result<Unit>> ApproveRequest(
string requestId,
BaseHrApprovalDecisionRequest request,
CancellationToken token)
{
var authenticatedUser = await _hrSessionHelper.RetrieveAuthenticatedUser(token);
var pendingRequest = await _context.HrApprovalRequests
.FirstOrDefaultAsync(r => r.RequestId == requestId, token);
if (pendingRequest == null)
{
return Result<Unit>.Failure("The specified request was not found.");
}
IHrApprovalStrategy strategy = _hrApprovalWorkflowStrategyFactory.Resolve(
pendingRequest.TargetType, pendingRequest.ActionType, pendingRequest.Status);
var approvalContext = new HrApprovalStrategyContext(strategy);
return await approvalContext.Invoke(authenticatedUser, pendingRequest, request, token);
}
One line. No conditional logic, no knowledge of how many strategies exist or what any of them do. The full picture, end to end, now looks like this:
What This Buys You at 50+ Strategies
This doesn't make resolution logic disappear - it makes it declarative instead of imperative. The mapping from "type of request" to "strategy that handles it" still exists; it just lives as a one-line attribute next to the class it describes, instead of as a line buried in a giant method far away from the code it routes to.
That has a few concrete payoffs at scale:
-
Locality. To understand what
ApproveLeaveRequestWorkflowActionhandles, you read one attribute on the class itself - not a separate factory file you have to cross-reference. - No central bottleneck file. There's no single file that every new approval type must touch. The factory and the DI registration are written once, ever.
- Startup-time safety. Duplicate or conflicting registrations surface when the app boots - as a log warning at minimum, or as a hard failure that stops deployment if you choose the stricter option - instead of as silent bugs discovered in production.
- The pattern degrades gracefully at scale. Whether there are 10 strategies or 500, the factory's code doesn't grow by a single line.
The Next Gap
There's still something missing, and it's visible if you look closely at the real system this is based on: not every strategy should be callable by just anyone holding the right role. Approving an expense claim might be fine for any authorizer, but approving a role change to "Director" probably shouldn't be - that's a decision that might need a specific permission beyond the authorizer role generally. Right now, nothing in IHrApprovalStrategy, the factory, or the Context enforces that - permission checking would have to be written inside every strategy that needs it, which means copy-pasted authorization code scattered across 50+ classes.
That's the subject of Part 3: adding a permission check as a cross-cutting concern, wrapped around any strategy that needs it, without writing a single if (!user.HasPermission(...)) inside the strategies themselves. It's a job for the Decorator pattern.
Further Reading
- Attributes and reflection - C#, Microsoft Learn - the official overview of how custom attributes and reflection work together in .NET
-
Accessing Custom Attributes - .NET, Microsoft Learn - specifically on reading attribute metadata back out at runtime via
GetCustomAttributes, which is whatDiscoverStrategies()does -
Scrutor - GitHub (khellang/Scrutor) - the assembly-scanning and decoration library behind the
services.Scan(...)registration block in this article
Next: [Part 3 - Decorating Strategies: Adding Permission Checks Without Touching Business Logic]













