patterns
Patterns - Software Design & Architecture
Know when to use (and when NOT to use) design patterns
When to Use This Skill
Use this skill when:
- Architecting new systems or refactoring existing ones
- Identifying code smells and anti-patterns
- Choosing appropriate design patterns for specific problems
- Applying SOLID principles to improve code quality
- Making code more testable, maintainable, and extensible
Don't use this skill when:
- Simple, straightforward code is sufficient
- Premature optimization or abstraction would add complexity
- The problem doesn't warrant pattern application
Critical Patterns
Pattern 1: SOLID Principles
When: Designing maintainable, testable code
Single Responsibility Principle (SRP):
// ✅ Good: Each class has one responsibility
class UserRepository {
save(user: User) { /* DB logic */ }
}
class EmailService {
sendWelcomeEmail(user: User) { /* Email logic */ }
}
class UserReportGenerator {
generate(user: User) { /* Report logic */ }
}
// ❌ Bad: God class doing everything
class UserService {
saveUser(user: User) { /* ... */ }
sendEmail(user: User) { /* ... */ }
generateReport(user: User) { /* ... */ }
}
Dependency Inversion Principle (DIP):
// ✅ Good: Depend on abstractions
interface MessageSender {
send(message: string): void;
}
class EmailService implements MessageSender {
send(message: string) { /* SMTP logic */ }
}
class UserRegistration {
constructor(private messageSender: MessageSender) {}
register(user: User) {
this.messageSender.send(`Welcome ${user.name}`);
}
}
// ❌ Bad: Tight coupling to concrete class
class UserRegistration {
private emailService = new EmailService();
register(user: User) {
this.emailService.send(`Welcome ${user.name}`);
}
}
Why: SOLID principles create code that's easier to test, maintain, and extend. They prevent common pitfalls like tight coupling and god classes.
For complete SOLID deep dive: SOLID Principles Reference
Pattern 2: Factory Pattern
When: Creating objects with complex initialization or multiple variants
Good:
interface Button {
render(): void;
}
class WindowsButton implements Button {
render() { console.log('Render Windows button'); }
}
class MacButton implements Button {
render() { console.log('Render Mac button'); }
}
class ButtonFactory {
static create(os: 'windows' | 'mac'): Button {
switch (os) {
case 'windows': return new WindowsButton();
case 'mac': return new MacButton();
default: throw new Error('Unknown OS');
}
}
}
// Usage
const button = ButtonFactory.create('windows');
button.render();
When to use:
- Object creation is complex or varies by context
- Need to decouple object creation from usage
- Creating families of related objects
When NOT to use:
- Simple object creation (just use
new) - Only one type of object exists
Pattern 3: Strategy Pattern
When: Swapping algorithms or behaviors at runtime
Good:
interface PaymentStrategy {
pay(amount: number): void;
}
class CreditCardPayment implements PaymentStrategy {
pay(amount: number) {
console.log(`Paid $${amount} with credit card`);
}
}
class PayPalPayment implements PaymentStrategy {
pay(amount: number) {
console.log(`Paid $${amount} with PayPal`);
}
}
class ShoppingCart {
constructor(private paymentStrategy: PaymentStrategy) {}
checkout(amount: number) {
this.paymentStrategy.pay(amount);
}
}
// Usage
const cart = new ShoppingCart(new CreditCardPayment());
cart.checkout(100);
Bad:
// ❌ Multiple if/else statements
class ShoppingCart {
checkout(amount: number, paymentType: string) {
if (paymentType === 'creditCard') {
// Process credit card
} else if (paymentType === 'paypal') {
// Process PayPal
} else if (paymentType === 'crypto') {
// Process crypto
}
}
}
Why: Strategy pattern eliminates conditional logic and makes adding new payment methods easy without modifying existing code (Open/Closed Principle).
Pattern 4: Observer Pattern
When: Implementing event-driven systems or pub/sub
Good:
interface Observer {
update(data: any): void;
}
class Subject {
private observers: Observer[] = [];
subscribe(observer: Observer) {
this.observers.push(observer);
}
notify(data: any) {
this.observers.forEach(observer => observer.update(data));
}
}
class EmailSubscriber implements Observer {
update(data: any) {
console.log(`Email sent: ${data}`);
}
}
class SMSSubscriber implements Observer {
update(data: any) {
console.log(`SMS sent: ${data}`);
}
}
// Usage
const newsletter = new Subject();
newsletter.subscribe(new EmailSubscriber());
newsletter.subscribe(new SMSSubscriber());
newsletter.notify('New article published!');
When to use:
- One-to-many dependencies
- Event systems
- State synchronization across components
Pattern 5: Adapter Pattern
When: Integrating incompatible interfaces
Good:
// Legacy interface
class OldPaymentGateway {
processPayment(amount: number) {
console.log(`Old gateway: $${amount}`);
}
}
// New interface
interface PaymentProcessor {
pay(amount: number, currency: string): void;
}
// Adapter
class PaymentAdapter implements PaymentProcessor {
constructor(private oldGateway: OldPaymentGateway) {}
pay(amount: number, currency: string) {
if (currency !== 'USD') {
throw new Error('Old gateway only supports USD');
}
this.oldGateway.processPayment(amount);
}
}
When to use:
- Integrating third-party libraries
- Working with legacy code
- Reusing existing classes with different interfaces
Anti-Patterns
❌ Anti-Pattern 1: Premature Abstraction
Don't do this:
// ❌ Over-engineered for simple config
interface ConfigStrategy { get(key: string): any; }
class JSONConfigStrategy implements ConfigStrategy { /* ... */ }
class YAMLConfigStrategy implements ConfigStrategy { /* ... */ }
class ConfigFactory { /* ... */ }
Do this instead:
// ✅ Start simple
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000
};
Why: YAGNI (You Aren't Gonna Need It). Add abstraction when you have a concrete need, not "just in case."
❌ Anti-Pattern 2: God Object
Don't do this:
// ❌ One class doing everything
class Application {
handleAuth() { /* ... */ }
renderUI() { /* ... */ }
saveToDatabase() { /* ... */ }
sendEmail() { /* ... */ }
generateReports() { /* ... */ }
// 50 more methods...
}
Do this instead:
// ✅ Apply Single Responsibility Principle
class AuthService { /* ... */ }
class UIRenderer { /* ... */ }
class DatabaseService { /* ... */ }
class EmailService { /* ... */ }
class ReportGenerator { /* ... */ }
❌ Anti-Pattern 3: Copy-Paste Programming
Don't do this:
// ❌ Duplicated code
function validateEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function validateUserEmail(user: User) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email);
}
Do this instead:
// ✅ DRY (Don't Repeat Yourself)
function isValidEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function validateUserEmail(user: User) {
return isValidEmail(user.email);
}
Code Examples
Example 1: Factory Pattern for Payment Processing
interface PaymentProcessor {
process(amount: number): Promise<void>;
}
class StripeProcessor implements PaymentProcessor {
async process(amount: number) {
console.log(`Processing $${amount} via Stripe`);
}
}
class PayPalProcessor implements PaymentProcessor {
async process(amount: number) {
console.log(`Processing $${amount} via PayPal`);
}
}
class PaymentFactory {
static create(provider: 'stripe' | 'paypal'): PaymentProcessor {
switch (provider) {
case 'stripe': return new StripeProcessor();
case 'paypal': return new PayPalProcessor();
}
}
}
// Usage
const processor = PaymentFactory.create('stripe');
await processor.process(99.99);
Example 2: Dependency Injection for Testability
interface Logger {
log(message: string): void;
}
class UserService {
constructor(private logger: Logger) {}
createUser(name: string) {
this.logger.log(`Creating user: ${name}`);
// User creation logic
}
}
// Production
const service = new UserService(new ConsoleLogger());
// Testing
const mockLogger = { log: jest.fn() };
const testService = new UserService(mockLogger);
For comprehensive examples and detailed implementations, see the references/ folder.
Quick Reference
When to Use Each Pattern
| Pattern | Use When | Don't Use When |
|---|---|---|
| Factory | Complex object creation, multiple variants | Simple new is sufficient |
| Builder | Many optional parameters | Few parameters, simple construction |
| Singleton | Truly need ONE global instance | Just want convenience (use DI) |
| Adapter | Integrating incompatible interfaces | Interfaces already compatible |
| Decorator | Adding responsibilities dynamically | Static behavior is fine |
| Facade | Simplifying complex subsystem | Subsystem is already simple |
| Observer | Event-driven, one-to-many updates | Simple callbacks work |
| Strategy | Swappable algorithms at runtime | Fixed algorithm |
| Command | Undo/redo, queuing operations | Direct method calls work |
SOLID Quick Checklist
- S: Each class has single, focused responsibility
- O: New features added via extension, not modification
- L: Subtypes don't break parent class behavior
- I: Interfaces are small and focused
- D: Depend on abstractions, inject dependencies
Progressive Disclosure
For detailed implementations and examples:
- Design Patterns Guide - Complete pattern implementations (Factory, Builder, Singleton, Adapter, Decorator, Facade, Observer, Strategy, Command)
- SOLID Principles - Deep dive into SRP, OCP, LSP, ISP, DIP with real-world examples
References
- Design Patterns Guide
- SOLID Principles
- Refactoring.Guru - Design Patterns
- Refactoring.Guru - SOLID Principles
Maintained by dsmj-ai-toolkit