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

مبادئ التصميم Design Principles بأمثلة قبل وبعد

لماذا يكسر تعديل صغير في الفاتورة شيئاً في المحاسبة، وكيف تساعدك مبادئ مثل SOLID وDRY على كود يتغير دون مفاجآت.

مبادئ التصميم Design Principles بأمثلة قبل وبعد

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

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

  • ما هي مبادئ التصميم، ولماذا هي إرشادات Heuristics وليست قوانين.
  • مبادئ SOLID الخمسة واحداً واحداً: المسؤولية الواحدة SRP، والمفتوح والمغلق OCP، والاستبدال LSP، وفصل الواجهات ISP، وعكس الاعتماديات DIP.
  • مبدأ عدم التكرار DRY، ومبدأ البساطة KISS، ومبدأ YAGNI، وما يفهم منها خطأ.
  • الترابط Coupling والتماسك Cohesion، وهما المقياس الذي تعود إليه كل المبادئ.
  • تفضيل التركيب Composition على الوراثة Inheritance.
  • ماذا تفعل عندما تتعارض المبادئ، وكيف تستخدمها في مراجعة الكود Code Review.

لماذا نحتاج مبادئ التصميم أصلاً؟

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

وقبل أن نبدأ تذكر ما قاله Robert C. Martin نفسه، وهو من جمع مبادئ SOLID ونشرها، في مقاله Getting a SOLID start عام 2009: إن هذه المبادئ ليست قواعد ولا قوانين ولا حقائق مطلقة، وإنما إرشادات تطبق بتقدير، وإن تطبيقها بشكل آلي سيئ مثل عدم تطبيقها أصلاً. لذلك سوف تجد في نهاية كل مبدأ فقرة «متى يصبح عبئاً؟»، والسبب أن أغلب التعقيد الزائد Over-engineering الذي نراه في المشاريع جاء من تطبيق مبدأ صحيح في المكان الخطأ.

والأمثلة في هذا المقال مكتوبة بلغة C# على .NET 10 بطريقة التطبيقات المكونة من ملف واحد File-based Apps، أي أنك تحفظ الملف وتشغله بالأمر dotnet run file.cs دون مشروع، وبعضها بلغة TypeScript نشغلها مباشرة على Node.js 22 بخيار Type Stripping الذي يحذف الأنواع ويشغل الكود. ونفترض أنك تعرف الكلاس والواجهة Interface والوراثة، فإذا كانت جديدة عليك فقد شرحناها في قسم البرمجة الكائنية OOP.

مبادئ SOLID الخمسة

SOLID اختصار لخمسة مبادئ للتصميم الكائني جمعها Robert C. Martin في ورقة نشرها قرابة عام 2000، ثم صار الاختصار أشهر من الورقة نفسها، وكل حرف فيه اسم مبدأ: S للمسؤولية الواحدة، وO للمفتوح والمغلق، وL للاستبدال، وI لفصل الواجهات، وD لعكس الاعتماديات. وفي عام 2020 كتب Martin مقالاً بعنوان Solid Relevance يرد فيه على سؤال هل ما زالت هذه المبادئ مهمة في زمن الخدمات المصغرة Microservices، وأعاد فيه صياغة كل مبدأ في سطر أو سطرين، وسوف نعتمد على هذه الصياغات الحديثة لأنها أوضح من الصياغات الأولى.

مبدأ المسؤولية الواحدة Single Responsibility Principle

أشهر صياغة لهذا المبدأ أن «الوحدة البرمجية Module يجب أن يكون لها سبب واحد فقط للتغيير»، وأشهر فهم خاطئ له أن «الكلاس يجب أن يفعل شيئاً واحداً» أو «الدالة يجب أن تكون قصيرة». وفي مقاله The Single Responsibility Principle عام 2014 يشرح Martin أن الفكرة تعود إلى ورقة David Parnas عام 1972 عن تقسيم الأنظمة إلى وحدات، ثم يسأل: ما هو «سبب التغيير»؟ والإجابة عنده أن السؤال الصحيح ليس «ماذا يفعل الكود» وإنما «لمن يستجيب الكود»، أي من هو الشخص أو القسم الذي يطلب تغييره، ويعيد صياغة المبدأ هكذا: اجمع معاً الأشياء التي تتغير لنفس الأسباب، وافصل الأشياء التي تتغير لأسباب مختلفة.

الصورة التالية تبين المثال الذي بدأنا به المقال قبل الفصل وبعده:

في اليمين كلاس واحد اسمه InvoiceService فيه Total وPrint ودالة Round مشتركة، يطلب تغييره شخصان Finance وSupport، وعلامة خطأ حمراء. وفي اليسار كلاسان: InvoiceCalculator يرتبط بشخص Finance ويخرج 413.40، وInvoicePrinter يرتبط بشخص Support ويطبع 413
كلاس واحد يخدم طرفين يجعل طلب أحدهما يكسر حسابات الآخر

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

// dotnet run srp.cs
var items = new List<Line> { new("Keyboard", 149.99m, 2), new("Mouse", 59.50m, 1) };

Console.WriteLine("== before: one class, two actors ==");
var old = new Before.InvoiceService();
Console.Write(old.Print(items));
Console.WriteLine($"sent to accounting: {old.Total(items)}");

Console.WriteLine("== after: one class per reason to change ==");
var total = new After.InvoiceCalculator().Total(items);
Console.Write(new After.InvoicePrinter().Print(items, total));
Console.WriteLine($"sent to accounting: {total}");

record Line(string Name, decimal Price, int Qty);

namespace Before
{
    class InvoiceService
    {
        // Support asked for whole numbers on the printed invoice, so someone changed the shared helper
        decimal Round(decimal v) => Math.Round(v, 0);

        public decimal Total(List<Line> items) => Round(items.Sum(i => i.Price * i.Qty) * 1.15m);

        public string Print(List<Line> items)
        {
            var s = "";
            foreach (var i in items) s += $"  {i.Name,-10} x{i.Qty}  {Round(i.Price * i.Qty)}\n";
            return s + $"  {"TOTAL",-10}      {Total(items)}\n";
        }
    }
}

namespace After
{
    class InvoiceCalculator   // changes when finance changes a rule
    {
        public decimal Total(List<Line> items) => Math.Round(items.Sum(i => i.Price * i.Qty) * 1.15m, 2);
    }

    class InvoicePrinter      // changes when support changes the layout
    {
        static string Whole(decimal v) => Math.Round(v, 0).ToString();

        public string Print(List<Line> items, decimal total)
        {
            var s = "";
            foreach (var i in items) s += $"  {i.Name,-10} x{i.Qty}  {Whole(i.Price * i.Qty)}\n";
            return s + $"  {"TOTAL",-10}      {Whole(total)}\n";
        }
    }
}

ونشغله بالأمر:

dotnet run srp.cs

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

== before: one class, two actors ==
  Keyboard   x2  300
  Mouse      x1  60
  TOTAL           413
sent to accounting: 413
== after: one class per reason to change ==
  Keyboard   x2  300
  Mouse      x1  60
  TOTAL           413
sent to accounting: 413.40

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

  • في النسخة القديمة الفاتورة المطبوعة صحيحة كما طلب الدعم، لكن الرقم الذي يذهب إلى المحاسبة صار 413 بدل 413.40، لأن Total() وPrint() تستخدمان نفس الدالة Round().
  • في النسخة الجديدة InvoiceCalculator يتغير فقط عندما تتغير قواعد الحساب، وInvoicePrinter يتغير فقط عندما يتغير شكل الطباعة، ولكل منهما دالة تقريب خاصة به.
  • قد يبدو أن الفصل كرر سطر التقريب في مكانين، وهذا مقصود، لأن التقريب في الطباعة والتقريب في الحساب قراران مختلفان لطرفين مختلفين، وسوف نعود إلى هذه النقطة في مبدأ DRY.

