Design patterns are often misunderstood as rigid rules or "magic incantations" that guarantee scalable code. In reality, they are proven solutions to recurring problems in software design. However, knowing the name of a pattern is only half the battle; understanding when and how to apply it without over-engineering is where seniority is truly demonstrated. This post dives into the three core categories of design patterns—Creational, Structural, and Behavioral—with a focus on practical implementation rather than just theoretical definitions.
Creational Patterns: Managing Object Creation
Creational patterns deal with object creation mechanisms, trying to create objects in a manner suitable to the situation. The most common is the Factory Method. Instead of directly instantiating objects, you ask a factory to create them for you. This decouples the client code from the concrete classes, making the system more extensible.
class NotificationService {
public send() { /* ... */ }
}
class EmailNotification extends NotificationService {
public send() { console.log("Email sent"); }
}
class SMSService extends NotificationService {
public send() { console.log("SMS sent"); }
}
class NotificationFactory {
public static create(type: string): NotificationService {
switch(type) {
case 'email': return new EmailNotification();
case 'sms': return new SMSService();
default: throw new Error("Invalid type");
}
}
}
// Usage
const notifier = NotificationFactory.create('email');
notifier.send();
This approach allows you to add new notification types (like Push Notifications) without modifying existing client code, adhering to the Open/Closed Principle.
Structural Patterns: Composing Objects and Classes
Structural patterns are concerned with how classes and objects are composed to form larger structures. The Decorator Pattern is a favorite among developers because it allows you to extend the functionality of an individual object without modifying its structure. This is often a better alternative to inheritance when you need to add responsibilities dynamically.
interface Coffee {
cost(): number;
description(): string;
}
class BasicCoffee implements Coffee {
cost() { return 1.50; }
description() { return "Black Coffee"; }
}
class MilkDecorator implements Coffee {
private component: Coffee;
constructor(component: Coffee) { this.component = component; }
cost() { return this.component.cost() + 0.50; }
description() { return this.component.description() + " + Milk"; }
}
class SugarDecorator implements Coffee {
private component: Coffee;
constructor(component: Coffee) { this.component = component; }
cost() { return this.component.cost() + 0.10; }
description() { return this.component.description() + " + Sugar"; }
}
const coffee = new SugarDecorator(new MilkDecorator(new BasicCoffee()));
console.log(coffee.description()); // "Black Coffee + Milk + Sugar"
Behavioral Patterns: Communication Between Objects
Behavioral patterns are concerned with algorithms and the assignment of responsibilities between objects. The Strategy Pattern allows you to define a family of algorithms, encapsulate each one, and make them interchangeable at runtime. This is perfect for scenarios where the behavior of an object needs to change based on context, such as different sorting algorithms or payment processing methods.
interface PaymentStrategy {
pay(amount: number): void;
}
class CreditCardPayment implements PaymentStrategy {
pay(amount: number) { console.log(`Paid $${amount} via Credit Card`); }
}
class PayPalPayment implements PaymentStrategy {
pay(amount: number) { console.log(`Paid $${amount} via PayPal`); }
}
class ShoppingCart {
private paymentStrategy: PaymentStrategy;
setPaymentMethod(strategy: PaymentStrategy) {
this.paymentStrategy = strategy;
}
checkout(amount: number) {
this.paymentStrategy.pay(amount);
}
}
Conclusion
Design patterns are not about showing off knowledge; they are about writing maintainable, flexible code. The key to mastering them is to recognize the problems they solve before reaching for the solution itself. As you progress in your career, you'll find that you're not just "using" patterns, but that your architectural decisions naturally align with them. Start with simple implementations, refactor towards these structures when complexity arises, and always prioritize simplicity over cleverness.