الأدلة التقنية

أنماط التصميم Design Patterns التي تقابلها فعلاً

أشهر أنماط التصميم التي تقابلها في كل مشروع، والمشكلة التي يحلها كل نمط، ومتى يصبح عبئاً على من يعدل الكود بعدك.

أنماط التصميم Design Patterns التي تقابلها فعلاً

عندما تفتح مشروع ASP.NET Core أو Spring Boot لأول مرة سوف تجد أسماء مثل IHttpClientFactory وRestClient.Builder وapp.UseMiddleware وApplicationEventPublisher، وقد تستخدمها شهوراً دون أن تعرف أن كل اسم منها يشير إلى حل معروف له اسم ومشكلة وثمن، وأن الفريق الذي كتب الإطار اختاره لسبب محدد. وهذه الحلول هي أنماط التصميم Design Patterns، وفهمها يفيدك في أمرين: أن تقرأ كود الأطر وتعرف لماذا صمم بهذا الشكل، وأن تعرف متى تحتاج النمط في كودك ومتى تكون إضافته تعقيداً زائداً Over-engineering يدفع ثمنه من يعدل الكود بعدك.

وفي مقال الفرق بين تصميم البرمجيات ومعماريتها وتصميم الأنظمة ذكرنا أن تصميم البرمجيات Software Design يهتم بشكل الكلاسات والدوال داخل البرنامج الواحد، والأنماط هي أدوات هذا المستوى، أما المبادئ التي تقف خلفها مثل SOLID وفصل الاعتماديات فقد شرحناها في مبادئ التصميم SOLID وDRY وKISS، لذلك سوف نشير إليها هنا برابط ولن نعيد شرحها. وسوف نتناول في هذا المقال ما يلي:

  • ما هو نمط التصميم، ومن أين جاءت هذه الأسماء.
  • تسعة أنماط تقابلها فعلاً في العمل اليومي: Strategy وFactory وAdapter وDecorator وObserver وRepository وحقن الاعتماديات Dependency Injection وBuilder وSingleton.
  • لكل نمط: المشكلة التي يحلها، ثم كود C# كامل شغلناه ومخرجه الحقيقي، ثم أين تقابله في .NET وSpring وExpress، ثم متى يصبح تعقيداً زائداً.
  • نفس الأنماط في مكتبة Java القياسية وفي Node.js مع Express، بكود شغلناه أيضاً.
  • لماذا يعامل Singleton كرائحة في الكود Code Smell في أغلب الحالات، وما البديل.
  • جدول يجمع علامات الإفراط في استخدام كل نمط.

ما هو نمط التصميم Design Pattern؟

نمط التصميم وصف لحل متكرر لمشكلة تصميم متكررة، له اسم معروف ومشكلة واضحة يحلها وثمن يدفعه من يستخدمه، وهو ليس مكتبة تقوم بتنزيلها ولا كوداً تنسخه كما هو، وإنما فكرة تكتبها بلغتك وحسب حاجتك. والفائدة الأكبر للنمط هي الاسم نفسه، فعندما يقول لك زميلك في مراجعة الكود Code Review «اجعل حساب الشحن Strategy» فقد وصف لك شكل الحل كاملاً في كلمة واحدة، تماماً كما يقول النجار لزميله «وصلة تعشيق» فيفهم الشكل دون أن يرسمه.

وأغلب هذه الأسماء جاءت من كتاب Design Patterns: Elements of Reusable Object-Oriented Software الذي نشره أربعة مؤلفين هم Erich Gamma وRichard Helm وRalph Johnson وJohn Vlissides في أكتوبر 1994، ولذلك يسمى كتاب عصابة الأربعة Gang of Four واختصاراً GoF، وفيه 23 نمطاً موزعة على ثلاث مجموعات: أنماط الإنشاء Creational التي تهتم بطريقة إنشاء الكائنات، وأنماط البنية Structural التي تهتم بطريقة تركيب الكائنات مع بعضها، وأنماط السلوك Behavioral التي تهتم بطريقة توزيع المسؤوليات وتواصل الكائنات. ونحن نذكر الكتاب هنا كمرجع فقط، والأمثلة والرسومات في هذا المقال من كتابتنا. أما نمطا Repository وحقن الاعتماديات فقد جاءا لاحقاً من كتابات Martin Fowler عن تطبيقات المؤسسات، كما سيأتي في كل قسم.

والصورة التالية تبين الأنماط التسعة التي اخترناها، وهي الأنماط التي سوف تقابلها في أغلب المشاريع وفي كود الأطر نفسها، وكل نمط في المجموعة التي ينتمي إليها:

أربعة أعمدة ملونة: عمود CREATIONAL فيه Factory وBuilder وSingleton، وعمود STRUCTURAL فيه Adapter وDecorator، وعمود BEHAVIORAL فيه Strategy وObserver، وعمود APPLICATION فيه Repository وDependency Injection، ولكل نمط رسم صغير وجملة عربية تصف فكرته
الأنماط التسعة في هذا المقال، والمجموعة التي ينتمي إليها كل نمط

وقد يتساءل البعض: لماذا تسعة فقط من 23؟ والإجابة أن بعض أنماط الكتاب تقابلها كل يوم دون أن تكتبها بنفسك لأن اللغة أو الإطار بناها لك (مثل Iterator الذي أصبح foreach)، وبعضها نادر في تطبيقات الويب وأنظمة الأعمال، أما التسعة هنا فهي التي سوف تكتبها أو تقرأها أو تسأل عنها في مقابلات العمل بشكل متكرر.

وكل الأنماط هنا تعتمد على فكرة الواجهة Interface، أي أن الكود يتعامل مع «شيء يحسب تكلفة الشحن» وليس مع كلاس محدد، فإذا كانت فكرة الكلاس والواجهة جديدة عليك فابدأ بقسم البرمجة الكائنية OOP في مقال أنماط البرمجة، والسبب في أهمية هذه الفكرة هو مبدأ عكس الاعتماديات الذي شرحناه في قسم Dependency Inversion.

💡
أمثلة C# في هذا المقال مكتوبة كملفات منفردة، ويمكنك تشغيل أي ملف منها بالأمر dotnet run strategy.cs على .NET 10 دون إنشاء مشروع، وهي ميزة File-based apps، وإذا احتاج المثال حزمة NuGet فسوف تجد في أول الملف سطراً مثل #:package [email protected] يقوم بتنزيلها. أما مثال Java فيعمل بالأمر java Patterns.java مباشرة بفضل JEP 330، ومثال Node.js يعمل بالأمر node app.js بعد npm install.

نمط Strategy: قاعدة الحساب قطعة يمكن تبديلها

لنفرض أن لديك متجراً يحسب تكلفة الشحن، وبدأ بطريقة واحدة ثم أضاف الشحن السريع ثم الاستلام من الفرع، والحل البسيط الذي يكتبه أغلبنا أول مرة هو دالة فيها سلسلة من if أو switch على اسم الطريقة، كما في المثال التالي:

// Before Strategy: every new shipping method edits this one method.
Console.WriteLine(ShippingCost("express", weightKg: 3));
Console.WriteLine(ShippingCost("drone", weightKg: 3));

static decimal ShippingCost(string method, int weightKg)
{
    if (method == "standard") return 15;
    else if (method == "express") return 30 + 5 * weightKg;
    else if (method == "pickup") return 0;
    else throw new ArgumentException($"unknown shipping method: {method}");
}

وهذا الكود يعمل، ومشكلته تظهر مع الوقت: كل طريقة شحن جديدة تعني تعديل نفس الدالة، وكل تعديل قد يكسر طريقة كانت تعمل، والاختبار يحتاج أن يمر على كل الفروع في دالة واحدة، وعندما تصبح قاعدة الشحن السريع خمسين سطراً بدل سطر واحد تصبح الدالة كلها صعبة القراءة. ونمط Strategy يحل ذلك بأن يجعل كل قاعدة كلاساً مستقلاً خلف واجهة واحدة، ويختار الكود القاعدة المناسبة وقت التشغيل دون أن يعرف تفاصيلها، والصورة التالية تبين الفكرة:

في اليمين CheckoutService يحمل طلباً وزنه 3 كيلوغرامات بقيمة 250 ريالاً، وسهم منه إلى مربع منقط اسمه IShippingStrategy فيه الدالة Cost، ومن المربع ثلاثة خيارات: Standard بتكلفة 15 وExpress بتكلفة 45 وهو المختار بسهم برتقالي، وPickup بتكلفة صفر
كل طريقة شحن كلاس مستقل خلف الواجهة IShippingStrategy، والكود الذي يحسب الطلب لا يعرف أيها يعمل

والمثال التالي يكتب نفس القواعد بنمط Strategy، ويضيف في آخره مثالين من .NET نفسها:

// Strategy: each shipping rule is its own class behind one interface; the caller picks one at run time.
var order = new Order(WeightKg: 3, Total: 250m);

IShippingStrategy[] options = [new StandardShipping(), new ExpressShipping(), new PickupFromStore()];
foreach (var s in options)
    Console.WriteLine($"{s.Name,-10} {s.Cost(order),6:0.00} SAR");

// The same idea without a class: a function is a strategy too
Func<Order, decimal> freeAbove200 = o => o.Total >= 200 ? 0 : 15;
Console.WriteLine($"{"Promo",-10} {freeAbove200(order),6:0.00} SAR");

// .NET uses the pattern itself: Sort takes the comparison rule from outside
var names = new List<string> { "Riyadh", "Abha", "Jeddah" };
names.Sort(StringComparer.Ordinal);
Console.WriteLine(string.Join(", ", names));
names.Sort((a, b) => b.Length.CompareTo(a.Length));
Console.WriteLine(string.Join(", ", names));

record Order(int WeightKg, decimal Total);

interface IShippingStrategy
{
    string Name { get; }
    decimal Cost(Order order);
}