ويقول Martin في نفس المقال إن هذا المبدأ طريقة أخرى لتعريف التماسك Cohesion والترابط Coupling: تزيد التماسك بين ما يتغير لنفس السبب، وتقلل الترابط بين ما يتغير لأسباب مختلفة، ولذلك تجد له تطبيقات خارج الكلاسات، فلا تضع استعلامات SQL في صفحات العرض، ولا تبني HTML في الكود الذي يحسب النتائج.

متى يصبح عبئاً؟ عندما تفهم المسؤولية على أنها «دالة واحدة لكل كلاس»، فتجد مشروعاً فيه مئات الكلاسات الصغيرة مثل InvoiceTotalGetter وInvoiceTotalRounder، وكل تعديل يحتاج أن تفتح عشرة ملفات لتفهم ما يحدث. والقاعدة العملية: إذا كان نفس الشخص يطلب تغيير الجزأين دائماً وفي نفس الوقت، فهما مسؤولية واحدة ويبقيان معاً، ولا تقم بالفصل قبل أن ترى طرفين مختلفين يطلبان التغيير فعلاً.

مبدأ المفتوح والمغلق Open-Closed Principle

صاغ هذا المبدأ Bertrand Meyer في كتابه Object-Oriented Software Construction عام 1988، ونصه أن الوحدة البرمجية يجب أن تكون مفتوحة للتمديد Open for Extension ومغلقة للتعديل Closed for Modification، أي أنك تضيف سلوكاً جديداً بكتابة كود جديد وليس بتعديل كود قديم يعمل ومختبر. وفي مقاله The Open Closed Principle يضرب Martin مثالاً بمحررات النصوص وبيئات التطوير التي تضيف لها الإضافات Plugins دون أن تعدل كودها أو تعيد نشرها، ويعتبر نظام الإضافات أوضح تطبيق لهذا المبدأ.

لنفرض أن لديك دالة تحسب السعر حسب نوع العميل، والصورة التالية تبين الفرق بين أن تعدل نفس الدالة لكل نوع جديد وبين أن تضيف قاعدة جديدة بجانب القديمة:

في اليمين دالة Pricing.Price فيها switch على نوع العميل وحالات vip وemployee وstudent جديدة، مع قلم تعديل وثلاث خطوات edit ثم retest ثم redeploy. وفي اليسار كلاس Pricing مغلق بقفل وتحته فتحة IDiscountRule تتصل بها ثلاثة مقابس VipRule وEmployeeRule وStudentRule الجديدة بلون أخضر
في النسخة الجديدة إضافة نوع عميل تعني كلاساً جديداً وليس تعديل دالة مشتركة

والكود التالي فيه الطريقتان:

// dotnet run ocp.cs
var customers = new[] { new Customer("Sara", "regular"), new Customer("Omar", "vip"), new Customer("Lina", "student") };

Console.WriteLine("== before: a switch that every new type must edit ==");
foreach (var c in customers) Console.WriteLine($"  {c.Name,-6} {c.Type,-8} pays {Before.Pricing.Price(c, 200m)}");

Console.WriteLine("== after: a new rule is a new class ==");
var pricing = new After.Pricing(new After.IDiscountRule[] { new After.VipRule(), new After.EmployeeRule(), new After.StudentRule() });
foreach (var c in customers) Console.WriteLine($"  {c.Name,-6} {c.Type,-8} pays {pricing.Price(c, 200m)}");

record Customer(string Name, string Type);

namespace Before
{
    static class Pricing
    {
        public static decimal Price(Customer c, decimal amount) => c.Type switch
        {
            "vip" => amount * 0.90m,
            "employee" => amount * 0.80m,
            // "student" was requested: edit this method, retest every case, redeploy the module
            _ => amount,
        };
    }
}

namespace After
{
    interface IDiscountRule
    {
        bool AppliesTo(Customer c);
        decimal Apply(decimal amount);
    }

    class VipRule : IDiscountRule
    {
        public bool AppliesTo(Customer c) => c.Type == "vip";
        public decimal Apply(decimal amount) => amount * 0.90m;
    }

    class EmployeeRule : IDiscountRule
    {
        public bool AppliesTo(Customer c) => c.Type == "employee";
        public decimal Apply(decimal amount) => amount * 0.80m;
    }

    class StudentRule : IDiscountRule   // added later, nothing else was edited
    {
        public bool AppliesTo(Customer c) => c.Type == "student";
        public decimal Apply(decimal amount) => amount - 15m;
    }

    class Pricing(IEnumerable<IDiscountRule> rules)
    {
        public decimal Price(Customer c, decimal amount) =>
            rules.FirstOrDefault(r => r.AppliesTo(c))?.Apply(amount) ?? amount;
    }
}

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

== before: a switch that every new type must edit ==
  Sara   regular  pays 200
  Omar   vip      pays 180.00
  Lina   student  pays 200
== after: a new rule is a new class ==
  Sara   regular  pays 200
  Omar   vip      pays 180.00
  Lina   student  pays 185

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

  • في النسخة القديمة الطالبة Lina دفعت 200 لأن أحداً لم يعدل switch بعد، وتعديله يعني لمس دالة يستخدمها كل النظام ثم اختبار كل الحالات القديمة من جديد.
  • في النسخة الجديدة الكلاس Pricing لا يعرف أسماء القواعد، وإنما يعرف الواجهة IDiscountRule فقط، وإضافة StudentRule لم تغير سطراً في الكلاسات الموجودة.
  • هذا الشكل هو نمط الاستراتيجية Strategy، وقد شرحناه مع أمثلة من أطر العمل في مقال أنماط التصميم.

متى يصبح عبئاً؟ عندما تضع نقطة تمديد في كل مكان «احتياطاً»، فتجد واجهة لها تطبيق واحد فقط منذ ثلاث سنوات، ومصنعاً Factory يصنع شيئاً واحداً، وإعدادات لاختيار سلوك لم يتغير أبداً. وليس كل switch مخالفة للمبدأ، فإذا كانت الحالات ثابتة بطبيعتها مثل أيام الأسبوع، أو كان الكود صغيراً ويعدله شخص واحد، فالتعديل المباشر أبسط وأوضح. والحل الأنسب أن تنتظر حتى ترى المكان الذي يتغير فعلاً، ثم تفتحه للتمديد في ذلك المكان فقط.

مبدأ الاستبدال Liskov Substitution Principle

نشرت Barbara Liskov وJeannette Wing الصياغة الدقيقة لهذا المبدأ في ورقة A Behavioral Notion of Subtyping في مجلة ACM Transactions on Programming Languages and Systems عام 1994، وفكرتها أن أي خاصية يمكن إثباتها عن كائنات النوع الأساسي يجب أن تبقى صحيحة عن كائنات أي نوع فرعي Subtype منه. وبلغة المطور: إذا كان الكود يعمل مع النوع الأساسي، فيجب أن يعمل بنفس الصحة إذا أعطيته أي نوع فرعي دون أن يعرف ذلك. ويقول Martin في مقال 2020 إن الناس، وهو منهم، ظنوا أن المبدأ عن الوراثة، وهو في الحقيقة عن الأنواع الفرعية، فكل كلاس يطبق واجهة هو نوع فرعي منها، وإذا فاجأ أحد هذه التطبيقات الكود الذي يستخدم الواجهة فسوف تنتشر جمل if وswitch التي تسأل عن النوع الفعلي.

والمخالفة الأشهر لهذا المبدأ موجودة في .NET نفسها، فالمصفوفة Array تطبق الواجهة IList<T> التي فيها الدالة Add، لكن المصفوفة ثابتة الحجم، لذلك تذكر صفحة Array في توثيق Microsoft أن الدوال التي تضيف أو تحذف عناصر ترمي NotSupportedException عندما تستخدم المصفوفة عبر هذه الواجهات. والصورة التالية تبين ما يحدث في نظام دفع عندما يكسر نوع فرعي وعد النوع الأساسي:

