الگوهای طراحی وب؛ TypeScript، معماری و Trade-off

فرض کنید فروشگاه شما باید هنگام اختلال درگاه A به درگاه B برود. تیم یک شرط تازه به Controller اضافه می‌کند، Retry نیز در سه لایه فعال است و Callback هر Provider شکل متفاوتی دارد. در اولین اختلال شبکه، یک سفارش دو بار پرداخت می‌شود؛ لاگ‌ها نشان نمی‌دهند کدام تصمیم گرفته شد و تست هم فقط مسیر موفق را پوشش داده است. مشکل کمبود نام‌های معماری نیست؛ مشکل این است که مرز تغییر، مالک State و پیامد Failure طراحی نشده‌اند.

الگوی طراحی زمانی ارزش دارد که یک مسئله تکرارشونده را در Context مشخص، با نیروها و Trade-offهای روشن حل کند. Singleton، Factory یا Observer مدال مهندسی نیستند؛ هر کدام هزینه، دامنه اعتبار و Failure mode دارند. در این راهنما یاد می‌گیریم در وب مدرن—از TypeScript و Node.js تا React/Vue و سیستم توزیع‌شده—چگونه الگو را انتخاب، پیاده‌سازی، آزمون و در صورت لزوم حذف کنیم.

الگوی طراحی چیست و چه چیزی نیست؟

Design pattern یک پیاده‌سازی آماده یا قطعه کد قابل Copy/Paste نیست. توصیفی نام‌دار از مسئله‌ای تکرارشونده، Context، نیروهای متعارض، ساختار راه‌حل و پیامدهاست. کتاب کلاسیک Design Patterns اثر Gamma، Helm، Johnson و Vlissides ۲۳ الگوی شیءگرا را گردآوری کرد؛ خود معرفی ناشر نیز بر شرایط کاربرد، محدودیت‌ها و Consequence تأکید دارد، نه حفظ نام‌ها.

مفهومپرسشنمونه
Principleچه جهت کلی را ترجیح می‌دهیم؟جداسازی دغدغه‌ها، کمینه‌کردن Coupling
Patternاین فشار تکرارشونده را با چه ساختاری حل کنیم؟Adapter، Strategy، Observer
Architecture styleمرزهای بزرگ سیستم چگونه سازمان یابند؟Layered، Modular monolith، Microservices
Tacticیک Quality attribute را چگونه بهبود دهیم؟Timeout، Cache، Redundancy
Framework/libraryکدام ابزار بخشی از اجرا را فراهم می‌کند؟React، Nest، Laravel، ORM
Algorithmمراحل محاسباتی دقیق چیست؟Sort، Backoff با Jitter

Microservices یک الگوی GoF نیست؛ MVC یک الگوی معماری/Presentation با گونه‌های متعدد است؛ و Dependency injection تکنیکی برای Composition و وارونگی کنترل است. مخلوط‌کردن این سطح‌ها، مقایسه‌هایی مثل «Strategy یا Microservices؟» می‌سازد که اساساً هم‌رده نیستند.

سه دسته GoF هنوز چه ارزشی دارند؟

  • Creational: تصمیم ساخت و Lifecycle یک Object را از مصرف‌کننده جدا می‌کند؛ مانند Factory Method، Abstract Factory و Builder.
  • Structural: چند جزء را پشت مرز قابل‌فهم ترکیب می‌کند؛ مانند Adapter، Facade، Decorator و Proxy.
  • Behavioral: مسئولیت و همکاری را سازمان می‌دهد؛ مانند Strategy، Observer، Command و State.

این دسته‌بندی واژگان مفیدی می‌دهد، اما فهرست خرید نیست. JavaScript دارای Closure، First-class function، Module و Prototype است؛ رفتاری که در زبان کلاس‌محور به چند کلاس نیاز دارد شاید در TypeScript با یک تابع و یک Type ساده بیان شود. هدف حفظ نیت و Trade-off الگوست، نه تقلید نمودار کلاس.

الگو را از «نیروی تغییر» انتخاب کنید

پیش از نام‌بردن Pattern، این قرارداد را کامل کنید:

Context: checkout در ایران با دو PSP و Callback ناهمگون
Pressure: Provider، سیاست Retry و فرمت پاسخ تغییر می‌کند
Invariant: یک سفارش حداکثر یک پرداخت موفقِ ثبت‌شده دارد
Failure: timeout، callback تکراری، پاسخ دیررس، مبلغ/واحد ناسازگار
Quality: correctness > availability > latency
Evidence: incident، change history، test gap و load profile
Decision: Adapter + Strategy + idempotency state machine
Consequence: mapping code و contract tests بیشتر
Exit trigger: یک Provider و بدون تغییر برای ۱۲ ماه