class StandardShipping : IShippingStrategy
{
    public string Name => "Standard";
    public decimal Cost(Order order) => 15;
}

class ExpressShipping : IShippingStrategy
{
    public string Name => "Express";
    public decimal Cost(Order order) => 30 + 5 * order.WeightKg;
}

class PickupFromStore : IShippingStrategy
{
    public string Name => "Pickup";
    public decimal Cost(Order order) => 0;
}

والمخرج سوف يكون كما يلي:

Standard    15.00 SAR
Express     45.00 SAR
Pickup       0.00 SAR
Promo        0.00 SAR
Abha, Jeddah, Riyadh
Jeddah, Riyadh, Abha

في الإعداد أعلاه لاحظ التالي:

  • الواجهة IShippingStrategy هي العقد الوحيد بين الكود الذي يحسب الطلب وقواعد الشحن، وإضافة طريقة رابعة تعني كلاساً جديداً دون تعديل أي كلاس موجود، وهذا تطبيق مباشر لمبدأ Open/Closed.
  • السطر Func<Order, decimal> freeAbove200 هو Strategy أيضاً لكن دون كلاس، ففي C# وJava وJavaScript تكفي الدالة Lambda عندما تكون القاعدة سطراً واحداً ولا تحتاج حالة State.
  • names.Sort(StringComparer.Ordinal) وnames.Sort((a, b) => …) هما نفس النمط في .NET: الدالة List.Sort تعرف كيف ترتب، وأنت تعطيها قاعدة المقارنة من الخارج.

أين تقابل Strategy في الأطر؟

  • .NET: كل دالة تقبل IComparer<T> أو IEqualityComparer<T>، مثل List.Sort وnew Dictionary<string, int>(StringComparer.OrdinalIgnoreCase).
  • Java وSpring: الواجهة Comparator في List.sort، وفي Spring MVC يصف التوثيق أنواع الكائنات الخاصة مثل HandlerExceptionResolver بأنها Strategy لمعالجة الاستثناءات يمكنك تبديلها.
  • JavaScript: الدالة التي تمررها إلى Array.prototype.sort هي Strategy بلا كلاس.

متى يصبح Strategy تعقيداً زائداً؟

عندما يكون لديك فرعان لا يتغيران منذ سنوات، فواجهة وثلاثة كلاسات وFactory لاختيارها أثقل بكثير من if واحد يقرأه أي مطور في ثانية، والعلامة الواضحة هي أن تجد واجهة لها تنفيذ واحد فقط «لأننا قد نحتاج غيره يوماً»، وهذا ما يسميه مبدأ YAGNI. لذلك ابدأ بال if، وعندما تأتي القاعدة الثالثة أو تكبر قاعدة واحدة حتى تحتاج اختباراً مستقلاً، حولها إلى Strategy.

نمط Factory: مكان واحد يقرر أي كلاس ينشأ

في المثال السابق كتبنا new ExpressShipping() مباشرة، وهذا مقبول في مكان واحد، لكن تخيل أن متجرك يرسل إشعارات بالبريد أو برسالة SMS حسب إعداد العميل، وأن عشرة أماكن في الكود تحتاج أن ترسل إشعاراً، فالحل البسيط أن يكتب كل مكان if (channel == "sms") new SmsNotifier() else new EmailNotifier()، وعندما تضيف قناة ثالثة أو يتغير شكل إنشاء أحد الكلاسات (مثلاً أصبح يحتاج مفتاح API) سوف تبحث عن الأماكن العشرة وتنسى واحداً منها. ونمط Factory يجمع قرار الإنشاء في مكان واحد: الكود يطلب «مرسل إشعارات للقناة الفلانية» ويحصل على كائن جاهز يتعامل معه عبر الواجهة فقط.

ويجدر الإشارة هنا إلى أن كتاب GoF فيه نمطان للمصانع هما Factory Method وAbstract Factory، أما ما سنكتبه فهو الشكل الأبسط الذي تراه في أغلب المشاريع: دالة واحدة تقرر أي كلاس تنشئ، ويسميه البعض Simple Factory، والصورة التالية تبينه:

في اليمين كود يستدعي Create مع القيمة sms، وفي الوسط مصنع اسمه NotifierFactory فيه channel switch، وفي اليسار ثلاث نتائج: EmailNotifier للقيمة email، وSmsNotifier للقيمة sms وهو المختار بسهم برتقالي، وNotSupportedException باللون الأحمر للقيمة pigeon
الكود يطلب قناة بالاسم، والـ Factory وحده يعرف أي كلاس ينشئ لكل قناة

والمثال التالي يبين ذلك، ويضيف في آخره ال Factory الذي تستخدمه في ASP.NET Core كل يوم:

#:package [email protected]
// Factory: one place decides which class to create; callers ask for "a notifier" and never call new on a concrete type.
using Microsoft.Extensions.DependencyInjection;

foreach (var channel in new[] { "email", "sms" })
{
    INotifier n = NotifierFactory.Create(channel);
    n.Send("customer-1042", "Your order has shipped");
}

try { NotifierFactory.Create("pigeon"); }
catch (NotSupportedException e) { Console.WriteLine($"error: {e.Message}"); }

// The factory you meet in ASP.NET Core: IHttpClientFactory hands out configured HttpClient instances
var services = new ServiceCollection();
services.AddHttpClient("payments", c =>
{
    c.BaseAddress = new Uri("https://payments.example.com/");
    c.Timeout = TimeSpan.FromSeconds(5);
});
var factory = services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>();
var a = factory.CreateClient("payments");
var b = factory.CreateClient("payments");
Console.WriteLine($"{a.BaseAddress} timeout={a.Timeout.TotalSeconds}s same object={ReferenceEquals(a, b)}");

interface INotifier { void Send(string to, string text); }

class EmailNotifier : INotifier
{
    public void Send(string to, string text) => Console.WriteLine($"[email] to {to}: {text}");
}

class SmsNotifier : INotifier
{
    public void Send(string to, string text) => Console.WriteLine($"[sms]   to {to}: {text}");
}

static class NotifierFactory
{
    public static INotifier Create(string channel) => channel switch
    {
        "email" => new EmailNotifier(),
        "sms" => new SmsNotifier(),
        _ => throw new NotSupportedException($"no notifier for channel '{channel}'")
    };
}

والمخرج سوف يكون كما يلي:

[email] to customer-1042: Your order has shipped
[sms]   to customer-1042: Your order has shipped
error: no notifier for channel 'pigeon'
https://payments.example.com/ timeout=5s same object=False

في الإعداد أعلاه لاحظ التالي:

  • الكود في الحلقة لا يحتوي على كلمة new لأي كلاس إشعارات، وإنما يطلب من NotifierFactory.Create، وإضافة قناة WhatsApp مثلاً تعني سطراً في ال switch وكلاساً جديداً.
  • القناة غير المعروفة pigeon ترفض في مكان واحد برسالة واضحة، بدل أن يتصرف كل مكان في الكود بطريقته.
  • IHttpClientFactory هو Factory حقيقي: تسجل إعداد العميل مرة واحدة باسم payments، ثم كل CreateClient("payments") يعطيك HttpClient جديداً بنفس الإعداد، ولاحظ أن same object=False. والسبب في وجود هذا ال Factory كما يشرح التوثيق أنه يدير عمر ال Handlers التي تحمل الاتصالات تحتها ويعيد استخدامها، لأن إنشاء Handlers أكثر من اللازم قد يستنزف ال Sockets، وإبقاؤها مفتوحة للأبد يمنعها من ملاحظة تغير سجلات DNS.

أين تقابل Factory في الأطر؟

  • .NET: IHttpClientFactory، وILoggerFactory الذي ينشئ ال ILogger لكل كلاس كما في توثيق Logging.
  • Spring: ال Container نفسه اسمه BeanFactory، والواجهة FactoryBean تسمح لك بكتابة Factory خاص عندما يكون إنشاء الكائن معقداً، ودالة @Bean هي في الحقيقة Factory Method تعيد الكائن الذي يديره ال Container.
  • Express: الدالة express() نفسها Factory، وهي كما يصفها توثيق Express 5 الدالة التي تنشئ تطبيق Express، وكذلك express.Router().

متى يصبح Factory تعقيداً زائداً؟

عندما يكون للواجهة تنفيذ واحد، أو عندما يكون الإنشاء سطراً واحداً بلا قرار، فال Factory هنا طبقة لا تضيف شيئاً سوى ملف آخر تقفز إليه أثناء القراءة، وأسوأ صوره سلسلة NotifierFactoryFactory وAbstractNotifierFactoryProvider التي يضحك عليها المطورون في كل مكان. وإذا كنت تستخدم DI Container فهو Factory عام بالفعل، فلا تكتب Factory خاصاً إلا عندما يكون الاختيار مبنياً على قيمة لا تعرف إلا وقت التشغيل، مثل قناة الإشعار المحفوظة في إعدادات العميل.

نمط Adapter: يترجم بين واجهتنا وواجهة لا نملكها

الآن قرر الفريق أن يرسل رسائل SMS عبر مزود جديد، ولنسمه Acme، وهذا المزود يعطيك حزمة NuGet فيها كلاس اسمه AcmeSmsClient ودالة اسمها SubmitMessage تأخذ كائناً بشكل مختلف وترجع نتيجة بشكل مختلف. والحل البسيط أن تستدعي AcmeSmsClient مباشرة من كل مكان يرسل رسالة، وبهذا يصبح اسم المزود وشكل الكائنات الخاصة به منتشراً في الكود كله، فإذا ارتفعت أسعاره بعد سنة وقررت تغييره فأنت تعدل عشرات الملفات. ونمط Adapter يضع كلاساً صغيراً بين الطرفين: من جهة ينفذ واجهتنا INotifier، ومن الجهة الأخرى يستدعي المكتبة بشكلها، والصورة التالية تبين ذلك:

