DEV Community

Oluwagbemileke Femi Oyeyoade
Oluwagbemileke Femi Oyeyoade

Posted on

Stop Writing Switch Statements for Approval Workflows: The Strategy Pattern in Practice

Part 1 of 4: Strategy fundamentals, illustrated with an HR approval scenario


If you've built any kind of approval workflow - leave requests, expense claims, role changes, vendor onboarding - you already know how it starts. A service method, an if statement deciding what kind of request this is, and a switch deciding what to do about it.

It works. Until it doesn't.

I built and maintain a maker-checker approval system with 50+ distinct approval workflow actions in production - different combinations of what's being approved, what action is being taken (create, approve, reject, escalate), and what state the request is currently in (pending, reviewed, escalated). Each combination can carry meaningfully different business logic, different side effects, different audit trails. For this article, I'm using an HR approval scenario - leave requests, expense claims, role changes, onboarding - as a relatable stand-in for that same shape of problem, so the ideas translate regardless of what domain you work in.

A switch statement for that is a code crime scene.

This is the story of how the Strategy pattern - one of the oldest, least flashy patterns in the GoF book - became the backbone of that system. This is Part 1 of a 4-part series:

  1. Part 1 (this article): Strategy pattern fundamentals - replacing the switch with a Context and a family of interchangeable strategies
  2. Part 2: Resolving the right strategy at runtime without writing a second switch statement - using attributes and reflection
  3. Part 3: Adding cross-cutting concerns (like permission checks) to strategies without touching their business logic - the Decorator pattern
  4. Part 4: What happens after a strategy succeeds - decoupling side effects like emails and audit logs using the Observer pattern, and a neat trick for catching dozens of event types with a single handler

Let's start where most of us start: the switch statement.

The Shape of the System This Is Based On

Two architectural details are worth laying out up front, because they explain why the data this series works with looks the way it does, rather than that just being an arbitrary choice.

First: initiating a request and reviewing/approving it are deliberately separate concerns, with separate endpoints. Creating a leave request, submitting an expense claim, requesting a role change - each of these has its own dedicated endpoint, often with its own validation and its own shape of input data, because the data needed to create a leave request looks nothing like the data needed to create a role change request. This series has nothing to say about that side of the system; it's entirely about what happens next.

Second: reviewers and authorizers don't work request-type by request-type - they work off one unified list. Whoever reviews and approves things in this system expects to see pending requests - leave requests, expense claims, role changes, onboarding, everything - in a single queue, regardless of what type each one is, rather than needing a separate screen per request type. This is intentionally a simple, three-role model - initiator, reviewer, authorizer - rather than a configurable chain of approval routes, and the unified list reflects that simplicity: there's one "pending" view per role, not a different one per request type.

That pending view isn't identical for everyone. Each reviewer or authorizer's actionable queue - the list of things they can actually approve or reject right now - only contains requests currently sitting at their stage: a reviewer's pending tab holds requests awaiting review, an authorizer's pending tab holds requests already reviewed and awaiting final sign-off. A request that moves past the review stage drops out of the reviewer's pending tab, but it doesn't vanish - the initiator and the reviewer can still look it up elsewhere, in a history or timeline view, and see exactly where it is and what's happened to it. They just can't act on it from their pending list anymore, because there's nothing left for them to do at that stage. The filtering is about what's actionable for a given role at a given moment, not about hiding the request from people who were previously involved with it.

That requirement is what actually drives the rest of this series: if leave requests and role changes lived in entirely separate tables with entirely separate shapes, there'd be no single pending list - per role - to query in the first place. Instead, every request - no matter what it's initiating - gets normalized into one shared record type once it reaches the review stage:

public class HrApprovalRequest
{
    /// <summary>Unique, externally-facing identifier for this approval request.</summary>
    public string RequestId { get; set; }

    /// <summary>The ID of the entity being acted on (a leave request ID, a role change ID, etc).</summary>
    public int EntityId { get; set; }

    /// <summary>What kind of entity this request concerns - LeaveRequest, RoleChange, Onboarding, ...</summary>
    public HrTargetType TargetType { get; set; }

    /// <summary>What action is being requested - Create, Approve, Reject, Escalate, ...</summary>
    public HrApprovalActionType ActionType { get; set; }

    public ApprovalRequestStatus Status { get; set; }

    /// <summary>Snapshot of the data before the proposed change, where applicable.</summary>
    public string? OldRequestData { get; set; }

    /// <summary>Snapshot of the proposed new data - what gets applied if this is approved.</summary>
    public string? NewRequestData { get; set; }

    /// <summary>Used for optimistic concurrency - see below.</summary>
    public byte[] RowVersion { get; set; }