اگر نتوانید Pressure و Invariant را بنویسید، احتمالاً هنوز الگو را برای نمایش معماری انتخاب می‌کنید. یک ADR کوتاه باید گزینه ساده‌تر، گزینه منتخب، پیامد، Owner، تاریخ بازبینی و معیار خروج را ثبت کند.

پیش‌فرض خوب: Function، Module و Composition

در بسیاری از کدهای وب، یک تابع Pure یا Module کوچک کافی است. از Inheritance عمیق فقط برای اشتراک چند خط کد استفاده نکنید؛ رفتار را با Composition تزریق کنید. این کار Dependency را آشکار، تست را ساده و جایگزینی را محلی می‌کند.

type PricePolicy = (subtotal: number) => number;

export function checkoutTotal(
  subtotal: number,
  policy: PricePolicy,
): number {
  return policy(subtotal);
}

const noDiscount: PricePolicy = x => x;
const vipDiscount: PricePolicy = x => Math.round(x * 0.9);

TypeScript بر Structural typing تکیه دارد؛ یعنی سازگاری عمدتاً از Shape می‌آید، نه الزام وراثت اسمی. مستندات رسمی Type compatibility این رفتار و بخش‌های عامدانه Unsound سیستم نوع را توضیح می‌دهد. بنابراین Interface در Compile time جای Validation ورودی بیرونی در Runtime را نمی‌گیرد.

Singleton: یک نمونه دقیقاً در کدام Scope؟

Singleton می‌گوید برای یک Scope یک Instance و نقطه دسترسی کنترل‌شده داشته باشیم. کلمه مهم «Scope» است. Export یک Client از Module شاید در همان Module cache و Process یک نمونه بدهد؛ اما Worker، Process، Container، Region یا Serverless execution environment هر کدام نمونه خود را دارند. این الگو قفل توزیع‌شده، Leader election یا یک اتصال جهانی دیتابیس ایجاد نمی‌کند.

// یک pool در scope همین runtime، نه کل deployment
import { Pool } from 'some-db-client';

export const dbPool = new Pool({ max: 10 });

برای دیتابیس معمولاً Pool مدیریت‌شده می‌خواهیم، نه یک Connection منفرد و جاودانه. Connection می‌تواند قطع شود، Credential بچرخد و Runtime متوقف شود. وابستگی سراسری پنهان نیز تست را سخت و State را نشت‌پذیر می‌کند.

مناسب‌ترهشدار
Configuration immutable یا Registry در یک RuntimeMutation سراسری و وابستگی پنهان
Pool/Client با Lifecycle و Shutdown روشنفرض یک Instance در چند Replica
Composition root که یک Instance را تزریق می‌کندService locator قابل فراخوانی از همه‌جا

Factory، Factory Method و Abstract Factory را قاطی نکنید

تابعی که بر اساس ورودی Object می‌سازد اغلب «Simple factory» است. Factory Method در تعریف کلاسیک ساخت را به Method قابل Override/پیاده‌سازی واگذار می‌کند؛ Abstract Factory خانواده‌ای از Objectهای سازگار می‌سازد. در وب مدرن، نام دقیق زمانی مهم است که درباره Extension point گفتگو می‌کنیم؛ اما پیچیده‌کردن کد برای وفاداری لفظی لازم نیست.

type Channel = {
  send(message: string): Promise<{ providerId: string }>;
};

function createChannel(kind: 'sms' | 'email'): Channel {
  if (kind === 'sms') return new SmsChannel();
  return new EmailChannel();
}

Factory وقتی ارزش دارد که Construction پیچیده، Provider قابل تغییر یا Lifecycle/Config متفاوت است. اگر فقط `new User()` را پشت سه لایه پنهان می‌کند، Indirection بدون منفعت ساخته‌اید. ورودی Factory از Config بیرونی را نیز Runtime-validate و Allowlist کنید.

Adapter: تفاوت Provider را در مرز مهار کنید

Adapter قرارداد ناسازگار بیرونی را به زبان Domain شما ترجمه می‌کند. درگاه پرداخت ممکن است مبلغ را ریال بخواهد، Callback نام متفاوت داشته باشد و وضعیت‌های خاص برگرداند؛ Domain نباید این جزئیات را در هر Use case تکرار کند.

type PaymentRequest = {
  orderId: string;
  amountRial: bigint;
  idempotencyKey: string;
};

type PaymentResult =
  | { kind: 'pending'; authority: string }
  | { kind: 'rejected'; code: string };

interface PaymentGateway {
  request(input: PaymentRequest): Promise<PaymentResult>;
  verify(callback: unknown): Promise<VerifiedPayment>;
}