ثلاث مناطق: في اليمين OUR CODE فيه OrderService يستدعي INotifier.Send، وفي الوسط AcmeSmsAdapter برسم قابس كهربائي يحول الاستدعاء، وفي اليسار VENDOR SDK بصندوق اسمه AcmeSmsClient ودالته SubmitMessage، وسهم منقط يرجع بالقيمة 202
الـ Adapter وحده يعرف شكل مكتبة المزود، وبقية الكود يعرف واجهتنا فقط

والمثال التالي يبين ذلك، والكلاس AcmeSmsClient فيه يمثل مكتبة المزود التي لا نملك تعديلها:

// Adapter: our code speaks INotifier; the vendor SDK speaks something else. A small class translates between them.
INotifier notifier = new AcmeSmsAdapter(new AcmeSmsClient(apiKey: "test-key"));
notifier.Send("customer-1042", "Your order has shipped");

interface INotifier { void Send(string to, string text); }

// Imagine this class comes from a NuGet package: we cannot change its shape
class AcmeSmsClient(string apiKey)
{
    public AcmeResult SubmitMessage(AcmeMessage message)
    {
        Console.WriteLine($"[acme] key={apiKey[..4]}*** dest={message.Destination} body=\"{message.Body}\" unicode={message.Unicode}");
        return new AcmeResult(Accepted: true, Code: 202);
    }
}
record AcmeMessage(string Destination, string Body, bool Unicode);
record AcmeResult(bool Accepted, int Code);

class AcmeSmsAdapter(AcmeSmsClient client) : INotifier
{
    public void Send(string to, string text)
    {
        var result = client.SubmitMessage(new AcmeMessage(Destination: to, Body: text, Unicode: text.Any(c => c > 127)));
        if (!result.Accepted) throw new InvalidOperationException($"acme refused the message, code {result.Code}");
        Console.WriteLine($"[adapter] acme answered {result.Code}, our code only sees Send()");
    }
}

والمخرج سوف يكون كما يلي:

[acme] key=test*** dest=customer-1042 body="Your order has shipped" unicode=False
[adapter] acme answered 202, our code only sees Send()

في الإعداد أعلاه لاحظ التالي:

  • ال Adapter لا يغير الأسماء فقط، فهو يقرر أن الرسالة تحتاج Unicode إذا كان فيها حرف غير ASCII (والرسائل العربية كلها كذلك)، ويحول النتيجة Accepted: false إلى استثناء، أي أنه يترجم أسلوب المكتبة إلى أسلوب مشروعنا.
  • تغيير المزود يعني كتابة Adapter جديد وتغيير سطر التسجيل، ويبقى كل الكود الذي يرسل الرسائل كما هو، وفي الاختبارات تستطيع وضع تنفيذ وهمي لل INotifier كما شرحنا في قسم البدائل Test Doubles.
  • هذا الشكل هو ما تسميه Clean Architecture وHexagonal Architecture المحول Adapter الذي يقف خلف المنفذ Port، وقد شرحنا الصورة الكاملة في مقال Clean Architecture وHexagonal.

أين تقابل Adapter في الأطر؟

  • Java: يصف التوثيق الكلاس InputStreamReader بأنه جسر من تدفق البايتات إلى تدفق الحروف، أي أنه يجعل InputStream يعمل حيث يتوقع الكود Reader.
  • Spring MVC: الواجهة HandlerAdapter كما في توثيق DispatcherServlet تسمح لل DispatcherServlet أن يستدعي أي نوع من ال Handlers دون أن يعرف طريقة استدعائه.
  • Node.js: الدالة util.promisify تأخذ دالة بأسلوب ال Callback القديم وترجع دالة ترجع Promise، وهي Adapter جاهز سوف نشغله في قسم Node أدناه.

متى يصبح Adapter تعقيداً زائداً؟

عندما تلف كل مكتبة تستخدمها بواجهة خاصة بك «احتياطاً»، حتى المكتبات المستقرة مثل System.Text.Json أو ال Logger، فتصبح عندك عشرات الواجهات التي تشبه واجهات المكتبة تماماً ولا تضيف شيئاً. ويستحق ال Adapter مكانه عندما تكون المكتبة خارجية من مزود قد تغيره، أو عندما يختلف أسلوبها عن أسلوب مشروعك فعلاً، أو عندما تحتاج أن تختبر الكود دون أن يتصل بالمزود.

نمط Decorator: سلوك إضافي بأغلفة فوق نفس الواجهة

لديك خدمة تقرأ سعر المنتج من قاعدة البيانات، وطلب منك قياس زمن كل استدعاء، ثم طلب منك حفظ الأسعار في الذاكرة Cache لأن الاستعلام بطيء. والحل البسيط أن تضيف كود القياس وكود ال Cache داخل نفس الدالة، فتصبح دالة القراءة مسؤولة عن ثلاثة أشياء، وعندما تريد إيقاف ال Cache في الاختبارات أو إضافة إعادة المحاولة Retry تعدلها مرة أخرى. ونمط Decorator يضع كل سلوك في كلاس صغير ينفذ نفس الواجهة ويلف كائناً آخر من نفس النوع، فيعمل شيئاً قبل الاستدعاء أو بعده ثم يمرره إلى الكائن الذي بداخله، وتستطيع أن تركب الأغلفة بالترتيب الذي تريد، والصورة التالية تبين ذلك:

ثلاثة مستطيلات متداخلة: الخارجي LoggingPriceService يقيس الوقت، وداخله CachingPriceService يرجع السعر من الذاكرة، وداخله DbPriceService مع قاعدة بيانات وزمن 200 ملي ثانية تقريباً. الاستدعاء الأول من اليمين يصل إلى قاعدة البيانات في 249 ملي ثانية، والاستدعاء الثاني يتوقف عند ال Cache في صفر ملي ثانية
كل غلاف ينفذ نفس الواجهة ويضيف سلوكاً واحداً، والاستدعاء الثاني لا يصل إلى قاعدة البيانات

والمثال التالي يبني الأغلفة الثلاثة، ثم يبين أن ال Streams في .NET مبنية بنفس الطريقة:

// Decorator: wrap an object in another object with the same interface, adding one behaviour per wrapper.
using System.Diagnostics;
using System.IO.Compression;
using System.Text;

IPriceService service = new LoggingPriceService(new CachingPriceService(new DbPriceService()));
foreach (var sku in new[] { "KB-01", "KB-01", "MS-07" })
    Console.WriteLine($"{sku} = {service.GetPrice(sku)} SAR");

// .NET streams are decorators: GZipStream wraps any Stream and adds compression
var text = string.Concat(Enumerable.Repeat("order shipped; ", 200));
using var raw = new MemoryStream();
using (var gzip = new GZipStream(raw, CompressionLevel.Optimal, leaveOpen: true))
    gzip.Write(Encoding.UTF8.GetBytes(text));
Console.WriteLine($"plain {Encoding.UTF8.GetByteCount(text)} bytes, through GZipStream {raw.Length} bytes");

interface IPriceService { decimal GetPrice(string sku); }

class DbPriceService : IPriceService
{
    public decimal GetPrice(string sku)
    {
        Thread.Sleep(200); // stands in for a slow database query
        return sku == "KB-01" ? 349 : 99;
    }
}

class CachingPriceService(IPriceService inner) : IPriceService
{
    private readonly Dictionary<string, decimal> cache = new();
    public decimal GetPrice(string sku)
    {
        if (cache.TryGetValue(sku, out var price)) return price;
        return cache[sku] = inner.GetPrice(sku);
    }
}

class LoggingPriceService(IPriceService inner) : IPriceService
{
    public decimal GetPrice(string sku)
    {
        var sw = Stopwatch.StartNew();
        var price = inner.GetPrice(sku);
        Console.WriteLine($"  [log] GetPrice({sku}) took {sw.ElapsedMilliseconds} ms");
        return price;
    }
}

والمخرج سوف يكون كما يلي:

  [log] GetPrice(KB-01) took 249 ms
KB-01 = 349 SAR
  [log] GetPrice(KB-01) took 0 ms
KB-01 = 349 SAR
  [log] GetPrice(MS-07) took 223 ms
MS-07 = 99 SAR
plain 3000 bytes, through GZipStream 69 bytes

في الإعداد أعلاه لاحظ التالي:

  • السطر الأول يبني السلسلة كاملة: Logging(Caching(Db))، والكود الذي يستدعي GetPrice لا يعرف أن هناك أغلفة أصلاً لأنه يرى IPriceService فقط.
  • الاستدعاء الأول للمنتج KB-01 وصل إلى قاعدة البيانات وأخذ 249 ملي ثانية، والثاني رجع من ال Cache في صفر، وال DbPriceService نفسه لم يتغير فيه حرف. والأزمنة هنا للتوضيح فقط لأن البطء مصنوع بالدالة Thread.Sleep.
  • الترتيب مهم: لو وضعت ال Logging داخل ال Caching لما سجلت الاستدعاءات التي ترجع من ال Cache، لذلك فكر في الترتيب كما تفكر في ترتيب طبقات الملابس.
  • GZipStream يلف أي Stream آخر ويضيف الضغط، فكتبنا 3000 بايت وخرج منها 69 بايتاً لأن النص مكرر.

أين تقابل Decorator في الأطر؟

  • .NET وJava: ال Streams كلها، ففي Java يصف التوثيق الكلاس FilterOutputStream بأنه أصل كل ال Streams التي «تجلس فوق» Stream موجود وتضيف عليه وظيفة، مثل BufferedOutputStream وDataOutputStream.
  • Middleware في ASP.NET Core وExpress: قريب جداً من الفكرة، فكل Middleware كما يقول توثيق ASP.NET Core يقرر هل يمرر الطلب للتالي، ويستطيع العمل قبله وبعده، وفي Express يعرف التوثيق ال Middleware بأنه دالة تصل إلى الطلب والرد والدالة next. والاسم الأدق لهذه السلسلة في كتاب GoF هو Chain of Responsibility، لكن الأثر الذي تراه هو نفس أثر الأغلفة.
  • Spring: عندما تضع @Transactional على دالة فإن Spring يلف الكائن بكائن وسيط Proxy ينفذ نفس الواجهة ويفتح المعاملة قبل الاستدعاء ويغلقها بعده، كما يشرح توثيق المعاملات التصريحية Declarative Transactions الذي يقول إن هذا الدعم يعمل عبر AOP Proxy يدير المعاملة حول استدعاء الدالة.