في اليمين حلقة foreach تستدعي Refund على ثلاث بطاقات: Card بعلامة صح، ثم GiftCard بعلامة خطأ حمراء وتحتها NotSupportedException، ثم Card رمادية مكتوب تحتها never ran. وفي اليسار نفس الحلقة والبطاقات الثلاث خضراء، وتحت GiftCard مكتوب store credit
نوع فرعي واحد يرمي استثناء يوقف الحلقة كلها، فالمشكلة عند من يستخدم النوع وليست عند من كتبه

والكود التالي يبدأ بالمصفوفة، ثم مثال الدفع قبل الإصلاح وبعده:

// dotnet run lsp.cs
Console.WriteLine("== .NET itself: an array is an IList<int> that refuses Add ==");
IList<int> list = new[] { 1, 2, 3 };
try { list.Add(4); } catch (Exception e) { Console.WriteLine($"  {e.GetType().Name}: {e.Message}"); }

Console.WriteLine("== before: GiftCard is a PaymentMethod that cannot refund ==");
var paid = new List<Before.PaymentMethod> { new Before.Card(), new Before.GiftCard(), new Before.Card() };
try { foreach (var p in paid) Console.WriteLine($"  {p.Refund(50m)}"); }
catch (Exception e) { Console.WriteLine($"  {e.GetType().Name}: {e.Message}  <- the loop stopped, the third refund never ran"); }

Console.WriteLine("== after: a contract every payment method can keep ==");
var paid2 = new List<After.PaymentMethod> { new After.Card(), new After.GiftCard(), new After.Card() };
foreach (var p in paid2) Console.WriteLine($"  {p.Refund(50m)}");

namespace Before
{
    abstract class PaymentMethod
    {
        public abstract string Refund(decimal amount);
    }
    class Card : PaymentMethod
    {
        public override string Refund(decimal amount) => $"card: refunded {amount}";
    }
    class GiftCard : PaymentMethod
    {
        public override string Refund(decimal amount) => throw new NotSupportedException("gift cards cannot be refunded");
    }
}

namespace After
{
    // The promise: Refund gives the customer the value back and says how. It never throws for a valid amount.
    abstract class PaymentMethod
    {
        public abstract string Refund(decimal amount);
    }
    class Card : PaymentMethod
    {
        public override string Refund(decimal amount) => $"card: refunded {amount}";
    }
    class GiftCard : PaymentMethod
    {
        public override string Refund(decimal amount) => $"gift card: {amount} added as store credit";
    }
}

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

== .NET itself: an array is an IList<int> that refuses Add ==
  NotSupportedException: Collection was of a fixed size.
== before: GiftCard is a PaymentMethod that cannot refund ==
  card: refunded 50
  NotSupportedException: gift cards cannot be refunded  <- the loop stopped, the third refund never ran
== after: a contract every payment method can keep ==
  card: refunded 50
  gift card: 50 added as store credit
  card: refunded 50

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

  • المترجم Compiler لم يعترض على أي سطر، فالمخالفة في السلوك وليست في الأنواع، ولا تظهر إلا وقت التشغيل.
  • في النسخة القديمة توقفت الحلقة عند GiftCard، وعميل البطاقة الثالثة لم يسترجع ماله رغم أن بطاقته سليمة.
  • الإصلاح هنا كان بتغيير الوعد نفسه: الدالة Refund صار معناها «أعد القيمة للعميل وأخبرني كيف»، وهذا وعد تستطيع بطاقة الهدايا أن تفي به برصيد في المتجر.
  • والإصلاح الآخر الممكن أن تخرج Refund من النوع الأساسي إلى واجهة مستقلة لا تطبقها إلا الأنواع التي تستطيع الاسترجاع، وهذا يقودنا مباشرة إلى المبدأ التالي.

وقد يتساءل البعض: هل كل NotSupportedException مخالفة؟ والإجابة أن المخالفة تحدث عندما يعد النوع الأساسي بشيء لا يفي به النوع الفرعي، أما إذا كان الوعد نفسه يقول «قد لا تكون هذه العملية مدعومة، فاسأل أولاً»، كما تفعل الخاصية IsReadOnly في ICollection<T>، فالكود الذي يستخدمها يعرف أن عليه أن يسأل، وهذا تصميم مقصود وليس مفاجأة.

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

مبدأ فصل الواجهات Interface Segregation Principle

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

في اليمين واجهة FileStorage فيها upload وdownload وshareLink وlistVersions، يستخدم منها ReportJob دالة واحدة، وكلاس LocalDiskStorage يطبقها ودالتان فيه مكتوب بجانبهما throw. وفي اليسار ثلاث واجهات صغيرة BlobWriter وBlobReader وLinkSharing، وReportJob يعتمد على BlobWriter فقط، وLocalDisk يطبق اثنتين، وS3Storage يطبق الثلاث
الواجهة الكبيرة تجبر الكلاس على دوال لا يستطيع تنفيذها، والواجهات الصغيرة تجعل الخطأ يظهر عند الترجمة

هذه هي النسخة القديمة بلغة TypeScript:

// node --experimental-strip-types --disable-warning=ExperimentalWarning isp-before.ts
interface FileStorage {
  upload(name: string, data: string): void;
  download(name: string): string;
  shareLink(name: string, minutes: number): string;
  listVersions(name: string): string[];
}

class LocalDiskStorage implements FileStorage {
  private files = new Map<string, string>();
  upload(name: string, data: string) { this.files.set(name, data); }
  download(name: string) { return this.files.get(name) ?? ""; }
  shareLink(name: string, minutes: number): string { throw new Error("local disk cannot create share links"); }
  listVersions(name: string): string[] { throw new Error("local disk keeps no versions"); }
}

// The report job only uploads, yet it depends on all four methods
function exportReport(storage: FileStorage) {
  storage.upload("report.csv", "id,total\n1,413.40");
  console.log("report uploaded");
}

// Another team writes a feature against the same interface: the compiler is happy
function emailReportLink(storage: FileStorage) {
  console.log("link:", storage.shareLink("report.csv", 60));
}

const disk = new LocalDiskStorage();
exportReport(disk);
try { emailReportLink(disk); } catch (e) { console.log("runtime error:", (e as Error).message); }
node --experimental-strip-types --disable-warning=ExperimentalWarning isp-before.ts

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

report uploaded
runtime error: local disk cannot create share links

لاحظ أن فاحص الأنواع Type Checker سوف يقبل هذا الكود دون أي خطأ، لأن LocalDiskStorage يطبق الواجهة شكلياً، والخطأ لم يظهر إلا عند التشغيل. والآن نقسم الواجهة حسب من يستخدمها:

// node --experimental-strip-types --disable-warning=ExperimentalWarning isp-after.ts
interface BlobWriter { upload(name: string, data: string): void; }
interface BlobReader { download(name: string): string; }
interface LinkSharing { shareLink(name: string, minutes: number): string; }

class LocalDiskStorage implements BlobWriter, BlobReader {
  private files = new Map<string, string>();
  upload(name: string, data: string) { this.files.set(name, data); }
  download(name: string) { return this.files.get(name) ?? ""; }
}

class S3Storage implements BlobWriter, BlobReader, LinkSharing {
  upload(name: string, data: string) { console.log(`s3 put ${name}`); }
  download(name: string) { return ""; }
  shareLink(name: string, minutes: number) { return `https://files.example.com/${name}?expires=${minutes}m`; }
}

function exportReport(storage: BlobWriter) {
  storage.upload("report.csv", "id,total\n1,413.40");
  console.log("report uploaded");
}

function emailReportLink(storage: LinkSharing) {
  console.log("link:", storage.shareLink("report.csv", 60));
}