Adapter فقط Rename فیلد نیست. Unit تومان/ریال، Encoding فارسی، Timeout، Error taxonomy، Signature verification، Timestamp و Mapping وضعیت را در مرز Normalize می‌کند. Payload خامِ Redacted را برای Audit نگه دارید، اما Secret و داده حساس را Log نکنید.

Strategy: رفتار قابل تعویض با Policy صریح

Strategy خانواده رفتارهایی با Contract مشترک است که در Runtime یا Composition انتخاب می‌شوند. انتخاب Provider پرداخت، سیاست قیمت، Cache eviction یا روش رتبه‌بندی نمونه‌اند. انتخاب باید Policy روشن داشته باشد؛ «اگر خطا شد هر Provider دیگری» می‌تواند عملیات غیرایمن را تکرار کند.

type RoutingContext = {
  amountRial: bigint;
  health: ReadonlyMap<string, 'ok' | 'degraded' | 'down'>;
};

type RoutePayment = (ctx: RoutingContext) => 'psp_a' | 'psp_b';

const preferHealthy: RoutePayment = ({ health }) =>
  health.get('psp_a') === 'ok' ? 'psp_a' : 'psp_b';

Strategy را با Feature flag یا Experiment اشتباه نگیرید. Flag مکانیسم کنترل عرضه است؛ Strategy طراحی رفتار. اگر Selection روی داده مشتری اثر حقوقی یا مالی دارد، Reason code، Version و Audit trail لازم است.

Decorator و Middleware: رفتار مقطعی با ترتیب قابل مشاهده

Decorator بدون تغییر Contract، رفتار اضافه می‌کند. در JavaScript تابع Wrapper و در HTTP زنجیره Middleware مصداق‌های رایج‌اند: Logging، Authorization، Metrics، Cache یا Retry. ترتیب بخشی از Semantics است؛ Authenticate→Authorize با Authorize→Authenticate یکسان نیست.

function withTimeout<A, R>(
  ms: number,
  next: (arg: A, signal: AbortSignal) => Promise<R>,
) {
  return async (arg: A): Promise<R> => {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), ms);
    try { return await next(arg, controller.signal); }
    finally { clearTimeout(timer); }
  };
}

Wrapper باید Cancellation را واقعاً به Dependency برساند؛ `Promise.race` صرفاً انتظار Caller را قطع می‌کند و ممکن است کار بیرونی ادامه یابد. زنجیره Decoratorها را با Trace span و تست ترتیب قابل مشاهده کنید.

Facade: یک Use case روشن، نه یک God service

Facade عملیات چند Subsystem را پشت API هدف‌محور ساده می‌کند؛ مثلاً `placeOrder` می‌تواند Validation، Reservation، Payment initiation و ثبت Event را هماهنگ کند. Facade نباید همه Domain را به کلاس ۵۰۰۰خطی تبدیل کند. مرز آن باید حول Use case و Invariant باشد، نه صرفاً «همه سرویس‌ها».

Facade با Adapter فرق دارد: Adapter یک Interface ناسازگار را تبدیل می‌کند؛ Facade استفاده از مجموعه‌ای پیچیده را ساده می‌کند. گاهی Facade در داخل از چند Adapter استفاده می‌کند.

Proxy: کنترل دسترسی یا Lifecycle، با Semantics شفاف

Proxy همان Contract را نمایندگی می‌کند و دسترسی به Object واقعی را کنترل می‌کند: Lazy load، Remote proxy، Cache proxy یا Permission proxy. خطر اصلی این است که Caller تصور کند یک Property محلی ارزان می‌خواند، اما پشت آن شبکه، هزینه یا Failure نهفته باشد. API باید Async بودن، Timeout و Freshness را آشکار کند.

در JavaScript، شیء `Proxy` زبان با Design pattern Proxy یکی نیست، هرچند می‌تواند ابزار پیاده‌سازی آن باشد. Magic بیش از حد، Stack trace، Type inference و Performance را دشوار می‌کند؛ Proxy را فقط وقتی مزیت از ناپیدایی رفتار بیشتر است انتخاب کنید.

Observer و Event Emitter: اعلان محلی، نه تضمین تحویل

Observer وابستگان را از تغییر Subject آگاه می‌کند. در Node.js، `EventEmitter` نمونه رایج درون Process است. طبق مستندات رسمی Node.js Events، Listenerهای یک Event به‌صورت همگام و به ترتیب ثبت فراخوانی می‌شوند. بنابراین Handler سنگین می‌تواند مسیر Emit را Block کند و Exception/Rejected promise نیازمند سیاست روشن است.

emitter.on('order.paid', ({ orderId }) => {
  // کار کوتاه و محلی؛ نه جایگزین صف durable
  metrics.increment('order_paid', { orderId });
});