متى يصبح Decorator تعقيداً زائداً؟

عندما تصبح السلسلة طويلة ولا يعرف أحد ترتيبها، فيبحث المطور عن سبب سلوك غريب ويجده في الغلاف الخامس الذي سجله شخص آخر في ملف الإعداد، أو عندما تكتب Decorator لسلوك يحتاجه مكان واحد فقط ويمكن كتابته في سطرين داخل الدالة. ومن العلامات أيضاً أن تكتب Decorator لكل واجهة في المشروع لتسجيل الاستدعاءات، وهنا أداة واحدة على مستوى ال Middleware أو التتبع Tracing أوضح من مئة غلاف.

نمط Observer: أخبر كل من يهمه الأمر

عندما يتم حفظ طلب جديد في متجرك تحتاج أن ترسل إيصالاً بالبريد، وأن تحجز الكمية في المخزون، وأن تسجل الإيراد في التحليلات، والحل البسيط أن تكتب الاستدعاءات الثلاثة في آخر دالة حفظ الطلب، وبهذا يعرف كلاس الطلبات كل الأقسام الأخرى، وكل قسم جديد يريد أن يعرف بالطلبات يحتاج تعديل كلاس الطلبات. ونمط Observer يعكس العلاقة: كلاس الطلبات يعلن حدثاً Event اسمه «تم حفظ طلب»، ومن يهمه الأمر يشترك فيه Subscribe، وكلاس الطلبات لا يعرف عدد المشتركين ولا أسماءهم، والصورة التالية تبين ذلك:

في اليمين OrderService مع جرس اسمه event OrderPlaced، وأسهم مرقمة إلى خمسة مشتركين في اليسار: email وinventory وanalytics باللون الأخضر، ثم loyalty باللون الأحمر يرمي استثناء، ثم audit باللون الرمادي لا يعمل أبداً
المشتركون يعملون بترتيب تسجيلهم، والاستثناء في أحدهم يوقف من بعده

وفي C# النمط مبني في اللغة نفسها عبر كلمة event، والمثال التالي يبينه، ثم يكسره عمداً:

// Observer: the subject announces "an order was placed"; whoever cares subscribes, and the subject knows none of them.
var orders = new OrderService();
orders.OrderPlaced += o => Console.WriteLine($"  email:     receipt sent for order {o.Id}");
orders.OrderPlaced += o => Console.WriteLine($"  inventory: {o.Items} item(s) reserved");
orders.OrderPlaced += o => Console.WriteLine($"  analytics: revenue +{o.Total}");

orders.Place(new Order(1042, Items: 2, Total: 448));

// Break it: one subscriber throws, and the ones after it never run
orders.OrderPlaced += o => throw new InvalidOperationException("loyalty service is down");
orders.OrderPlaced += o => Console.WriteLine("  audit:     written");
try { orders.Place(new Order(1043, Items: 1, Total: 99)); }
catch (Exception e) { Console.WriteLine($"  error reached the caller: {e.Message}"); }

record Order(int Id, int Items, decimal Total);

class OrderService
{
    public event Action<Order>? OrderPlaced;

    public void Place(Order order)
    {
        Console.WriteLine($"order {order.Id} saved");
        OrderPlaced?.Invoke(order);
    }
}

والمخرج سوف يكون كما يلي:

order 1042 saved
  email:     receipt sent for order 1042
  inventory: 2 item(s) reserved
  analytics: revenue +448
order 1043 saved
  email:     receipt sent for order 1043
  inventory: 1 item(s) reserved
  analytics: revenue +99
  error reached the caller: loyalty service is down

في الإعداد أعلاه لاحظ التالي:

  • كلاس OrderService لا يحتوي على كلمة email أو inventory، وإضافة مشترك جديد سطر واحد خارجه.
  • الاستدعاء متزامن Synchronous وبترتيب التسجيل، أي أن Place لا ترجع حتى ينتهي كل المشتركين، فإذا كان أحدهم بطيئاً أصبح حفظ الطلب بطيئاً.
  • في الطلب الثاني رمى مشترك ال loyalty استثناء، فوصل الاستثناء إلى من استدعى Place، والمشترك audit الذي سجل بعده لم يعمل أبداً، مع أن الطلب نفسه تم حفظه. وهذا أهم ما يجب أن تعرفه عن Observer داخل العملية الواحدة: المشتركون ليسوا معزولين عن بعضهم.

أين تقابل Observer في الأطر؟

  • .NET: كلمة event في C#، والواجهتان IObservable<T> وIObserver<T> كما في صفحة Observer design pattern في توثيق .NET.
  • Spring: يقول توثيق ApplicationContext عن نظام الأحداث فيه إنه «نمط Observer القياسي»، وإن المشتركين عبر @EventListener يستقبلون الأحداث بشكل متزامن افتراضياً، أي أن publishEvent ينتظر حتى ينتهي كل المشتركين، تماماً كما رأينا في المثال.
  • Node.js: الكلاس EventEmitter وعليه تقوم أغلب مكتبات Node، والتوثيق يقول إنه يستدعي كل المشتركين بشكل متزامن بترتيب تسجيلهم.
  • Java القديمة: الكلاس java.util.Observable موجود منذ البداية لكنه مهمل Deprecated منذ Java 9، والتوثيق نفسه يقول إن نموذجه محدود وإن ترتيب الإشعارات فيه غير محدد، وينصح بالحزمة java.beans بدلاً منه، وهي ما استخدمناه في مثال Java أدناه.

متى يصبح Observer تعقيداً زائداً؟

عندما لا تستطيع أن تجيب على سؤال «ماذا يحدث عندما يحفظ الطلب؟» إلا بالبحث في المشروع كله عن المشتركين، فالفائدة التي حصلت عليها (أن كلاس الطلبات لا يعرف أحداً) لها ثمن (أن أحداً لا يعرف ماذا يحدث). وإذا كان المشترك واحداً ولن يتغير فاستدعاء مباشر أوضح، وإذا كان المشتركون يحتاجون أن يعملوا حتى لو تعطل أحدهم أو أن يعملوا على سيرفر آخر، فأنت لم تعد تتكلم عن نمط داخل البرنامج وإنما عن معمارية قائمة على الأحداث مع وسيط رسائل Message Broker، وهذا موضوع قسم Event-Driven Architecture في مقال أنماط المعمارية.

نمط Repository: البيانات كأنها مجموعة في الذاكرة

في كثير من المشاريع سوف تجد كود SQL أو استعلامات ORM داخل الكلاس الذي فيه قواعد العمل، فدالة «إتمام الطلب» تتحقق من القيمة ثم تفتح اتصالاً وتكتب INSERT ثم تحسب مجموع مشتريات العميل باستعلام آخر، والنتيجة أن قاعدة العمل لا يمكن اختبارها دون قاعدة بيانات، وأن نفس الاستعلام يتكرر في أماكن مختلفة بأشكال مختلفة قليلاً. ونمط Repository يضع بين قواعد العمل وقاعدة البيانات كلاساً يشبه المجموعة Collection، فيه دوال مثل «أضف طلباً» و«أعطني طلبات هذا العميل»، ويعرفه Martin Fowler في كتالوج Patterns of Enterprise Application Architecture بأنه وسيط بين طبقة المجال Domain وطبقة ربط البيانات يقدم واجهة تشبه المجموعة للوصول إلى كائنات المجال. والصورة التالية تبين الفكرة:

في اليمين CheckoutService يستدعي TotalSpent بلا SQL، وسهم إلى مربع منقط IOrderRepository فيه Add وForCustomer، ومنه خطان إلى تنفيذين: InMemoryOrderRepository مع رسم أنبوب اختبار للاختبارات، وSqliteOrderRepository مع قاعدة بيانات زرقاء للتشغيل الفعلي، وتحتها أن النتيجة في التشغيلين 547
نفس الكلاس يعمل مع تنفيذين للواجهة دون أي تعديل

والمثال التالي يكتب نفس ال Repository مرتين: مرة في الذاكرة ومرة على SQLite حقيقية، ويشغل نفس الكود عليهما:

#:package [email protected]
// Repository: the business code asks for orders as if they were in a collection; where they live is the repository's job.
using Microsoft.Data.Sqlite;

IOrderRepository[] stores = [new InMemoryOrderRepository(), new SqliteOrderRepository("Data Source=orders.db;Pooling=False")];
foreach (var repo in stores)
{
    var checkout = new CheckoutService(repo);
    checkout.PlaceOrder(1042, "customer-7", 448);
    checkout.PlaceOrder(1043, "customer-7", 99);
    checkout.PlaceOrder(1044, "customer-9", 1200);
    Console.WriteLine($"{repo.GetType().Name,-24} customer-7 spent {checkout.TotalSpent("customer-7")} SAR");
}
File.Delete("orders.db");

record Order(int Id, string CustomerId, decimal Total);

interface IOrderRepository
{
    void Add(Order order);
    IReadOnlyList<Order> ForCustomer(string customerId);
}

// The business code: no SQL, no connection strings
class CheckoutService(IOrderRepository orders)
{
    public void PlaceOrder(int id, string customerId, decimal total)
    {
        if (total <= 0) throw new ArgumentException("an order must have a positive total");
        orders.Add(new Order(id, customerId, total));
    }
    public decimal TotalSpent(string customerId) => orders.ForCustomer(customerId).Sum(o => o.Total);
}