exportReport(new LocalDiskStorage());
exportReport(new S3Storage());
emailReportLink(new S3Storage());
node --experimental-strip-types --disable-warning=ExperimentalWarning isp-after.ts

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

report uploaded
s3 put report.csv
report uploaded
link: https://files.example.com/report.csv?expires=60m

والآن لنكسر التصميم عمداً، ونضيف في آخر الملف السابق سطراً يمرر التخزين المحلي إلى دالة الروابط، ونحفظ النسخة المعدلة باسم isp-break.ts:

emailReportLink(new LocalDiskStorage());

ونشغل فاحص الأنواع على الملف بعد تثبيت TypeScript في المشروع:

npx tsc --noEmit --strict --target es2022 isp-break.ts

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

isp-break.ts(30,17): error TS2741: Property 'shareLink' is missing in type 'LocalDiskStorage' but required in type 'LinkSharing'.

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

  • الدالة exportReport صارت تطلب BlobWriter فقط، فأي تخزين يستطيع الرفع يصلح لها، وتغيير طريقة إنشاء الروابط لا يمسها.
  • الخطأ الذي كان يظهر عند العميل وقت التشغيل في النسخة القديمة صار يظهر عند المطور وقت الترجمة، وهذا أرخص بكثير.
  • الواجهات هنا صغيرة لأن المستخدمين مختلفون، وليس لأن «الواجهة الصغيرة أفضل» بشكل عام.

متى يصبح عبئاً؟ عندما تقسم كل واجهة إلى واجهات بدالة واحدة، حتى لو كان كل من يستخدمها يحتاج كل دوالها، فتصبح قراءة التوقيع Signature مثل Save(IReader & IWriter & IDeleter & IVersioner) أصعب من الواجهة الكبيرة نفسها. والسؤال الذي يحسم الأمر: هل يوجد فعلاً مستخدم يحتاج جزءاً من الواجهة دون الباقي، أو تطبيق لا يستطيع تنفيذ جزء منها؟ إذا لم يوجد فاتركها كما هي.

مبدأ عكس الاعتماديات Dependency Inversion Principle

يلخص Martin هذا المبدأ في مقال 2020 بجملتين: اعتمد في اتجاه التجريد Abstraction، والوحدات عالية المستوى High-level Modules يجب ألا تعتمد على التفاصيل منخفضة المستوى Low-level Details. والوحدة عالية المستوى هي منطق العمل Business Logic الذي يجلب المال، مثل «إنشاء طلب»، والتفاصيل هي قاعدة البيانات وخادم البريد وإطار العمل، وهي أشياء يمكن أن تتغير دون أن يتغير معنى «إنشاء طلب». والصورة التالية تبين لماذا نسميه «عكساً»:

في اليمين صندوق business rules فيه OrderService يكتب new SqlOrderStore، وسهم أحمر ينزل منه إلى قاعدة بيانات SqlOrderStore، وعلامة خطأ بجانبها no test without DB. وفي اليسار صندوق business rules own the interface فيه OrderService وواجهة IOrderStore، وسهمان أخضران يصعدان إلى الواجهة من SqlOrderStore للإنتاج ومن InMemoryOrderStore للاختبارات
في النسخة الجديدة الواجهة جزء من منطق العمل، والتفاصيل هي التي تعتمد عليها، فانقلب اتجاه السهم

والمثال التالي يبين الفرق، ومعه اختبار صغير يعمل دون قاعدة بيانات:

// dotnet run dip.cs
Console.WriteLine("== before: OrderService creates its own database class ==");
try { new Before.OrderService().PlaceOrder("A-100", 250m); }
catch (Exception e) { Console.WriteLine($"  {e.GetType().Name}: {e.Message}"); }

Console.WriteLine("== after: OrderService asks for what it needs ==");
var store = new After.InMemoryOrderStore();
var notifier = new After.FakeNotifier();
new After.OrderService(store, notifier).PlaceOrder("A-100", 250m);
Console.WriteLine($"  saved orders: {store.Count}, messages sent: {notifier.Sent.Count}");
Console.WriteLine(store.Count == 1 && notifier.Sent[0].Contains("A-100") ? "  test: PASS (no database, no mail server)" : "  test: FAIL");

namespace Before
{
    class SqlOrderStore
    {
        public SqlOrderStore()
        {
            var cs = Environment.GetEnvironmentVariable("ORDERS_DB")
                ?? throw new InvalidOperationException("ORDERS_DB is not set, cannot reach the database");
        }
        public void Save(string id, decimal amount) { /* INSERT INTO orders ... */ }
    }

    class OrderService
    {
        readonly SqlOrderStore store = new();   // the high-level policy depends on a low-level detail
        public void PlaceOrder(string id, decimal amount) => store.Save(id, amount);
    }
}

namespace After
{
    // The abstractions belong to the business code, written in its own words
    interface IOrderStore { void Save(string id, decimal amount); }
    interface INotifier { void Notify(string message); }

    class OrderService(IOrderStore store, INotifier notifier)
    {
        public void PlaceOrder(string id, decimal amount)
        {
            if (amount <= 0) throw new ArgumentException("amount must be positive");
            store.Save(id, amount);
            notifier.Notify($"order {id} placed");
        }
    }

    // Details implement the business interfaces: in production SqlOrderStore and SmtpNotifier, here in-memory fakes
    class InMemoryOrderStore : IOrderStore
    {
        readonly Dictionary<string, decimal> rows = new();
        public int Count => rows.Count;
        public void Save(string id, decimal amount) => rows[id] = amount;
    }
    class FakeNotifier : INotifier
    {
        public List<string> Sent { get; } = new();
        public void Notify(string message) => Sent.Add(message);
    }
}

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

== before: OrderService creates its own database class ==
  InvalidOperationException: ORDERS_DB is not set, cannot reach the database
== after: OrderService asks for what it needs ==
  saved orders: 1, messages sent: 1
  test: PASS (no database, no mail server)

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

  • في النسخة القديمة لم نستطع حتى أن نجرب قاعدة amount <= 0، لأن إنشاء OrderService نفسه يحاول الوصول إلى قاعدة البيانات.
  • الواجهتان IOrderStore وINotifier مكتوبتان بلغة منطق العمل («احفظ الطلب»، «أبلغ»)، وليس بلغة قاعدة البيانات («نفذ INSERT»)، وهذا هو معنى أن الواجهة ملك للوحدة العليا.
  • الكلاسان InMemoryOrderStore وFakeNotifier بدائل للاختبار Test Doubles، وقد شرحنا أنواعها في مقال أنواع الاختبارات.
  • تمرير الاعتماديات من الخارج عبر الـ Constructor هو حقن الاعتماديات Dependency Injection، وهو أسلوب لتطبيق هذا المبدأ وليس المبدأ نفسه، وقد شرحناه مع حاويات الحقن DI Containers في مقال أنماط التصميم.

وعندما تطبق هذا المبدأ على مستوى النظام كله، بحيث يعتمد كل شيء على منطق العمل ولا يعتمد منطق العمل على أي شيء، تحصل على ما يسمى Clean Architecture وHexagonal Architecture، وقد شرحنا قاعدة اتجاه الاعتماديات فيهما بمشروع كامل مع اختباراته.

متى يصبح عبئاً؟ عندما تضع واجهة أمام كل كلاس، فتجد IStringHelper وIDateFormatter لها تطبيق واحد لن يتغير، وكل «اذهب إلى التعريف» في المحرر يوصلك إلى واجهة فارغة بدل الكود. والتجريد يستحق مكانه عند الحدود الحقيقية: قاعدة البيانات، والخدمات الخارجية، والبريد، والوقت، والملفات، أي الأشياء التي تريد تبديلها في الاختبار أو قد تتغير فعلاً، أما الكلاسات الداخلية البسيطة فاستخدمها مباشرة.