EventEmitter تضمین Persistence، Retry پس از Crash، Backpressure توزیع‌شده یا Exactly-once نمی‌دهد. برای رویداد کسب‌وکاری که نباید گم شود، Broker، Outbox، Idempotent consumer، Schema/version و Dead-letter policy لازم‌اند. جزئیات این مرز در راهنمای Event-Driven Architecture، Outbox و Saga آمده است.

Command: قصد را به Object/Message قابل کنترل تبدیل کنید

Command یک درخواست معنادار مثل `CancelOrder` است، نه دستور CRUD مبهم `set status`. این الگو Queue، Permission، Audit، Retry یا Undo را ممکن می‌کند؛ اما Undo در عملیات بیرونی همیشه معکوس ساده نیست. Refund فرمان جبرانی است، نه حذف تاریخچه پرداخت.

type CancelOrder = {
  commandId: string;
  orderId: string;
  actorId: string;
  reason: string;
  expectedVersion: number;
};

Command handler باید Authorization، Idempotency، Concurrency و Invariant را بررسی کند. شناسه Command و Expected version جلوی بخشی از اجرای تکراری و Lost update را می‌گیرند، ولی Storage/Transaction مناسب نیز لازم است.

State: رفتار را بر اساس وضعیت معتبر محدود کنید

اگر شرط‌های وضعیت در چند Service پخش شده‌اند، State machine انتقال‌های مجاز را متمرکز می‌کند. برای پرداخت، `created→pending→verified|failed|expired` بهتر از Booleanهای مستقل `isPaid/isFailed/isExpired` است که ترکیب نامعتبر می‌سازند.

const transitions = {
  created: ['pending'],
  pending: ['verified', 'failed', 'expired'],
  verified: ['refunding'],
  refunding: ['refunded', 'refund_failed'],
} as const;

الگوی State در حافظه به‌تنهایی مشکل Race را حل نمی‌کند. Transition باید اتمیک، Version-aware و در صورت ارتباط با سیستم بیرونی قابل Reconcile باشد. راهنمای اتوماسیون سفارش فروشگاه State، Idempotency و Reconciliation را در چرخه سفارش کامل‌تر بررسی می‌کند.

React و Vue را دقیق‌تر از «Observer» توصیف کنید

React را نمی‌توان صرفاً Subject/Observer کلاسیک نامید. Component از Props، State و Context رندر می‌شود؛ برای Store بیرونی API صریح Subscription لازم است. مستندات رسمی React برای useSyncExternalStore قرارداد `subscribe`، `getSnapshot` و Snapshot سازگار با Server rendering را توضیح می‌دهد و استفاده از State داخلی را در صورت امکان ترجیح می‌دهد.

Vue به Dependency tracking واکنشی نزدیک‌تر است. مستندات Reactivity in Depth در Vue توضیح می‌دهد که Runtime access را Track و Mutation را Trigger می‌کند و هر Component یک Reactive effect برای Render دارد. این شباهت به Observer مفید است، اما جزئیات Proxy، Effect scheduling و Cleanup را نام الگو حذف می‌کند.

برای انتخاب محل State، Consistency، SSR، Offline و Ownership به راهنمای مدیریت State در فرانت‌اند پیچیده مراجعه کنید.

MVC یک قرارداد واحد جهانی ندارد

MVC خانواده‌ای از تفکیک Presentation، input coordination و model/domain است؛ معنی Controller و Model میان Rails، Django، Laravel و فریم‌ورک‌های UI یکسان نیست. در بسیاری از Backendهای وب، Route/Controller درخواست را Parse می‌کند، Application service Use case را هماهنگ و Domain Invariant را نگه می‌دارد. ریختن تمام منطق در Controller یا ORM model هر دو مرز را مخدوش می‌کند.

لایه/مرزمسئولیتنباید مالک باشد
HTTP adapter/controllerParse، auth context، status/headers، DTOقواعد اصلی کسب‌وکار
Application/use caseهماهنگی جریان و Transaction boundaryجزئیات HTTP/Provider
DomainInvariant و تصمیم مستقل از I/OORM/JSON/Framework
Infrastructure adapterDB، Queue، PSP، SMSمعنای کسب‌وکاری اصلی

این تفکیک نسخه اجباری Clean Architecture نیست؛ یک ابزار تشخیص Coupling است. برای CRUD ساده شاید Route→validated query کافی باشد.

MVVM «نسخه پیشرفته MVC» و Two-way binding الزامی نیست

MVVM در Contextهایی شکل گرفته که ViewModel وضعیت و فرمان‌های مناسب View را ارائه و Binding آن‌ها را متصل می‌کند. آن را نمی‌توان تکامل خطی MVC دانست. Two-way data binding نیز جزء ضروری هر پیاده‌سازی یا همیشه مزیت نیست؛ می‌تواند مسیر تغییر را پنهان کند. React فقط به‌خاطر Hooks و Context «MVVM» نمی‌شود.

