As a senior software engineer, you’ve probably worked with codebases full of deeply nested if/else blocks and oversized methods that try to do everything at once. At some point, you hit the wall and decide: no more—this needs a refactor.
But then the real question appears: how do you refactor complex conditional logic where behavior changes based on multiple criteria? For most of us, a light bulb turns on: Strategy, Template Method, or Chain of Responsibility. Great—this means you’re thinking in the right direction.
The next challenge, though, is the important one: how do you actually apply these patterns correctly in a real-world scenario?
In this article we'll go through a simple process to apply this.
First take a look into this snippet:
export class CheckoutService {
async checkout(order: Order) {
// 1. Validation, run validation rules here and throw exceptions if a rule is broken
if (!order.items.length) {
throw new Error('Empty order');
}
if (order.total < 50 && order.paymentMethod === 'COD') {
throw new Error('COD not allowed for orders < 50');
}
// 2. Discounts, apply discounts per coupon type each type has a different logic
if (order.coupon) {
if (order.coupon.type === 'PERCENT') {
order.total -= order.total * order.coupon.value / 100;
} else if (order.coupon.type === 'FIXED') {
order.total -= order.coupon.value;
}
}
if (order.user.isVip) {
order.total *= 0.9;
}
// 3. Taxes, apply taxes per country each country has a different tax rate
if (order.country === 'EG') {
order.total *= 1.14;
} else if (order.country === 'US') {
order.total *= 1.08;
}
// 4. Payment, process payment based on payment method
if (order.paymentMethod === 'STRIPE') {
await stripe.charge(order);
} else if (order.paymentMethod === 'PAYPAL') {
await paypal.pay(order);
} else if (order.paymentMethod === 'COD') {
// do nothing
}
// 5. Notifications
if (order.user.email) {
await emailService.send(order.user.email, 'Order placed');
}
// 6. Analytics
await analytics.track('order_completed', order);
return order;
}
}
As you see no magic most of our code bases looks like this, but unfortunately this style not for medium/enterprise level software.
So let’s break it down:
First, identify where the Strategy pattern makes sense—for example: applying coupons, calculating taxes, or processing payments.
Second, notice that the code follows a simple chain of steps, yet all these operations are handled inside a single class or method.
Now take a look at this refactored code:
export interface User {
id: number;
email: string;
}
export interface Coupon {
id: number;
code: string;
type: 'percentage' | 'fixed' | 'free_shipping';
value: number;
expiration_date: Date;
}
export interface Order {
total: number;
discount: number;
tax: number;
delivery_fee: number;
coupon: Coupon;
user: User;
}
export interface ProcessHandler {
handle(): void;
}
// this idea can be cloned for taxation & payment too
export class DiscountProcessHandler implements ProcessHandler {
constructor(private readonly order: Order) {}
handle(): void {
console.log('DiscountProcessHandler');
// this is not the optimized version, we should use a factory pattern to create the discount handler per coupon type
// here we apply a simple strategy pattern to apply the discount per coupon type
switch (this.order.coupon.type) {
case 'percentage':
this.applyPercentageDiscount();
break;
case 'fixed':
this.applyFixedDiscount();
break;
case 'free_shipping':
this.applyFreeShipping();
break;
}
}
private applyPercentageDiscount(): void {
this.order.discount = this.order.total * 0.1;
}
private applyFixedDiscount(): void {
this.order.discount = 10;
}
private applyFreeShipping(): void {
this.order.discount = 0;
this.order.delivery_fee = 0;
}
}
As you can see, this is where a lot of interfaces start popping up.
And let me share a quote with you again over and over
We design for abstraction, not for concrete implementations
Now lets bring down the other magic Chain Hanlder Registry
It'll be used to register our process for easy configure it back again and easy refactor it to be more dynamic
export class ProcessHandlerRegistry {
private handlers = new Map<string, ProcessHandler>();
register(handler: ProcessHandler) {
this.handlers.set(handler.key, handler);
}
get(key: string): ProcessHandler {
const handler = this.handlers.get(key);
if (!handler) {
throw new Error(`Handler ${key} not found`);
}
return handler;
}
}
export class ProcessExecutor {
constructor(
private readonly registry: ProcessHandlerRegistry,
) {}
async execute(order: Order) {
for (const key of this.config) {
const handler = this.registry.get(key);
await handler.handle(order);
}
}
}
Now our blue print is ready for use with some principles applied in its right places
Lets run it
const registry = new ProcessHandlerRegistry();
registry.register(new ValidationHandler());
registry.register(new DiscountHandler());
registry.register(new PaymentHandler());
registry.register(new NotificationHandler());
registry.register(new AnalyticsHandler());
registry.register(new FraudCheckHandler());
const chain = new ProcessExecutor(
registry,
);
await chain.execute({ order });
By this way you have successfully process your order in a better way than previous and now you can do a lot of things, e.g. add a new handler to do a process or even you can make it configure with feature flags and other many things...
Final Thought:
At the end of the day, it’s all about design. Build your software like an engine with replaceable pieces, and you’re on the right track. Thanks, my friend 👊
Top comments (0)