مبدأ لا تكرر نفسك DRY

جاء هذا المبدأ من كتاب The Pragmatic Programmer لـ Andy Hunt وDave Thomas عام 1999، وفي مقابلة معهما عام 2003 يقول Dave Thomas إن DRY من أكثر أجزاء الكتاب التي يساء فهمها، لأن أغلب الناس يظنون أنه يعني «لا تكرر الكود»، والمقصود أوسع من ذلك: كل معلومة Knowledge في النظام يجب أن يكون لها تمثيل واحد واضح ومرجعي، والمعلومة هنا تشمل الكود ومخطط قاعدة البيانات Schema وخطط الاختبار والتوثيق. والصورة التالية تبين ما يحدث عندما تكتب نفس القاعدة في ثلاثة أماكن:

في اليمين ثلاث شاشات: cart banner فيها freeFrom 200 باللون الأحمر، وcheckout fee وemail فيهما freeFrom 300، وتحتها مطور مكتوب عنده changed 2 of 3. وفي اليسار ترس فوق قاعدة واحدة shipping.freeFrom = 300 تخرج منها أسهم إلى الشاشات الثلاث
المعلومة المكررة تتغير في مكانين وتنسى في الثالث، فيرى العميل رسالة ويدفع مبلغاً مختلفاً

لنفرض أن قسم التسويق رفع الحد الأدنى للشحن المجاني من 200 إلى 300، والمطور وجد مكانين من ثلاثة:

// node --experimental-strip-types --disable-warning=ExperimentalWarning dry-before.ts
// Marketing raised the free-shipping minimum from 200 to 300. The developer found two of the three places.

function cartBanner(subtotal: number): string {
  return subtotal >= 200 ? "free shipping!" : `add ${200 - subtotal} for free shipping`;   // missed
}

function shippingFee(subtotal: number): number {
  return subtotal >= 300 ? 0 : 25;                                                         // updated
}

function confirmationEmail(subtotal: number): string {
  return `shipping: ${subtotal >= 300 ? "free" : "25"}`;                                   // updated
}

const subtotal = 250;
console.log("cart  :", cartBanner(subtotal));
console.log("charge:", shippingFee(subtotal));
console.log("email :", confirmationEmail(subtotal));
node --experimental-strip-types --disable-warning=ExperimentalWarning dry-before.ts

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

cart  : free shipping!
charge: 25
email : shipping: 25

العميل يرى «free shipping!» في السلة ثم يدفع 25 عند الدفع، وهذا النوع من الأخطاء لا تكشفه اختبارات الوحدة Unit Tests لأن كل دالة صحيحة بالنسبة لنفسها. والحل أن تكون القاعدة في مكان واحد وكل شاشة تسأله:

// node --experimental-strip-types --disable-warning=ExperimentalWarning dry-after.ts
// One authoritative place for the rule, every screen asks it
const shipping = {
  freeFrom: 300,
  fee: 25,
  feeFor(subtotal: number) { return subtotal >= this.freeFrom ? 0 : this.fee; },
};

const cartBanner = (s: number) =>
  shipping.feeFor(s) === 0 ? "free shipping!" : `add ${shipping.freeFrom - s} for free shipping`;
const confirmationEmail = (s: number) => `shipping: ${shipping.feeFor(s) === 0 ? "free" : shipping.fee}`;

const subtotal = 250;
console.log("cart  :", cartBanner(subtotal));
console.log("charge:", shipping.feeFor(subtotal));
console.log("email :", confirmationEmail(subtotal));
node --experimental-strip-types --disable-warning=ExperimentalWarning dry-after.ts

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

cart  : add 50 for free shipping
charge: 25
email : shipping: 25

الشاشات الثلاث متفقة الآن، والتغيير القادم من التسويق سطر واحد في shipping.freeFrom.

متى يصبح عبئاً؟ عندما تدمج كودين لأنهما متشابهان في الشكل، وهما في الحقيقة معلومتان مختلفتان تشابهتا بالصدفة. المثال التالي يبين ذلك: اسم المستخدم ورمز المنتج SKU كانا يقبلان نفس الشكل، فدمجهما أحدهم في دالة واحدة «حتى لا نكرر»، ثم طلب المستودع رموزاً بشكل جديد:

// node --experimental-strip-types --disable-warning=ExperimentalWarning dry-overuse.ts
// Usernames and product SKUs shared one validator "to stay DRY".
// The warehouse started sending SKUs like WH-2026-ABC, so the shared rule was widened:
function validShortCode(s: string): boolean {
  return /^[A-Za-z0-9-]{3,30}$/.test(s);   // was /^[a-z0-9]{3,20}$/
}
const validProductSku = (s: string) => validShortCode(s);
const validUsername = (s: string) => validShortCode(s);

console.log("sku  WH-2026-ABC :", validProductSku("WH-2026-ABC"), " <- what the warehouse asked for");
console.log("user ---Admin--- :", validUsername("---Admin---"), " <- nobody asked for this");
node --experimental-strip-types --disable-warning=ExperimentalWarning dry-overuse.ts

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

sku  WH-2026-ABC : true  <- what the warehouse asked for
user ---Admin--- : true  <- nobody asked for this

صار النظام يقبل اسم مستخدم مثل ---Admin--- دون أن يطلب ذلك أحد، لأن قاعدتين مختلفتين ربطتا بخيط واحد. والقاعدة العملية التي يذكرها Martin Fowler في كتابه Refactoring باسم Rule of Three: لا تقم بالدمج عند التكرار الأول، وانتظر حتى ترى النمط الثالث وتتأكد أن الأماكن الثلاثة تتغير لنفس السبب. ويتضح هذا أكثر بين الوحدات والخدمات المختلفة، فمكتبة مشتركة تجمع كل «الكود المكرر» بين خدمات مستقلة تربطها كلها ببعضها، وقد ناقشنا أثر ذلك في مقال أنماط المعمارية، وكيف تعني نفس الكلمة أشياء مختلفة في أجزاء مختلفة من النظام في مقال التصميم الموجه بالمجال DDD.

مبدأ البساطة KISS

KISS اختصار للعبارة الإنجليزية Keep It Simple, Stupid، ومعناها أن تختار أبسط حل يؤدي المطلوب، والبساطة هنا تقاس بسرعة فهم زميلك للكود وبسهولة تعديله، وليس بعدد الأسطر. ويقول أحد مبادئ البيان الرشيق Agile Manifesto إن البساطة هي «فن تعظيم كمية العمل الذي لا تحتاج أن تعمله»، ويرتب Kent Beck قواعد التصميم البسيط، كما ينقلها Martin Fowler في BeckDesignRules، بهذا الترتيب: ينجح في الاختبارات، ويوضح نيته، ولا يكرر، وفيه أقل عدد من العناصر، والترتيب مهم، فوضوح النية مقدم على قلة العناصر. والصورة التالية تقارن تعبيراً ذكياً واحداً بخطوات واضحة:

في اليمين صندوق فيه خط برتقالي متشابك مكتوب تحته totalClever one expression، وحشرة حمراء في الزاوية، وتحته qty 0 يعطي 145 silently wrong. وفي اليسار أربع خطوات مرقمة subtotal مع فحص الكمية ثم discount ثم shipping ثم total، وتحتها qty 0 يعطي error invalid quantity 0
التعبير الذكي يخفي القرارات داخله، والخطوات الواضحة تجعل كل قرار مكتوباً

والكود التالي فيه الحسابان، ونجربهما على طلب عادي ثم على طلب كميته صفر:

// node --experimental-strip-types --disable-warning=ExperimentalWarning kiss.ts
type Item = { price: number; qty?: number };
type Order = { items: Item[]; couponPct?: number };