به‌جای چسباندن برچسب، Data flow را رسم کنید: Source of truth کجاست، چه کسی Mutation می‌دهد، Derived state کجا محاسبه می‌شود، Effect چه زمانی اجرا و در SSR/Hydration چگونه سازگار می‌شود. این نمودار از دعوای نام‌گذاری ارزش عملی بیشتری دارد.

Repository و Unit of Work: فقط با مرز واقعی

Repository مجموعه‌ای Domain-oriented را پشت Contract دسترسی ارائه می‌دهد؛ Unit of Work تغییرات مرتبط را در Transaction هماهنگ می‌کند. اگر Repository فقط تمام Methodهای ORM را با همان Entity و Query لو می‌دهد، Abstraction جدیدی نساخته و تست Mock-heavy ایجاد کرده است.

interface OrderRepository {
  getForUpdate(id: string): Promise<Order | null>;
  save(order: Order, expectedVersion: number): Promise<void>;
}

Queryهای گزارش‌گیری پیچیده لازم نیست از همان Repository عبور کنند. Read model هدف‌محور می‌تواند DTO لازم را مستقیم بسازد. این جداسازی به معنی CQRS توزیع‌شده و دو دیتابیس نیست.

CQRS را از جداسازی ساده Read/Write آغاز کنید

CQRS یعنی مدل Command و Query را بر اساس نیاز متفاوتشان جدا کنیم. می‌تواند با یک دیتابیس و بدون Broker شروع شود. راهنمای رسمی Azure برای CQRS صریحاً می‌گوید مدل ساده CRUD برای دامنه ساده مناسب است و CQRS—به‌ویژه با Event Sourcing—پیچیدگی، Messaging failure و Eventual consistency اضافه می‌کند.

تنها وقتی Read/Write نیازهای متفاوت، Domain task پیچیده یا Scale/Permission مستقل دارند آن را گسترش دهید. «برای آینده» دلیل کافی برای دو دیتابیس، Projection، Replay و Consistency lag نیست.

الگوهای Resilience: Timeout پیش از Retry

در تماس شبکه‌ای، این ترکیب رایج است: Deadline/Timeout، Retry محدود با Backoff و Jitter برای Failure واقعاً گذرا، Circuit breaker برای توقف فشار بر Dependency بیمار، Bulkhead برای محدودکردن دامنه خرابی و Fallback ایمن. Retry بدون Idempotency می‌تواند پرداخت یا سفارش را تکرار کند؛ Retry چندلایه نیز Storm می‌سازد.

Patternحل می‌کندحل نمی‌کند
Timeout/Deadlineانتظار نامحدودلغو خودکار کار Remote
Retry + Jitterخطای گذرای مجازخطای دائمی یا عملیات غیرIdempotent
Circuit breakerفشار تکراری بر Dependency خرابجایگزین Capacity یا Root cause
Bulkheadجداسازی Pool/Quota و Blast radiusتضمین موفقیت Dependency
Fallbackکاهش کنترل‌شده قابلیتپنهان‌کردن داده غلط

راهنمای AWS برای Circuit Breaker کاربرد آن را در Timeout/Failure تکراری و خطر تشدید مصرف منابع با Retry توضیح می‌دهد. Circuit باید State، Threshold، Probe، Exception taxonomy، Telemetry و رفتار کاربر در حالت Open داشته باشد.

Cache-Aside: Freshness و Stampede بخشی از الگو هستند

در Cache-aside، برنامه ابتدا Cache را می‌خواند، در Miss از Source بار می‌گیرد و Cache را پر می‌کند. دیاگرام ساده، سختی‌های اصلی را حذف می‌کند: TTL، Invalidation، Negative caching، Key version، Permission leakage، Stampede، داده قدیمی و رفتار هنگام خرابی Cache.

قبل از افزودن Cache، Bottleneck را با Profiling ثابت کنید. راهنمای پروفایلینگ و Debugging وب‌اپلیکیشن مسیر Symptom→Measurement→Hypothesis→Canary را پوشش می‌دهد. Cache برای Query سریعِ کم‌هزینه ممکن است فقط Consistency risk اضافه کند.

Monolith ماژولار پیش‌فرض کم‌ریسک‌تری است

Microservices به‌صورت خودکار مقیاس، استقلال یا نگهداری بهتر نمی‌دهد. تماس درون Process را به شبکه، Partial failure، Contract version، Observability توزیع‌شده، Deployment متعدد و مالکیت داده تبدیل می‌کند. اگر مرز Domain و تیم روشن نیست، سیستم توزیع‌شده Coupling را حذف نمی‌کند؛ آن را دورتر و دیرقابل‌تشخیص می‌کند.