class InMemoryOrderRepository : IOrderRepository
{
    private readonly List<Order> items = new();
    public void Add(Order order) => items.Add(order);
    public IReadOnlyList<Order> ForCustomer(string customerId) => items.Where(o => o.CustomerId == customerId).ToList();
}

class SqliteOrderRepository : IOrderRepository
{
    private readonly string cs;
    public SqliteOrderRepository(string connectionString)
    {
        cs = connectionString;
        using var db = Open();
        Exec(db, "DROP TABLE IF EXISTS orders; CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id TEXT NOT NULL, total TEXT NOT NULL)");
    }
    public void Add(Order order)
    {
        using var db = Open();
        var cmd = db.CreateCommand();
        cmd.CommandText = "INSERT INTO orders (id, customer_id, total) VALUES ($id, $c, $t)";
        cmd.Parameters.AddWithValue("$id", order.Id);
        cmd.Parameters.AddWithValue("$c", order.CustomerId);
        cmd.Parameters.AddWithValue("$t", order.Total.ToString(System.Globalization.CultureInfo.InvariantCulture));
        cmd.ExecuteNonQuery();
    }
    public IReadOnlyList<Order> ForCustomer(string customerId)
    {
        using var db = Open();
        var cmd = db.CreateCommand();
        cmd.CommandText = "SELECT id, customer_id, total FROM orders WHERE customer_id = $c";
        cmd.Parameters.AddWithValue("$c", customerId);
        using var r = cmd.ExecuteReader();
        var list = new List<Order>();
        while (r.Read()) list.Add(new Order(r.GetInt32(0), r.GetString(1), decimal.Parse(r.GetString(2), System.Globalization.CultureInfo.InvariantCulture)));
        return list;
    }
    private SqliteConnection Open() { var c = new SqliteConnection(cs); c.Open(); return c; }
    private static void Exec(SqliteConnection db, string sql) { var cmd = db.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); }
}

والمخرج سوف يكون كما يلي:

InMemoryOrderRepository  customer-7 spent 547 SAR
SqliteOrderRepository    customer-7 spent 547 SAR

في الإعداد أعلاه لاحظ التالي:

  • الكلاس CheckoutService فيه القاعدة الوحيدة المهمة (القيمة يجب أن تكون موجبة)، ولا يعرف هل البيانات في قائمة أم في SQLite أم في PostgreSQL، لذلك تستطيع اختباره بالتنفيذ الذي في الذاكرة في أجزاء من الثانية.
  • الدوال في الواجهة لها أسماء من لغة العمل (ForCustomer) وليست ExecuteQuery(string sql)، وهذا هو الفرق بين Repository حقيقي وغلاف رقيق فوق قاعدة البيانات.
  • الاستعلامات في SqliteOrderRepository تستخدم المعاملات Parameters مثل $c ولا تدمج القيم في نص SQL، والسبب هو الحماية من SQL Injection كما في ثغرات الويب بالتجربة.
  • في التصميم الموجه بالمجال DDD يكون لكل مجموعة Aggregate كما شرحنا في قسم Aggregates Repository واحد، وليس لكل جدول.

أين تقابل Repository في الأطر؟

  • Spring Data: يقول توثيق Spring Data JPA إن الواجهة المركزية فيه اسمها Repository، وإن CrudRepository يعطيك دوال الحفظ والقراءة جاهزة، وأنت تكتب الواجهة فقط ويقوم Spring بكتابة التنفيذ.
  • Entity Framework Core: يقول دليل Microsoft عن تصميم طبقة البيانات إن ال DbContext ينفذ نمطي Repository وUnit of Work أصلاً، وهذه نقطة مهمة في القسم التالي.

متى يصبح Repository تعقيداً زائداً؟

أشهر صور الإفراط في عالم .NET هي ال Generic Repository فوق EF Core: واجهة IRepository<T> فيها Add وGetById وGetAll تمرر كل شيء إلى DbSet كما هو، فتخسر قدرات EF مثل Include والاستعلامات المركبة ولا تكسب شيئاً، لأن DbContext نفسه Repository كما ذكرنا. والحل الأنسب أن تكتب Repository محدداً عندما تحتاجه فعلاً: عندما تريد أن تختبر قواعد العمل دون قاعدة بيانات، أو عندما تتكرر نفس الاستعلامات في أماكن كثيرة، أو عندما تبني Clean Architecture وتريد أن تبقى طبقة المجال بلا اعتماد على EF. ويجدر الإشارة إلى أن توثيق EF Core في صفحة اختيار طريقة الاختبار ينصح بالاختبار على قاعدة بيانات حقيقية، وإذا احتجت بديلاً للاختبار فعلاً فالنمط الذي يقترحه هو Repository، وليس محاكاة DbSet.

حقن الاعتماديات Dependency Injection: الكلاس يستلم ما يحتاجه ولا ينشئه

في كل الأمثلة السابقة كان الكلاس يستلم ما يحتاجه في ال Constructor، فال CheckoutService استلم IOrderRepository ولم يكتب new SqliteOrderRepository(...) بنفسه، وهذا هو حقن الاعتماديات. والحل البسيط الذي يكتبه أغلبنا في البداية هو أن ينشئ كل كلاس اعتمادياته بنفسه، فيصبح كل كلاس مربوطاً بتنفيذ محدد وبطريقة إنشائه، ولا تستطيع أن تختبره ببديل ولا أن تغير قاعدة البيانات دون أن تفتح كل الكلاسات. والاسم نفسه اقترحه Martin Fowler في مقاله Inversion of Control Containers and the Dependency Injection pattern في يناير 2004، حيث قال إن مصطلح «عكس التحكم» Inversion of Control أعم من اللازم ويربك الناس، فاتفق مع عدد من أصحاب هذه الأدوات على اسم Dependency Injection، وذكر ثلاث طرق للحقن: عبر ال Constructor وعبر ال Setter وعبر واجهة، والطريقة التي تراها اليوم في أغلب الأطر هي ال Constructor.

ويجدر الإشارة إلى الفرق بين الاسمين: مبدأ عكس الاعتماديات Dependency Inversion يقول إن الكود يعتمد على واجهة وليس على تنفيذ، أما حقن الاعتماديات فهو الطريقة التي يصل بها التنفيذ إلى الكلاس، وال DI Container أداة تقوم بالحقن بدلاً منك، وتستطيع أن تحقن بيدك دون أي Container كما سنرى. والصورة التالية تبين ما يفعله ال Container في ASP.NET Core، ومدة حياة Lifetime كل كائن:

في اليمين مربع CONTAINER فيها ثلاثة تسجيلات: Clock بمدة AddSingleton، وIOrderRepository بمدة AddScoped، وCheckoutService بمدة AddTransient. في اليسار طلبان: الطلب الأول فيه checkout#2 وcheckout#3 يشتركان في repo#2، والطلب الثاني فيه checkout#4 وcheckout#5 يشتركان في repo#3، وclock#2 واحد مشترك بين الطلبين. وفي الأسفل شريط أحمر فيه رسالة الخطأ Cannot consume scoped service from singleton
Singleton واحد للتطبيق، وScoped واحد لكل طلب، وTransient جديد في كل مرة

والمثال التالي يحقن بيده أولاً، ثم بال Container الذي يستخدمه ASP.NET Core، ثم يكسر قاعدة مدد الحياة عمداً:

#:package [email protected]
// Dependency Injection: a class receives what it needs from outside instead of creating it.
using Microsoft.Extensions.DependencyInjection;

// 1) By hand: the "composition root" is just a few lines of new
var byHand = new CheckoutService(new InMemoryOrderRepository(), new Clock());
Console.WriteLine($"by hand:   {byHand.Describe()}");

// 2) With the container that ASP.NET Core uses
var services = new ServiceCollection();
services.AddSingleton<Clock>();                                  // one for the whole application
services.AddScoped<IOrderRepository, InMemoryOrderRepository>(); // one per scope (per HTTP request in ASP.NET Core)
services.AddTransient<CheckoutService>();                         // a new one every time
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true });

for (var request = 1; request <= 2; request++)
{
    using var scope = provider.CreateScope();
    var a = scope.ServiceProvider.GetRequiredService<CheckoutService>();
    var b = scope.ServiceProvider.GetRequiredService<CheckoutService>();
    Console.WriteLine($"request {request}: {a.Describe()} | {b.Describe()}");
}

// 3) Break it: a singleton that captures a scoped service
services.AddSingleton<ReportCache>();
try { services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); }
catch (AggregateException e) { Console.WriteLine($"error: {e.InnerExceptions[0].Message}"); }

interface IOrderRepository { int Id { get; } }
class InMemoryOrderRepository : IOrderRepository { private static int next; public int Id { get; } = ++next; }
class Clock { private static int next; public int Id { get; } = ++next; }

class CheckoutService(IOrderRepository orders, Clock clock)
{
    private static int next;
    public int Id { get; } = ++next;
    public string Describe() => $"checkout#{Id} repo#{orders.Id} clock#{clock.Id}";
}

class ReportCache(IOrderRepository orders) { public int RepoId => orders.Id; }

والمخرج سوف يكون كما يلي:

by hand:   checkout#1 repo#1 clock#1
request 1: checkout#2 repo#2 clock#2 | checkout#3 repo#2 clock#2
request 2: checkout#4 repo#3 clock#2 | checkout#5 repo#3 clock#2
error: Error while validating the service descriptor 'ServiceType: ReportCache Lifetime: Singleton ImplementationType: ReportCache': Cannot consume scoped service 'IOrderRepository' from singleton 'ReportCache'.