// Before: one clever expression
const totalClever = (o: Order) =>
  o.items.reduce((s, i) => s + i.price * (i.qty || 1), 0) * (1 - (o.couponPct ?? 0) / 100) +
  (o.items.reduce((s, i) => s + i.price * (i.qty || 1), 0) < 200 ? 25 : 0);

// After: plain steps with names
function totalSimple(o: Order): number {
  let subtotal = 0;
  for (const item of o.items) {
    const qty = item.qty ?? 1;
    if (qty <= 0) throw new Error(`invalid quantity ${qty}`);
    subtotal += item.price * qty;
  }
  const discount = subtotal * (o.couponPct ?? 0) / 100;
  const shipping = subtotal >= 200 ? 0 : 25;
  return subtotal - discount + shipping;
}

const normal: Order = { items: [{ price: 120, qty: 2 }, { price: 30 }], couponPct: 10 };
const zeroQty: Order = { items: [{ price: 120, qty: 0 }] };

console.log("normal  clever:", totalClever(normal), " simple:", totalSimple(normal));
console.log("qty 0   clever:", totalClever(zeroQty));
try { console.log("qty 0   simple:", totalSimple(zeroQty)); } catch (e) { console.log("qty 0   simple:", (e as Error).message); }
node --experimental-strip-types --disable-warning=ExperimentalWarning kiss.ts

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

normal  clever: 243  simple: 243
qty 0   clever: 145
qty 0   simple: invalid quantity 0

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

  • الحسابان يعطيان نفس النتيجة 243 للطلب العادي، فالكود الذكي ليس خاطئاً في الحالة التي جربها كاتبه.
  • في الطلب الذي كميته صفر حول i.qty || 1 الصفر إلى 1 بصمت، فدفع العميل 145 عن منتج لم يطلبه، والسبب أن || في JavaScript تعامل الصفر كأنه قيمة غير موجودة.
  • في النسخة البسيطة كل قرار في سطره: الكمية الافتراضية بـ ??، والكمية غير الصحيحة ترفض صراحة، وهذا ما جعل الخطأ يظهر بدل أن يختفي في الرقم.
  • والتعبير الذكي حسب المجموع الفرعي مرتين، أي أن السطر الأقصر لم يكن حتى الأسرع.

متى يصبح عبئاً؟ عندما يصبح «البسيط» حجة لتجاهل حالات حقيقية، مثل كتابة كود بلا معالجة أخطاء أو بلا تحقق Validation من المدخلات لأن «الكود البسيط أجمل»، أو حجة لرفض أداة معروفة مثل مكتبة للتواريخ لصالح كود يدوي أقصر لكنه يخطئ في المناطق الزمنية. والبساطة المقصودة هي بساطة الحل الكامل، وليست حذف أجزاء من المشكلة.

مبدأ لن تحتاجه YAGNI

YAGNI اختصار You Aren't Gonna Need It، وجاء من البرمجة القصوى Extreme Programming، وصاغه Ron Jeffries في مقاله You're NOT gonna need it عام 1998 بأن تنفذ الأشياء عندما تحتاجها فعلاً، وليس عندما تتوقع أنك ستحتاجها. وفي مقاله Yagni عام 2015 يسمي Martin Fowler الميزة التي تبنيها لاحتياج متوقع «ميزة افتراضية Presumptive Feature»، ويبين أنها تكلفك حتى لو كان توقعك صحيحاً، كما في الصورة التالية:

خط زمني من اليمين إلى اليسار: today ثم month 2 ثم month 4 ثم month 6. فوقه card payments needed now باللون الأخضر، ثم 5-provider payment layer بالبرتقالي، وصندوق أحمر منقط extra code to read, test and change in every feature، وصندوق رمادي second provider maybe never. وتحته أربع تكاليف مرقمة: cost of build وcost of delay وcost of carry وcost of repair
الميزة التي تبنى قبل وقتها تكلف وقت بنائها، وتؤخر ميزة مطلوبة، ويدفع ثمن تعقيدها في كل تعديل

والتكاليف الأربع في مقال Fowler هي:

  1. تكلفة البناء Cost of Build: الوقت الذي صرفته في التحليل والكتابة والاختبار، ويضيع كله إذا لم تأت الحاجة أبداً.
  2. تكلفة التأخير Cost of Delay: الميزة المطلوبة فعلاً التي تأخرت لأن الفريق كان مشغولاً بالميزة الافتراضية، وهذه تدفعها حتى لو كان توقعك صحيحاً.
  3. تكلفة الحمل Cost of Carry: الكود الإضافي يجعل كل تعديل بعده أبطأ، لأن عليك أن تقرأه وتختبره وتحافظ عليه.
  4. تكلفة الإصلاح Cost of Repair: إذا جاءت الحاجة لكن بشكل مختلف عما توقعت، فعليك أن تعيد بناء ما بنيته.

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

وقد يتساءل البعض: أليست الواجهة الصغيرة نفسها مخالفة لـ YAGNI؟ والإجابة في نفس مقال Fowler: المبدأ ينطبق على القدرات التي تبنيها لميزة افتراضية، ولا ينطبق على الجهد الذي يجعل الكود أسهل في التعديل، فإعادة الهيكلة Refactoring والاختبارات الآلية والنشر المستمر Continuous Delivery هي الأدوات التي تجعل تأجيل الميزات آمناً، وبدونها يتحول YAGNI من ممارسة مفيدة إلى مشكلة.

متى يصبح عبئاً؟ عندما تستخدمه لتأجيل قرارات تكلفة تغييرها لاحقاً عالية جداً، مثل شكل المعرفات في قاعدة البيانات، أو تشفير البيانات الحساسة، أو فصل بيانات العملاء في نظام متعدد المستأجرين Multi-Tenancy، فهذه ليست «ميزات افتراضية» وإنما أساس يصعب تغييره بعد أن تمتلئ قاعدة البيانات. والحد الفاصل أن تسأل: كم سيكلف إضافة هذا الشيء لاحقاً؟ إذا كانت الإجابة «كلاساً جديداً» فأجله، وإذا كانت «ترحيل كل البيانات» فقرر الآن.

الترابط Coupling والتماسك Cohesion

إذا رجعت إلى كل المبادئ السابقة ستجد أنها تقيس شيئين: كم تعتمد الوحدات على بعضها، وكم ترتبط محتويات الوحدة الواحدة ببعضها. والمصطلحان من ورقة Structured Design التي نشرها W. Stevens وG. Myers وL. Constantine في مجلة IBM Systems Journal عام 1974، وما زالا أوضح طريقة للحكم على أي تصميم:

  • الترابط Coupling: مقدار اعتماد وحدة على وحدة أخرى، فكلما زاد احتاج التعديل في واحدة إلى تعديل في الأخرى، والهدف ترابط منخفض Low Coupling.
  • التماسك Cohesion: مقدار ارتباط ما في داخل الوحدة الواحدة ببعضه، فالكلاس الذي يجمع الطلبات والبريد وتنسيق التواريخ تماسكه منخفض، والهدف تماسك عال High Cohesion.

والصورة التالية تبين الشكلين، وكل لون فيها مسؤولية واحدة:

في اليمين أربع وحدات Orders وBilling وUsers وUtils، في كل وحدة دوائر بألوان مختلطة، وخطوط حمراء كثيرة تربط دوائر داخل الوحدات ببعضها. وفي اليسار أربع وحدات Orders وBilling وUsers وNotify، كل وحدة بلون واحد، وبينها ثلاثة أسهم فقط مكتوب عليها API
التماسك العالي والترابط المنخفض: كل وحدة تجمع ما يتغير معاً، وتكلم غيرها من باب واحد

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

// dotnet run coupling.cs
Console.WriteLine("== before: other code reaches into the order's list ==");
var o1 = new Before.Order();
o1.AddLine("Keyboard", 300m);
o1.Lines.Add(new("Mouse", 60m));          // a report module "just adds" a line directly
Console.WriteLine($"  lines: {o1.Lines.Count}, total: {o1.Total}  <- total is wrong, Mouse is missing");