Monolith ماژولار می‌تواند مرز Module، API داخلی، تست و مالکیت روشن داشته باشد و یک واحد Deploy بماند. وقتی نیاز مستند به Scale/Release/Isolation مستقل و توان عملیاتی وجود داشت، استخراج تدریجی سنجیده می‌شود. برای مقایسه Fit و هزینه، راهنمای مونولیت ماژولار یا Microservices را ببینید.

Patternهای توزیع‌شده Trade-off را حذف نمی‌کنند

کاتالوگ Cloud Design Patterns در Azure Architecture Center هر الگو را با مسئله، ملاحظات و Trade-off معرفی می‌کند و یادآور می‌شود شبکه قابل‌اعتماد، Latency صفر و Observability قابل‌تعویق نیستند. چند مثال:

  • Outbox: ثبت تغییر Domain و قصد انتشار در یک Transaction؛ هنوز Relay، Duplicate و Lag دارد.
  • Saga: هماهنگی چند Transaction محلی با Compensation؛ Rollback ACID جهانی نیست.
  • Strangler Fig: مهاجرت تدریجی پشت Facade؛ هزینه معماری انتقالی و مسیر داده دوگانه دارد.
  • Queue-based load leveling: جذب Burst؛ Latency، Backlog، Poison message و Capacity consumer می‌خواهد.
  • Anti-corruption layer: جلوگیری از نشت مدل Legacy؛ Mapping و Drift باید نگهداری شود.

الگوها معمولاً به‌صورت بسته همکاری می‌کنند

در سناریوی پرداخت ایران، ترکیب معقول می‌تواند چنین باشد:

HTTP Controller
  → PlaceOrder use case (Facade/Application service)
    → PSP Adapter
    → Routing Strategy
    → Payment State Machine
    → Repository + optimistic concurrency
    → Transactional Outbox
      → idempotent consumers
Resilience wrapper: deadline + bounded retry + circuit + bulkhead
Cross-cutting: auth, audit, metrics, trace, redaction

هر جزء مسئله جدا حل می‌کند. Adapter بدون State machine از Callback تکراری محافظت نمی‌کند؛ Circuit breaker بدون Timeout معنی ندارد؛ Outbox بدون Idempotent consumer تحویل دقیقاً یک‌بار نمی‌سازد. معماری سالم زنجیره ادعاهای هر Pattern را صریح می‌کند.

ماتریس انتخاب الگو

نشانه مسئلهکاندیداگزینه ساده‌ترGate
Providerها Contract متفاوت دارندAdapterیک Mapping functionتغییر/Provider دوم واقعی است؟
رفتار در Runtime انتخاب می‌شودStrategyتابع ParameterPolicy و Reason قابل ثبت است؟
Behavior مقطعی تکرار می‌شودDecorator/MiddlewareHelper صریحترتیب و Failure مشخص است؟
وضعیت انتقال‌های محدود داردStateEnum + transition functionInvariant و Concurrency تعریف شده؟
اعلان محلی یک‌به‌چندObserver/Event emitterفراخوانی صریحگم‌شدن Event قابل قبول است؟
Read/Write واقعاً نامتقارنCQRSدو Service/Query model در یک DBپیچیدگی و Lag پذیرفته شده؟
Dependency Remote ناپایدارTimeout/Retry/Circuit/BulkheadTimeout + errorIdempotency و Failure taxonomy هست؟

تست الگو باید ادعای آن را اثبات کند

فقط تست کلاس‌ها کافی نیست. Pattern یک ادعا درباره تغییر، استقلال یا Failure دارد؛ همان ادعا را تست کنید:

  • Contract test: هر Adapter ورودی/خروجی و Error taxonomy یکسان دارد.
  • Property/invariant test: مجموع پرداخت موفق یک سفارش از مبلغ سفارش بیشتر نمی‌شود.
  • State transition test: انتقال نامعتبر و Callback دیررس رد یا Reconcile می‌شود.
  • Concurrency test: دو درخواست با Idempotency key یک اثر مالی دارند.
  • Failure injection: Timeout، پاسخ malformed، قطع شبکه و Provider کند آزموده می‌شود.
  • Architecture test: Domain به Framework/PSP import نمی‌کند و Module boundary شکسته نمی‌شود.
  • Load test: Pool، Queue، Circuit و Backpressure در Saturation رفتار قابل قبول دارند.

Mock همه‌چیز ممکن است Mapping واقعی، Transaction و Serialization را پنهان کند. Unit test را با Contract، Integration و تعداد کمی Journey واقعی ترکیب کنید.

Observability را داخل Pattern طراحی کنید

