Part 3 of 4: Cross-cutting concerns on top of Strategy, using the Decorator pattern
Part 1 of this series replaced a 50+ case switch statement with a family of strategy classes. Part 2 replaced a second switch statement - the one that would've picked which strategy to run - with attribute-based discovery via reflection. By the end of Part 2, adding a new approval type meant exactly one thing: write a class, decorate it with an attribute, done.
There's a gap that surfaces the moment your approval system distinguishes who is allowed to approve what. Approving an expense claim might be fine for any authorizer. Approving a role change to "Director" probably shouldn't be - that's a decision that might need a specific permission beyond just holding the authorizer role generally.
Two related but separate things are worth distinguishing here, since Part 1 covers one of them. Part 1 established that each role's actionable (pending) queue is filtered by the request's current stage - a reviewer's pending tab holds requests awaiting review, an authorizer's pending tab holds requests already reviewed and awaiting final sign-off, and a request that moves past the review stage drops out of the reviewer's pending tab (though the initiator and reviewer can still look it up elsewhere and see its full timeline - filtered out of what's actionable, not hidden from view). That's a stage-based visibility filter: it determines what's actionable in someone's pending list at a given moment, and it lives in the query that builds the list. This article is about a different, second layer: even for a request someone's role would normally let them act on, is this specific request one this specific person is allowed to approve? The role-based permission check below guards the approval action itself, at the moment it's attempted - a finer-grained gate than "which stage is this request at." The two checks live in different places because they answer different questions: the stage filter is about workflow sequencing, the permission check is about authorization.
Where does that action-level check go?
The Tempting Answer (And Why It's Wrong)
The obvious place is inside the strategy itself:
public async Task<Result<Unit>> ExecuteAsync(
HrAdminUser authenticatedUser,
HrApprovalRequest pendingRequest,
BaseHrApprovalDecisionRequest request,
CancellationToken token)
{
if (!authenticatedUser.HasPermission(HrPermission.ApproveRoleChange))
{
return Result<Unit>.Failure("You do not have permission to approve role changes.");
}
// ...the actual approval logic from Part 1...
}
This works for one strategy. It stops working the moment you have to repeat it. If 15 of your 50+ strategies need a permission check, you now have the exact same if (!authenticatedUser.HasPermission(...)) block copy-pasted into 15 different classes - which means 15 places to update if the permission-checking logic ever changes, and 15 chances for someone to forget to add the check to strategy #16.
Worse, it muddies what each strategy class is actually responsible for. ApproveRoleChangeWorkflowAction was supposed to answer one question: what happens when a role change is approved? Now it's also answering a second, unrelated question: is this user even allowed to do that? Those are two different concerns, and Part 1's entire argument was that mixing concerns is what got us into the switch-statement mess in the first place.
Permission checking is what's usually called a cross-cutting concern - a piece of logic that applies across many otherwise-unrelated classes, rather than belonging to any one of them. Logging, caching, retry logic, and transaction handling are the other usual suspects. The right tool for layering a cross-cutting concern onto existing classes, without editing those classes, is the Decorator pattern.
The Idea: Wrap, Don't Modify
A decorator implements the same interface as the thing it's wrapping, holds a reference to the real implementation, and adds behavior before and/or after delegating to it:
From the outside, PermissionCheckingStrategyDecorator is an IHrApprovalStrategy - nothing calling it knows or cares that it isn't the real strategy. From the inside, it does one job (check the permission) and then forwards the call to whatever strategy it's wrapping:
/// <summary>
/// Wraps an IHrApprovalStrategy with a permission check. If the authenticated
/// user lacks the required permission, the inner strategy is never invoked.
/// </summary>
public class PermissionCheckingStrategyDecorator : IHrApprovalStrategy
{
private readonly IHrApprovalStrategy _inner;
private readonly HrPermission _requiredPermission;
private readonly ILogger<PermissionCheckingStrategyDecorator> _logger;
public PermissionCheckingStrategyDecorator(
IHrApprovalStrategy inner,
HrPermission requiredPermission,
ILogger<PermissionCheckingStrategyDecorator> logger)
{
_inner = inner;
_requiredPermission = requiredPermission;
_logger = logger;
}
public async Task<Result<Unit>> ExecuteAsync(
HrAdminUser authenticatedUser,
HrApprovalRequest pendingRequest,
BaseHrApprovalDecisionRequest request,
CancellationToken token)
{
if (!authenticatedUser.HasPermission(_requiredPermission))
{
_logger.LogWarning(
"User {UserId} lacks permission {Permission} for request {RequestId}",
authenticatedUser.Id, _requiredPermission, pendingRequest.RequestId);
return Result<Unit>.Failure(
$"You do not have permission to perform this action.");
}
return await _inner.ExecuteAsync(authenticatedUser, pendingRequest, request, token);
}
}
ApproveRoleChangeWorkflowAction from Part 1 doesn't change at all. It still has no idea permission checking exists. The decorator sits in front of it, intercepting the call, and only forwards to the real strategy if the check passes.
The Part That Makes This Actually Scale: Declaring Permissions, Not Wiring Them
A decorator that's manually constructed for one strategy isn't an improvement over inline checks - it's the same logic, just in a different file. The win only materializes if applying the decorator to 15+ strategies doesn't require 15+ lines of setup code somewhere.
This is solved the same way Part 2 solved strategy discovery: an attribute that lets each strategy declare what it needs, instead of something else having to know:
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class RequiresHrPermissionAttribute : Attribute
{
public HrPermission Permission { get; }
public RequiresHrPermissionAttribute(HrPermission permission)
{
Permission = permission;
}
}
Now a strategy that needs gatekeeping just adds the attribute, on top of the routing attribute from Part 2:
[HrWorkflowStrategy(HrTargetType.RoleChange, HrApprovalActionType.Approve)]
[RequiresHrPermission(HrPermission.ApproveRoleChange)]
public class ApproveRoleChangeWorkflowAction : IHrApprovalStrategy
{
// ...unchanged business logic...
}
A strategy that doesn't need a permission check - say, an employee submitting their own leave request for review - simply doesn't carry the attribute, and is never wrapped at all. The decorator becomes opt-in, declared per-class, with zero central configuration listing out which strategies need protecting.
Tying It Together in the Factory
Recall the factory from Part 2: it resolves a strategy type from its routing attribute, then asks the DI container for an instance. That's exactly where the decorator gets applied - after resolution, before the strategy is handed back to the caller:
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))
{
throw new InvalidOperationException(
$"No strategy found for TargetType={targetType}, ActionType={actionType}, Status={status}");
}
var strategy = (IHrApprovalStrategy)_serviceProvider.GetRequiredService(strategyType);
var requiredPermissionAttr = strategyType.GetCustomAttribute<RequiresHrPermissionAttribute>();
if (requiredPermissionAttr is null)
{
return strategy; // No permission requirement declared - return as-is.
}
// Wrap it. The caller still just sees an IHrApprovalStrategy.
return ActivatorUtilities.CreateInstance<PermissionCheckingStrategyDecorator>(
_serviceProvider, strategy, requiredPermissionAttr.Permission);
}
ActivatorUtilities.CreateInstance constructs PermissionCheckingStrategyDecorator through the DI container, but lets you pass in extra constructor arguments (the strategy instance and the required permission) that aren't themselves registered services. The container fills in ILogger<PermissionCheckingStrategyDecorator> from its own registrations and slots in the two values you supplied - full DI resolution without registering a decorator instance for every possible inner strategy.
The full resolution flow now looks like this:
And the calling code - the service method from Parts 1 and 2 - doesn't change by a single character. It still just calls Resolve(...), wraps the result in HrApprovalStrategyContext, and invokes it. It has no idea whether what comes back is a bare strategy or a decorated one, and it doesn't need to.
Why This Is Worth the Extra Layer
For one strategy, this is over-engineering - a plain if statement would do. The decorator earns its cost at the scale this series keeps coming back to: 50+ strategies, where some need permission checks and some don't, and that set changes over time as new approval types get added or business rules shift.
What you get from routing the check through a decorator instead of inlining it:
-
The permission check exists in exactly one place. If the logic for "what counts as having permission" changes - say, you add a delegation feature where a manager can temporarily grant approval rights - you change
PermissionCheckingStrategyDecoratoronce, and every protected strategy picks up the change automatically. -
Strategies stay honest about what they do.
ApproveRoleChangeWorkflowActioncontains only role-change logic. Reading the class tells you exactly one thing, and the[RequiresHrPermission(...)]attribute sitting above it tells you the other thing, without the two being tangled together in the method body. - Protection is opt-in and visible. Whether a strategy is gated is a one-line declaration you can see by glancing at the class - not something you have to read the entire method body to confirm.
- It composes. Nothing about this design assumes there's only ever one decorator. The same mechanism - attribute declared on the strategy, applied in the factory after resolution - works for adding rate limiting, audit logging at the strategy level, or feature-flagging a strategy off entirely, each as its own decorator stacked in whatever order makes sense.
What's Left
Across three articles, a strategy now: gets resolved without a switch statement, gets permission-checked without inline if blocks, and successfully does its job - updates a record, commits a transaction, returns Result<Unit>.Success(Unit.Value).
And then what? Somewhere, someone probably needs an email. Somewhere else, every single one of these 50+ approval decisions probably needs to land in an audit trail, because "who approved what, and when" is exactly the kind of thing a system like this gets asked about during a compliance review.
If that logic goes inside the strategies, we've recreated the same mixing-of-concerns problem permission checking was just solved for. Part 4 covers the fix: publishing an event when a strategy succeeds, and letting a completely separate set of handlers react to it - including one handler that, through a neat use of class hierarchies, can catch dozens of different event types without ever being told about most of them by name.
Further Reading
- Decorator pattern - Refactoring Guru - the canonical explanation of wrapping an object to add behavior without modifying it, with a C#-specific example at refactoring.guru/design-patterns/decorator/csharp/example
-
Scrutor - GitHub (khellang/Scrutor) - see the "Decoration" section of the README for
services.Decorate<>(), the library-level equivalent of the manual decorator wiring shown here -
ActivatorUtilities.CreateInstance - Microsoft Learn - the API used in the factory to construct
PermissionCheckingStrategyDecoratorwith a mix of DI-resolved and manually-supplied constructor arguments
Next: [Part 4 - What Happens After Approval: Decoupling Side Effects with the Observer Pattern]


Top comments (0)