Console.WriteLine("== after: the order owns its rules ==");
var o2 = new After.Order();
o2.AddLine("Keyboard", 300m);
o2.AddLine("Mouse", 60m);
Console.WriteLine($"  lines: {o2.Lines.Count}, total: {o2.Total}");

record OrderLine(string Name, decimal Price);

namespace Before
{
    class Order
    {
        public List<OrderLine> Lines = new();
        public decimal Total;
        public void AddLine(string name, decimal price) { Lines.Add(new(name, price)); Total += price; }
    }
}

namespace After
{
    class Order
    {
        readonly List<OrderLine> lines = new();
        public IReadOnlyList<OrderLine> Lines => lines;
        public decimal Total => lines.Sum(l => l.Price);
        public void AddLine(string name, decimal price)
        {
            if (price <= 0) throw new ArgumentException("price must be positive");
            lines.Add(new(name, price));
        }
    }
}

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

== before: other code reaches into the order's list ==
  lines: 2, total: 300  <- total is wrong, Mouse is missing
== after: the order owns its rules ==
  lines: 2, total: 360

في النسخة القديمة أضافت وحدة التقارير سطراً إلى القائمة مباشرة، فصار في الطلب منتجان ومجموعه 300، لأن الإضافة تجاوزت AddLine التي تحدث المجموع. والآن نكسر التصميم الجديد عمداً، ونغير في الملف السطر الذي يضيف Mouse ليستخدم نفس الطريق المختصر، ونحفظ النسخة المعدلة باسم coupling-break.cs:

o2.Lines.Add(new("Mouse", 60m));
dotnet run coupling-break.cs

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

coupling-break.cs(11,10): error CS1061: 'IReadOnlyList<OrderLine>' does not contain a definition for 'Add' and no accessible extension method 'Add' accepting a first argument of type 'IReadOnlyList<OrderLine>' could be found (are you missing a using directive or an assembly reference?)

The build failed. Fix the build errors and run again.

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

  • الكلاس الجديد يعرض IReadOnlyList بدل List، فالقراءة متاحة لكل من يريد، أما التعديل فطريقه الوحيد AddLine، وهذا ما يسمى التغليف Encapsulation.
  • المجموع صار يحسب من القائمة نفسها بدل حفظه في حقل منفصل، فلا يمكن أن يختلف عنها.
  • الخطأ CS1061 ظهر وقت الترجمة، أي أن التصميم نفسه منع الترابط الخاطئ بدل أن نعتمد على أن يتذكره كل مطور.

أما التماسك المنخفض فعلامته المشهورة في أي مشروع كلاس اسمه Utils أو Helpers أو Common يكبر كل شهر، وفيه تنسيق التواريخ وإرسال البريد وحساب الضريبة، فكل وحدة في النظام تعتمد عليه، وأي تعديل فيه يمس الجميع. والحل أن تنقل كل دالة إلى الوحدة التي تستخدمها، فحساب الضريبة مكانه مع الفواتير، وإرسال البريد مكانه مع الإشعارات.

متى يصبح عبئاً؟ لا يوجد نظام بلا ترابط، فالوحدات يجب أن تتكلم مع بعضها وإلا فلا يوجد نظام، والهدف ليس صفر ترابط وإنما ترابط عبر واجهات واضحة قليلة. والمبالغة في تقليل الترابط تظهر عندما تحول كل استدعاء بين وحدتين إلى رسالة في طابور Queue أو حدث Event حتى داخل نفس البرنامج، فيصبح تتبع ما يحدث أصعب من الترابط الذي هربت منه.

تفضيل التركيب Composition على الوراثة Inheritance

في كتاب Design Patterns الذي نشره Gamma وHelm وJohnson وVlissides عام 1994، والمعروف بكتاب عصابة الأربعة Gang of Four، يأتي مبدآن في الفصل الأول قبل أي نمط: برمج على الواجهة وليس على التطبيق، وفضل تركيب الكائنات على وراثة الكلاسات. والوراثة تعني أن الكلاس «هو نوع من» الأب ويأخذ سلوكه وقت كتابة الكود، أما التركيب فيعني أن الكائن «يملك» كائنات أخرى ويطلب منها العمل، ويمكن أن تغيرها وقت التشغيل. والصورة التالية تبين ما يحدث عندما تحل مشكلة الإشعارات بالوراثة:

في اليمين شجرة وراثة: Notifier وتحته EmailNotifier وSmsNotifier وSlackNotifier، وتحتها أربعة كلاسات حمراء Email+Sms وEmail+Slack وSms+Slack وEmail+Sms+Slack، ومكتوب 3 channels = 7 combinations. وفي اليسار كلاس Notifier واحد فيه ثلاث فتحات تتصل بها أيقونات بريد وهاتف ومحادثة Email وSms وSlack، وتحته settings = email,slack
الوراثة تحتاج كلاساً لكل مجموعة قنوات، والتركيب يجمع القنوات وقت التشغيل

والكود التالي فيه الطريقتان:

// dotnet run composition.cs
Console.WriteLine("== before: one subclass per combination ==");
new Before.EmailAndSmsNotifier().Send("order A-100 shipped");
Console.WriteLine($"  classes needed for 3 channels: {(1 << 3) - 1} combinations, each a subclass");

Console.WriteLine("== after: compose the channels you need ==");
var settings = "email,slack";   // could come from user preferences
var channels = settings.Split(',').Select(After.Channels.ByName).ToList();
new After.Notifier(channels).Send("order A-100 shipped");

namespace Before
{
    class Notifier { public virtual void Send(string msg) { } }
    class EmailNotifier : Notifier
    {
        public override void Send(string msg) { Console.WriteLine($"  email: {msg}"); base.Send(msg); }
    }
    class EmailAndSmsNotifier : EmailNotifier     // and EmailAndSlack, SmsAndSlack, EmailSmsAndSlack ...
    {
        public override void Send(string msg) { base.Send(msg); Console.WriteLine($"  sms:   {msg}"); }
    }
}

namespace After
{
    interface IChannel { void Send(string msg); }
    class Email : IChannel { public void Send(string msg) => Console.WriteLine($"  email: {msg}"); }
    class Sms : IChannel { public void Send(string msg) => Console.WriteLine($"  sms:   {msg}"); }
    class Slack : IChannel { public void Send(string msg) => Console.WriteLine($"  slack: {msg}"); }

    static class Channels
    {
        public static IChannel ByName(string name) => name switch
        {
            "email" => new Email(), "sms" => new Sms(), "slack" => new Slack(),
            _ => throw new ArgumentException($"unknown channel {name}"),
        };
    }

    class Notifier(IEnumerable<IChannel> channels)    // has channels, is not a channel
    {
        public void Send(string msg) { foreach (var c in channels) c.Send(msg); }
    }
}

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

== before: one subclass per combination ==
  email: order A-100 shipped
  sms:   order A-100 shipped
  classes needed for 3 channels: 7 combinations, each a subclass
== after: compose the channels you need ==
  email: order A-100 shipped
  slack: order A-100 shipped

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

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

متى يصبح عبئاً؟ عندما ترفض الوراثة كلياً حتى في مكانها الطبيعي، فالعلاقة «هو نوع من» الحقيقية والثابتة مكانها الوراثة، وأطر العمل نفسها تطلبها، مثل الكلاس ControllerBase في ASP.NET Core الذي ترث منه الـ Controllers، أو Exception الذي ترث منه أخطاؤك الخاصة. والمشكلة تبدأ عندما تستخدم الوراثة لإعادة استخدام الكود فقط وليس لأن العلاقة صحيحة، أو عندما تتجاوز شجرة الوراثة مستويين أو ثلاثة.

عندما تتعارض المبادئ