الگویی که در Production قابل مشاهده نیست، هنگام Incident به جعبه سیاه تبدیل می‌شود. حداقل این ابعاد را ثبت کنید:

  • نام/نسخه Strategy و دلیل Selection؛
  • Adapter/Provider، Operation و نتیجه Normalize‌شده؛
  • State قبلی/بعدی، Version و رد انتقال؛
  • Retry attempt، Deadline remaining و Circuit state؛
  • Queue lag، Duplicate، Dead letter و Compensation؛
  • Correlation/Trace ID از درخواست تا Callback و Event.

High-cardinality ID را بی‌محابا به Metric label تبدیل نکنید؛ آن را در Trace/Log جست‌وجوپذیر نگه دارید. Secret، Token، PAN و Payload شخصی را Redact کنید.

امنیت در مرزهای Pattern متوقف نمی‌شود

Adapter مرز اعتماد است: ورودی Provider را `unknown` بگیرید، Schema و Signature و Timestamp را بررسی و Replay را مهار کنید. Factory مبتنی بر ورودی کاربر نباید Class دلخواه Load کند. Decorator Authorization باید پیش از Side effect اجرا شود. Event subscriber نیز Identity و Tenant context را از Payload خام باور نکند.

API contract، Authentication/Authorization، Rate limit، Idempotency و Error handling در راهنمای طراحی امن و قابل‌اتکای API وب تکمیل شده‌اند. Pattern جای Threat modeling یا Secure default را نمی‌گیرد.

Performance را اندازه بگیرید، از روی نام حدس نزنید

Indirection چند تابع معمولاً گلوگاه وب نیست، اما Proxy جادویی، Reflection، Serialization، شبکه و Allocation در Hot path می‌توانند هزینه‌دار شوند. از Pattern برای ادعای Performance استفاده نکنید مگر Baseline، Load model و Profile داشته باشید.

در Frontend، Subscription پهن، Derived state ناپایدار و Render غیرضروری مهم‌تر از نام Observer است؛ در Backend، Query N+۱، Pool saturation و Retry amplification مهم‌اند. راهنمای Performance و Security اپلیکیشن JavaScript سنجش RUM، Budget، Rendering، Memory و Supply chain را پوشش می‌دهد.

سناریوی ایرانی: پرداخت و پیامک با Failure واقعی

یک فروشگاه ایرانی دو درگاه پرداخت و دو Provider پیامک دارد. محدودیت‌های واقعی شامل ریال/تومان، Callback تکراری، Timeout نامشخص، اختلال مقطعی شبکه، Encoding فارسی، Template ID متفاوت و تاخیر Delivery report است.

  1. Domain مبلغ را با `bigint` و واحد صریح Rial نگه می‌دارد؛ تبدیل تومان فقط در Presentation یا Adapter قراردادی انجام می‌شود.
  2. هر PSP یک Adapter و Contract test با Fixtureهای واقعیِ بدون داده حساس دارد.
  3. Strategy فقط قبل از ایجاد Authority Provider را انتخاب و دلیل را Audit می‌کند؛ پس از آن Provider خودسرانه عوض نمی‌شود.
  4. State machine Callback تکراری/دیررس را با Idempotency و Transaction مهار می‌کند و Verify سمت سرور را معیار موفقیت می‌داند.
  5. Timeout، Retry محدود و Circuit بر اساس عملیات تنظیم می‌شوند؛ `request payment` و `verify` سیاست یکسان ندارند.
  6. پیامک Notification است، نه Source of truth سفارش؛ Failover متن/Template/Consent را حفظ می‌کند.
  7. Trace از Order تا Authority/Callback/Event می‌رود و عملیات مالی با Reconciliation روزانه کنترل می‌شود.

این طراحی «عدم شکست» را تضمین نمی‌کند؛ Failure را قابل تشخیص، محدود و بازیابی می‌کند.

Migration به Pattern باید Incremental باشد

برای اصلاح Controller بزرگ، Big-bang rewrite لازم نیست:

  1. با Characterization test رفتار فعلی را ثبت کنید.
  2. یک Seam در مرز I/O یا تصمیم ایجاد کنید.
  3. یک Provider را پشت Adapter ببرید و Contract test بسازید.
  4. انتخاب را به Strategy استخراج و Reason code اضافه کنید.
  5. State transition را متمرکز و Concurrency را تست کنید.
  6. Telemetry و Canary اضافه کنید و مسیر قدیم/جدید را مقایسه کنید.
  7. پس از اثبات، مسیر قدیم و Flag موقت را حذف کنید.

Pipeline باید Architecture/contract test، Security scan و Rollback artifact را اجرا کند. برای Release gate و Progressive delivery از راهنمای CI/CD امن استفاده کنید.