في الإعداد أعلاه لاحظ التالي:

  • السطر الأول هو حقن كامل بلا Container، والمكان الذي تجمع فيه الكائنات يسمى جذر التركيب Composition Root، وفي برنامج صغير قد يكون هذا كل ما تحتاجه.
  • في الطلب الأول حصلنا على checkout#2 وcheckout#3 لأن ال Transient جديد في كل مرة، لكنهما يشتركان في repo#2 لأن ال Scoped واحد داخل ال Scope، وفي الطلب الثاني تغير ال repo إلى repo#3، أما clock#2 فبقي نفسه لأنه Singleton. وال Scope في ASP.NET Core هو طلب HTTP واحد كما في توثيق Dependency injection in ASP.NET Core.
  • آخر سطر هو الخطأ الذي يقع فيه أغلب المطورين مرة على الأقل: كائن Singleton يستلم كائناً Scoped، فيحتفظ به طول عمر التطبيق ويصبح ال Scoped Singleton فعلياً، وهذا ما يقصده توثيق .NET في قسم Scope validation عندما يقول إن عمر الخدمة «يرتقي» إلى Singleton. والخيار ValidateScopes يكشفه عند بناء ال Container، وهو مفعل افتراضياً عندما يعمل التطبيق في بيئة التطوير Development.
⚠️
إذا وضعت DbContext داخل خدمة Singleton فسوف يعمل التطبيق في البداية، ثم تبدأ أخطاء غريبة تحت الضغط لأن عدة طلبات تستخدم نفس ال DbContext في نفس الوقت، وتوثيق DbContext Lifetime يقول إن AddDbContext يسجله Scoped افتراضياً وإنه غير آمن للاستخدام من عدة خيوط Threads في نفس الوقت، لذلك لا تقم بتعطيل ValidateScopes لتسكت الخطأ، وإنما غير مدة حياة الخدمة أو أنشئ Scope داخلها عند الحاجة.

أين تقابل حقن الاعتماديات في الأطر؟

  • ASP.NET Core: ال Container مبني في الإطار، وال Options مثل IOptions<T> كما في Options pattern وال ILogger<T> تصل إلى الكلاس عبر ال Constructor.
  • Spring: ال Container هو قلب الإطار، والتوثيق في قسم Dependency Injection يقول إن فريق Spring ينصح عموماً بالحقن عبر ال Constructor، لأنه يسمح بكائنات لا تتغير ويضمن ألا تكون الاعتماديات المطلوبة null، أما @Autowired على الحقول مباشرة فتراه في الكود القديم كثيراً.
  • Express وNode.js: لا يوجد DI Container في Express، والطريقة الشائعة هي أن تمرر الاعتماديات كمعاملات للدالة التي تبني ال Router، كما سنرى في مثال Node أدناه.

متى يصبح حقن الاعتماديات تعقيداً زائداً؟

الحقن نفسه نادراً ما يكون زائداً، أما ما يزيد فهو ما حوله: واجهة لكل كلاس حتى لو كان له تنفيذ واحد ولن يستبدل في الاختبارات، أو Constructor فيه عشرة اعتماديات (وهذه علامة أن الكلاس يفعل أكثر من شيء، راجع Single Responsibility)، أو استدعاء GetService من داخل الكلاسات، وهو ما يسمى Service Locator، وتنصح Microsoft في إرشادات DI بتجنبه لأنه يخفي الاعتماديات بدل أن يظهرها في ال Constructor. وفي سكربت صغير أو أداة سطر أوامر تكفي الحقن اليدوي في دالة Main دون أي حاوية.

نمط Builder: بناء الكائن خطوة بعد خطوة

بعض الكائنات لها حقول كثيرة اختيارية، مثل طلب تقرير له اسم وفترة وتجميع وصيغة وترتيب وحد أقصى للنتائج، والحل البسيط أن تكتب Constructor فيه عشرة معاملات، فيصبح استدعاؤه new ReportRequest("sales", d1, d2, null, "csv", null, 100, true) ولا يعرف أحد معنى true في آخره، أو أن تكتب عدة Constructors لكل مجموعة من المعاملات. ونمط Builder يبني الكائن بخطوات لها أسماء، ثم يتحقق من القيم كلها مرة واحدة في الخطوة الأخيرة Build()، والصورة التالية تبين ذلك:

أربع خطوات مرقمة من اليمين: For sales ثم Between ثم GroupBy city ثم AsCsv، ثم خطوة خامسة خضراء Build مع درع يتحقق من القيم، وتحتها نتيجتان: كائن ReportRequest جاهز، وخطأ أحمر عندما يكون تاريخ النهاية قبل تاريخ البداية
كل خطوة تضيف جزءاً، والتحقق كله في Build

والمثال التالي يبين ال Builder، ثم يبين متى لا تحتاجه في C#:

// Builder: build a complex object step by step, then validate it once in Build().
var report = new ReportRequestBuilder()
    .For("sales")
    .Between(new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30))
    .GroupBy("city")
    .AsCsv()
    .Build();
Console.WriteLine(report);

try { new ReportRequestBuilder().For("sales").Between(new DateOnly(2026, 9, 30), new DateOnly(2026, 9, 1)).Build(); }
catch (InvalidOperationException e) { Console.WriteLine($"error: {e.Message}"); }

// C# often does not need a builder: named arguments and records with "with" do the same for simple objects
var simple = new ReportRequest("sales", new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30), GroupBy: null, Format: "pdf");
var copy = simple with { Format = "csv" };
Console.WriteLine(copy);

// The builders you already use in .NET
var sb = new System.Text.StringBuilder().Append("order ").Append(1042).Append(" shipped");
var uri = new UriBuilder("https", "shop.example.com") { Path = "orders/1042", Query = "lang=ar" }.Uri;
Console.WriteLine($"{sb} | {uri}");

record ReportRequest(string Name, DateOnly From, DateOnly To, string? GroupBy, string Format);

class ReportRequestBuilder
{
    private string? name; private DateOnly from, to; private string? groupBy; private string format = "pdf";
    public ReportRequestBuilder For(string reportName) { name = reportName; return this; }
    public ReportRequestBuilder Between(DateOnly start, DateOnly end) { from = start; to = end; return this; }
    public ReportRequestBuilder GroupBy(string column) { groupBy = column; return this; }
    public ReportRequestBuilder AsCsv() { format = "csv"; return this; }
    public ReportRequest Build()
    {
        if (name is null) throw new InvalidOperationException("report name is required");
        if (to < from) throw new InvalidOperationException($"end date {to} is before start date {from}");
        return new ReportRequest(name, from, to, groupBy, format);
    }
}

والمخرج سوف يكون كما يلي:

ReportRequest { Name = sales, From = 2026-09-01, To = 2026-09-30, GroupBy = city, Format = csv }
error: end date 2026-09-01 is before start date 2026-09-30
ReportRequest { Name = sales, From = 2026-09-01, To = 2026-09-30, GroupBy = , Format = csv }
order 1042 shipped | https://shop.example.com/orders/1042?lang=ar

في الإعداد أعلاه لاحظ التالي:

  • كل دالة في ال Builder ترجع this، لذلك تستطيع أن تكتب الخطوات متتالية، وتقرأ السلسلة كجملة: لتقرير المبيعات، بين تاريخين، مجمعاً حسب المدينة، بصيغة CSV.
  • التحقق في Build() رفض الطلب الذي فيه تاريخ النهاية قبل البداية، فلا يوجد في البرنامج كائن ReportRequest غير صحيح أبداً.
  • الجزء الثاني من المثال يبين أن C# الحديثة لا تحتاج Builder للكائنات البسيطة: المعاملات المسماة Named Arguments مثل GroupBy: null تشرح نفسها، والتعبير with ينشئ نسخة من ال record مع تغيير حقل واحد.
  • StringBuilder وUriBuilder من .NET نفسها، وكلاهما يبني الكائن على خطوات.

أين تقابل Builder في الأطر؟

  • ASP.NET Core: أول سطر في كل تطبيق هو WebApplication.CreateBuilder(args)، ثم تضيف الخدمات والإعدادات إلى ال Builder، ثم builder.Build() يعطيك التطبيق، كما في توثيق WebApplication and WebApplicationBuilder.
  • Java: الواجهة HttpRequest.Builder في مكتبة Java القياسية، وسوف نشغلها أدناه.
  • Spring Boot: يقول توثيق REST Clients إن Spring Boot يجهز لك RestClient.Builder وينصح بأن تحقنه في الكلاس وتبني منه ال RestClient، وكذلك RestTemplateBuilder للكود الأقدم.

متى يصبح Builder تعقيداً زائداً؟

عندما يكون للكائن ثلاثة حقول كلها مطلوبة، فال Builder هنا كلاس كامل يكرر الحقول نفسها ويضيف مكاناً جديداً تنسى فيه حقلاً عند التعديل، والمعاملات المسماة أو ال record أوضح بكثير. ويستحق ال Builder مكانه عندما تكثر الحقول الاختيارية، أو عندما يحتاج الكائن تحققاً يربط بين عدة حقول (مثل التاريخين في مثالنا)، أو عندما يكون البناء نفسه على مراحل كما في WebApplicationBuilder.

نمط Singleton: ولماذا يعامل كرائحة في الكود Code Smell

نمط Singleton يضمن أن للكلاس نسخة واحدة فقط في البرنامج كله، ويعطيك طريقة للوصول إليها من أي مكان، وهو أسهل نمط في الكتاب، لكن السهولة نفسها هي المشكلة: أي كلاس يستطيع أن يقرأ النسخة أو يغيرها دون أن يظهر ذلك في توقيع دالته، فتصبح النسخة متغيراً عاماً Global Variable باسم أنيق. والمثال التالي يكتب Singleton بالطريقة الصحيحة تقنياً (آمن مع الخيوط Threads عبر Lazy<T>)، ثم يشغل «اختبارين» صحيحين في نفس العملية:

// Singleton: one instance for the whole process, reachable from anywhere. Easy to write, and that is the problem.
Console.WriteLine(ReferenceEquals(DiscountSettings.Instance, DiscountSettings.Instance));

