From Spaghetti to Clean Code: Refactoring with Strategy Pattern
Imagine an order system. When an order ships, we need to notify the customer. It starts with email. Then push notifications. Then Slack for power users. Then SMS for critical alerts. And WhatsApp is coming next quarter.
A common first attempt is to put all the notification logic directly inside the `ship` method.
class Order {
constructor(public id: number, private customer: any) {}
ship() {
// shipping logic here.
// -------- problem starts here ----------
if (this.customer.prefersEmail) {
console.log(`Sending email to ${this.customer.email}`);
// actual email sending code
}
// Reason to change #2: email logic
if (this.customer.prefersPush) {
console.log(`Sending push to ${this.customer.pushToken}`);
// push code
}
// Reason to change #3: push logic
if (this.customer.prefersSlack) {
console.log(`Sending Slack to ${this.customer.slackId}`);
// slack code
}
// ↑ Reason to change #4: Slack logic
if (this.customer.prefersSms) {
console.log(`Sending SMS to ${this.customer.phone}`);
// sms code
}
// Reason to change #5: SMS logic
// Adding WhatsApp means adding another `if` here would mean another reason to change
}
}The code works, but every new notification channel means editing this same method. The method grows, and a mistake in one channel could accidentally break another because everything is coupled.
The class is now open for modification, closed for extension (violates open for extension and closed for modification principle)
The high-level Order depends on low-level details (violates Dependency Inversion Principle). The dependency inversion principle says that a class should depend on the abstraction and not the implementation.
Every notification channel is tangled together a bug in one can break others.
The Solution: Strategy Pattern
The Strategy pattern says, encapsulate each algorithm (each notification method) in its own class, and let the main class depend on an abstraction.
The Strategy pattern is a behavioral design pattern that defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from the clients that use it.
In the context of design patterns, behavioral means the pattern focuses on how objects interact, communicate, and distribute responsibilities, in other words, how they behave. These patterns deal with algorithms, flow of control, and the assignment of duties among objects.
The Strategy pattern is behavioral because it encapsulates a family of algorithms (behaviors) and makes them interchangeable. Instead of hard‑coding a single behavior inside a class, you let the class delegate to a pluggable strategy, changing its behavior at runtime. That’s a classic example of managing object behavior in a flexible, decoupled way.
As for Encapsulation, it is the practice of bundling data and the methods that operate on that data into a single unit (a class), and hiding the internal details from the outside world. In object‑oriented programming, it’s one of the core principles
So we would have one interface NotificationStrategy and that would be implemented by notification classes.
Here’s the refactored version.
interface NotificationStrategy {
send(orderId: number, recipient: any): void;
}
class EmailNotification implements NotificationStrategy {
send(orderId: number, recipient: { email: string }) {
console.log(`Email to ${recipient.email} about order ${orderId}`);
// email sending logic...
}
}
class PushNotification implements NotificationStrategy {
send(orderId: number, recipient: { pushToken: string }) {
console.log(`Push to ${recipient.pushToken} about order ${orderId}`);
// push notification implementation
}
}
class SlackNotification implements NotificationStrategy {
send(orderId: number, recipient: { slackId: string }) {
console.log(`Slack to ${recipient.slackId} about order ${orderId}`);
// slack notification implementation
}
}
class SmsNotification implements NotificationStrategy {
send(orderId: number, recipient: { phone: string }) {
console.log(`SMS to ${recipient.phone} about order ${orderId}`);
}
}
class Order {
private strategies: NotificationStrategy[] = [];
constructor(public id: number, private customer: any) {}
addStrategy(strategy: NotificationStrategy) {
this.strategies.push(strategy);
}
ship() {
// shipping logic
// Delegate to all attached strategies, Order no longer knows the details
for (const strategy of this.strategies) {
strategy.send(this.id, this.customer);
}
}
}
Now the `Order` class no longer knows anything about email, Slack, or SMS. It just knows it has a list of strategies. The strategies are built outside and injected:
const order = new Order(123, customer);
if (customer.prefersEmail) order.addStrategy(new EmailNotification());
if (customer.prefersPush) order.addStrategy(new PushNotification());
if (customer.prefersSlack) order.addStrategy(new SlackNotification());
if (customer.prefersSms) order.addStrategy(new SmsNotification());
order.ship();
So now you can see Order class has only one reason to change & it knows nothing about the implementation details of notification classes.
Also let’s say if during next release if one of notification methods has logical error, then rest of the methods will work fine and independently of erroneous method.