إذا طبقت كل مبدأ إلى أقصاه ستجد أنها تتعارض، وهذا طبيعي لأن كل مبدأ يدفع في اتجاه، والتصميم الجيد هو التوازن بينها في مشروعك أنت. وأشهر حالات التعارض:

  • DRY ضد الترابط المنخفض: دمج الكود المتشابه بين وحدتين يقلل التكرار لكنه يربطهما، والحل أن تسأل هل هما معلومة واحدة فعلاً، فإذا لم تكونا فالتكرار أرخص من الترابط.
  • OCP ضد YAGNI: نقاط التمديد تحتاج توقعاً لما سيتغير، وYAGNI يقول لا تتوقع، والحل أن تبدأ بالكود المباشر، وعندما يتغير نفس المكان مرة ثانية تفتحه للتمديد.
  • SRP وISP ضد KISS: الفصل الكثير ينتج كلاسات وواجهات صغيرة كثيرة يصعب تتبعها، والحل أن تفصل حسب الأطراف الذين يطلبون التغيير فعلاً، وليس حسب عدد الدوال.
  • DIP ضد KISS: كل واجهة طبقة إضافية يقرؤها المطور، والحل أن تضع التجريد عند الحدود مع العالم الخارجي فقط.

والجدول التالي يجمع المبادئ كلها مع السؤال الذي تسأله في مراجعة الكود، وعلامة المبالغة في تطبيقه:

المبدأالسؤال الذي تسألهعلامة المبالغة
SRPمن يطلب تغيير هذا الكلاس؟ هل هم أكثر من طرف؟كلاسات بدالة واحدة، وعشرة ملفات لفهم طلب واحد
OCPهل تحتاج إضافة حالة جديدة تعديل كود قديم مختبر؟واجهات ومصانع لها تطبيق واحد منذ سنوات
LSPهل يتفاجأ من يستخدم النوع الأساسي بأحد الأنواع الفرعية؟نادراً، وإن حدث فهو تجنب الأنواع الفرعية كلها
ISPهل يعتمد أحد على دوال لا يستخدمها، أو يطبق دوالاً لا يستطيعها؟واجهة لكل دالة، وتوقيعات تجمع خمس واجهات
DIPهل يعرف منطق العمل قاعدة البيانات أو الإطار بالاسم؟واجهة أمام كل كلاس داخلي
DRYإذا تغيرت هذه القاعدة، كم مكاناً يجب أن أعدل؟دمج كودين متشابهين بالصدفة
KISSهل يفهم زميلي هذا الكود في دقيقة؟حذف معالجة الأخطاء والتحقق باسم البساطة
YAGNIهل يوجد طلب حقيقي لهذه الميزة الآن؟تأجيل قرارات أساسية يصعب تغييرها لاحقاً
Coupling وCohesionهل يجبرني تعديل هنا على تعديل هناك؟طوابير وأحداث بين أجزاء نفس البرنامج دون حاجة
Compositionهل العلاقة «هو نوع من» فعلاً، أم أنني أريد الكود فقط؟رفض الوراثة حتى عندما يطلبها الإطار

كيف تستخدم المبادئ في مراجعة الكود Code Review؟

لا تكتب في المراجعة «هذا يخالف SRP»، لأن اسم المبدأ لا يقنع أحداً ويفتح نقاشاً نظرياً، واكتب بدلاً من ذلك أثر المشكلة على التغيير القادم: «إذا طلب قسم الدعم تغيير شكل الفاتورة فسوف يتغير رقم المحاسبة»، أو «لا أستطيع كتابة اختبار لهذه الدالة دون قاعدة بيانات». وفيما يلي أسئلة عملية تغطي أغلب ما سبق:

  1. هل يوجد في هذا التعديل كلاس يطلب تغييره أكثر من قسم أو طرف؟
  2. هل أضاف التعديل حالة جديدة بتعديل switch أو if في كود قديم، وهل تكرر ذلك في نفس المكان من قبل؟
  3. هل يوجد تطبيق لواجهة يرمي NotSupportedException أو يترك دالة فارغة؟
  4. هل يمكن اختبار منطق العمل الجديد دون قاعدة بيانات أو شبكة؟
  5. هل أضاف التعديل قيمة أو قاعدة موجودة في مكان آخر بالفعل؟
  6. هل يوجد كود لميزة لم يطلبها أحد بعد، أو واجهة جديدة لها تطبيق واحد؟
  7. هل يعدل كود خارجي بيانات كائن مباشرة متجاوزاً دواله؟
  8. هل أضيف شيء جديد إلى Utils أو Common؟

تدريب عملي

  1. خذ الملف ocp.cs وأضف نوع عميل جديداً بخصم ثابت 30 لمن يشتري لأول مرة، دون أن تعدل الكلاس Pricing، ثم فكر: ماذا يحدث إذا انطبقت قاعدتان على نفس العميل؟ وأين يجب أن يكتب هذا القرار؟
  2. ابحث في مشروعك عن أكبر كلاس اسمه Utils أو Helper، واكتب بجانب كل دالة فيه اسم الوحدة التي تستخدمها، ثم انقل دالة واحدة إلى مكانها.
  3. إذا أردت تمارين بلا مساعدة لتثبيت هذه المهارات، فقد جمعنا تمارين في تمارين Kata في البرمجة والخوارزميات.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • مبادئ التصميم إرشادات لتقليل تكلفة التغيير القادم، وليست قوانين، وصاحب SOLID نفسه يقول إن تطبيقها بشكل آلي سيئ مثل تجاهلها.
  • SRP يفصل الكود حسب الطرف الذي يطلب التغيير، وOCP يضيف السلوك بكود جديد وليس بتعديل القديم، وLSP يطلب أن يفي كل نوع فرعي بوعد النوع الأساسي، وISP يعطي كل مستخدم الواجهة التي يحتاجها، وDIP يجعل التفاصيل تعتمد على منطق العمل وليس العكس.
  • DRY عن المعرفة وليس عن تشابه الأسطر، فكودان متشابهان لسببين مختلفين يبقيان منفصلين، والدمج يأتي بعد التكرار الثالث.
  • KISS يعني أبسط حل كامل يفهمه زميلك، وYAGNI يعني ألا تبني ميزة قبل أن يطلبها أحد، مع الحرص على قرارات الأساس التي يصعب تغييرها لاحقاً.
  • الترابط المنخفض والتماسك العالي هما المقياس الذي تعود إليه كل المبادئ، والتركيب أمرن من الوراثة إلا حيث تكون العلاقة «هو نوع من» حقيقية.
  • في المراجعة اكتب أثر المشكلة على التغيير القادم بدل اسم المبدأ.

وأقرب المقالات إلى هذا الموضوع على الموقع:

دليلالتصميم والمعمارية: أربعة مستويات لنشاط واحدما الفرق بين تصميم البرمجيات ومعماريتها وتصميم الأنظمة ومعمارية المؤسسة، ومن يقرر في كل مستوى وكم يعيش قراره.دليلأنماط التصميم Design Patterns التي تقابلها فعلاًأشهر أنماط التصميم التي تقابلها في كل مشروع، والمشكلة التي يحلها كل نمط، ومتى يصبح عبئاً على من يعدل الكود بعدك.دليلClean Architecture: كيف تعزل منطق العمل عن قاعدة البياناتلماذا تحتاج قاعدة بيانات لكي تختبر قاعدة عمل من سطر واحد، وكيف يعزل اتجاه الاعتماديات منطق العمل عن التفاصيل.دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.

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

  • أكتوبر 2026: كتابة المقال، وتشغيل كل الأمثلة على .NET SDK 10.0.400 وNode.js 22.14.0 وفحص أنواع TypeScript بالإصدار 7.0.2، ومراجعة المصادر الأصلية لكل مبدأ.
Could not load content
نشرة عرب رووت | ArabRoot

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

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

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

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