// Two "tests" that run in the same process share the same global object
Test("summer sale applies 20%", () =>
{
    DiscountSettings.Instance.Percent = 20;
    return Checkout.Price(100) == 80;
});
Test("normal price has no discount", () => Checkout.Price(100) == 100);

static void Test(string name, Func<bool> body) => Console.WriteLine($"{(body() ? "PASS" : "FAIL")}  {name}");

sealed class DiscountSettings
{
    private static readonly Lazy<DiscountSettings> instance = new(() => new DiscountSettings());
    public static DiscountSettings Instance => instance.Value;
    private DiscountSettings() { }
    public int Percent { get; set; }
}

static class Checkout
{
    // Hidden dependency: nothing in the signature says this method reads global state
    public static decimal Price(decimal amount) => amount * (100 - DiscountSettings.Instance.Percent) / 100;
}

والمخرج سوف يكون كما يلي:

True
PASS  summer sale applies 20%
FAIL  normal price has no discount

في الإعداد أعلاه لاحظ التالي:

  • الاختبار الثاني صحيح تماماً، فالسعر دون خصم يجب أن يكون 100، لكنه فشل لأن الاختبار الأول غير القيمة العامة وتركها 20. وإذا شغلت الاختبار الثاني وحده فسوف ينجح، وهذا أسوأ نوع من الاختبارات المتقلبة Flaky Tests لأن النتيجة تعتمد على ترتيب التشغيل.
  • الدالة Checkout.Price(decimal amount) تقول في توقيعها إنها تحتاج المبلغ فقط، لكنها في الحقيقة تعتمد على الخصم العام، وهذا اعتماد مخفي لا تراه إلا إذا قرأت الكود.
  • لا تستطيع أن تعطي الدالة خصماً مختلفاً في الاختبار دون أن تغير القيمة للبرنامج كله.
في الوسط كرة أرضية زرقاء بعنوان GLOBAL STATE وفيها DiscountSettings.Instance وPercent يساوي 20، وفي اليسار ثلاثة كلاسات تقرأ القيمة بخطوط منقطة مخفية، وفي اليمين اختباران: test 1 باللون الأخضر PASS يغير القيمة إلى 20، وtest 2 باللون الأحمر FAIL يتوقع 100 ويحصل على 80
النسخة الواحدة تحمل حالة تتسرب بين الاختبارات، ومن يقرأها لا يظهر ذلك في توقيعه

وقد يتساءل البعض: لكن ال Logger والإعدادات يجب أن تكون نسخة واحدة فعلاً، فما الحل؟ والإجابة أن المشكلة ليست في النسخة الواحدة، وإنما في الوصول العام إليها، والحل الأنسب أن تسجل الكلاس في ال Container بمدة حياة Singleton وتحقنه في ال Constructor، فتحصل على نسخة واحدة في التشغيل، وعلى اعتماد ظاهر في التوقيع، وعلى نسخة مختلفة في كل اختبار. وهذا ما تقوله Microsoft صراحة في إرشادات DI: تجنب الكلاسات والأعضاء الثابتة Static التي تحمل حالة، وتجنب الحالة العامة بأن تصمم التطبيق ليستخدم خدمات Singleton بدلاً منها، وتجنب الوصول الثابت إلى الخدمات.

أين تقابل Singleton في الأطر؟

  • Spring: كل Bean في Spring هو Singleton افتراضياً، لكن توثيق Bean Scopes يوضح أن Singleton في Spring يختلف عن Singleton في كتاب GoF، فالأول نسخة واحدة لكل Bean في كل Container، والثاني نسخة واحدة لكل ClassLoader مكتوبة في الكلاس نفسه، والأول هو الشكل الذي ننصح به.
  • .NET: AddSingleton في ال Container، وهو نفس فكرة Spring.
  • Node.js: يقول توثيق Modules إن الوحدة Module تحفظ بعد أول تحميل، فكل require لنفس الملف يرجع نفس الكائن، أي أن كل Module في Node هو Singleton دون أن تكتب شيئاً، وهو مفيد ومخيف في نفس الوقت للسبب نفسه.
  • Java: إذا احتجت Singleton حقيقياً فالشكل الأبسط هو enum له قيمة واحدة، لأن مواصفة Java في القسم 8.9 تقول إن ال enum لا يوجد له نسخ غير ثوابته.

متى يكون Singleton مقبولاً؟

عندما يكون الكائن بلا حالة تتغير، أو حالته للقراءة فقط بعد التشغيل، ولا يحتاج أن يستبدل في الاختبارات، مثل كائن يحمل جدولاً ثابتاً من الثوابت، ومع ذلك فالتسجيل في ال Container بمدة Singleton يعطيك نفس النتيجة دون أي ثمن تقريباً، لذلك اجعل ال Singleton المكتوب بيدك آخر خيار وليس أوله.

نفس الأنماط في Java وNode.js

الأنماط لا تخص لغة معينة، والطريقة الأسرع لتتأكد من ذلك أن تراها في المكتبة القياسية للغة أخرى. والملف التالي يستخدم مكتبة Java القياسية فقط، دون Spring، ويمر على ستة من الأنماط التي شرحناها:

// The same patterns in the Java standard library (no Spring needed): java Patterns.java
import java.beans.PropertyChangeSupport;
import java.io.*;
import java.net.URI;
import java.net.http.HttpRequest;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;

public class Patterns {
    enum AppConfig { INSTANCE; int timeoutSeconds = 5; }   // Singleton, the enum way

    public static void main(String[] args) throws Exception {
        // Strategy: List.sort takes the comparison rule as a Comparator
        var cities = new ArrayList<>(List.of("Riyadh", "Abha", "Jeddah", "Dammam"));
        cities.sort(Comparator.naturalOrder());
        System.out.println("strategy:  " + cities);
        cities.sort(Comparator.comparing(String::length).thenComparing(Comparator.reverseOrder()));
        System.out.println("strategy:  " + cities);

        // Adapter: InputStreamReader makes a byte stream usable where a character Reader is expected
        InputStream bytes = new ByteArrayInputStream("مرحبا".getBytes(StandardCharsets.UTF_8));
        Reader chars = new InputStreamReader(bytes, StandardCharsets.UTF_8);
        System.out.println("adapter:   " + new BufferedReader(chars).readLine());

        // Decorator: each stream wraps another and adds one behaviour
        var raw = new ByteArrayOutputStream();
        try (var out = new DataOutputStream(new BufferedOutputStream(raw))) { out.writeInt(1042); }
        System.out.println("decorator: " + raw.size() + " bytes written through Data(Buffered(ByteArray))");

        // Observer: PropertyChangeSupport notifies every registered listener
        var events = new PropertyChangeSupport(new Object());
        events.addPropertyChangeListener(e -> System.out.println("observer:  status " + e.getOldValue() + " -> " + e.getNewValue()));
        events.firePropertyChange("status", "PAID", "SHIPPED");

        // Builder: java.net.http.HttpRequest
        HttpRequest req = HttpRequest.newBuilder(URI.create("https://shop.example.com/orders/1042"))
                .timeout(Duration.ofSeconds(AppConfig.INSTANCE.timeoutSeconds))
                .header("Accept", "application/json")
                .GET()
                .build();
        System.out.println("builder:   " + req.method() + " " + req.uri() + " timeout=" + req.timeout().get());

        // Singleton: the enum constant is the single instance
        System.out.println("singleton: " + (AppConfig.INSTANCE == AppConfig.valueOf("INSTANCE")));
    }
}

ولتشغيله دون تثبيت Java على جهازك يمكنك استخدام Container مؤقت يحذف نفسه بعد التشغيل:

docker run --rm --name lab-arch-patterns -v "$PWD:/src" -w /src eclipse-temurin:25-jdk java Patterns.java

والمخرج سوف يكون كما يلي:

strategy:  [Abha, Dammam, Jeddah, Riyadh]
strategy:  [Abha, Riyadh, Jeddah, Dammam]
adapter:   مرحبا
decorator: 4 bytes written through Data(Buffered(ByteArray))
observer:  status PAID -> SHIPPED
builder:   GET https://shop.example.com/orders/1042 timeout=PT5S
singleton: true

في الإعداد أعلاه لاحظ التالي:

  • ال Comparator هو Strategy، والترتيب الثاني يجمع قاعدتين: الطول أولاً، ثم الترتيب العكسي للأسماء المتساوية في الطول.
  • ال InputStreamReader هو Adapter حول تدفق بايتات، ولذلك ظهرت كلمة «مرحبا» صحيحة لأننا حددنا الترميز UTF-8، وال DataOutputStream(new BufferedOutputStream(...)) هو Decorator فوق Decorator.
  • PropertyChangeSupport من الحزمة java.beans هو البديل الذي ينصح به توثيق Observable المهمل.
  • HttpRequest.newBuilder() بنى الطلب دون أن يرسله، وطباعة المهلة ظهرت بصيغة ISO-8601 للمدد PT5S أي خمس ثوان.

أما في Node.js فالمثال التالي يبني تطبيق Express صغيراً على المنفذ 18431 على العنوان 127.0.0.1 فقط، ويرسل له طلباً واحداً ثم يغلقه، وفيه خمسة أنماط. وأولاً ملف package.json مع إصدار Express مثبت:

{
  "name": "arabroot-design-patterns-node",
  "private": true,
  "type": "commonjs",
  "dependencies": {
    "express": "5.2.1"
  }
}

ثم الملف counter.js الذي سنستخدمه لنرى Singleton في نظام الوحدات:

// require() caches a module after the first load, so every require gets the same object: a Singleton for free
let count = 0;
module.exports = { next: () => ++count };

ثم الملف الرئيسي app.js:

// Patterns you meet in Node.js and Express: node app.js
const express = require('express');
const { EventEmitter } = require('node:events');
const { promisify } = require('node:util');

