Sitemap

Strategy Pattern: Because Your Giant if Statement is Crying for Help

5 min readMay 12, 2025

As projects grow, so does the complexity of decision-making logic — often leading to tangled webs of if statements, switch blocks, or bloated classes. The Strategy pattern is a behavioral design pattern that lets you define a family of algorithms, put each of them into a separate class, and make their objects interchangeable.

Strategy Pattern Cartoon

Writing good code is more than just making things work — it’s about making them easy to change, read, and reuse. But as your application grows, it’s easy to fall into the trap of overloading classes with behavior and piling on conditionals. That’s where design patterns come in. In this article, we’ll take a closer look at the Strategy pattern by analyzing a real-world example I encountered a few weeks ago.

🧨 The Problem: Code That Doesn’t Scale

I’m currently working on an open-source project where I had to implement a “Task Progress Calculator” that, based on a given parameter, had to do specific calculations to determine the overall progress of a task.

I started off by creating a TaskProgressCalculator class and adding several progress calculations.

final readonly class TaskProgressCalculator
{
public function __construct(
private TaskTagRepository $TaskTagRepository,
//...
) {
}

public function calculateProgressFor(Task $task): float
{
if (TaskType::DISTANCE === $task->getType()) {
// Calculations for distance-based tasks.
// Includes DB queries to distance tables.
return $calculatedProgress;
} elseif (TaskType::TIME === $task->getType()) {
// Calculations for time-based tasks.
// Includes DB queries to time tables.
return $calculatedProgress;
} elseif (TaskType::WORKLOAD === $task->getType()) {
// Calculations for workload-based tasks.
return $calculatedProgress;
}

throw new \RuntimeException(sprintf('No progress calculation found for task type %s', $taskType->value));
}
}

Easy enough, the TaskProgressCalculator class is responsible for determining the progress of a task based on its type. It acts as a central decision-maker. When calculateProgressFor() is called with a specific Task, the class checks the type and runs the corresponding calculation logic. Each branch may query different data sources (like distance or time tracking tables) to compute the progress, returning the $calculatedProgress .

I ended up with this corresponding test:

class MaintenanceTaskProgressCalculatorTest extends ContainerTestCase
{
public function testCalculateProgressForTaskTypeDistance(): void
{
//...
}

public function testCalculateProgressForTaskTypeTime(): void
{
//...
}

public function testCalculateProgressForTaskTypeWorkload(): void
{
//...
}

public function testCalculateProgressForInvalidTaskType(): void
{
//...
}
}

This kind of code might work for now, but it quickly becomes hard to read, hard to test, and even harder to extend. So how do we fix it?

Let’s look at a cleaner, more scalable approach.

🛠 The Solution

This previous implementation works, but as more task types can be added, the if/elseif chain becomes harder to manage and violates the Open/Closed Principle — the idea that classes should be open for extension but closed for modification.

This sets the stage perfectly for applying the Strategy Pattern, which lets you encapsulate each type of calculation in its own class and cleanly use the correct one at runtime.

I started by creating a new TaskProgressCalculation interface

interface TaskProgressCalculation
{
public function supports(TaskType $taskType): bool;

public function calculate(ProgressCalculationContext $context): float;
}

Then I added the actual implementations using this interface

final readonly class DistanceUsedProgressCalculation implements TaskProgressCalculation
{
public function supports(TaskType $taskType): bool
{
return in_array($taskType, [
TaskType::DISTANCE,
TaskType::DISTANCE_IN_MILES,
]);
}

public function calculate(ProgressCalculationContext $context): float
{
// Calculations for distance-based tasks.
// Includes DB queries to distance tables.
return $calculatedProgress;
}
}
final readonly class TimeUsedProgressCalculation implements TaskProgressCalculation
{
public function supports(TaskType $taskType): bool
{
return TaskType::TIME === taskType;
}

public function calculate(ProgressCalculationContext $context): float
{
// Calculations for time-based tasks.
// Includes DB queries to time tables.
return $calculatedProgress;
}
}
final readonly class WorkloadProgressCalculation implements TaskProgressCalculation
{
public function supports(TaskType $taskType): bool
{
return TaskType::WORKLOAD === taskType;
}

public function calculate(ProgressCalculationContext $context): float
{
// Calculations for workload-based tasks.
return $calculatedProgress;
}
}

After which I updated the TaskProgressCalculator to use them:

final readonly class TaskProgressCalculator
{
public function __construct(
private array $calculations
) {
}

public function calculateProgressFor(TaskType $taskType): TaskProgress
{
foreach ($this->calculations as $calculation) {
if (!$calculation->supports($taskType)) {
continue;
}

return $calculation->calculate($context);
}

throw new \RuntimeException(sprintf('No progress calculation found for task type %s', $taskType->value));
}
}

$calculations = [
new DistanceUsedProgressCalculation(),
new TimeUsedProgressCalculation(),
new WorkloadProgressCalculation(),
];

By refactoring the TaskProgressCalculator class to use the Strategy pattern, I made the codebase more modular, extensible, and easier to maintain. Each task type now has its own dedicated class responsible for its specific calculation logic, which eliminates the need for conditional statements inside TaskProgressCalculator. This not only improves readability but also adheres to the Open/Closed Principle, making it straightforward to add new task types in the future.

For the Symfony nerds 🤓

Whenever I implement a Strategy pattern in Symfony, I tend to use tagged iterators for automatic discovery of my strategies. This eliminates the need for a harcoded array of strategies and allows each strategy to fully leverage dependency injection.

To set this up, tell Symfony to tag each implementation of TaskProgressCalculation with a specific tag by adding the AutoConfigureTag attribute

#[AutoconfigureTag('app.progress_calculation')]
interface TaskProgressCalculation
{
public function supports(TaskType $taskType): bool;

public function calculate(ProgressCalculationContext $context): float;
}

Then, configure Symfony to collect all services tagged with app.progress_calculation and inject them into the TaskProgressCalculator

services:
App\Domain\Task\Progress\TaskProgressCalculator:
class: App\Domain\Task\Progress\TaskProgressCalculator
arguments: [!tagged_iterator app.progress_calculation]

With this in place, the TaskProgressCalculator can now be refactored as follows:

final readonly class TaskProgressCalculator
{
public function __construct(
private iterable $taskProgressCalculations,
) {
}

public function calculateProgressFor(TaskType $taskType): TaskProgress
{
foreach ($this->taskProgressCalculations as $calculation) {
if (!$calculation->supports($taskType)) {
continue;
}

return $calculation->calculate($context);
}

throw new \RuntimeException(sprintf('No progress calculation found for task type %s', $taskType->value));
}
}

Refactoring to the Strategy pattern not only cleaned up the conditional logic but also made the codebase more flexible, testable, and aligned with SOLID principles. By leveraging Symfony’s tagged iterators, we eliminated boilerplate and made it easy to add new strategies without touching existing code — exactly what maintainable architecture should aim for.

Let me know what you think — I’d love to hear how you’ve used the Strategy pattern in your own projects!

--

--

Robin Ingelbrecht
Robin Ingelbrecht

Written by Robin Ingelbrecht

My name is Robin Ingelbrecht, and I'm an open source (web) developer at heart and always try to up my game. Obviously, I'm also into gaming 🎮.

Responses (2)