5 Design Patterns שכל מפתח חייב להכיר
כל קוד עובד. לא כל קוד שרוד. הקוד שכתבתם לפני שנה — כמה קשה לשנות אותו היום? Design Patterns הם לא כלי לכתוב קוד "יפה". הם כלי לכתוב קוד שניתן לשנות בלי לפחד.
ב-Google, מראיינים לעתים קרובות שואלים: "איזה design pattern היה מאפשר לנו להוסיף את הfeature הזה בלי לשנות קוד קיים?" ב-monday.com, code reviews כוללים שאלות כמו "האם אנחנו מפרים Open/Closed Principle כאן?" Design Patterns הם שפה משותפת. מי שמכיר אותם — מדבר ברמה שונה.
למה Patterns קיימים
ב-1994, ארבעה מחברים (ה-"Gang of Four") תיעדו 23 patterns — פתרונות שחזרו על עצמם בפרויקטים שונים לבעיות דומות. לא המציאו אותם — תיעדו מה מפתחים מנוסים עשו באינסטינקט.
הרעיון המרכזי: בעיות חוזרות ראויות לפתרונות מוכחים. כמה פעמים כתבתם קוד ש"נראה לכם שעשיתם את זה קודם"? כנראה עשיתם. ה-Pattern נותן לבעיה שם, לפתרון מבנה, ולצוות שלכם common language.
שלוש קטגוריות: Creational (איך יוצרים objects), Structural (איך מרכיבים objects), Behavioral (איך objects מתקשרים). הפוסט הזה מכסה אחד-שניים מכל קטגוריה — הפרקטיים שתשתמשו בהם שוב ושוב.
1. Singleton — מופע יחיד
הבעיה: Connection Pool לDB, Logger, Configuration Manager — אם יוצרים כמה instances, יש בעיות: כמה connections פתוחים, logs מפוזרים, config לא סינכרוני.
הפתרון: Singleton — מבטיח שרק instance אחד של class יקום, ונותן גישה גלובלית אליו.
class DatabasePool {
private static instance: DatabasePool | null = null;
private pool: Connection[] = [];
// constructor פרטי — לא ניתן לקרוא new DatabasePool() מבחוץ
private constructor() {
this.pool = createConnections(process.env.DATABASE_URL!, 10);
}
static getInstance(): DatabasePool {
if (!DatabasePool.instance) {
DatabasePool.instance = new DatabasePool();
}
return DatabasePool.instance;
}
async query(sql: string, params: unknown[]): Promise<Row[]> {
const conn = await this.getConnection();
try {
return await conn.execute(sql, params);
} finally {
this.releaseConnection(conn);
}
}
}
// שימוש — כל קריאה מחזירה אותו instance
const db = DatabasePool.getInstance();
await db.query("SELECT * FROM users WHERE id = ?", [userId]);מתי להשתמש: כשמשאב יקר ושיתוף חייב להיות מדויק — DB connections, thread pools, cache. מתי לא: Singleton הוא global state — מקשה על testing. שקלו dependency injection כחלופה בcodebase גדול.
2. Factory Method — יצירה בלי לדעת את הפרטים
הבעיה: אתם בונים מערכת notifications. לפעמים שולחים email, לפעמים SMS, לפעמים push notification. הקוד שמחליט מה לשלוח לא צריך לדעת איך כל סוג עובד בפנים.
הפתרון: Factory Method — פונקציה/method שיוצרת את האובייקט המתאים לפי פרמטר, מבלי שה-caller יודע איזה class ספציפי נוצר.
interface Notification {
send(to: string, message: string): Promise<void>;
}
class EmailNotification implements Notification {
async send(to: string, message: string): Promise<void> {
await sendgrid.send({ to, subject: "New notification", html: message });
}
}
class SMSNotification implements Notification {
async send(to: string, message: string): Promise<void> {
await twilio.messages.create({ to, from: SMS_FROM, body: message });
}
}
class PushNotification implements Notification {
async send(to: string, message: string): Promise<void> {
await firebase.messaging().send({ token: to, notification: { body: message } });
}
}
// ה-Factory
function createNotification(type: "email" | "sms" | "push"): Notification {
switch (type) {
case "email": return new EmailNotification();
case "sms": return new SMSNotification();
case "push": return new PushNotification();
default: throw new Error(`Unknown notification type: ${type}`);
}
}
// שימוש — ה-caller לא יודע ולא צריך לדעת
const notification = createNotification(user.preferredChannel);
await notification.send(user.contact, "Your order is ready!");הכוח האמיתי: כשרוצים להוסיף WhatsApp notifications — מוסיפים class אחד ושורה אחת בswitch. שאר הקוד לא משתנה. זה Open/Closed Principle בפעולה.
3. Observer — listen לאירועים
הבעיה: משתמש השלים הזמנה. צריך: לשלוח email אישור, לעדכן inventory, לשלוח notification ל-analytics, לשלוח SMS. אם שמים את כל זה בOrdersService — הoS הפך ל-god object שיודע על הכל.
הפתרון: Observer Pattern — subject מנפיק events, observers מאזינים ומגיבים. Subject לא יודע מי מאזין.
type EventHandler<T> = (data: T) => void | Promise<void>;
class EventBus {
private listeners = new Map<string, EventHandler<unknown>[]>();
on<T>(event: string, handler: EventHandler<T>): void {
const handlers = this.listeners.get(event) ?? [];
this.listeners.set(event, [...handlers, handler as EventHandler<unknown>]);
}
async emit<T>(event: string, data: T): Promise<void> {
const handlers = this.listeners.get(event) ?? [];
await Promise.all(handlers.map(h => h(data)));
}
}
const eventBus = new EventBus();
// Observers — כל אחד אחראי לעצמו
eventBus.on("order.completed", async ({ orderId, userId, total }) => {
await emailService.sendOrderConfirmation(userId, orderId);
});
eventBus.on("order.completed", async ({ orderId, items }) => {
await inventoryService.decrementStock(items);
});
eventBus.on("order.completed", async ({ orderId, total, userId }) => {
await analytics.track("purchase", { orderId, total, userId });
});
// Subject — לא יודע מי מאזין
class OrdersService {
async completeOrder(orderId: string): Promise<void> {
const order = await this.ordersRepo.complete(orderId);
// emit ותן לכולם להגיב
await eventBus.emit("order.completed", order);
}
}זה בדיוק מה שקורה ב-Node.js EventEmitter, ב-React (useState triggers re-render), ב-Redux (dispatch → reducers → side effects). Observer הוא הבסיס של Event-Driven programming.
Observer Pattern מאפשר loose coupling מעולה — subject ו-observers לא מכירים אחד את השני. החיסרון: debugging קשה יותר כשהflow עובר דרך events ולא דרך function calls ישירים. כלים כמו Distributed Tracing חשובים כשמשתמשים בזה בscale.
4. Strategy — אלגוריתמים החלפים
הבעיה: מערכת תשלומים שצריכה לתמוך ב-Credit Card, PayPal, Apple Pay, ו-Stripe. הגישה הנאיבית: if/else ענקי בפונקציה processPayment. כל provider חדש = לגעת בקוד ישן = risk.
הפתרון: Strategy Pattern — כל אלגוריתם (payment provider) הוא class נפרד שמממש interface משותף. ניתן להחליף ביניהם בזמן ריצה.
interface PaymentStrategy {
charge(amount: number, currency: string, metadata: PaymentMetadata): Promise<PaymentResult>;
refund(transactionId: string, amount: number): Promise<RefundResult>;
}
class StripeStrategy implements PaymentStrategy {
async charge(amount: number, currency: string, meta: PaymentMetadata) {
const intent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // Stripe works in cents
currency,
payment_method: meta.paymentMethodId,
confirm: true,
});
return { transactionId: intent.id, status: intent.status };
}
async refund(transactionId: string, amount: number) {
const refund = await stripe.refunds.create({
payment_intent: transactionId,
amount: Math.round(amount * 100),
});
return { refundId: refund.id };
}
}
class PayPalStrategy implements PaymentStrategy {
async charge(amount: number, currency: string, meta: PaymentMetadata) {
const order = await paypal.orders.create({ amount, currency });
const captured = await paypal.orders.capture(order.id);
return { transactionId: captured.id, status: "success" };
}
async refund(transactionId: string, amount: number) {
const refund = await paypal.captures.refund(transactionId, { amount });
return { refundId: refund.id };
}
}
// Context — לא יודע איזה Strategy נבחר
class PaymentProcessor {
constructor(private strategy: PaymentStrategy) {}
setStrategy(strategy: PaymentStrategy): void {
this.strategy = strategy;
}
async processPayment(amount: number, currency: string, meta: PaymentMetadata) {
return this.strategy.charge(amount, currency, meta);
}
}
// שימוש
const processor = new PaymentProcessor(new StripeStrategy());
await processor.processPayment(99.99, "ILS", paymentMeta);
// לעבור ל-PayPal — שורה אחת
processor.setStrategy(new PayPalStrategy());להוסיף Crypto payments: מוסיפים CryptoStrategy implements PaymentStrategy. אף שורה קיימת לא משתנה. זה Open/Closed Principle במיטבו.
5. Decorator — הוספת יכולות בלי ירושה
הבעיה: יש לכם function שקוראת מDB. רוצים להוסיף: caching, logging, retry על failure, rate limiting. אם תוסיפו את כל זה לfunction — היא תהפוך ל-300 שורות של logic מעורב.
הפתרון: Decorator Pattern — עוטפים פונקציה/object ומוסיפים יכולות מסביב, בלי לשנות את הקוד המקורי.
type AsyncFn<T> = (...args: unknown[]) => Promise<T>;
// Decorator: Caching
function withCache<T>(fn: AsyncFn<T>, ttlMs = 60_000): AsyncFn<T> {
const cache = new Map<string, { value: T; expiresAt: number }>();
return async (...args) => {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return cached.value;
}
const value = await fn(...args);
cache.set(key, { value, expiresAt: Date.now() + ttlMs });
return value;
};
}
// Decorator: Retry
function withRetry<T>(fn: AsyncFn<T>, maxRetries = 3, delayMs = 1000): AsyncFn<T> {
return async (...args) => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn(...args);
} catch (error) {
if (attempt === maxRetries) throw error;
await new Promise(r => setTimeout(r, delayMs * attempt)); // exponential backoff
}
}
throw new Error("Should not reach here");
};
}
// Decorator: Logging
function withLogging<T>(fn: AsyncFn<T>, name: string): AsyncFn<T> {
return async (...args) => {
const start = Date.now();
try {
const result = await fn(...args);
logger.info(`${name} succeeded in ${Date.now() - start}ms`);
return result;
} catch (error) {
logger.error(`${name} failed after ${Date.now() - start}ms`, { error });
throw error;
}
};
}
// הפונקציה הבסיסית — נקייה, פשוטה
async function fetchUser(userId: string): Promise<User> {
return db.query("SELECT * FROM users WHERE id = ?", [userId]);
}
// הרכבה — כל decorator מוסיף שכבה
const fetchUserWithLogging = withLogging(fetchUser, "fetchUser");
const fetchUserWithRetry = withRetry(fetchUserWithLogging, 3, 500);
const fetchUserCached = withCache(fetchUserWithRetry, 30_000);
// שימוש — caller לא יודע על הdecorators
const user = await fetchUserCached("user-123");זה בדיוק מה שExpress Middleware עושה — כל middleware הוא Decorator שמוסיף functionality (auth, logging, rate limiting) בלי לשנות את ה-route handler עצמו.
אפשר להרכיב decorators בסדרים שונים ולקבל התנהגויות שונות. Cache לפני Retry (cache לא יחזיר errors) שונה מRetry לפני Cache (retry על cache miss). הגמישות הזו היא הכוח של הPattern.
מתי להשתמש — ומתי לא
| Pattern | Use When | Don't Use When |
|---|---|---|
| Singleton | משאב shared יקר (DB pool, config) | בדיקות — global state מקשה על isolation |
| Factory | יצירת objects לפי type שמשתנה | יש רק type אחד ולא צפוי שיהיו עוד |
| Observer | event-driven, loose coupling נדרש | הflow חייב להיות synchronous וברור |
| Strategy | כמה אלגוריתמים חלופיים לאותה בעיה | יש רק אלגוריתם אחד ולא יהיו עוד |
| Decorator | הוספת cross-cutting concerns (logging, cache, retry) | הlogic שמוסיפים הוא core logic, לא wrapping |
Design Patterns הם כלי, לא מטרה. "Pattern-itis" — הכנסת patterns בכוח גם כשאין להם סיבה — הוא בעיה אמיתית. הקוד הכי ברור הוא לפעמים הקוד הפשוט ביותר. הוסיפו pattern רק כשיש בעיה שהוא פותר.
חידון
Strategy Pattern ב-Payment Processing מאפשר להוסיף Crypto provider. מה עוד צריך לשנות בדרך כלל?