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

توثيق المعمارية: مخططات C4 وسجلات القرارات المعمارية ADR

كيف ترسم نظامك بمخططات C4 يفهمها المطور الجديد، وتحفظ سبب كل قرار مهم في سجل ADR داخل المستودع.

توثيق المعمارية: مخططات C4 وسجلات القرارات المعمارية ADR

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

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

  • نموذج C4 ومستوياته الأربعة: السياق System Context والتطبيقات Containers والمكونات Components والكود Code، مع مخططاتنا لنظام حجز مواعيد عيادات كمثال.
  • المخططات ككود Diagrams as Code بلغة Structurizr DSL وبصيغة Mermaid، مع تشغيلها فعلياً في Docker ورؤية نتيجتها.
  • سجل القرار المعماري Architecture Decision Record واختصاراً ADR كما وصفه Michael Nygard عام 2011، وقالب MADR، ومثال كامل لقرار حقيقي في النظام.
  • أين تحفظ القرارات والمخططات، ولماذا يكون مكانها المستودع Repository وليس الـ Wiki.
  • الأدوات: adr-tools وlog4brains وربط القرارات بنموذج Structurizr، مع تجربة نكسر فيها الإعداد عمداً ونقرأ الخطأ.

نموذج C4: خريطة للنظام بأربعة مستويات تكبير

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

ويتكون النموذج كما في موقعه الرسمي من مجموعة مجردات Abstractions متدرجة هي النظام البرمجي Software System والـ Container والمكون Component والكود Code، ومن أربعة مخططات تقابلها، ومن مخططات مساعدة نذكرها بعد قليل، والأهم أنه لا يفرض رموزاً معينة Notation Independent ولا أداة معينة Tooling Independent، فيمكنك أن ترسمه على السبورة أو في draw.io أو بالكود كما سيأتي.

والصورة التالية تبين المستويات الأربعة كخطوات تكبير، ومن يقرأ كل مستوى:

أربع لوحات من اليمين إلى اليسار: System Context فيها شخص ونظام برتقالي ونظامان خارجيان رماديان، ثم Containers فيها Web App وAPI وDatabase داخل حد منقط مكتوب تحته deployable units، ثم Components فيها Controller وService وRepository داخل حد مكتوب تحته same process، ثم Code فيها كلاسان BookingService وBooking. وتحت كل لوحة الجمهور: الجميع، ثم التقنيون، ثم المعماري والمطورون، ثم المطورون
كل مستوى في C4 يقرب الصورة خطوة، ولكل مستوى جمهوره

ولكي لا يبقى الكلام نظرياً سوف نرسم كل مستوى لنظام واحد نستخدمه في بقية المقال: نظام حجز مواعيد لعدة عيادات Clinic Booking System، يحجز فيه المريض موعده من المتصفح، ويدير موظف الاستقبال جداول الأطباء، ويرسل النظام تذكيراً قبل الموعد بيوم، ويأخذ عربوناً بالبطاقة لبعض المواعيد.

المستوى الأول: مخطط السياق System Context

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

مخطط سياق: في الأعلى شخصان Reception Staff وPatient، وفي الوسط مربع برتقالي Clinic Booking System، وفي الأسفل ثلاثة أنظمة خارجية رمادية Identity Provider وPayment Gateway وSMS and Email Provider، وعلى كل سهم وصف مثل Books appointments using وCharges cards using، وسهم منقط من مزود الرسائل إلى المريض Sends reminders to، وعلى اليسار مفتاح للألوان
مخطط السياق لنظام حجز المواعيد: النظام في الوسط، ومن يستخدمه، وما يعتمد عليه

في المخطط أعلاه لاحظ التالي:

  • كل عنصر مكتوب نوعه بين قوسين مربعين، Person أو Software System، ومعه وصف قصير لمسؤوليته.
  • كل سهم في اتجاه واحد وعليه وصف يقرأ كجملة: «Patient يحجز المواعيد باستخدام النظام»، وليست كلمة «Uses» الغامضة.
  • الأنظمة الخارجية رمادية لأنها ليست ملكنا ولا نتحكم فيها، والمفتاح Key يشرح هذا اللون، ولو تركته لظن القارئ أن الرمادي يعني نظاماً معطلاً.

المستوى الثاني: مخطط التطبيقات Container Diagram

هنا ندخل داخل حدود النظام ونرى ما يعمل فيه، وأول ما يجب أن تعرفه أن كلمة Container في C4 لا علاقة لها بـ Docker، وصفحة تعريف Container تبدأ بعبارة «Not Docker!» ثم تقول إنه تطبيق أو مخزن بيانات، أي شيء يجب أن يعمل لكي يعمل النظام كله: تطبيق ويب على السيرفر، أو تطبيق SPA في المتصفح، أو تطبيق جوال، أو قاعدة بيانات، أو Bucket في S3، أو حتى سكربت Shell. ويشرح Simon Brown في نفس الصفحة أنه أراد اسماً لا يوحي بطريقة التشغيل الفعلية، فثلاثة تطبيقات ويب قد تعمل على سيرفر Tomcat واحد أثناء التطوير، وكل واحد منها على سيرفر مستقل في الإنتاج، وتبقى في الحالتين ثلاثة Containers في C4، أما مكان تشغيلها فمسألة نشر منفصلة.

مخطط تطبيقات: داخل حد أزرق منقط مكتوب عليه Clinic Booking System خمسة عناصر: Web App من نوع React SPA، وBooking API من نوع ASP.NET Core، وMessage Queue من نوع RabbitMQ، وDatabase من نوع PostgreSQL، وReminder Worker من نوع Worker Service، وعلى الأسهم بينها البروتوكولات JSON/HTTPS وAMQP وSQL، وخارج الحد الأنظمة الخارجية الثلاثة والشخصان
مخطط التطبيقات: ما يعمل داخل النظام، وبأي تقنية، وكيف يتكلم كل جزء مع الآخر

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

وقد يتساءل البعض: أين موزع الحمل Load Balancer؟ وكم نسخة من الـ API تعمل؟ والإجابة أن مخطط التطبيقات لا يتكلم عن ذلك عمداً، لأن عدد النسخ يختلف بين بيئة الإنتاج وبيئة الاختبار، وهذه التفاصيل مكانها مخطط النشر Deployment Diagram الذي نذكره بعد قليل، وإذا كان سؤالك عن الفرق بين التقسيم المنطقي والفصل الفعلي في الشبكة فقد فصلناه في مقال Tiers وLayers.

المستوى الثالث: مخطط المكونات Component Diagram

الآن نقرب أكثر وندخل داخل Container واحد، وهو هنا الـ Booking API. والمكون Component في C4 كما في صفحة تعريفه مجموعة من الوظائف المترابطة خلف واجهة Interface واضحة، وفي لغة مثل C# أو Java أبسط طريقة لتخيله أنه مجموعة كلاسات خلف Interface، والنقطة المهمة أن المكونات لا تنشر منفصلة، فكلها تعمل داخل نفس العملية Process، والـ Container هو وحدة النشر.

مخطط مكونات: داخل حد بنفسجي منقط اسمه Booking API ستة مكونات: Bookings Controller في الأعلى، وتحته Booking Service وAvailability Service، وتحتهما Payment Adapter وReminder Publisher وBooking Repository، وخارج الحد Web App في الأعلى، وPayment Gateway وMessage Queue وDatabase في الأسفل مع البروتوكولات HTTPS وAMQP وSQL
مخطط المكونات داخل Booking API: من يستدعي من داخل نفس العملية

ولو قرأت مقال Clean Architecture وHexagonal فسوف تلاحظ أن Payment Adapter وBooking Repository هما المحولات Adapters التي تفصل منطق الحجز عن العالم الخارجي، ومخطط المكونات مكان جيد لإظهار هذا الفصل لمن ينضم إلى الفريق. أما متى ترسمه، فالموقع الرسمي في صفحة المخطط لا يوصي به لكل الفرق، ويقول ارسمه فقط إذا كان يضيف قيمة، وفكر في توليده آلياً إذا كان سيعيش طويلاً، والسبب أن المكونات تتغير مع كل إعادة هيكلة Refactoring، والمخطط المرسوم باليد يتقادم أسرع من أي مستوى آخر.

المستوى الرابع: الكود Code

آخر مستوى هو داخل المكون نفسه: مخطط كلاسات UML Class Diagram أو مخطط علاقات قاعدة البيانات Entity Relationship Diagram. وتقول صفحته إنه اختياري تماماً، ولا ينصح به إلا لأهم المكونات وأعقدها، لأن أغلب بيئات التطوير IDE تولده عند الطلب. لذلك فالنصيحة العملية: لا تقم برسم هذا المستوى باليد إطلاقاً، وإذا احتجته في مراجعة تصميم فولده من الكود في نفس اليوم ثم احذفه.

المخططات المساعدة: المشهد العام والتسلسل والنشر

بالإضافة إلى المستويات الأربعة يعرف النموذج ثلاثة مخططات مساعدة تحتاجها في مواقف محددة:

  • مخطط المشهد العام System Landscape: كما في صفحته هو مخطط سياق لعدة أنظمة معاً داخل المؤسسة أو القسم، ويفيد من يدير مجموعة أنظمة ويريد أن يرى كيف تترابط، وهذا يقترب من عمل معمارية المؤسسة Enterprise Architecture التي شرحناها في مقال المستويات.
  • المخطط الديناميكي Dynamic Diagram: يبين كيف تتعاون العناصر وقت التشغيل لتنفيذ ميزة واحدة، مثل حجز موعد مع دفع العربون، بأسهم مرقمة حسب الترتيب كما في صفحته.
  • مخطط النشر Deployment Diagram: يبين أين تعمل نسخ الـ Containers في بيئة محددة، على أي سيرفر أو VM أو Container من Docker أو خدمة سحابية، ويكون مخطط لكل بيئة كما في صفحته، وهنا مكان موزع الحمل وعدد النسخ وقاعدة البيانات الاحتياطية.

قواعد بسيطة تجعل المخطط مفهوماً دون شرح

بما أن C4 لا يفرض أشكالاً معينة، فهو يعطي في صفحة الرموز قواعد بسيطة تكفي لكي يفهم القارئ المخطط وحده، وهي التي التزمنا بها في المخططات أعلاه:

  1. لكل مخطط عنوان يذكر نوعه ونطاقه، مثل «Container diagram for Clinic Booking System».
  2. لكل مخطط مفتاح Key يشرح الأشكال والألوان وأنواع الخطوط، والألوان الزرقاء والرمادية التي تراها في الأمثلة ليست جزءاً من النموذج، فاختر ما تريد بشرط أن تكون ثابتة في كل المخططات.
  3. لكل عنصر نوعه ووصف قصير، ولكل Container ومكون تقنيته.
  4. كل سهم في اتجاه واحد وعليه وصف، والأسهم بين Containers يكتب عليها البروتوكول.
  5. الاختصارات تشرح في المفتاح، لأن «OHS» أو «BFF» قد تكون واضحة لك وغامضة لغيرك.

المخططات ككود Diagrams as Code

الحل البسيط أن ترسم المخططات الثلاثة في أداة رسم مثل draw.io وتحفظها كصور في الـ Wiki، وهذا يعمل في الأسبوع الأول، لكن لاحظ ما يحدث عندما تغير اسم مكون من «BookingSvc API» إلى «Booking API»: عليك أن تتذكر كل مخطط ظهر فيه الاسم وتعدله يدوياً، وفي الغالب سوف تعدل واحداً وتنسى الآخرين، وبعد أشهر قليلة لا يثق أحد بالمخططات، والسبب الأعمق أن تغيير الصورة لا يظهر في مراجعة الكود Code Review، فلا أحد يرى أن المخطط تغير أو أنه لم يتغير.

والطريقة الأفضل أن تكتب المعمارية كنص داخل المستودع، فيظهر كل تغيير فيها كسطر في الـ diff ويراجع في نفس الـ Pull Request مع الكود، وهناك أسلوبان: أن تكتب كل مخطط كنص مستقل كما في Mermaid وPlantUML، أو أن تكتب نموذجاً Model واحداً فيه كل العناصر والعلاقات مرة واحدة ثم تولد منه كل المخططات كما في Structurizr، والصورة التالية تبين الفرق:

في اليمين ثلاث رسومات منفصلة components.png وcontainers.png وcontext.png، في واحدة منها الاسم BookingSvc API باللون الأحمر وفي الأخريين Booking API. وفي اليسار ملف workspace.dsl يدخل إلى أداة structurizr export التي تخرج ملف Mermaid بامتداد mmd وملف PlantUML وموقعاً ثابتاً site، بخطوات مرقمة من 1 إلى 3
رسومات منفصلة تختلف مع الوقت، ونموذج واحد تتولد منه كل المخططات

النموذج بلغة Structurizr DSL

Structurizr مجموعة أدوات من نفس صاحب نموذج C4، ولغتها Structurizr DSL موثقة في صفحة الـ DSL، والفكرة فيها أنك تعرف كل عنصر وكل علاقة في قسم model مرة واحدة، ثم تختار في قسم views أي مخطط تريد ومن أي زاوية. والملف التالي هو النموذج الكامل لنظام حجز المواعيد، وهو نفس النظام الذي رسمناه أعلاه، ونحفظه في docs/workspace.dsl داخل المستودع:

workspace "Clinic Booking" "Online appointment booking for a small chain of clinics" {

    !identifiers hierarchical

    model {
        patient = person "Patient" "Books, moves and cancels appointments"
        reception = person "Reception Staff" "Manages doctors' schedules and walk-in bookings"

        booking = softwareSystem "Clinic Booking System" "Lets patients book appointments online and staff manage schedules" {
            spa = container "Web App" "Booking pages for patients and the staff dashboard" "React SPA"
            api = container "Booking API" "Availability, bookings and payments" "ASP.NET Core" {
                bookingController = component "Bookings Controller" "HTTP endpoints for creating and cancelling bookings" "ASP.NET Core Controller"
                availability = component "Availability Service" "Computes free slots from schedules and existing bookings" "C# class"
                bookingService = component "Booking Service" "Business rules: no double booking, cancellation window" "C# class"
                paymentAdapter = component "Payment Adapter" "Wraps the payment gateway API" "C# class"
                reminderPublisher = component "Reminder Publisher" "Publishes reminder messages" "C# class"
                repo = component "Booking Repository" "Reads and writes bookings" "EF Core"
            }
            worker = container "Reminder Worker" "Sends reminders 24 hours before each appointment" "Worker Service"
            queue = container "Message Queue" "Reminder messages" "RabbitMQ" "Queue"
            db = container "Database" "Doctors, schedules, patients and bookings" "PostgreSQL" "Database"
        }

        idp = softwareSystem "Identity Provider" "Sign-in for patients and staff (OIDC)" "External"
        payments = softwareSystem "Payment Gateway" "Card payments for paid bookings" "External"
        sms = softwareSystem "SMS and Email Provider" "Delivers reminders" "External"

        patient -> booking.spa "Books appointments using" "HTTPS"
        reception -> booking.spa "Manages schedules using" "HTTPS"
        booking.spa -> booking.api.bookingController "Calls" "JSON/HTTPS"
        booking.spa -> idp "Signs users in with" "OIDC"
        booking.api.bookingController -> booking.api.availability "Asks for free slots"
        booking.api.bookingController -> booking.api.bookingService "Creates and cancels bookings with"
        booking.api.bookingService -> booking.api.repo "Saves bookings with"
        booking.api.availability -> booking.api.repo "Reads bookings with"
        booking.api.bookingService -> booking.api.paymentAdapter "Charges deposits with"
        booking.api.bookingService -> booking.api.reminderPublisher "Schedules reminders with"
        booking.api.repo -> booking.db "Reads from and writes to" "SQL/TCP"
        booking.api.paymentAdapter -> payments "Charges cards using" "HTTPS"
        booking.api.reminderPublisher -> booking.queue "Publishes reminders to" "AMQP"
        booking.worker -> booking.queue "Consumes reminders from" "AMQP"
        booking.worker -> sms "Sends reminders using" "HTTPS"
        sms -> patient "Sends reminders to"
    }

    views {
        systemContext booking "Context" {
            include *
            autolayout tb
        }
        container booking "Containers" {
            include *
            autolayout tb
        }
        component booking.api "Components" {
            include *
            autolayout tb
        }
        styles {
            element "Person" {
                shape person
            }
            element "Database" {
                shape cylinder
            }
            element "Queue" {
                shape pipe
            }
            element "External" {
                background #999999
                color #ffffff
            }
        }
    }

    !adrs adr
}

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

  • !identifiers hierarchical يجعل اسم المكون يتضمن مكانه، مثل booking.api.repo، فيصبح واضحاً في كل علاقة أين يقع العنصر.
  • العلاقة booking.api.repo -> booking.db مكتوبة بين مكون وقاعدة بيانات، ومع ذلك سوف تظهر في مخطط التطبيقات كسهم من Booking API إلى Database، لأن الأداة تستنتج العلاقات الأعلى من العلاقات الأدق، وهذا ما يسمى العلاقات الضمنية Implied Relationships.
  • قسم views فيه ثلاثة مخططات فقط، كل واحد بسطر include *، ولو أضفت Container جديداً في النموذج فسوف يظهر في المخططات الثلاثة دون أن تلمسها.
  • !adrs adr في آخر الملف يربط سجلات القرارات الموجودة في مجلد docs/adr بالنموذج، وسوف نعود إليه في قسم الأدوات.

التحقق من النموذج وتوليد المخططات

نسخة Structurizr الحالية تأتي كـ Docker Image واحد اسمه structurizr/structurizr فيه كل الأوامر، ونستخدم الإصدار 2026.09.19 بالتحديد، والأمر الأول هو validate الذي يقرأ الملف ويتأكد من أنه صحيح، وهو الأمر الذي تضعه في خط CI لكي يفشل الـ Pull Request إذا كسر أحدهم النموذج:

docker run --rm -v "$PWD/docs:/docs:ro" structurizr/structurizr:2026.09.19 validate -w /docs/workspace.dsl
echo "exit $?"

والمخرج سوف يكون كما يلي، أي لا توجد رسالة، والأمر انتهى بالرمز 0:

exit 0

الآن نقوم بتوليد ملفات Mermaid من النموذج بأمر export كما في صفحة التصدير إلى Mermaid:

docker run --rm -v "$PWD/docs:/docs:ro" -v "$PWD/out:/out" structurizr/structurizr:2026.09.19 export -w /docs/workspace.dsl -f mermaid -o /out

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

12:45:26.113 [main] INFO com.structurizr.command.ExportCommand -- Exporting workspace from /docs/workspace.dsl
12:45:26.645 [main] INFO com.structurizr.command.ExportCommand -- Exporting with MermaidDiagramExporter
12:45:26.795 [main] INFO com.structurizr.command.ExportCommand -- Writing /out/structurizr-Context.mmd
12:45:26.813 [main] INFO com.structurizr.command.ExportCommand -- Writing /out/structurizr-Containers.mmd
12:45:26.825 [main] INFO com.structurizr.command.ExportCommand -- Writing /out/structurizr-Components.mmd
12:45:26.830 [main] INFO com.structurizr.command.ExportCommand -- Finished

ثلاثة ملفات من نموذج واحد، ونفس الأمر مع -f plantuml أو -f c4plantuml يخرج ملفات PlantUML، ومع -f static يخرج موقعاً ثابتاً كاملاً يعرض المخططات في المتصفح كما في صفحة الموقع الثابت، ويمكنك أن تنشره مع التوثيق دون أي سيرفر. والصورة التالية مخطط التطبيقات كما رسمته Structurizr بنفسها من ملفنا في ذلك الموقع الثابت، دون أي تعديل منا على مكان العناصر:

مخطط Container View: Clinic Booking System كما يرسمه موقع Structurizr الثابت: حد كبير فيه Reminder Worker وWeb App وBooking API وMessage Queue بشكل أنبوب وDatabase بشكل أسطوانة، وخارجه شخصان Reception Staff وPatient، وثلاثة أنظمة رمادية SMS and Email Provider وIdentity Provider وPayment Gateway، وكل الأسهم منقطة وعليها الأوصاف والبروتوكولات
مخطط التطبيقات كما ولدته Structurizr من ملف workspace.dsl، بالترتيب الآلي autolayout

ولاحظ أن الترتيب الآلي مقبول لكنه ليس جميلاً، فمثلاً الـ Reminder Worker في أعلى المخطط بعيداً عن الـ Queue التي يقرأ منها، ويمكنك في أمر local الذي يعرض النموذج في المتصفح أن تحرك العناصر بالفأرة وتحفظ أماكنها كما في صفحة local، أو أن تقبل الترتيب الآلي مقابل ألا تفكر في الرسم إطلاقاً، وهذا في رأينا ثمن معقول لمخططات تبقى مطابقة للنموذج.

مخططات C4 بصيغة Mermaid

إذا كان فريقك لا يريد أداة إضافية، فصيغة Mermaid هي الأقرب إليك، لأن GitHub يرسمها مباشرة داخل ملفات Markdown وفي الـ Issues والـ Pull Requests كما في توثيق GitHub. وفي Mermaid نوع مخطط خاص بـ C4، لكن انتبه إلى أن صفحته الرسمية تصفه بأنه تجريبي حتى الآن، وأن صيغته وخصائصه قد تتغير في إصدارات قادمة، وأن صيغته متوافقة مع C4-PlantUML. والملف التالي مخطط التطبيقات بصيغة Mermaid، ونحفظه في docs/diagrams/containers-c4.mmd:

C4Container
    title Container diagram for Clinic Booking System

    Person(patient, "Patient", "Books, moves and cancels appointments")
    Person(reception, "Reception Staff", "Manages doctors' schedules")

    System_Boundary(booking, "Clinic Booking System") {
        Container(spa, "Web App", "React SPA", "Booking pages and staff dashboard")
        Container(api, "Booking API", "ASP.NET Core", "Availability, bookings and payments")
        Container(worker, "Reminder Worker", "Worker Service", "Sends reminders 24 hours before")
        ContainerQueue(queue, "Message Queue", "RabbitMQ", "Reminder messages")
        ContainerDb(db, "Database", "PostgreSQL", "Doctors, schedules and bookings")
    }

    System_Ext(idp, "Identity Provider", "OIDC sign-in")
    System_Ext(pay, "Payment Gateway", "Card payments")
    System_Ext(sms, "SMS and Email Provider", "Delivers reminders")

    Rel(patient, spa, "Books appointments using", "HTTPS")
    Rel(reception, spa, "Manages schedules using", "HTTPS")
    Rel(spa, api, "Calls", "JSON/HTTPS")
    Rel(spa, idp, "Signs users in with", "OIDC")
    Rel(api, db, "Reads and writes", "SQL/TCP")
    Rel(api, pay, "Charges cards using", "HTTPS")
    Rel(api, queue, "Publishes reminders to", "AMQP")
    Rel(worker, queue, "Consumes reminders from", "AMQP")
    Rel(worker, sms, "Sends reminders using", "HTTPS")

    UpdateLayoutConfig($c4ShapeInRow="3", $c4BoundaryInRow="1")

ولكي نحوله إلى صورة نستخدم أداة mmdc من Docker Image الخاص بها minlag/mermaid-cli:

docker run --rm -u "$(id -u):$(id -g)" -v "$PWD/docs/diagrams:/data" minlag/mermaid-cli:12.0.1 -i containers-c4.mmd -o containers-c4.png -c mermaid.json -s 2

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

Generating single mermaid chart

والصورة التالية هي الناتج كما خرج من الأداة:

مخطط C4Container رسمته Mermaid: شخصان باللون الأزرق الداكن في الأعلى، وتحتهما ثلاثة أنظمة خارجية رمادية فوق بعضها، ثم حد منقط Clinic Booking System فيه Web App وBooking API وReminder Worker وMessage Queue وDatabase باللون الأزرق، والأسهم طويلة تعبر فوق العناصر، وبعض الأوصاف متداخلة مثل Consumes reminders from وReads and writes
نفس مخطط التطبيقات بصيغة Mermaid: النص بسيط، لكن الترتيب الآلي يخلط الأوصاف

في الصورة أعلاه لاحظ أن الأوصاف تتداخل والأسهم تعبر فوق العناصر، والسبب كما تقول صفحة Mermaid نفسها أن مخطط C4 فيها لا يستخدم خوارزمية ترتيب آلية كاملة، وأن مكان العناصر يتحدد بترتيب الأسطر في الملف، وعدد العناصر في كل صف يتغير بالسطر UpdateLayoutConfig، ولا يدعم المفتاح Legend بعد، فيمكنك أن تحسن الرسم بتغيير ترتيب الأسطر، لكنك سوف تقضي وقتاً في ترتيب الرسم بدل كتابة المعمارية. لذلك فالخلاصة العملية: Mermaid مناسبة لمخطط سياق صغير أو مخطط تسلسل داخل ملف README، أما إذا كانت لديك عدة مخططات لنفس النظام فالنموذج الواحد في Structurizr أفضل، ويمكنك أن تصدر منه Mermaid إذا أردت عرضها في GitHub كما فعلنا أعلاه.

أي أداة تختار؟

الأداةنقاط القوةنقاط الضعفتناسب
draw.io وأدوات الرسمحرية كاملة في الشكل، ولا تحتاج تعلم أي صيغةلا يوجد diff مفهوم، وكل مخطط يعدل وحده فتختلف المخططات مع الوقترسم لمرة واحدة في عرض أو ورشة
Mermaidنص بسيط، ويرسمه GitHub مباشرة دون أي أداةنوع C4 فيها تجريبي ولا يدعم المفتاح Legend، والترتيب يتحدد بترتيب الأسطر، وكل مخطط ملف مستقلمخطط أو اثنان داخل README
C4-PlantUMLمكتبة C4 ناضجة فوق PlantUML، وترتيب أفضل من Mermaidتحتاج Java أو سيرفر PlantUML للرسم، وكل مخطط ملف مستقلفرق تستخدم PlantUML أصلاً
Structurizr DSLنموذج واحد لكل المخططات، وتحقق آلي، وتصدير إلى Mermaid وPlantUML وموقع ثابت، وربط بسجلات القراراتصيغة جديدة يتعلمها الفريق، وأداة إضافية في خط CIنظام فيه عدة مخططات تعيش طويلاً

سجل القرار المعماري Architecture Decision Record

المخطط يبين شكل النظام الآن، لكنه لا يجيب عن سؤال المطور الجديد في بداية المقال: لماذا أصبح بهذا الشكل؟ ويصف Michael Nygard في مقاله Documenting Architecture Decisions المنشور في نوفمبر 2011 هذا الموقف بالضبط: الشخص الجديد الذي لا يعرف سبب القرار أمامه خياران، أن يقبله دون فهم، فإذا تراكمت القرارات المقبولة دون فهم أصبح الفريق يخاف من تغيير أي شيء، أو أن يغيره دون فهم، فيكسر شيئاً كان القرار يحميه، مثل متطلب غير وظيفي لم يختبره أحد بعد.

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

صيغة Nygard: خمسة أقسام في صفحة أو صفحتين

الصيغة التي وضعها Nygard فيها خمسة أقسام فقط، والمقال نفسه مكتوب بنفس الصيغة كأنه أول ADR:

  • العنوان Title: عبارة اسمية قصيرة مع رقم، مثل «ADR 9: LDAP for Multitenant Integration».
  • السياق Context: القوى المؤثرة في القرار، تقنية وسياسية واجتماعية وخاصة بالمشروع، مكتوبة كحقائق محايدة دون رأي.
  • القرار Decision: ردنا على هذه القوى، بجمل كاملة بصيغة المبني للمعلوم تبدأ بـ «We will».
  • الحالة Status: «proposed» إذا لم يتفق عليه أصحاب المصلحة بعد، و«accepted» بعد الاتفاق، و«deprecated» أو «superseded» إذا غيره قرار لاحق، مع رابط للقرار الجديد.
  • العواقب Consequences: كل نتائج القرار، الإيجابية والسلبية والمحايدة، وليس الإيجابية فقط، لأنها كلها سوف تؤثر على الفريق لاحقاً.

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

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

قالب MADR: الخيارات التي رفضتها مكتوبة أيضاً

بعد صيغة Nygard ظهرت قوالب كثيرة، وأشهرها اليوم MADR واسمه الكامل Markdown Architectural Decision Records، وآخر إصدار له 4.0.0 صدر في سبتمبر 2024 كما في صفحة MADR. والفرق الأهم عن صيغة Nygard أن MADR يطلب أن تكتب الخيارات التي درستها ولماذا رفضت كل واحد منها، والسبب أن أول سؤال يطرحه من يقرأ القرار بعد سنة هو «ولماذا لم تستخدموا كذا؟»، فإذا كانت الإجابة مكتوبة فلن يعيد أحد نفس الدراسة. وأقسام القالب الكامل هي:

  • بيانات وصفية Metadata اختيارية في أول الملف بصيغة YAML: الحالة status وقيمها proposed وrejected وaccepted وdeprecated وsuperseded by، والتاريخ، ومن قرر ومن استشير ومن أبلغ.
  • السياق وبيان المشكلة Context and Problem Statement، ويفضل أن تكون المشكلة سؤالاً.
  • محركات القرار Decision Drivers: ما الذي يهمنا في الحل.
  • الخيارات المدروسة Considered Options.
  • نتيجة القرار Decision Outcome مع العواقب Consequences بصيغة «Good, because» و«Bad, because»، وقسم التأكيد Confirmation الذي يقول كيف نتحقق من أن الكود يلتزم بالقرار.
  • إيجابيات وسلبيات كل خيار Pros and Cons of the Options، ثم معلومات إضافية More Information.

ولاحظ أن الحالة rejected موجودة في MADR وليست في صيغة Nygard، وهي مفيدة لأن القرار المرفوض بعد نقاش طويل يستحق أن يبقى مكتوباً هو أيضاً، والصورة التالية تبين الحالات وانتقالاتها:

حالات سجل القرار: Proposed يتحول بالمراجعة والدمج review, merge إلى Accepted أو يتحول إلى Rejected، وAccepted يتحول إلى Superseded عندما يستبدله قرار جديد أو إلى Deprecated. وفي الأسفل مثال من التجربة: الملف 0004-send-reminders-through-rabbitmq-and-a-worker.md بحالة Accepted, supersedes ADR 2، والملف 0002-send-reminders-directly-from-the-booking-api.md بحالة Superseded by ADR 4، وبينهما سهمان links both ways
حالات سجل القرار، والقرار القديم لا يحذف وإنما يرتبط بالقرار الذي استبدله

مثال كامل: قرار منع الحجز المزدوج

لنأخذ مثالاً حقيقياً من نظام حجز المواعيد: مريضان يفتحان نفس الموعد الفارغ لنفس الطبيب في نفس اللحظة، والـ Booking Service يتحقق من أن الموعد فارغ ثم يحفظ الحجز، فيمر الطلبان من التحقق قبل أن يحفظ أي منهما، والنتيجة حجزان لموعد واحد. والقرار هنا يمس قاعدة البيانات والاختبارات وكل من يكتب في جدول الحجوزات، لذلك يستحق سجلاً، وهذا هو السجل الثالث في مثالنا بصيغة Nygard كما تولده أداة adr-tools، بعد أن ملأنا أقسامه:

# 3. Prevent double booking with a database exclusion constraint

Date: 2026-10-07

## Status

Accepted

## Context

Two patients can open the same free slot of the same doctor at the same moment.
The Booking Service checks availability and then inserts the booking, so two
requests can both pass the check before either insert is committed. We run two
instances of the Booking API behind a load balancer, so an in-process lock does
not help.

Options considered:

1. Check in application code only (current state): simple, but racy.
2. SELECT ... FOR UPDATE on the doctor's schedule row: works, but every booking
   for a doctor waits on one row, and every code path must remember to take it.
3. A PostgreSQL exclusion constraint on (doctor_id, slot) for confirmed bookings:
   the database rejects any overlap, whatever code path or instance writes.

## Decision

We will store each booking's time as a tstzrange and add an exclusion constraint
using GiST (btree_gist extension) on doctor_id with = and slot with &&, limited
to rows with status = 'confirmed'. The Booking API keeps its availability check
for a friendly message, and maps the constraint error (SQLSTATE 23P01) to
HTTP 409 "slot already taken".

## Consequences

* Double booking is impossible at the database level, also for imports and
  admin scripts that bypass the API.
* We depend on a PostgreSQL feature: moving to another database means
  replacing this rule; this is acceptable as PostgreSQL is our standard.
* Cancelled bookings must change status (not delete) so history stays, and
  the partial constraint ignores them.
* An integration test must run against a real PostgreSQL, not an in-memory
  fake, to cover this rule.

في السجل أعلاه لاحظ التالي:

  • السياق يشرح لماذا لا يكفي الحل البسيط، أي التحقق في الكود، ولماذا لا يفيد القفل داخل العملية Lock مع نسختين من الـ API، وهذه بالضبط المعلومة التي ضاعت في قصة بداية المقال.
  • القرار يذكر الآلية بدقة: قيد الاستبعاد Exclusion Constraint في PostgreSQL الموثق في صفحة القيود، والرمز 23P01 الذي يتحول إلى HTTP 409، فيعرف القارئ أين يبحث في الكود.
  • العواقب فيها السلبية أيضاً: الاعتماد على ميزة خاصة بـ PostgreSQL، والحاجة إلى اختبار تكامل على قاعدة حقيقية وليس على بديل في الذاكرة، وهذا يربط القرار بما شرحناه في تجربة Clean Architecture عن الاختبار دون قاعدة بيانات وحدوده.

ونفس القرار بقالب MADR يبدو كما يلي، ولاحظ أن الخيارات المرفوضة صار لها قسم خاص بإيجابياتها وسلبياتها:

---
status: accepted
date: 2026-10-07
decision-makers: Booking team lead, two backend developers
consulted: DBA
informed: Reception team, support
---

# Prevent double booking with a database exclusion constraint

## Context and Problem Statement

Two patients can open the same free slot of the same doctor at the same moment,
and two instances of the Booking API run behind a load balancer. How do we make
sure that one slot is booked once, whatever instance or script writes it?

## Decision Drivers

* No double booking, also for imports and admin scripts
* No global lock that slows every booking
* PostgreSQL is our standard database

## Considered Options

* Check in application code only
* SELECT ... FOR UPDATE on the doctor's schedule row
* PostgreSQL exclusion constraint on (doctor_id, slot)

## Decision Outcome

Chosen option: "PostgreSQL exclusion constraint on (doctor_id, slot)", because it
is the only option that the database enforces for every writer.

### Consequences

* Good, because double booking is impossible at the database level
* Bad, because the rule depends on a PostgreSQL feature (btree_gist)
* Bad, because an integration test must run against a real PostgreSQL

### Confirmation

An integration test books the same slot from two connections at once and
expects one success and one SQLSTATE 23P01 mapped to HTTP 409.

## Pros and Cons of the Options

### Check in application code only

* Good, because it is simple
* Bad, because two requests can pass the check before either insert commits

### SELECT ... FOR UPDATE on the doctor's schedule row

* Good, because it works on any relational database
* Bad, because every booking for one doctor waits on one row
* Bad, because every code path must remember to take the lock

وقد يتساءل البعض: أي القالبين أختار؟ والإجابة أن صيغة Nygard أقصر وتكفي لأغلب القرارات، وMADR أفضل عندما تكون هناك عدة خيارات جدية ونقاش طويل، والأهم من القالب أن يكتب الفريق القرار فعلاً، لذلك اختر قالباً واحداً واكتبه في أول ADR في المشروع، وهذا ما تفعله أداة adr-tools تلقائياً كما سنرى.

أين تحفظ القرارات والمخططات؟

Nygard كتب في مقاله أن السجلات تحفظ في مستودع المشروع، في doc/arch/adr-NNN.md، بصيغة نصية خفيفة مثل Markdown، وأداة adr-tools تستخدم doc/adr افتراضياً، وMADR يقترح docs/decisions. والمسار نفسه لا يهم كثيراً، المهم أن يكون داخل المستودع وليس في الـ Wiki أو في مستند مشترك، والسبب ثلاثة أشياء:

  1. القرار يدخل مع الكود الذي يطبقه في نفس الـ Pull Request، فيراجع المراجعون السياق والعواقب مع التغيير نفسه، ويصبح الدمج Merge هو لحظة قبول القرار.
  2. القرار يحمل نفس إصدار الكود، فإذا رجعت إلى إصدار قديم وجدت القرارات التي كانت سارية وقتها، ويظهر كل قرار في git log وفي البحث داخل المستودع.
  3. الأدوات تقرأه من مكان واحد: Structurizr تربطه بالنموذج، وأداة مثل log4brains تنشره كموقع، وخط CI يتحقق منه.

والاعتراض المعروف أن مدير المشروع أو العميل لا يعيش في المستودع، وقد أجاب Nygard عن ذلك في نفس المقال بأن GitHub يعرض ملفات Markdown بشكل مريح مثل أي صفحة Wiki، فيكفي أن ترسل الرابط، واليوم يمكنك أيضاً أن تنشر القرارات والمخططات كموقع ثابت من خط CI. والصورة التالية تبين شكل المستودع ومسار القرار من الفرع إلى الموقع المنشور:

في اليمين شجرة مستودع clinic-booking فيها src/BookingApi وdocs وتحته workspace.dsl ومجلد adr فيه أربعة ملفات من 0001 إلى 0004، وdecisions.md من adr generate toc، والملف .adr-dir. وفي اليسار أربع خطوات مرقمة: فرع جديد فيه adr new مع تغيير الكود، ثم Pull Request يناقش فيه المراجعون السياق والنتائج، ثم merge يجعل الحالة Accepted، ثم خط CI ينفذ structurizr validate وexport وlog4brains build وينشر موقعاً ثابتاً
القرار يدخل مع الكود في نفس الـ Pull Request، وخط CI يتحقق من النموذج وينشر التوثيق

ولاحظ في الشجرة أن ملف الفهرس decisions.md خارج مجلد adr، وليس README.md داخله كما يفعل كثيرون، والسبب سوف تراه في التجربة عندما نكسر الإعداد عمداً.

أدوات سجلات القرارات

يمكنك أن تكتب السجلات يدوياً بنسخ آخر ملف وتغيير رقمه، لكن هذا يجعل الأرقام والروابط بين القرارات مسؤوليتك، والأدوات تقوم بذلك عنك. وأشهرها adr-tools من Nat Pryce وهي مجموعة سكربتات Shell، وآخر إصدار لها 3.0.0 صدر في يوليو 2018، وما زالت تعمل كما هي لأنها سكربتات Bash بسيطة، وطرق تثبيتها في صفحة التثبيت.

تجربة عملية مع adr-tools

سوف نشغل الأداة داخل Container من Alpine لكي لا نثبت شيئاً على الجهاز، فنقوم بإنشاء الـ Container وتثبيت Bash وGit فيه، ثم ننزل الإصدار 3.0.0 من الأداة:

docker run -d --name lab-arch-docs-adr alpine:3.22 sleep 3600
docker exec lab-arch-docs-adr apk add --no-cache bash git
docker exec lab-arch-docs-adr sh -c 'wget -qO- https://github.com/npryce/adr-tools/archive/refs/tags/3.0.0.tar.gz | tar -xz -C /opt'

الآن نقوم بإنشاء مستودع جديد لنظام الحجز، ونبدأ سجل القرارات في docs/adr، ثم نضيف ثلاثة قرارات: إرسال التذكيرات مباشرة من الـ API، ثم منع الحجز المزدوج، ثم قراراً رابعاً يستبدل الثاني بخيار -s 2، لأننا اكتشفنا أن نسختين من الـ API ترسلان التذكير مرتين:

docker exec -it lab-arch-docs-adr bash
export PATH=/opt/adr-tools-3.0.0/src:$PATH
mkdir -p /work/clinic-booking && cd /work/clinic-booking && git init -q
adr init docs/adr
adr new Send reminders directly from the Booking API
adr new Prevent double booking with a database exclusion constraint
adr new -s 2 Send reminders through RabbitMQ and a worker

والمخرج سوف يكون كما يلي، سطر لكل ملف أنشأته الأداة:

docs/adr/0001-record-architecture-decisions.md
docs/adr/0002-send-reminders-directly-from-the-booking-api.md
docs/adr/0003-prevent-double-booking-with-a-database-exclusion-constraint.md
docs/adr/0004-send-reminders-through-rabbitmq-and-a-worker.md

ولاحظ أن adr init أنشأ القرار الأول بنفسه، وعنوانه «Record architecture decisions»، أي أن أول قرار في المشروع هو قرار استخدام السجلات نفسها، مع رابط لمقال Nygard. والآن لنرى ما فعله خيار -s 2 في القرارين الثاني والرابع:

sed -n "1,10p" docs/adr/0002-*.md
sed -n "1,11p" docs/adr/0004-*.md

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

# 2. Send reminders directly from the Booking API

Date: 2026-10-07

## Status

Superceded by [4. Send reminders through RabbitMQ and a worker](0004-send-reminders-through-rabbitmq-and-a-worker.md)

## Context

# 4. Send reminders through RabbitMQ and a worker

Date: 2026-10-07

## Status

Accepted

Supercedes [2. Send reminders directly from the Booking API](0002-send-reminders-directly-from-the-booking-api.md)

## Context

في المخرج أعلاه لاحظ التالي:

  • الأداة غيرت حالة القرار الثاني ووضعت رابطاً للرابع، ووضعت في الرابع رابطاً عكسياً للثاني، فمن يفتح أي منهما يصل إلى الآخر، ولم تحذف القرار القديم كما تطلب صيغة Nygard.
  • الكلمة مكتوبة «Superceded» وليس «Superseded»، وهذه كتابة الأداة نفسها كما في صفحتها على GitHub، وليست خطأ منا، فلا تتفاجأ بها إذا بحثت في المستودع بالكتابة الصحيحة ولم تجد شيئاً.
  • كل قرار جديد يبدأ بالحالة Accepted، وإذا كان فريقك يراجع القرار في Pull Request قبل قبوله فغير الحالة إلى Proposed بعد إنشاء الملف، واجعل الدمج هو لحظة تغييرها إلى Accepted.

بعد ذلك نولد فهرساً لكل القرارات بأمر adr generate toc، ونضيف الخيار -p adr/ لكي تعمل الروابط من مجلد docs، ونحفظ الناتج في docs/decisions.md:

adr generate toc -p adr/ > docs/decisions.md
cat docs/decisions.md

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

# Architecture Decision Records

* [1. Record architecture decisions](adr/0001-record-architecture-decisions.md)
* [2. Send reminders directly from the Booking API](adr/0002-send-reminders-directly-from-the-booking-api.md)
* [3. Prevent double booking with a database exclusion constraint](adr/0003-prevent-double-booking-with-a-database-exclusion-constraint.md)
* [4. Send reminders through RabbitMQ and a worker](adr/0004-send-reminders-through-rabbitmq-and-a-worker.md)

ربط القرارات بنموذج Structurizr

السطر !adrs adr في ملف workspace.dsl يجعل Structurizr تقرأ كل ملفات Markdown في مجلد docs/adr وتضيفها إلى النموذج، وتقول صفحة الـ ADRs في توثيقها إن المستورد الافتراضي يتوقع ملفات أنشأتها adr-tools أو تتبع نفس الصيغة، وإن هناك مستورداً آخر لصيغة MADR. ولكي نتأكد من أن القرارات دخلت النموذج فعلاً نصدر النموذج كملف JSON ونقرأ منه قائمة القرارات:

docker run --rm -v "$PWD/docs:/docs:ro" -v "$PWD/out:/out" structurizr/structurizr:2026.09.19 export -w /docs/workspace.dsl -f json -o /out
node -e 'const j=require(process.argv[1]); for(const d of j.documentation.decisions) console.log(d.id, "|", d.title, "|", d.status, "|", d.date, "|", JSON.stringify(d.links||[]))' "$PWD/out/workspace.json"

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

1 | Record architecture decisions | Accepted | 2026-10-07T00:00:00Z | []
2 | Send reminders directly from the Booking API | Superseded | 2026-10-07T00:00:00Z | [{"description":"Superceded by","id":"4"}]
3 | Prevent double booking with a database exclusion constraint | Accepted | 2026-10-07T00:00:00Z | []
4 | Send reminders through RabbitMQ and a worker | Accepted | 2026-10-07T00:00:00Z | [{"description":"Supercedes","id":"2"}]

ولاحظ أن Structurizr فهمت الكتابة الخاطئة «Superceded» وحولت الحالة إلى Superseded، واحتفظت بالروابط بين القرارين، فأصبحت القرارات والمخططات في نموذج واحد يمكن عرضه معاً.

نكسر الإعداد عمداً: أخطاء ستقابلها

الخطأ الأول شائع جداً: أن تضع الفهرس في docs/adr/README.md لكي يعرضه GitHub تلقائياً عند فتح المجلد، فلنفرض أنك فعلت ذلك ثم شغلت validate:

adr generate toc > docs/adr/README.md
docker run --rm -v "$PWD/docs:/docs:ro" structurizr/structurizr:2026.09.19 validate -w /docs/workspace.dsl

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

12:57:39.053 [main] ERROR com.structurizr.command.ValidateCommand -- Error importing decisions: java.lang.NumberFormatException: For input string: "READ" at line 76 of /docs/workspace.dsl: !adrs adr

والسبب أن المستورد يقرأ كل ملفات Markdown في المجلد كما تقول صفحة التوثيق، ويتوقع أن يبدأ اسم كل ملف برقم القرار كما تفعل adr-tools، فحاول أن يقرأ «READ» من بداية README كرقم وفشل، والحل أن تضع الفهرس خارج مجلد القرارات كما فعلنا في docs/decisions.md، وتذكر هذه القاعدة: مجلد adr للقرارات المرقمة فقط.

والخطأ الثاني خطأ إملائي في النموذج، فلنفرض أن أحدهم كتب booking.workr بدل booking.worker في علاقة الـ Worker بالـ Queue:

12:57:21.992 [main] ERROR com.structurizr.command.ValidateCommand -- The source element "booking.workr" does not exist at line 41 of /docs/workspace.dsl: booking.workr -> booking.queue "Consumes reminders from" "AMQP"

والرسالة تذكر العنصر والسطر بالضبط، والأهم أن الأمر انتهى برمز خطأ، فإذا وضعت validate في خط CI فلن يدمج أي Pull Request يكسر النموذج، وهذا بالضبط ما لا تستطيع أن تفعله مع صورة في الـ Wiki.

log4brains: القرارات كموقع

إذا أردت أن يقرأ غير المطورين القرارات في موقع مرتب بدل ملفات في المستودع، فأداة log4brains مصممة لذلك، فهي كما في صفحتها تسجل القرارات من بيئة التطوير وتنشرها تلقائياً كموقع ثابت، وقالبها الافتراضي MADR، وأوامرها الأساسية log4brains init وlog4brains adr new وlog4brains preview لمعاينة الموقع، وlog4brains build الذي تضعه في خط CI لكي ينشر الموقع على GitHub Pages أو GitLab Pages أو S3، وتحتاج Node.js. ولاحظ أن آخر إصدار لها 1.1.0 صدر في ديسمبر 2024، فراجع نشاط المشروع قبل أن تعتمد عليه في مؤسسة كبيرة، أما ملفات القرارات نفسها فهي Markdown عادية تبقى معك مهما تغيرت الأداة.

حذف التجربة

بعد الانتهاء نحذف الـ Container، ثم الـ Images التي نزلناها للتجربة إذا لم تعد تحتاجها:

docker rm -f lab-arch-docs-adr
docker rmi structurizr/structurizr:2026.09.19 minlag/mermaid-cli:12.0.1

أخطاء شائعة في توثيق المعمارية

  • مخطط يخلط المستويات: Containers ومكونات وجداول قاعدة بيانات في نفس الصورة، فلا يفهمه المدير ولا يستفيد منه المطور، والحل مخطط لكل مستوى.
  • مربعات بلا نوع وأسهم بلا وصف: سهم مكتوب عليه «Uses» أو لا شيء، ولون لا يشرحه مفتاح، فيخمن كل قارئ معنى مختلفاً.
  • رسم مستوى الكود باليد: يتقادم مع أول Refactoring، قم بتوليده من الـ IDE عند الحاجة فقط.
  • ADR يصف ما تم بعد شهور: السجل يكتب وقت اتخاذ القرار، لأن السياق والخيارات المرفوضة تنسى بسرعة، والسجل الذي يكتب متأخراً يصبح تبريراً وليس توثيقاً.
  • تعديل نص قرار مقبول: إذا تغير القرار فاكتب قراراً جديداً يستبدله، ولا تقم بتعديل القديم، لأن تاريخ تغير التفكير جزء من المعلومة.
  • عواقب إيجابية فقط: القرار الذي لا سلبيات مكتوبة له لم يدرس جيداً، أو أن كاتبه يحاول إقناع المراجعين وليس إخبار المطور القادم.
  • سجل لكل صغيرة: إذا أصبح اختيار اسم مكتبة تسجيل صغيرة يحتاج ADR، فسوف يتوقف الفريق عن كتابتها كلها، لذلك اقصرها على القرارات التي يصعب تغييرها.

تدريب عملي

  1. ارسم مخطط سياق لنظام تعمل عليه الآن، في Mermaid أو Structurizr، والتزم بقواعد الرموز: عنوان ومفتاح ونوع لكل عنصر ووصف على كل سهم، ثم اعرضه على شخص غير تقني واسأله ماذا فهم.
  2. اختر قراراً معمارياً اتخذه فريقك في آخر ستة أشهر، واكتبه بصيغة Nygard، ثم اكتبه بقالب MADR مع خيارين رفضتموهما على الأقل، ولاحظ أي المعلومات لم يتذكرها أحد.
  3. أضف structurizr validate إلى خط CI في مشروعك، وغير اسم عنصر في علاقة واحدة فقط، وتأكد من أن الخط يفشل.

الخلاصة

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

  • نموذج C4 يرسم النظام بأربعة مستويات تكبير: السياق للجميع، والتطبيقات Containers للتقنيين، والمكونات عند الحاجة فقط، والكود يولد من الـ IDE ولا يرسم باليد، ومعها مخططات المشهد العام والتسلسل والنشر.
  • Container في C4 تطبيق أو مخزن بيانات يجب أن يعمل لكي يعمل النظام، وليس Container من Docker.
  • القواعد البسيطة تجعل أي مخطط مفهوماً: عنوان ومفتاح، ونوع ووصف لكل عنصر، وسهم في اتجاه واحد عليه وصف وبروتوكول.
  • المخططات ككود تجعل التغيير يظهر في الـ diff ويراجع مع الكود، وMermaid مناسبة لمخطط صغير في README، وStructurizr مناسبة لنموذج واحد تتولد منه كل المخططات ويتحقق منه خط CI.
  • سجل القرار ADR ملف قصير لكل قرار مهم: سياق وقرار وحالة وعواقب بصيغة Nygard، أو مع الخيارات المرفوضة بقالب MADR، والقرار القديم لا يحذف وإنما يستبدل بقرار جديد.
  • مكان القرارات والمخططات هو المستودع، تدخل مع الكود في نفس الـ Pull Request، وأدوات مثل adr-tools وlog4brains وStructurizr تدير الأرقام والروابط والنشر.

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

دليلالتصميم والمعمارية: أربعة مستويات لنشاط واحدما الفرق بين تصميم البرمجيات ومعماريتها وتصميم الأنظمة ومعمارية المؤسسة، ومن يقرر في كل مستوى وكم يعيش قراره.دليلClean Architecture: كيف تعزل منطق العمل عن قاعدة البياناتلماذا تحتاج قاعدة بيانات لكي تختبر قاعدة عمل من سطر واحد، وكيف يعزل اتجاه الاعتماديات منطق العمل عن التفاصيل.دليلأنماط المعمارية Architecture Styles ومتى تختار كلاً منهاكيف تختار بين التطبيق الواحد والخدمات التي تتبادل الأحداث، وما الذي يناسب نظامك من أشهر أنماط المعمارية.دليلالتصميم الموجه بالمجال Domain-Driven Design ببساطةلماذا تعني كلمة «الطلب» ثلاثة أشياء في نفس الشركة، وكيف يقسم DDD النظام إلى سياقات لكل منها لغته ونموذجه.دليلمعمارية المؤسسة Enterprise Architecture ببساطةعندما تملك المؤسسة عشرات الأنظمة لا يعرف أحد ماذا يتوقف إذا أوقفت أحدها، وهنا يأتي دور معمارية المؤسسة وأطرها المعروفة.دليلالفرق بين Tiers وLayers: متى يكون نظامك ثلاثي الطبقات فعلاً؟ماذا يعني أن يكون نظامك ثلاثي الطبقات 3-tier فعلاً، والفرق بين الطبقة داخل الكود والمستوى في الشبكة، وما تطلبه الضوابط بالنص.

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

  • أكتوبر 2026: كتابة المقال، ومراجعة مصادر C4 ومقال Nygard وMADR 4.0.0، وتنفيذ التجربة على Docker Engine 29.8.0 مع structurizr/structurizr:2026.09.19 وminlag/mermaid-cli:12.0.1 وadr-tools 3.0.0 على alpine:3.22.
نشرة عرب رووت | ArabRoot

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

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

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

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