async function main() {
  // Observer: EventEmitter
  const orders = new EventEmitter();
  orders.on('placed', (o) => console.log(`observer:   email receipt for order ${o.id}`));
  orders.on('placed', (o) => console.log(`observer:   reserve ${o.items} item(s)`));

  // Adapter: promisify turns a callback-style function into one that returns a Promise
  function legacyStock(sku, callback) { setTimeout(() => callback(null, sku === 'KB-01' ? 7 : 0), 10); }
  const stock = promisify(legacyStock);
  console.log(`adapter:    KB-01 in stock = ${await stock('KB-01')}`);

  // Singleton: both require calls return the same cached module object
  const a = require('./counter');
  const b = require('./counter');
  a.next(); b.next();
  console.log(`singleton:  same object = ${a === b}, count = ${a.next()}`);

  // Dependency Injection without a container: pass the repository into the function that builds the router
  function makeOrdersRouter(repo, events) {
    const router = express.Router();
    router.post('/orders', express.json(), (req, res) => {
      const order = repo.add(req.body);
      events.emit('placed', order);
      res.status(201).json(order);
    });
    return router;
  }
  const memoryRepo = { rows: [], add(o) { const row = { id: 1042 + this.rows.length, ...o }; this.rows.push(row); return row; } };

  // Factory: express() creates the application; app.use() adds middleware that wraps every request
  const app = express();
  app.use((req, res, next) => {
    res.on('finish', () => console.log(`middleware: ${req.method} ${req.url} -> ${res.statusCode}`));
    next();
  });
  app.use(makeOrdersRouter(memoryRepo, orders));

  const server = app.listen(18431, '127.0.0.1', async () => {
    const r = await fetch('http://127.0.0.1:18431/orders', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ items: 2 }) });
    console.log(`response:   ${r.status} ${await r.text()}`);
    server.close();
  });
}

main();

وبعد npm install نقوم بتشغيله بالأمر node app.js، والمخرج سوف يكون كما يلي:

adapter:    KB-01 in stock = 7
singleton:  same object = true, count = 3
observer:   email receipt for order 1042
observer:   reserve 2 item(s)
middleware: POST /orders -> 201
response:   201 {"id":1042,"items":2}

في الإعداد أعلاه لاحظ التالي:

  • الدالة makeOrdersRouter(repo, events) هي حقن اعتماديات دون Container: ال Router لا يعرف من أين جاء ال repo، وفي الاختبار تمرر له repo في الذاكرة كما فعلنا هنا.
  • العداد وصل إلى 3 مع أننا استدعينا require('./counter') مرتين، لأن الاستدعاءين رجعا بنفس الكائن، وكل زيادة من أي منهما تظهر في الآخر.
  • سطر ال middleware ظهر قبل سطر ال response، لأن الحدث finish يعمل عندما ينتهي السيرفر من إرسال الرد، أي قبل أن يقرأ ال fetch الرد ويطبعه، وهذا يبين أن ال Middleware يعمل قبل الطلب وبعده.
  • لاحظ كذلك أن مشتركي ال EventEmitter عملوا داخل معالجة الطلب نفسه، قبل إرسال الرد، لأن emit متزامن كما في مثال C#.

متى تتحول الأنماط إلى تعقيد زائد Over-engineering؟

كل نمط في هذا المقال يحل مشكلة ويضيف ثمناً: ملفات أكثر، وقفزات أكثر أثناء القراءة، ومفاهيم يجب أن يعرفها كل من يدخل المشروع، والمشكلة أن هذا الثمن تدفعه من أول يوم، أما الفائدة فلا تأتي إلا إذا جاءت المشكلة فعلاً. لذلك القاعدة التي ننصح بها هي: اكتب الحل البسيط أولاً، وعندما تتكرر المشكلة نفسها مرتين أو ثلاثاً أو تكبر حتى تؤلم، أعد ترتيب الكود Refactoring إلى النمط المناسب، وفي تلك اللحظة سوف تعرف شكل النمط الذي تحتاجه بدقة لأنك رأيت المشكلة. والجدول التالي يجمع العلامات:

النمطيستحق مكانه عندماعلامة الإفراط
Strategyثلاث قواعد أو أكثر، أو قاعدة كبيرة تحتاج اختباراً مستقلاًواجهة بتنفيذ واحد، أو فرعان لا يتغيران منذ سنوات
Factoryالكلاس يختار وقت التشغيل من قيمة في الإعداد أو الطلبFactory لكلاس واحد، أو Factory يصنع Factory
Adapterمكتبة من مزود قد تغيره، أو أسلوبها يختلف عن مشروعكواجهة خاصة فوق كل مكتبة مستقرة تكرر نفس الدوال
Decoratorسلوك عام (Cache، Logging، Retry) حول خدمة دون تعديلهاسلسلة طويلة لا يعرف أحد ترتيبها
Observerعدة أقسام مستقلة تتفاعل مع نفس الحدثلا تعرف ماذا يحدث عند الحفظ إلا بالبحث في المشروع كله
Repositoryاختبار قواعد العمل دون قاعدة بيانات، أو استعلامات متكررةGeneric Repository يمرر كل شيء إلى EF Core كما هو
Dependency Injectionأي كلاس له اعتماديات خارجيةواجهة لكل كلاس، أو عشرة معاملات في ال Constructor، أو Service Locator
Builderحقول اختيارية كثيرة أو تحقق يربط عدة حقولBuilder لكائن بثلاثة حقول مطلوبة
Singletonنسخة واحدة بلا حالة متغيرة، ويفضل عبر ال Containerحالة عامة تتغير ويصل إليها أي كلاس

والعلامة العامة التي تجمع كل ما سبق: إذا احتاج مطور جديد أن يفتح ستة ملفات ليفهم ماذا تفعل دالة واحدة، وكانت خمسة منها واجهات وFactories لا تقرر شيئاً، فالتصميم أثقل من المشكلة، وهذا ما يسميه مبدأ KISS.

تدريب عملي

  1. خذ مثال Strategy وأضف طريقة شحن رابعة اسمها SameDay تكلفتها 50 ريالاً إذا كان الوزن أقل من 5 كيلوغرامات و80 إذا كان أكثر، ولاحظ أنك لم تحتج تعديل أي كلاس موجود، ثم اكتب نفس القاعدة في دالة ShippingCost القديمة وقارن.
  2. في مثال Decorator اعكس ترتيب الغلافين ليصبح new CachingPriceService(new LoggingPriceService(new DbPriceService()))، وشغل المثال، وفسر لماذا اختفى سطر ال log في الاستدعاء الثاني.
  3. في مثال Singleton احذف ال DiscountSettings.Instance واجعل Checkout كلاساً عادياً يستلم DiscountSettings في ال Constructor، ثم أعد كتابة الاختبارين ليعطي كل منهما نسخته، وتأكد أن الاثنين ينجحان بأي ترتيب.

الخلاصة

  • نمط التصميم اسم لحل معروف لمشكلة متكررة، وفائدته الأكبر أنه لغة مشتركة بين المطورين، وأغلب الأسماء من كتاب GoF عام 1994، وRepository وحقن الاعتماديات من كتابات Martin Fowler.
  • Strategy يجعل القاعدة قطعة يمكن تبديلها، وFactory يجمع قرار الإنشاء في مكان واحد، وAdapter يعزل مكتبة لا تملكها خلف واجهتك، وDecorator يضيف سلوكاً بأغلفة فوق نفس الواجهة.
  • Observer يفصل من يعلن الحدث عن المشتركين، لكن الاستدعاء داخل العملية متزامن، واستثناء في مشترك واحد يوقف من بعده كما رأينا في C# وكما يقول توثيق Spring وNode.
  • Repository يجعل قواعد العمل قابلة للاختبار دون قاعدة بيانات، وال DbContext في EF Core Repository أصلاً، فلا تلفه بواجهة عامة لا تضيف شيئاً.
  • حقن الاعتماديات طريقة، وال Container أداة، وانتبه لمدد الحياة: Singleton لا يستلم Scoped، وValidateScopes يكشف ذلك.
  • Singleton المكتوب بيدك حالة عامة باسم أنيق، والبديل نسخة واحدة يديرها ال Container وتصل عبر ال Constructor.
  • كل نمط له ثمن تدفعه من أول يوم، لذلك اكتب الحل البسيط أولاً وانتقل إلى النمط عندما تتكرر المشكلة فعلاً.

وفي المقالات التالية من هذه المرحلة ننتقل من مستوى الكلاسات إلى مستوى التطبيق كله، وأقرب المقالات إلى هذا الموضوع:

دليلمبادئ التصميم Design Principles بأمثلة قبل وبعدلماذا يكسر تعديل صغير في الفاتورة شيئاً في المحاسبة، وكيف تساعدك مبادئ مثل SOLID وDRY على كود يتغير دون مفاجآت.دليلClean Architecture: كيف تعزل منطق العمل عن قاعدة البياناتلماذا تحتاج قاعدة بيانات لكي تختبر قاعدة عمل من سطر واحد، وكيف يعزل اتجاه الاعتماديات منطق العمل عن التفاصيل.دليلالتصميم الموجه بالمجال Domain-Driven Design ببساطةلماذا تعني كلمة «الطلب» ثلاثة أشياء في نفس الشركة، وكيف يقسم DDD النظام إلى سياقات لكل منها لغته ونموذجه.دليلالتصميم والمعمارية: أربعة مستويات لنشاط واحدما الفرق بين تصميم البرمجيات ومعماريتها وتصميم الأنظمة ومعمارية المؤسسة، ومن يقرر في كل مستوى وكم يعيش قراره.دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتشغيل كل الأمثلة على .NET SDK 10.0.400 (runtime 10.0.11) وOpenJDK 25.0.4.1 داخل eclipse-temurin:25-jdk وNode.js 22.14.0 مع Express 5.2.1، ومراجعة كل ادعاء عن الأطر في توثيقها الرسمي.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.