    public DateTime DateCreated { get; set; }
    public DateTime? DateModified { get; set; }
    public DateTime? AuthorizedAt { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

A few of these columns are doing more work than they might look like at a glance:

  • TargetType and ActionType are exactly the two values Part 2's attribute-based factory resolves against. The whole reason that factory takes (targetType, actionType, status) as its lookup key is that this is the actual shape of the data sitting in this table - there's no richer context to resolve against than what's already on the row.
  • Status is what actually drives the per-viewer filtering mentioned above - it's how a query can say "show this reviewer only the requests still at the review stage" versus "show this authorizer only the ones already reviewed and awaiting final sign-off," off the same underlying table, rather than needing separate tables per stage.
  • OldRequestData and NewRequestData are typically JSON snapshots, deserialized inside whichever strategy handles that request type, into whatever shape that specific request type actually needs (a LeaveRequest-shaped payload, a RoleChange-shaped payload, and so on). This is part of what makes the unified queue possible - the table itself doesn't need a different schema per request type, because the type-specific shape lives inside a serialized blob, not in dedicated columns.
  • RowVersion matters specifically because multiple eligible reviewers can plausibly have the same request visible in their respective queues at once - eligibility isn't exclusive, it's just filtered. A RowVersion (or similar concurrency token) is what stops two authorizers from both successfully approving the same request, or one approving a request the other just rejected, by causing the second writer's SaveChangesAsync to fail with a concurrency exception instead of silently overwriting the first.

This is the table the rest of the series queries against. Every code sample from here on - the strategies in this article, the factory in Part 2, the decorator in Part 3, the events in Part 4 - exists to answer one question well: given a row in this one shared table, what should actually happen when someone approves or rejects it?

The real system this series is based on reuses the exact same strategy interface, factory, and request table for a second, separate maker-checker relationship - privileged users managing other privileged users (creating, updating, deactivating accounts with elevated access), with its own pair of roles, a Super Initiator and a Super Authorizer, distinct from the regular Initiator/Reviewer/Authorizer chain this series otherwise uses as its running example. Nothing about the strategy interface, the Context, or the factory needed to change to support that - it's handled by the same IHrApprovalStrategy family, just resolved against a different TargetType (something like AdminUser, alongside LeaveRequest, RoleChange, and the rest). That's the actual test of whether an architecture is general: it transfers to a structurally similar but otherwise unrelated problem without anyone touching the shared infrastructure.

The Problem: One Action, Many Algorithms

Picture an HR approval system - the shape of the problem translates regardless of domain. A BaseHrApprovalDecisionRequest comes in - someone clicking "Approve" or "Reject" on a pending request. The request could be:

  • A leave request being approved or rejected
  • An expense claim being approved or rejected
  • A role change being approved, and needing to then actually update the employee's role
  • An employee onboarding request being approved, which should provision accounts, notify IT, and publish an event

One layering note before the code: in this system, the controller stays thin - it does little more than authenticate the caller and forward the call. The actual ApproveRequest method, and all the logic discussed in this article, lives in a HrApprovalRequestService. That distinction matters less for the pattern itself than for where you'd actually go looking for this code in a real codebase, so I'll refer to it as the service method from here on rather than a "controller action."

Each of these needs completely different logic once approved. Visually, the naive approach forces every request through one branching decision tree before anything useful happens:

Multiple request methods

In code, that diagram becomes:

public async Task<Result<object>> ApproveRequest(
    string requestId,
    BaseHrApprovalDecisionRequest request,
    CancellationToken token)
{
    var pendingRequest = await _context.HrApprovalRequests
        .FirstOrDefaultAsync(r => r.RequestId == requestId, token);

    if (pendingRequest.TargetType == HrTargetType.LeaveRequest)
    {
        // 40 lines of leave-specific approval logic
    }
    else if (pendingRequest.TargetType == HrTargetType.ExpenseClaim)
    {
        // 35 lines of expense-specific approval logic
    }
    else if (pendingRequest.TargetType == HrTargetType.RoleChange)
    {
        // 50 lines of role-change logic, including a check on
        // whether the request is Pending vs already Reviewed
    }
    else if (pendingRequest.TargetType == HrTargetType.Onboarding)
    {
        // 60 lines of onboarding logic
    }
    // ...repeat for every target type, multiplied by every action,
    // multiplied by every status...
}
Enter fullscreen mode Exit fullscreen mode

At 4 cases, this is mildly annoying. At 50+, it's unmaintainable for a few concrete reasons:

  • The method violates the Single Responsibility Principle - it now "owns" the business logic of every approval type in the system, not just the job of routing a request to its handler.
  • It violates the Open/Closed Principle - adding a new approval type means editing this method, risking regressions in unrelated branches that have nothing to do with your change.
  • Testing is miserable - unit testing one branch means standing up the dependencies for all branches, because they all live in one method.
  • Merge conflicts become routine - if two developers add two different approval types in the same sprint, they're both editing the same giant method.

This is exactly the shape of problem the Strategy pattern exists to solve: a family of algorithms, selected at runtime, that need to be interchangeable without the calling code knowing the details of each one.

The Fix: Define the Algorithm as an Interface

The first move is to recognize that "approve this leave request" and "approve this role change" are both instances of the same abstraction - "execute an approval decision" - even though their implementations are completely different.

That abstraction becomes an interface. Note that responses use the Result pattern rather than throwing exceptions for expected failure cases (request not found, already processed, validation failed) - Result<T> is a value type that explicitly carries either a success value or a structured error, forcing callers to handle both paths instead of relying on try/catch for control flow:

/// <summary>
/// Defines a contract for all HR approval workflow strategies.
/// </summary>
public interface IHrApprovalStrategy
{
    /// <summary>
    /// Executes the approval workflow logic for a given HR request.
    /// </summary>
    /// <param name="authenticatedUser">The HR admin executing the action.</param>
    /// <param name="pendingRequest">The approval request being decided on.</param>
    /// <param name="request">The raw approve/reject decision payload.</param>
    /// <param name="token">A cancellation token for cooperative cancellation.</param>
    Task<Result<Unit>> ExecuteAsync(
        HrAdminUser authenticatedUser,
        HrApprovalRequest pendingRequest,
        BaseHrApprovalDecisionRequest request,
        CancellationToken token);
}
Enter fullscreen mode Exit fullscreen mode

(Unit here just stands in for "no meaningful return value, but still a Result we can inspect for success/failure" - the same role void would play if Result<T> didn't need a type argument.)

This is the entire contract. It says nothing about leave requests or expense claims - it just says "given a request and a decision, do whatever needs doing, and return a result." That's the whole point: the interface captures what all strategies have in common, not what makes them different.

Now each approval type gets its own class implementing this interface:

/// <summary>
/// Approves or rejects a pending leave request.
/// </summary>
public class ApproveLeaveRequestWorkflowAction : IHrApprovalStrategy
{
    private readonly HrContext _context;
    private readonly IEventPublisher _eventPublisher;
    private readonly ILogger<ApproveLeaveRequestWorkflowAction> _logger;

    public ApproveLeaveRequestWorkflowAction(
        HrContext context,
        IEventPublisher eventPublisher,
        ILogger<ApproveLeaveRequestWorkflowAction> logger)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _eventPublisher = eventPublisher ?? throw new ArgumentNullException(nameof(eventPublisher));
        _logger = logger;
    }

    public async Task<Result<Unit>> ExecuteAsync(
        HrAdminUser authenticatedUser,
        HrApprovalRequest pendingRequest,
        BaseHrApprovalDecisionRequest request,
        CancellationToken token)
    {
        await using var transaction = await _context.Database.BeginTransactionAsync(token);

        try
        {
            if (pendingRequest.Status != ApprovalRequestStatus.Pending)
            {
                return Result<Unit>.Failure("The selected request is not pending approval.");
            }

            var leaveRequest = await _context.LeaveRequests
                .FirstOrDefaultAsync(l => l.Id == pendingRequest.EntityId, token);

            if (leaveRequest == null)
            {
                return Result<Unit>.Failure("No associated leave request found.");
            }

            var finalStatus = request.IsApproved
                ? ApprovalRequestStatus.Approved
                : ApprovalRequestStatus.Rejected;

            pendingRequest.Status = finalStatus;
            pendingRequest.AuthorizedAt = DateTime.UtcNow;
            leaveRequest.IsApproved = request.IsApproved;

            _context.HrApprovalRequests.Update(pendingRequest);
            await _context.SaveChangesAsync(token);
            await transaction.CommitAsync(token);

            await _eventPublisher.Publish(
                new LeaveRequestDecisionEvent(authenticatedUser.Id, leaveRequest.Id, request.IsApproved),
                token);

            return Result<Unit>.Success(Unit.Value);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error approving leave request for {RequestId}", pendingRequest.RequestId);
            await transaction.RollbackAsync(token);
            return Result<Unit>.Failure("A system error occurred while processing the request.");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

One line matters more than it looks: await _eventPublisher.Publish(new LeaveRequestDecisionEvent(...), token). The strategy doesn't send an email, doesn't write an audit log, doesn't know if anyone is even listening - it announces "this happened" and moves on. That's deliberate. Part 4 covers what happens once a strategy succeeds: side effects like notifications and audit trails are handled by a completely separate set of classes, using the Observer pattern. The strategy itself stays focused on its one job - deciding the approval outcome.

A ApproveRoleChangeWorkflowAction or ApproveEmployeeOnboardingWorkflowAction would implement the same IHrApprovalStrategy interface, but internally do completely different things - update a role and trigger a permissions sync, or provision accounts and notify IT. None of that complexity leaks into the interface, and none of it needs to live in the service method anymore.

This is the core idea of Strategy: each algorithm becomes its own class, all implementing one shared interface, so they become interchangeable from the caller's point of view. Structurally, it looks like this:

IHrApprovalStrategy

Every box on the right is a separate class, in a separate file, that can be changed, tested, and reviewed without touching any of its siblings.

The Context: Decoupling "What to Run" from "How to Run It"

Strategy pattern has one more piece that's easy to skip but worth keeping: the Context class. Its job is narrow - it holds a reference to some strategy and knows how to invoke it, without knowing or caring which concrete strategy it's holding.

public class HrApprovalStrategyContext
{
    private IHrApprovalStrategy _approvalStrategy;

    public HrApprovalStrategyContext(IHrApprovalStrategy approvalStrategy)
    {
        _approvalStrategy = approvalStrategy;
    }

    /// <summary>
    /// Swaps the strategy at runtime, if needed.
    /// </summary>
    public void SetStrategy(IHrApprovalStrategy approvalStrategy)
    {
        _approvalStrategy = approvalStrategy;
    }

    /// <summary>
    /// Executes whichever strategy is currently set.
    /// </summary>
    public async Task<Result<Unit>> Invoke(
        HrAdminUser authenticatedUser,
        HrApprovalRequest pendingRequest,
        BaseHrApprovalDecisionRequest request,
        CancellationToken token)
    {
        return await _approvalStrategy.ExecuteAsync(authenticatedUser, pendingRequest, request, token);
    }
}
Enter fullscreen mode Exit fullscreen mode

It looks almost too simple to be useful - and that's exactly the point. The Context's entire job is to be boring. It's a thin wrapper that lets the calling code say "run whatever strategy you've been given" without ever writing the word if or switch.

Here's the request flow once both pieces are in place - notice the service method and the Context never reference a concrete strategy class by name:

Sequence Diagram for approval requests

With both pieces in place, the service method collapses down to 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.");
    }

    // ⚠️ We still need to figure out WHICH strategy to use here.
    // That's the subject of Part 2.
    IHrApprovalStrategy strategy = /* ??? */;

    var approvalContext = new HrApprovalStrategyContext(strategy);
    return await approvalContext.Invoke(authenticatedUser, pendingRequest, request, token);
}
Enter fullscreen mode Exit fullscreen mode

Notice what's gone: no if, no switch, no knowledge of leave requests, role changes, or onboarding flows. The service method's job is reduced to its actual responsibility - validate the request, find the record, and hand off execution. What happens during approval is entirely delegated to whichever strategy gets plugged in. (The controller above this, as mentioned, stays just as thin as it was before - it was never the one doing this work in the first place.)

What We've Actually Gained

Strategy pattern doesn't make any individual approval handler simpler - the leave-request logic is still 40-odd lines of transaction handling, validation, and event publishing. It doesn't reduce complexity; it relocates it, from one unmanageable method into many small, independently testable, independently changeable classes.

But that relocation is the whole win:

  • New approval type → new class. No existing code is touched, so no existing tests can break.
  • Each strategy is independently unit-testable with only the dependencies it actually needs - no need to mock the entire universe of approval types to test one of them.
  • Each strategy can evolve on its own schedule. A bug fix in expense-claim approval can't accidentally regress role-change approval, because they no longer share a method body.
  • The service method becomes permanently simple, regardless of whether the system has 4 approval types or 400.

The Catch

There's an obvious gap in the code above: that /* ??? */ where the strategy gets selected. We've successfully moved the algorithms out of the service method - but something, somewhere, still has to answer the question: given this request, which of my 50+ strategy classes should I run?

If that "something" is a switch statement, we haven't actually solved the problem. We've just moved it one layer down and given it a nicer name.

That's exactly the problem Part 2 tackles: resolving the correct strategy at runtime, automatically, using attributes and reflection - so that adding strategy #51 doesn't require editing a resolver method either.


Further Reading


Next: [Part 2 - Stop Writing Factory Switch Statements: Resolving Strategies with Attributes and Reflection]

Top comments (0)