لنفرض أن لديك متجراً إلكترونياً صغيراً بدأ بجدول واحد اسمه Orders وكلاس واحد اسمه Order، ومع الوقت أضاف فريق المبيعات حقول الخصم والكوبون، وأضاف فريق الشحن حقول العنوان والوزن ورقم التتبع، وأضاف فريق الفوترة حقول الضريبة وحالة الدفع، وبعد سنتين أصبح الكلاس فيه ستون حقلاً وعشرون حالة، وكل تعديل يطلبه فريق الشحن يكسر شيئاً عند فريق الفوترة، وفي كل اجتماع يقول أحدهم «الطلب» ويقصد شيئاً مختلفاً عما يقصده الآخر. وهذه المشكلة ليست في لغة البرمجة ولا في قاعدة البيانات، وإنما في طريقة فهمنا للمجال الذي نبني له البرنامج وطريقة تقسيمه، وهذا بالضبط ما يعالجه التصميم الموجه بالمجال Domain-Driven Design واختصاراً DDD.
وفي مدخل هذه المرحلة شرحنا الفرق بين تصميم البرمجيات Software Design ومعمارية البرمجيات Software Architecture، وهنا سوف نأخذ أداة تقع بين الاثنين، فهي تبدأ من الحديث مع أهل العمل وتنتهي بأسماء الكلاسات والدوال. وسوف نناقش في هذا المقال ما يلي:
- ما هو DDD، ولماذا قسمه الناس إلى جانب استراتيجي Strategic وجانب تكتيكي Tactical.
- المجالات الفرعية Subdomains: أين تضع أفضل مهندسيك، وأين تستخدم أداة جاهزة.
- اللغة المشتركة Ubiquitous Language، والسياق المحدد Bounded Context، وخريطة السياقات Context Map وعلاقاتها.
- ورشة EventStorming باختصار.
- الكيان Entity وكائن القيمة Value Object وال Aggregate والحدث Domain Event، بكود C# حقيقي ومخرجاته، مع نفس كائن القيمة في Python.
- متى يكون DDD أكبر من حاجتك، والأخطاء الشائعة عند تطبيقه.
ما هو التصميم الموجه بالمجال Domain-Driven Design؟
المجال Domain هو مجال المعرفة أو النشاط الذي يخدمه البرنامج، فمجال برنامج المتجر هو البيع والشحن والفوترة، ومجال برنامج العيادة هو المواعيد والملفات الطبية، والنموذج Model هو مجموعة المفاهيم المختارة من هذا المجال التي نستخدمها لحل مشكلاته، وهذان التعريفان مأخوذان من وثيقة DDD Reference التي نشرها Eric Evans عام 2015 بترخيص Creative Commons، وهي ملخص رسمي لأنماط كتابه الصادر عام 2003. ويلخص Evans في هذه الوثيقة الفكرة كلها في ثلاث نقاط: أن تركز على المجال الأساسي Core Domain، وأن تبني النموذج بالتعاون بين أهل المجال والمطورين، وأن تتكلم لغة مشتركة داخل سياق محدد بوضوح.
ويصف Martin Fowler في مقاله DomainDrivenDesign هذا الأسلوب بأنه يجعل التطوير يدور حول نموذج للمجال يفهم عملياته وقواعده، ويقول إنه مناسب بالذات للمجالات المعقدة التي فيها منطق كثير متشابك، وهذه الجملة الأخيرة مهمة جداً لأننا سوف نعود إليها في آخر المقال عندما نتكلم عن الحالات التي لا تحتاج فيها DDD أصلاً.
والناس يقسمون DDD إلى جزأين:
- الجانب الاستراتيجي Strategic Design: كيف تقسم المجال الكبير إلى أجزاء، وما اللغة في كل جزء، وكيف تتعامل الأجزاء والفرق مع بعضها. وهو لا يحتاج سطراً واحداً من الكود، ويفيد أي فريق تقريباً.
- الجانب التكتيكي Tactical Design: أنماط داخل الكود مثل الكيان Entity وكائن القيمة Value Object وال Aggregate والحدث Domain Event وال Repository، وهي تفيد عندما تكون القواعد كثيرة ومعقدة، ولها ثمن في الوقت والتعقيد.
وقد يتساءل البعض: هل DDD إطار عمل Framework أو طريقة لترتيب المجلدات؟ والإجابة لا، فهو طريقة تفكير وأسماء لأنماط، ولا يوجد شيء تقوم بتثبيته، وإذا رأيت مشروعاً فيه مجلدات اسمها Domain وAggregates لكن أسماء الكلاسات فيه لا يفهمها أحد من أهل العمل، فهو لم يطبق DDD وإنما استعار أسماءه.
المجالات الفرعية Subdomains: أين تضع أفضل جهدك؟
المتجر ليس مجالاً واحداً، فيه البيع والتسعير، والشحن، والفوترة، وحسابات المستخدمين، وكل جزء من هذه مجال فرعي Subdomain، وليست كلها بنفس الأهمية. ويقسمها دليل Microsoft في صفحة Domain analysis for microservices إلى ثلاثة أنواع:
- المجال الأساسي Core Domain: الجزء الذي يميز الشركة عن منافسيها ويعطيها الميزة، وهو يستحق النمذجة الدقيقة وأفضل المهندسين. ويقول Evans في نمط Core Domain إن هذا الجزء يجب أن يكون صغيراً وأن تضع فيه أفضل الكفاءات، لأن المهندسين الأقوياء يميلون عادة إلى البنية التقنية بدل فهم المجال.
- المجال الداعم Supporting Subdomain: جزء يحتاجه العمل ويخص الشركة فلا تجده جاهزاً كما هو، لكنه ليس سبب تميزها، مثل الفوترة في كثير من المتاجر.
- المجال العام Generic Subdomain: مشكلة حلها العالم من قبل، مثل تسجيل الدخول وإدارة المستخدمين، والأنسب أن تستخدم فيها حلاً جاهزاً، ويقول Evans في نمط Generic Subdomains إن عليك أن تفكر في الحلول الجاهزة لهذه الأجزاء ولا تضع فيها مهندسي المجال الأساسي.
ولنفرض أن متجرنا ينافس بالعروض والتسعير الذكي، فالمبيعات والتسعير هي المجال الأساسي، والشحن والفوترة مجالات داعمة، والهوية والدخول مجال عام لا يستحق أن تكتبه بنفسك، فتستخدم مثلاً Authentik للدخول الموحد وتنتهي منه. ولاحظ أن التصنيف يختلف من شركة إلى أخرى، فالشحن عند شركة توصيل هو المجال الأساسي نفسه.
اللغة المشتركة Ubiquitous Language
اللغة المشتركة Ubiquitous Language هي لغة يتفق عليها الفريق كله، المطورون وخبراء المجال Domain Experts، ويستخدمونها في الكلام والمستندات والكود والاختبارات، داخل سياق محدد واحد، وهذا تعريف Evans في الوثيقة المرجعية. ويضيف Fowler في مقاله UbiquitousLanguage أن هذه اللغة يجب أن تكون دقيقة لأن البرنامج لا يتحمل الغموض، وأنها تتطور كلما زاد فهم الفريق للمجال.
الصورة التالية تبين الفكرة: جملة يقولها أهل العمل، ثم قاموس صغير يتفق عليه الفريق، ثم الكود الذي يستخدم نفس الكلمات بدل أسماء لا يفهمها إلا من كتبها:

الطريقة البسيطة التي يبدأ بها أغلب الفرق أن يسمع المطور المتطلبات بلغة أهل العمل ثم يكتب الكود بأسمائه هو: UpdateStatus(o, 4) وFlag2 وProcessItem، وتعمل هذه الطريقة في البداية، لكنها تفشل لسببين: الأول أن كل حديث مع أهل العمل يحتاج ترجمة في رأس المطور من «تأكيد الدفع» إلى «الحالة 4»، وكل ترجمة فرصة لخطأ، والثاني أن المطور الجديد يقرأ الكود فلا يفهم القاعدة، ويقرأ وثيقة المتطلبات فلا يجد مكانها في الكود. أما الطريقة الصحيحة فهي أن تكون القاعدة «لا نشحن قبل تأكيد الدفع» موجودة في الكود بنفس الكلمات تقريباً: دالة اسمها Ship ترفض العمل إذا لم يحدث PaymentConfirmed.
وفي الفرق العربية يتكلم أهل العمل بالعربية ويكتب المطورون الكود بالإنجليزية، وهنا ننصح بقاموس صغير في المستودع Repository، مثلاً ملف docs/glossary.md، فيه المصطلح العربي كما يقوله أهل العمل، والاسم الإنجليزي الذي يستخدمه الكود، وتعريف من سطر واحد، وأي كلمة جديدة تظهر في اجتماع تضاف إليه قبل أن تظهر في الكود. وإذا قال لك خبير المجال إن كلمة في الكود لا تعني له شيئاً، فغالباً الاسم خطأ وليس الخبير.
السياق المحدد Bounded Context: كلمة واحدة لها أكثر من معنى
عندما تبدأ بكتابة القاموس سوف تكتشف بسرعة أن نفس الكلمة لها معان مختلفة في أقسام الشركة، والصورة التالية تبين ذلك في متجرنا:

الطريقة البسيطة هي أن تبني نموذجاً واحداً موحداً للشركة كلها: كلاس Order واحد وجدول واحد فيه كل الحقول التي يحتاجها الجميع، وهذا ما كان ينصح به الناس قديماً كما يذكر Fowler في مقاله BoundedContext، حيث يحكي أنه عمل في شركة كهرباء كانت كلمة «العداد» فيها تعني أشياء مختلفة بحسب القسم، ثم ينقل عن Evans أن توحيد نموذج المجال بالكامل في نظام كبير غير ممكن وغير مجد اقتصادياً. والسبب أن النموذج الموحد يجبر كل قسم على حمل حقول وقواعد أقسام أخرى، فيكبر الكلاس، وتتعارض القواعد، ويصبح كل تغيير اجتماعاً بين ثلاثة فرق.
والحل الذي يقترحه DDD هو السياق المحدد Bounded Context، وهو حد واضح يكون داخله نموذج واحد متسق ولغة واحدة، وخارجه قد تعني نفس الكلمة شيئاً آخر. ويطلب Evans في الوثيقة المرجعية أن ترسم هذا الحد صراحة بثلاث طرق: من يعمل عليه من الفرق، وأي جزء من التطبيق يستخدمه، وأين يظهر فعلياً مثل المستودع Code Base ومخطط قاعدة البيانات Schema. ففي متجرنا سياق المبيعات فيه Order بسطور وأسعار، وسياق الشحن فيه Shipment بعنوان ووزن، وسياق الفوترة فيه Invoice بمبلغ وضريبة، وكلها تشير إلى نفس رقم الطلب OrderId لكن لا يحمل أي منها نموذج الآخر.
وقد يتساءل البعض: هل كل Bounded Context خدمة مصغرة Microservice؟ والإجابة لا بالضرورة، فتقول Microsoft في صفحة Identify domain-model boundaries إن تصميم الخدمات المصغرة يبدأ من حدود السياقات، وإن السياق الواحد قد يتكون من عدة خدمات فعلية، لكن العكس غير صحيح، أي لا ينبغي أن تقسم خدمة واحدة على عدة سياقات. ويمكن أيضاً أن يكون كل سياق وحدة Module داخل تطبيق واحد، وهذا ما نشرحه في الـ Modular Monolith، وهو غالباً البداية الأنسب للفرق الصغيرة قبل أن تفكر في الخدمات المصغرة.
خريطة السياقات Context Map: كيف تتعامل السياقات مع بعضها؟
بعد تقسيم النظام إلى سياقات تبقى مسألة مهمة، لأن السياقات تتبادل البيانات، والفرق التي تملكها لها علاقات قوة ومصالح مختلفة، فهناك فريق يقرر شكل البيانات ويسمى المصدر Upstream، وفريق يستهلكها ويتأثر بقراراته ويسمى المستهلك Downstream. وخريطة السياقات Context Map رسم يبين كل سياق في المشروع والأنظمة التي تتكامل معه، ونوع العلاقة بين كل اثنين، ويطلب Evans أن تكون أسماء السياقات جزءاً من اللغة المشتركة. والصورة التالية خريطة لمتجرنا:

وأنواع العلاقات كما تسردها الوثيقة المرجعية، مع ما يقابلها في متجرنا:
| العلاقة | معناها | متى تختارها |
|---|---|---|
| الشراكة Partnership | فريقان ينجحان معاً أو يفشلان معاً، فيخططان معاً ويطوران الواجهة بينهما معاً | ميزة واحدة تحتاج تغييراً في السياقين لنفس الإصدار |
| النواة المشتركة Shared Kernel | جزء صغير من النموذج والكود يتشاركه فريقان، ولا يغيره أحدهما دون الآخر | كائن مثل Money بين المبيعات والفوترة، بشرط أن يبقى صغيراً جداً |
| العميل والمورد Customer/Supplier | المصدر يضع احتياجات المستهلك في خطته، ويتفقان على اختبارات قبول للواجهة | الشحن يحتاج حقلاً جديداً في حدث المبيعات، والمبيعات تلتزم به |
| التبعية Conformist | المستهلك يقبل نموذج المصدر كما هو لأن المصدر لن يغيره لأجله | المبيعات تستخدم بيانات المستخدم كما يرسلها نظام الهوية عبر OpenID Connect |
| طبقة الحماية من الفساد Anticorruption Layer واختصاراً ACL | المستهلك يبني طبقة تترجم نموذج النظام الآخر إلى لغته، فلا يتسرب النموذج الغريب إلى نموذجه | بوابة الدفع الخارجية أو نظام قديم Legacy بأسماء وحالات غريبة |
| الخدمة المفتوحة Open Host Service واختصاراً OHS | المصدر يقدم واجهة عامة موثقة للجميع بدل مترجم خاص لكل مستهلك | المبيعات تنشر حدث OrderPlaced لكل من يحتاجه |
| اللغة المنشورة Published Language | صيغة تبادل موثقة ومستقلة عن النموذج الداخلي، وتأتي غالباً مع OHS | مخطط JSON لحدث OrderPlaced بإصدار v1 |
| طرق منفصلة Separate Ways | لا يوجد تكامل أصلاً، لأن ثمن التكامل أكبر من فائدته | نظام إجازات الموظفين لا يحتاج أن يعرف شيئاً عن الطلبات |
| الكتلة الطينية Big Ball of Mud | جزء قديم بلا حدود واضحة، فترسم حوله حداً ولا تحاول تطبيق نمذجة دقيقة داخله | النظام القديم الذي سوف يستبدل بعد سنة |
في الجدول أعلاه لاحظ التالي:
- طبقة ACL هي أهم علاقة عملياً، لأن أغلب الأنظمة تتكامل مع أنظمة لا تملكها، وفي الكود تكون غالباً مجموعة Adapters تحول رد بوابة الدفع إلى كائنات بلغتك، وهي نفس فكرة نمط Adapter الذي شرحناه في أنماط التصميم التي تقابلها فعلاً.
- النواة المشتركة Shared Kernel تبدأ صغيرة ثم تكبر إذا لم ينتبه أحد، فإذا وجدت أن مكتبة
Commonالمشتركة بين الفرق أصبح فيها كلاسOrderكامل، فقد عدت إلى النموذج الموحد من باب خلفي. - الخريطة تصف الواقع وليس ما تتمناه، فإذا كان فريقك فعلياً Conformist لأن الفريق الآخر لا يستجيب، فاكتب ذلك، لأن الخريطة تساعدك على التخطيط، والخريطة الكاذبة لا تساعد أحداً.
ولمن يريد قوالب جاهزة لرسم هذه الخرائط، يحتفظ مجتمع DDD Crew بمستودع مفتوح اسمه context-mapping فيه شرح مختصر لكل علاقة ورموزها. أما كيف تحفظ هذه الخريطة مع بقية مخططات النظام وقراراته، فهذا موضوع مخططات C4 وسجلات القرارات ADR.
كيف تكتشف السياقات؟ ورشة EventStorming باختصار
قد يتساءل البعض: من أين أعرف أين تقع الحدود أصلاً؟ والإجابة أنها لا تأتي من المطور وحده خلف شاشته، وإنما من جلسات مع أهل العمل، وأشهر طريقة لذلك اليوم ورشة EventStorming، وتذكر صفحة الكتاب الرسمية أن Alberto Brandolini جاء بفكرتها عام 2013 كأداة لنمذجة عمليات العمل المعقدة بسرعة بأسلوب DDD. والفكرة بسيطة في شكلها: لفافة ورق طويلة على الحائط، وأوراق لاصقة ملونة، وأهل العمل والمطورون في غرفة واحدة، يكتبون الأحداث التي تقع في العمل بصيغة الماضي («تم تأكيد الطلب»، «تم الدفع»، «تم تسليم الشحنة») ويرتبونها حسب الزمن. ويذكر الموقع الرسمي أن الورشة تستخدم لاستكشاف المجالات المعقدة، ولإظهار التناقض بين وجهات النظر، ولرسم حدود المعمارية، ولتصميم برامج تعتمد على الأحداث. والفائدة العملية أن الحدود بين السياقات تظهر على الحائط في الأماكن التي تتغير فيها الكلمات أو يتغير فيها الأشخاص الذين يتكلمون، ولمن يريد التفاصيل فكتاب Brandolini موجود على Leanpub.
من اللغة إلى الكود: الأنماط التكتيكية
إلى هنا لم نكتب سطراً من الكود، والجانب الاستراتيجي وحده يفيد أكثر الفرق، أما الأنماط التكتيكية فهي مفيدة داخل السياق الذي فيه قواعد كثيرة، وهو عندنا سياق المبيعات. وسوف نكتب ملفاً واحداً بلغة C# فيه كائن قيمة Money، وAggregate اسمه Order يحرس قواعده، وحدث OrderPlaced، وسياق شحن صغير يترجم الحدث إلى لغته. والملف يعمل كتطبيق من ملف واحد File-based App، وهي ميزة جاءت مع .NET 10 كما في توثيق Microsoft، فلا تحتاج ملف مشروع. والكود الكامل كما يلي:
// Sales context of a small online store: Money (Value Object), Order (Aggregate Root),
// OrderLine (inside the aggregate) and OrderPlaced (Domain Event). Run: dotnet run ddd.cs
using System.Collections.ObjectModel;
// ---------- Value Object: equal when the values are equal, never changes ----------
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentException("Money cannot be negative");
if (currency is not ("SAR" or "USD")) throw new ArgumentException($"Unknown currency {currency}");
Amount = amount;
Currency = currency;
}
public Money Add(Money other)
{
if (other.Currency != Currency)
throw new InvalidOperationException($"Cannot add {other.Currency} to {Currency}");
return new Money(Amount + other.Amount, Currency); // a new object, the old one stays as it was
}
public Money Times(int quantity) => new(Amount * quantity, Currency);
public override string ToString() => $"{Amount:0.00} {Currency}";
}
// ---------- Domain Event: something that happened, named in the past tense ----------
public sealed record OrderPlaced(Guid OrderId, Guid CustomerId, Money Total, DateTime PlacedAtUtc);
// ---------- Entity inside the aggregate: reachable only through the root ----------
public sealed class OrderLine
{
public string Sku { get; }
public int Quantity { get; private set; }
public Money UnitPrice { get; }
internal OrderLine(string sku, int quantity, Money unitPrice) { Sku = sku; Quantity = quantity; UnitPrice = unitPrice; }
internal void Increase(int by) => Quantity += by;
public Money LineTotal => UnitPrice.Times(Quantity);
}
public enum OrderStatus { Draft, Placed }
// ---------- Aggregate Root: the only door to change an order ----------
public sealed class Order
{
public const int MaxQuantityPerSku = 5; // business rule from the sales team
private readonly List<OrderLine> _lines = new();
private readonly List<object> _events = new();
public Guid Id { get; } = Guid.NewGuid(); // identity: two orders with the same lines are still two orders
public Guid CustomerId { get; }
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public IReadOnlyList<OrderLine> Lines => new ReadOnlyCollection<OrderLine>(_lines);
public IReadOnlyList<object> DomainEvents => _events;
public Order(Guid customerId) => CustomerId = customerId;
public Money Total => _lines.Select(l => l.LineTotal).Aggregate((a, b) => a.Add(b));
public void AddLine(string sku, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft) throw new InvalidOperationException("A placed order cannot change");
if (quantity <= 0) throw new ArgumentException("Quantity must be at least 1");
if (_lines.Count > 0 && _lines[0].UnitPrice.Currency != unitPrice.Currency)
throw new InvalidOperationException("All lines of one order use one currency");
var line = _lines.FirstOrDefault(l => l.Sku == sku);
var newQuantity = (line?.Quantity ?? 0) + quantity;
if (newQuantity > MaxQuantityPerSku)
throw new InvalidOperationException($"At most {MaxQuantityPerSku} of {sku} per order (asked {newQuantity})");
if (line is null) _lines.Add(new OrderLine(sku, quantity, unitPrice));
else line.Increase(quantity);
}
public void Place()
{
if (Status != OrderStatus.Draft) throw new InvalidOperationException("Order already placed");
if (_lines.Count == 0) throw new InvalidOperationException("An empty order cannot be placed");
Status = OrderStatus.Placed;
_events.Add(new OrderPlaced(Id, CustomerId, Total, DateTime.UtcNow));
}
}
// ---------- Shipping context: its own model, its own words ----------
public sealed record Shipment(Guid OrderId, string Recipient, int Parcels);
public static class Shipping
{
// Shipping does not care about prices; it translates the event into its own language
public static Shipment OnOrderPlaced(OrderPlaced e, string recipientAddress, int itemCount)
=> new(e.OrderId, recipientAddress, Parcels: (itemCount + 2) / 3);
}
public static class Program
{
static void Try(string title, Action action)
{
try { action(); Console.WriteLine($" OK {title}"); }
catch (Exception ex) { Console.WriteLine($" REFUSED {title}: {ex.Message}"); }
}
public static void Main()
{
Console.WriteLine("1) Value Object");
var a = new Money(50m, "SAR");
var b = new Money(50m, "SAR");
Console.WriteLine($" a == b: {a == b}, same object: {ReferenceEquals(a, b)}");
var c = a.Add(new Money(25m, "SAR"));
Console.WriteLine($" a.Add(25) = {c}, a is still {a}");
Try("add USD to SAR", () => a.Add(new Money(10m, "USD")));
Console.WriteLine("2) Entity identity");
var customer = Guid.NewGuid();
var o1 = new Order(customer); o1.AddLine("BOOK-1", 1, a);
var o2 = new Order(customer); o2.AddLine("BOOK-1", 1, a);
Console.WriteLine($" same lines, same order? {o1.Id == o2.Id}");
Console.WriteLine("3) Aggregate rules (invariants)");
var order = new Order(customer);
Try("add 3 x KB-9 at 120 SAR", () => order.AddLine("KB-9", 3, new Money(120m, "SAR")));
Try("add 2 more KB-9", () => order.AddLine("KB-9", 2, new Money(120m, "SAR")));
Try("add 1 more KB-9", () => order.AddLine("KB-9", 1, new Money(120m, "SAR")));
Try("add a line priced in USD", () => order.AddLine("MOUSE-2", 1, new Money(20m, "USD")));
Try("place an empty order", () => new Order(customer).Place());
Console.WriteLine($" total = {order.Total}, status = {order.Status}");
Console.WriteLine("4) Domain Event");
order.Place();
Try("add a line after placing", () => order.AddLine("CABLE-1", 1, new Money(15m, "SAR")));
foreach (var e in order.DomainEvents) Console.WriteLine($" event: {e.GetType().Name} total={((OrderPlaced)e).Total}");
var placed = (OrderPlaced)order.DomainEvents[0];
var shipment = Shipping.OnOrderPlaced(placed, "Building 7, Example Street", order.Lines.Sum(l => l.Quantity));
Console.WriteLine($" shipping created: {shipment.Parcels} parcels to {shipment.Recipient}");
}
}والتشغيل بالأمر التالي:
dotnet run ddd.csوالمخرج سوف يكون كما يلي:
1) Value Object
a == b: True, same object: False
a.Add(25) = 75.00 SAR, a is still 50.00 SAR
REFUSED add USD to SAR: Cannot add USD to SAR
2) Entity identity
same lines, same order? False
3) Aggregate rules (invariants)
OK add 3 x KB-9 at 120 SAR
OK add 2 more KB-9
REFUSED add 1 more KB-9: At most 5 of KB-9 per order (asked 6)
REFUSED add a line priced in USD: All lines of one order use one currency
REFUSED place an empty order: An empty order cannot be placed
total = 600.00 SAR, status = Draft
4) Domain Event
REFUSED add a line after placing: A placed order cannot change
event: OrderPlaced total=600.00 SAR
shipping created: 2 parcels to Building 7, Example Streetوسوف نشرح هذا المخرج جزءاً جزءاً في الأقسام التالية.
الكيان Entity وكائن القيمة Value Object
الكيان Entity كائن يعرف بهويته وليس بصفاته، فطلب رقم A17 يبقى نفس الطلب وهو في حالة المسودة بسطر واحد، ثم بثلاثة سطور، ثم بعد تأكيده، ولو تغيرت كل صفاته، ويقول Evans إن النموذج يجب أن يحدد ما معنى أن يكون الشيء هو نفسه. أما كائن القيمة Value Object فيعرف بقيمته فقط ولا هوية له، فورقة 50 ريالاً تساوي أي ورقة 50 ريالاً أخرى، ولا يهمك أي واحدة منهما في جيبك، ويطلب Evans أن يكون كائن القيمة غير قابل للتغيير Immutable وأن تكون عملياته دون آثار جانبية. والصورة التالية تبين الفرق:

وفي المخرج أعلاه لاحظ التالي:
a == bأعطتTrueمع أنهما كائنان مختلفان في الذاكرة (same object: False)، والسبب أنMoneyمعرف بالكلمةrecord، وتقول Microsoft في توثيق records إن ال record يأتي بالمساواة بالقيمة Value Equality جاهزة، أما ال class العادي فيتساوى فقط إذا كان نفس الكائن.a.Add(25)أعادت كائناً جديداً قيمته 75، وبقيaكما هو 50، وهذا هو عدم التغيير، ويشرح Fowler في مقاله ValueObject لماذا يهم ذلك: إذا كان كائن القيمة قابلاً للتعديل وكان متغيران يشيران إليه، فتعديله من مكان يغيره في المكان الآخر دون أن تقصد، ويسمي هذا Aliasing Bug.- جمع ريال مع دولار رفضه الكائن نفسه، أي أن القاعدة تعيش مع البيانات التي تخصها بدل أن تتكرر في كل مكان يجمع فيه أحد مبلغين.
- طلبان بنفس السطور ليسا نفس الطلب (
same lines, same order? False)، لأن الطلب كيان لهIdخاص به.
والفكرة ليست خاصة بلغة C#، فنفس كائن القيمة في Python يكون dataclass مجمداً كما يلي:
# The same Money value object in Python: a frozen dataclass. Run: python money.py
from dataclasses import dataclass, FrozenInstanceError
from decimal import Decimal
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str
def __post_init__(self):
if self.amount < 0:
raise ValueError("Money cannot be negative")
def add(self, other: "Money") -> "Money":
if other.currency != self.currency:
raise ValueError(f"Cannot add {other.currency} to {self.currency}")
return Money(self.amount + other.amount, self.currency)
a = Money(Decimal("50"), "SAR")
b = Money(Decimal("50"), "SAR")
print("a == b:", a == b, "| same object:", a is b)
print("in a set:", len({a, b}), "item")
print("a.add(25):", a.add(Money(Decimal("25"), "SAR")), "| a:", a)
try:
a.amount = Decimal("1")
except FrozenInstanceError as e:
print("change a.amount:", type(e).__name__, e)
try:
a.add(Money(Decimal("10"), "USD"))
except ValueError as e:
print("add USD:", e)وعند تشغيل الأمر python money.py سوف تجد أن المخرج كما يلي:
a == b: True | same object: False
in a set: 1 item
a.add(25): Money(amount=Decimal('75'), currency='SAR') | a: Money(amount=Decimal('50'), currency='SAR')
change a.amount: FrozenInstanceError cannot assign to field 'amount'
add USD: Cannot add USD to SARلاحظ أن الكائنين المتساويين أصبحا عنصراً واحداً داخل ال Set، لأن frozen=True يجعل ال dataclass قابلاً للهاش Hashable حسب قيمه كما في توثيق dataclasses، ولاحظ أن محاولة تعديل الحقل انتهت بالخطأ FrozenInstanceError. وتذكر أن الكيان والكائن مفهومان في البرمجة الكائنية OOP التي شرحناها في أنماط البرمجة، والجديد هنا هو السؤال الذي تسأله لكل مفهوم: هل تهمني هويته، أم تهمني قيمته فقط؟ وأغلب الفرق تكتب كيانات أكثر من حاجتها، والنصيحة أن تجعل كل شيء كائن قيمة ما لم تجد سبباً لهوية، مثل العنوان والمبلغ والفترة الزمنية والبريد الإلكتروني.
ال Aggregate: حدود القواعد والمعاملة الواحدة
في المتجر قاعدة تقول إن العميل لا يطلب أكثر من خمس قطع من نفس الصنف في الطلب الواحد، فأين تكتب هذه القاعدة؟ الطريقة البسيطة أن تكتبها في الشاشة أو في ال Controller، ثم يأتي API جديد للتطبيق المحمول ويضيف السطور مباشرة فلا يمر بالقاعدة، أو تتحقق منها في دالة الإضافة بينما يعدل كود آخر الكمية في السطر نفسه مباشرة، وفي الحالتين تصبح القاعدة مكتوبة لكنها غير محروسة.
وال Aggregate هو الحل في DDD: مجموعة من الكيانات وكائنات القيمة تعامل كوحدة واحدة، ولها كيان رئيسي واحد اسمه ال Aggregate Root، ولا يمسك أحد من خارجها إلا هذا الكيان الرئيسي، فهو الذي يتحقق من القواعد الثابتة Invariants للمجموعة كلها. ويقول Fowler في مقاله DDD_Aggregate إن الطلب مع سطوره مثال واضح على ذلك، وإن ال Aggregate هو الوحدة التي تحمل وتحفظ كاملة، وإن المعاملة Transaction لا يجب أن تتجاوز حدوده. والصورة التالية تبين حدود ال Aggregate في متجرنا:

وفي الكود أعلاه لاحظ التالي:
- السطور محفوظة في قائمة خاصة
_lines، والخارج يرىIReadOnlyListفقط، والطريقة الوحيدة للإضافة هيAddLineالتي تتحقق من الكمية والعملة وحالة الطلب، لذلك رفضت القطعة السادسة منKB-9برسالةasked 6مع أن كل استدعاء وحده كان صغيراً. - الطلب يشير إلى العميل بـ
CustomerIdوإلى المنتج بـSkuفقط، ولا يحمل كائن العميل أو المنتج، والسبب أن العميل Aggregate آخر له قواعده ومعاملته، ولو حملته داخل الطلب لأصبح حفظ الطلب يقفل بيانات العميل أيضاً. - القواعد التي تمتد على أكثر من Aggregate، مثل «لا تتجاوز مشتريات العميل حداً شهرياً»، لا تحاول فرضها في معاملة واحدة، وإنما بالأحداث كما في القسم التالي. ويقول Evans في الوثيقة المرجعية إن القواعد داخل حدود ال Aggregate تطبق فوراً، وبين ال Aggregates تطبق بشكل غير متزامن Asynchronous.
- اجعل ال Aggregate صغيراً، فكل ما بداخله يحمل ويحفظ ويقفل معاً، وال Aggregate الكبير معناه تعارض بين المستخدمين على نفس السجلات وبطء في كل عملية.
والآن لنكسر القاعدة عمداً: لو حاول كود خارجي أن يضيف سطراً مباشرة دون المرور بال Root، أي أضفنا قبل order.Place() السطر التالي:
order.Lines.Add(new OrderLine("KB-9", 50, new Money(1m, "SAR")));فسوف يرفض المترجم البرنامج قبل أن يعمل، والمخرج سوف يكون كما يلي:
ddd.cs(132,21): 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.وهذا أفضل مكان يمكن أن يظهر فيه الخطأ، لأن القاعدة أصبحت جزءاً من شكل الكود وليست تعليقاً يقرؤه المطور أو لا يقرؤه. ولاحظ أن الخطأ ذكر Add فقط ولم يشتك من new OrderLine، والسبب أن المنشئ Constructor معرف بـ internal وكل الكود هنا في ملف واحد، لذلك في المشروع الحقيقي ضع نموذج المجال في مشروع Project منفصل حتى يصبح internal حاجزاً فعلياً.
الأحداث Domain Events: شيء حدث ويهم أهل العمل
الحدث Domain Event هو شيء حدث في المجال ويهم خبراء المجال، ويطلب Evans أن تمثله ككائن في النموذج، وتقول Microsoft إن الحدث شيء حدث في الماضي، لذلك نسميه بصيغة الماضي: OrderPlaced وليس PlaceOrder، لأن الأمر Command طلب قد يرفض، أما الحدث فواقعة حصلت. وفي الكود أعلاه دالة Place تتحقق من القواعد ثم تغير الحالة وتضيف OrderPlaced إلى قائمة الأحداث، ولا تعرف شيئاً عن الشحن أو الفوترة، ثم يأخذ سياق الشحن هذا الحدث ويترجمه إلى لغته: Shipment وعدد طرود، دون أن يهتم بالسعر. والصورة التالية تبين الطريق الكامل للحدث في نظام حقيقي:

وتفرق Microsoft في صفحة Domain events: design and implementation بين نوعين: الحدث داخل نفس المجال Domain Event الذي يعالج غالباً داخل نفس العملية Process وقد يكون متزامناً أو غير متزامن، وحدث التكامل Integration Event الذي يخرج إلى سياقات أو أنظمة أخرى ويجب أن يكون غير متزامن، ولا يرسل إلا بعد نجاح حفظ البيانات، وإلا كأن العملية لم تحدث. وهنا نقطتان عمليتان:
- إذا حفظت الطلب ثم أرسلت الحدث إلى ال Broker في خطوتين، فقد يتعطل السيرفر بينهما فيضيع الحدث، أو يرسل الحدث ثم يفشل الحفظ، والحل المعروف هو نمط Outbox الذي شرحناه في الأنظمة الموزعة للمبتدئين: تحفظ الحدث في جدول داخل نفس المعاملة، ثم يرسله عامل منفصل.
- الشحن والفوترة سوف يعرفان بالطلب بعد لحظات وليس في نفس اللحظة، وهذا هو الاتساق النهائي Eventual Consistency، وعلى الواجهة أن تتعامل معه، فتقول للمستخدم «تم استلام طلبك» ولا تعرض رقم الفاتورة فوراً.
وفكرة النشر والاشتراك هنا قريبة من نمط Observer الذي شرحناه في أنماط التصميم، وعندما تصبح الأحداث هي طريقة التواصل الرئيسية بين أجزاء النظام كله فهذا نمط معماري كامل نشرحه في المعمارية الموجهة بالأحداث Event-Driven.
ال Repository وال Factory والخدمات Domain Services
تكمل الوثيقة المرجعية الأنماط التكتيكية بثلاثة أنماط لن نفصلها هنا لأن لها مكاناً آخر في الموقع:
- ال Repository: واجهة تحمل وتحفظ Aggregate كاملاً كأنه في قائمة في الذاكرة، وتخفي قاعدة البيانات عن نموذج المجال، وتنصح Microsoft في صفحة Designing the infrastructure persistence layer بـ Repository واحد لكل Aggregate، فلا يوجد
OrderLineRepositoryلأن السطر لا يحفظ وحده. وشرحنا النمط نفسه في أنماط التصميم. - ال Factory: عندما يكون إنشاء Aggregate صحيح معقداً، تضع منطق الإنشاء في مكان واحد بدل أن يتكرر، وشرحناه في نمط Factory.
- الخدمة Domain Service: عملية مهمة في المجال لا تخص كياناً واحداً بطبيعتها، مثل حساب سعر الشحن من عدة عوامل، فتضعها في خدمة بلا حالة Stateless باسم من اللغة المشتركة، بدل أن تحشرها في كيان لا علاقة له بها.
أين يعيش نموذج المجال في معمارية التطبيق؟
إذا كان نموذج المجال يستدعي قاعدة البيانات أو مكتبة HTTP مباشرة، فسوف تتغير قواعد العمل كلما تغيرت التقنية، ولن تستطيع اختبار القواعد دون تشغيل قاعدة بيانات. لذلك تقول Microsoft في صفحة Design a DDD-oriented microservice إن طبقة البنية التحتية Infrastructure تعتمد على طبقة المجال Domain، وطبقة المجال لا تعتمد على أي طبقة. ولاحظ أن الكود الذي كتبناه أعلاه لا يعرف شيئاً عن قاعدة بيانات أو إطار عمل، وهذا هو اتجاه الاعتماديات Dependency Direction الذي نشرحه بالتفصيل في قاعدة الاعتماديات وفي Hexagonal Architecture، حيث يصبح نموذج المجال في المركز وقاعدة البيانات وال Broker مجرد Adapters حوله. ويمكنك اختبار كل القواعد التي رأيتها في المخرج باختبارات وحدة Unit Tests سريعة كما في أنواع الاختبارات، دون قاعدة بيانات أو شبكة.
متى يكون DDD أكبر من حاجتك؟
الأنماط التكتيكية لها ثمن واضح: كلاسات أكثر، وتحويل بين نموذج المجال وجداول قاعدة البيانات، وأحداث يجب أن تتعامل مع وصولها المتأخر، وفريق يحتاج وقتاً حتى يتفق على اللغة. وتقول Microsoft صراحة في صفحة Design a DDD-oriented microservice إن أنماط DDD تطبق فقط على الخدمات المعقدة التي فيها قواعد عمل كثيرة، وإن المسؤوليات البسيطة مثل خدمة CRUD تدار بطرق أبسط، وفي صفحة Design a microservice domain model تذكر أن تطبيقها المرجعي eShopOnContainers طبق أنماط DDD في خدمة الطلبات Ordering ولم يطبقها في خدمة الكتالوج Catalog لأنها CRUD بسيطة. والصورة التالية تلخص القرار:

وبشكل عملي، لا تستخدم الأنماط التكتيكية في الحالات التالية:
- تطبيق CRUD: شاشات تضيف وتعدل وتحذف وتعرض بيانات بلا قواعد تذكر، مثل لوحة إدارة المحتوى أو دليل الموظفين، وهنا يكفي نموذج بسيط وطبقات واضحة، ويسمي البعض هذا النموذج الذي يحمل البيانات بلا سلوك Anemic Domain Model، وتقول Microsoft إنه قد يكون كافياً تماماً للخدمة البسيطة.
- مجال عام Generic: الدخول والصلاحيات والبريد والدفع، فاستخدم أداة أو خدمة جاهزة واحمها بطبقة ACL.
- نموذج أولي Prototype أو مشروع قصير العمر: لم تفهم المجال بعد، والكود سوف يرمى غالباً، فلا تبن Aggregates لقواعد لم تتضح، وهذا تطبيق لمبدأ YAGNI.
- لا يوجد خبير بالمجال: DDD قائم على الحديث مع من يفهم العمل، فإذا لم يتوفر هذا الشخص، فابدأ باللغة والحدود فقط.
أما الأخطاء الشائعة التي نراها عند من يطبق DDD لأول مرة فهي:
- DDD كترتيب مجلدات: مجلدات
EntitiesوValueObjectsوAggregatesبأسماء تقنية، دون أي حديث مع أهل العمل، وهذا يأخذ الثمن كله ويترك الفائدة. - Aggregate كبير جداً: العميل وكل طلباته وكل فواتيره في Aggregate واحد، فيقفل حفظ أي طلب كل شيء يخص العميل.
- خدمة مصغرة لكل كيان: خدمة للطلب وخدمة لسطر الطلب وخدمة للعميل، والحدود الصحيحة هي حدود السياقات وليس حدود الجداول، وإلا ظهر ما يسمى النظام الموزع المترابط Distributed Monolith.
- Shared Kernel يكبر بلا توقف: مكتبة مشتركة تبدأ بـ
Moneyوتنتهي بنصف النموذج، فيعود الترابط Coupling الذي شرحناه في مبادئ التصميم من باب خلفي. - بناء المجال العام بنفسك: فريق يقضي ثلاثة أشهر في نظام صلاحيات خاص بينما المجال الأساسي ينتظر.
تدريب عملي
- خذ نظاماً تعمل عليه الآن، واكتب قاموساً من عشر كلمات يستخدمها أهل العمل، ثم ابحث عن كل كلمة في الكود: كم كلمة تجدها بنفس الاسم؟ وكم كلمة لها معنيان بحسب القسم؟ الكلمات ذات المعنيين هي أماكن الحدود المحتملة بين السياقات.
- أضف إلى ملف
ddd.csقاعدة جديدة: لا يتجاوز مجموع الطلب 10000 ريال، ثم جرب أن تضيف سطوراً تتجاوزها، وفكر أين يجب أن تكتب القاعدة حتى لا يستطيع أي كود آخر تجاوزها.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- التصميم الموجه بالمجال DDD طريقة تفكير تبدأ من فهم المجال مع أهله، وجانبه الاستراتيجي (اللغة والحدود والعلاقات) يفيد أغلب الفرق، أما جانبه التكتيكي فيفيد الأجزاء المعقدة فقط.
- صنف المجالات الفرعية إلى Core وSupporting وGeneric، وضع أفضل جهدك في ال Core، واستخدم حلولاً جاهزة لل Generic.
- اللغة المشتركة Ubiquitous Language تعني نفس الكلمات في الاجتماع والكود والاختبارات، والسياق المحدد Bounded Context هو الحد الذي تبقى داخله الكلمة بمعنى واحد.
- خريطة السياقات Context Map توثق العلاقات بين السياقات والفرق، وأهم علاقة عملياً هي طبقة ACL أمام كل نظام لا تملكه.
- الكيان Entity يعرف بهويته، وكائن القيمة Value Object يعرف بقيمته ولا يتغير، وال Aggregate يحرس قواعده عبر ال Aggregate Root ويحفظ في معاملة واحدة، والأحداث Domain Events تنقل ما حدث إلى السياقات الأخرى مع اتساق نهائي.
- لا تطبق الأنماط التكتيكية على تطبيق CRUD أو نموذج أولي أو مجال عام، فهي في هذه الحالات تعقيد بلا فائدة.
وأقرب المقالات إلى هذا الموضوع على الموقع:
دليلالتصميم والمعمارية: أربعة مستويات لنشاط واحدما الفرق بين تصميم البرمجيات ومعماريتها وتصميم الأنظمة ومعمارية المؤسسة، ومن يقرر في كل مستوى وكم يعيش قراره.
دليلClean Architecture: كيف تعزل منطق العمل عن قاعدة البياناتلماذا تحتاج قاعدة بيانات لكي تختبر قاعدة عمل من سطر واحد، وكيف يعزل اتجاه الاعتماديات منطق العمل عن التفاصيل.
دليلأنماط المعمارية Architecture Styles ومتى تختار كلاً منهاكيف تختار بين التطبيق الواحد والخدمات التي تتبادل الأحداث، وما الذي يناسب نظامك من أشهر أنماط المعمارية.
دليلأنماط التصميم Design Patterns التي تقابلها فعلاًأشهر أنماط التصميم التي تقابلها في كل مشروع، والمشكلة التي يحلها كل نمط، ومتى يصبح عبئاً على من يعدل الكود بعدك.
دليلتوثيق المعمارية: مخططات C4 وسجلات القرارات المعمارية ADRكيف ترسم نظامك بمخططات C4 يفهمها المطور الجديد، وتحفظ سبب كل قرار مهم في سجل ADR داخل المستودع.
دليلالأنظمة الموزعة للمبتدئين: ماذا يحدث عندما تنقطع الشبكةلماذا تتصرف الأنظمة الموزعة بشكل غريب عند انقطاع الشبكة، وكيف تتفق السيرفرات على قرار واحد رغم ذلك.سجل التحديثات
- أكتوبر 2026: كتابة المقال، ومراجعة التعريفات من وثيقة DDD Reference لعام 2015 ومقالات Martin Fowler وإرشادات Microsoft، وتشغيل الأمثلة على .NET SDK 10.0.400 وPython 3.13.3.