نشانه‌های Over-engineering و Pattern abuse

  • برای یک پیاده‌سازی ثابت، Interface→AbstractFactory→Factory→Builder ساخته‌اید.
  • نام Pattern در Diagram هست، اما مسئله و Consequence در ADR نیست.
  • همه Dependencyها از Service locator سراسری گرفته می‌شوند.
  • Event bus جای فراخوانی صریح محلی را گرفته و ترتیب/مالکیت نامعلوم است.
  • Repository تنها نام Methodهای ORM را تکرار می‌کند.
  • Retry در Client، SDK، Service mesh و Job هم‌زمان فعال است.
  • Microserviceها دیتابیس یا Release را مشترک دارند و فقط Network hop اضافه شده است.
  • Mockها سبزند، اما Contract واقعی Provider و Failure path آزموده نشده‌اند.
  • کسی نمی‌تواند Pattern را حذف کند چون Owner و Exit trigger ندارد.

Review checklist برای Pull Request

  • مسئله، Context، Invariant و Alternative ساده‌تر نوشته شده‌اند.
  • Scopeِ Instance، State، Transaction و Failure روشن است.
  • نام Pattern با رفتار واقعی تطابق دارد و Magic پنهان نیست.
  • Interface کمینه و بر زبان مصرف‌کننده/Domain است.
  • Runtime validation در مرز داده بیرونی وجود دارد.
  • Timeout، Cancellation، Retry، Idempotency و Backpressure در I/O تعیین تکلیف شده‌اند.
  • Contract، Failure، Concurrency و Architecture test متناسب وجود دارد.
  • Log/Metric/Trace و Redaction قابل بهره‌برداری‌اند.
  • پیامد Performance، Security و عملیات سنجیده شده است.
  • ADR، Owner، Review date و Exit trigger ثبت شده‌اند.

پرسش‌های متداول

آیا باید هر ۲۳ الگوی GoF را حفظ کنیم؟

خیر. مسئله، Context و Consequence را یاد بگیرید و چند الگوی پرتکرار را در کد واقعی تشخیص دهید. کاتالوگ مرجع است. توانایی توضیح اینکه چرا یک تابع ساده از Pattern پیچیده بهتر است، مهم‌تر از نام‌بردن همه الگوهاست.

Singleton برای اتصال دیتابیس مناسب است؟

معمولاً یک Pool/Client با Lifecycle روشن در Scope همان Runtime مناسب است؛ اما Singleton یک اتصال جهانی در تمام Workerها، Containerها یا Serverless instanceها نمی‌سازد. Health، Rotation، Shutdown و سقف Pool را جدا طراحی کنید و وابستگی را ترجیحاً از Composition root تزریق کنید.

آیا React از Observer یا MVVM استفاده می‌کند؟

این قیاس‌ها فقط تا حدی مفیدند. React بر Render از Props/State/Context تکیه دارد و برای Store بیرونی `useSyncExternalStore` قرارداد Subscription می‌دهد. Hooks و Context به‌تنهایی React را MVVM نمی‌کنند؛ Data flow و مالک State را دقیق توصیف کنید.

Microservices بهترین الگو برای مقیاس‌پذیری است؟

خیر. Microservices سبک معماری با هزینه شبکه، داده، استقرار و عملیات است. اگر Scale/Release/Isolation مستقل و مرز تیمی اثبات نشده، Monolith ماژولار اغلب انتخاب کم‌ریسک‌تری است. ابتدا Bottleneck و Quality attribute را اندازه بگیرید.

از کجا بفهمیم یک Pattern ارزش پیچیدگی را دارد؟

یک Pressure تکرارشونده و Evidence‌دار، Invariant روشن و حداقل دو Variation واقعی داشته باشید؛ Alternative ساده‌تر را مقایسه کنید؛ Contract و Failure را تست کنید؛ سپس اثر را بر Change surface، Defect، Lead time و Recovery بسنجید. اگر منفعت ظاهر نشد، Pattern را ساده یا حذف کنید.

جمع‌بندی: نام کمتر، تصمیم بهتر

الگوی طراحی زبان فشرده تجربه است، نه مجوز افزودن لایه. از Function و Module شروع کنید؛ Scope و مرز را روشن کنید؛ Adapter را برای تفاوت بیرونی، Strategy را برای Policy قابل تعویض، State را برای انتقال معتبر و Resilience pattern را برای Failure مشخص به کار ببرید. در Frontend تفاوت React subscription و Vue dependency tracking، و در Backend تفاوت Event محلی و پیام Durable را حفظ کنید.

یک بخش پرخطای سیستم را امروز انتخاب کنید و به‌جای پرسیدن «کدام Pattern مدرن است؟» این چهار سؤال را بنویسید: چه چیزی تغییر می‌کند، چه چیزی باید ثابت بماند، چگونه شکست می‌خورد و کدام Evidence نشان می‌دهد ساختار تازه بهتر است؟ پاسخ همین‌ها معماری را هدایت می‌کند.

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *